Vue 2 与 Vue 3 的响应式实现
https://juejin.cn/post/7124351370521477128
什么是响应式数据?
响应式数据指的是数据与依赖建立起关系,当数据发生变化的时候,可以通知绑定数据的依赖进行相关操作,这个操作可以是相关 DOM 的更新,也可以是执行相应的回调函数。
Vue2 响应式的实现 - 观察者模式
Vue2 的数据通过 Object.defineProperty
对每个属性进行监听,当对属性进行读取的时候就会触发 getter
,当对属性进行设置的时候就会触发 setter
。
我们首先了解一下其中包含的 JavaScript 方法。
defineProperty
Object.defineProperty()
是 ES5 中的方法,该方法可以在一个对象上定义一个新属性,或修改一个对象的现有属性,并返回这个对象。详细的介绍可以看MDN-defineProperty()。
Object.defineProperty(obj, prop, descriptor)
obj
: 要定义属性的对象prop
: 一个字符串或Symbol
, 指定了要定义或修改的属性键descriptor
: 要定义或修改的属性的描述符
返回值:传入函数的对象 obj
,其指定的属性已经被添加或修改。
举一个例子:
let obj = {};
Object.defineProperty(obj, "num", {
value: 1,
writable: false
});
虽然我们可以直接添加属性和值 obj.num = 1
,但是通过 defineProperty()
方法,我们可以进行更多的配置。
函数的第三个参数 descriptor
所表示的属性描述符有两种形式:数据描述符和访问器描述符。数据描述符(value
, writable
)是一个具有可写或不可写值的属性,访问器描述符是由 getter/setter 函数对描述的属性。描述符只能是这两种类型之一,不能同时为两者。
getter
一个 getter 是一个获取某个特定属性的值的方法。get
语法将对象属性绑定到一个函数上,当获取该属性的时候,该函数被调用。详细介绍参照MDN-getter。
语法:
get prop(){
// 要执行的语句
}
prop
: 要绑定到给定函数的属性名称
示例:
// 普通方法
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
的方式调用绑定这一属性的函数。
setter
set
语法将对象属性绑定到某个函数上,当设置该属性的时候,该函数被调用。详细内容参考 MDN-setter。
语法:
set prop(val){
// 要执行的函数语句
}
prop
: 要绑定给函数的属性名val
: 用于保存设置给该属性的值
实例:
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.current
是 undefined
,这是我比较有疑问的地方。
具体实现
了解了 JavaScript 中一些相关的基本方法之后,我们再说在 Vue2 中具体的实现。
简单地说,Vue2 的响应式数据是通过 Object.defineProperty
对每个属性进行监听,当对属性进行读取的时候,就会触发 getter
,收集依赖;当对属性进行设置的时候,就会触发 setter
,触发依赖。
依赖是指一个属性的值与另一个属性的值之间的关系。当我们读取一个对象的属性时,比如 const myname = person.name
,此时 myname
依赖于 person.name
。换句话说,myname
的值取决于 person.name
的值。如果 person.name
发生变化,我们希望 myname
也能相应地更新。
数据劫持
首先,Vue 2 使用 Object.defineProperty
对每个属性进行拦截。当我们读取或者写入某个属性时,会触发对应的 getter
和 setter
。
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
存在(表示当前有一个观察者在计算中),那么这个观察者会被添加到依赖列表中。这就是依赖收集的过程。
/**
* 我们把依赖收集的代码封装成一个 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()
方法,通知所有依赖项执行更新操作。
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。
语法:
const p = new Proxy(target, handler);
参数:
target
: 要使用 Proxy 包装的目标对象,可以是任何类型的对象,包括数组、函数,甚至是另一个 Proxy。但不能是基本数据类型。handler
: 一个通常以函数作为属性的对象,各属性中的函数分别定义了在执行各种操作时代理p
的行为。
getter/setter 可以见文章上方有简单介绍。
具体实现
数据劫持
Vue3 中使用 Proxy
实现数据劫持。
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;
}
});
}
依赖收集
在 Proxy
的 get
拦截器中,调用 track
函数进行依赖收集。track
函数将当前的副作用函数保存起来。
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);
}
}
上图显示了依赖收集过程中数据之间的关系。
依赖触发
在 Proxy
的 set
拦截器中,调用 trigger
函数进行依赖触发。trigger
函数会取出之前保存的副作用函数并执行。
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const dep = depsMap.get(key);
if (dep) {
dep.forEach(effect => effect());
}
}
副作用函数
副作用函数是那些依赖于响应式数据的函数。当响应式数据发生变化时,这些副作用函数会重新执行。
function effect(fn) {
const effectFn = () => {
try {
activeEffect = effectFn;
fn();
} finally {
activeEffect = null;
}
};
effectFn();
}
示例
// 创建响应式对象
const person = reactive({ name: 'John', age: 30 });
// 创建一个副作用函数
effect(() => {
console.log(`Person's name is ${person.name}`);
});
// 修改响应式对象的属性,触发副作用函数
person.name = 'Jane'; // 输出: "Person's name is Jane"