Skip to content

Vue 2 与 Vue 3 的响应式实现

https://juejin.cn/post/7124351370521477128

什么是响应式数据?

响应式数据指的是数据与依赖建立起关系,当数据发生变化的时候,可以通知绑定数据的依赖进行相关操作,这个操作可以是相关 DOM 的更新,也可以是执行相应的回调函数。

Vue2 响应式的实现

Vue2 的数据通过 Object.defineProperty 对每个属性进行监听,当对属性进行读取的时候就会触发 getter,当对属性进行设置的时候就会触发 setter

我们首先了解一下其中包含的 JavaScript 方法。

defineProperty

Object.defineProperty() 是 ES5 中的方法,该方法可以在一个对象上定义一个新属性,或修改一个对象的现有属性,并返回这个对象。详细的介绍可以看MDN-defineProperty()

js
Object.defineProperty(obj, prop, descriptor)
  • obj : 要定义属性的对象
  • prop : 一个字符串或 Symbol, 指定了要定义或修改的属性键
  • descriptor : 要定义或修改的属性的描述符

返回值:传入函数的对象 obj,其指定的属性已经被添加或修改。

举一个例子:

js
let obj = {};
Object.defineProperty(obj, "num", {
	value: 1,
	writable: false
});

虽然我们可以直接添加属性和值 obj.num = 1,但是通过 defineProperty() 方法,我们可以进行更多的配置。

函数的第三个参数 descriptor 所表示的属性描述符有两种形式:数据描述符和访问器描述符。数据描述符value, writable)是一个具有可写或不可写值的属性,访问器描述符是由 getter/setter 函数对描述的属性。描述符只能是这两种类型之一,不能同时为两者。

getter

一个 getter 是一个获取某个特定属性的值的方法。get 语法将对象属性绑定到一个函数上,当获取该属性的时候,该函数被调用。详细介绍参照MDN-getter

语法:

js
get prop(){
	// 要执行的语句
}
  • prop : 要绑定到给定函数的属性名称

示例:

js
// 普通方法
const obj = {
	log: ["example", "test"],
	latest() {
		console.log("hi");
	},
};
// get 方法
const objGetter = {
	log: ["example", "test"],
	get latest() {
		console.log("hi");
	},
};

在控制台中输出可以看到如下图所示:obj 中有两个属性,而objGetter 中有一个属性,get 语法定义的属性没有被显示出来。但是可以通过 objGetter.latest 的方式调用绑定这一属性的函数

输出|400

setter

set 语法将对象属性绑定到某个函数上,当设置该属性的时候,该函数被调用。详细内容参考 MDN-setter

语法:

js
set prop(val){
	// 要执行的函数语句
}
  • prop : 要绑定给函数的属性名
  • val : 用于保存设置给该属性的值

实例:

js
const language = {
  set current(name) {
    this.log.push(name);
  },
  log: [],
};

language.current = "EN";
console.log(language.log); // ['EN']

language.current = "FA";
console.log(language.log); // ['EN', 'FA']

直接获取 language.currentundefined ,这是我比较有疑问的地方。

具体实现

了解了 JavaScript 中一些相关的基本方法之后,我们再说在 Vue2 中具体的实现。

简单地说,Vue2 的响应式数据是通过 Object.defineProperty 对每个属性进行监听,当对属性进行读取的时候,就会触发 getter,收集依赖;当对属性进行设置的时候,就会触发 setter ,触发依赖。

依赖是指一个属性的值与另一个属性的值之间的关系。当我们读取一个对象的属性时,比如 const myname = person.name,此时 myname 依赖于 person.name。换句话说,myname 的值取决于 person.name 的值。如果 person.name 发生变化,我们希望 myname 也能相应地更新。

数据劫持

首先,Vue 2 使用 Object.defineProperty 对每个属性进行拦截。当我们读取或者写入某个属性时,会触发对应的 gettersetter

js
function defineReactive(obj, key, val) {
    const dep = new Dep();
    
    Object.defineProperty(obj, key, {
        enumerable: true,
        configurable: true,
        get() {
            if (Dep.target) {
                dep.depend();
            }
            return val;
        },
        set(newVal) {
            if (newVal === val) return;
            val = newVal;
            dep.notify();
        }
    });
}

依赖收集

当属性的 getter 被调用时,如果 Dep.target 存在(表示当前有一个观察者在计算中),那么这个观察者会被添加到依赖列表中。这就是依赖收集的过程。

js
/**
* 我们把依赖收集的代码封装成一个 Dep 类,它专门帮助我们管理依赖。
* 使用这个类,我们可以收集依赖、删除依赖或者向依赖发送通知等。
**/
class Dep {
    constructor() {
        this.subs = []
    }
    
    addSub(sub) {
        this.subs.push(sub)
    }
    
    removeSub(sub) {
        remove(this.subs, sub)
    }
	// 如果 `Dep.target` 存在(表示当前正在计算的依赖),则将其添加到 `subs` 数组中。
    depend() { 
        if(Dep.target){
            this.addSub(Dep.target)
        }
    }
	// 通知所有依赖项,让它们执行各自的 `update` 方法。
    notify() {
        const subs = this.subs.slice()
        for(let i = 0, l = subs.length; i < l; i++) {
            subs[i].update()
        }
    }
}

// 删除依赖,该函数用于从一个数组中移除指定的元素。
function remove(arr, item) {
    if(arr.length) {
        const index = arr.indexOf(item)
        if(index > -1){
            return arr.splice(index, 1)
        } 
    }
}

依赖通知

当属性的 setter 被调用时,属性的值发生变化,会触发 dep.notify() 方法,通知所有依赖项执行更新操作。

js
const dep = new Dep();

const person = {};
defineReactive(person, 'name', 'John');

// 创建一个观察者
const watcher = {
    update() {
        console.log(`Person's name is now ${person.name}`);
    }
};

// 设置当前的目标依赖
Dep.target = watcher;

// 读取属性,触发依赖收集
const myname = person.name; // 此时 watcher 被收集到依赖列表中

// 清除当前的目标依赖
Dep.target = null;

// 修改属性,触发依赖通知
person.name = 'Jane'; // 输出: "Person's name is now Jane"

Vue3 响应式的实现

Vue 3 使用 Proxy 代理对象,拦截对对象的操作,实现响应式数据的劫持和依赖管理。Proxy 可以直接监听对象的操作,并且可以直接监听数组的变化。

首先我们还是先了解相关的 JavaScript 方法。

Proxy

Proxy 对象用于创建一个对象的代理,从而实现基本操作的拦截和自定义(如属性查找、赋值、枚举、函数调用等)。

详细内容可参考 MDN-Proxy

语法:

js
const p = new Proxy(target, handler);

参数:

  • target : 要使用 Proxy 包装的目标对象,可以是任何类型的对象,包括数组、函数,甚至是另一个 Proxy。但不能是基本数据类型。
  • handler : 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理 p 的行为。

getter/setter 可以见文章上方有简单介绍。

具体实现

数据劫持

Vue3 中使用 Proxy 实现数据劫持。

js
function reactive(target) {
    return new Proxy(target, {
        get(target, key, receiver) {
            // 依赖收集
            track(target, key);
            return Reflect.get(target, key, receiver);
        },
        set(target, key, value, receiver) {
            const result = Reflect.set(target, key, value, receiver);
            // 依赖通知
            trigger(target, key);
            return result;
        }
    });
}

依赖收集

Proxyget 拦截器中,调用 track 函数进行依赖收集。track 函数将当前的副作用函数保存起来。

js
let activeEffect = null; // 当前正在执行的副作用函数
const targetMap = new WeakMap(); // 每个键(key)是一个响应式对象,每个值(value)是一个 `Map` 对象,表示该响应式对象的属性和其依赖关系。

// 在读取响应式数据时收集依赖,将当前的副作用函数 activeEffect 记录下来
function track(target, key) { // target: 被读取的对象;key: 被读取的对象的属性
    if (activeEffect) {
        let depsMap = targetMap.get(target);
        if (!depsMap) {
            targetMap.set(target, (depsMap = new Map()));
        }
        let dep = depsMap.get(key);
        if (!dep) {
            depsMap.set(key, (dep = new Set()));
        }
        dep.add(activeEffect);
    }
}

示意图

上图显示了依赖收集过程中数据之间的关系。

依赖触发

Proxyset 拦截器中,调用 trigger 函数进行依赖触发。trigger 函数会取出之前保存的副作用函数并执行。

js
function trigger(target, key) {
    const depsMap = targetMap.get(target);
    if (!depsMap) return;
    const dep = depsMap.get(key);
    if (dep) {
        dep.forEach(effect => effect());
    }
}

副作用函数

副作用函数是那些依赖于响应式数据的函数。当响应式数据发生变化时,这些副作用函数会重新执行。

js
function effect(fn) {
    const effectFn = () => {
        try {
            activeEffect = effectFn;
            fn();
        } finally {
            activeEffect = null;
        }
    };
    effectFn();
}

示例

js
// 创建响应式对象
const person = reactive({ name: 'John', age: 30 });

// 创建一个副作用函数
effect(() => {
    console.log(`Person's name is ${person.name}`);
});

// 修改响应式对象的属性,触发副作用函数
person.name = 'Jane'; // 输出: "Person's name is Jane"