手把手实现  Vue3.0 - 响应式 cover

手把手实现 Vue3.0 - 响应式

深入 Vue3.0 响应式核心

我希望每次 obj.a 的值发生改变时,console.log(obj.a) 自动执行一遍,要怎么办到?

const obj = {
a: 0
}
console.log(obj.a)

答案是响应式。在 Vue3.0 中,响应式的实现被封装成了独立的库 — @vue/reactivity。见识一下开箱即用的响应式魔法:

import { effect, reactive } from '@vue/reactivity';
const obj = reactive({
a: 0
})
effect(() => console.log(obj.a))

以上用到了两个方法:

  1. reactive:传入一个对象,把这个对象变成响应式对象
  2. effect:响应式数据发生改变时,要触发的动作(函数),传进去就好

这时候,如果:

import { effect, reactive } from '@vue/reactivity';
const obj = reactive({
a: 0
})
effect(() => console.log(obj.a))
obj.a++
obj.a++
obj.a++
/*
输出
0
1
2
3
*/

控制台先后输出: 0 1 2 3effect 方法执行时,会立即执行一遍传入的函数,输出初始值 0,而后每一次累加,都会触发 console.log 重新执行,输出 obj.a 的最新值

同理,可以想像,如果 effect 里面的函数,做了一些 DOM 操作,比如往界面上添加/更新元素,这不正是一个数据驱动视图的模型了么。

那就跟着源码的思路实现一个试试吧。

基于 “@vue/reactivity”: “3.2.31”,下文中涉及的变量名称与逻辑组织尽量与源码保持一致

思路

写代码之前,先理清楚思路。

Vue3.0 的响应式基于 Proxy 特性,Proxy 可以让被代理的目标对象,在其属性被访问/更新/删除时,额外做点事情。以上述的 obj = { a: 0 } 为例,在程序的任何地方访问 obj.a ,都是一次 get 操作,我们可以设置一个 getter 来处理这次访问:

const newObj = new Proxy(obj, {
get(target, key) {
console.log('读取 => ', key)
return target[key]
}
});
let a = newObj.a // 控制台输出 "读取 => a"

上面的代码给 obj 对象加了一层代理,obj 的属性被访问时,会输出访问的 key 值。

同理,也能设置一个 setter,在属性被赋值的时候,触发 setter:

const newObj = new Proxy(obj, {
get(target, key) {
console.log('读取 => ', key)
return target[key]
},
set(target, key, value) {
console.log('置值 => ', value)
target[key] = value
}
});
newObj.a = 100 // 控制台输出 "置值 => 100"

Proxy 对象还能劫持的动作还有:deleteProperty, has 等等。我们先聚焦最重要的 get 和 set 就好。

在对象被访问/赋值的时候,我们可以趁机做点啥,那到底做点啥呢?响应式的效果是在对象的某个属性发生变化的时候,自动触发和这个属性有关的某些动作,所以有一个问题非常关键:

哪些动作和这个属性值有关?即,程序里有哪些函数访问了这个属性?

如果知道有哪些函数访问了目标属性,当目标属性发生改变的时候(setter 里),我们就可以执行这些函数了,这不就实现响应式了吗。

那么, obj.a 要如何知道有哪些属性访问了它呢?

🤔🤔🤔🤔

答案是 getter,如果有某个函数访问了 obj.a,自然会触发我们设置的 getter,我们在 getter 里面,把这个函数给记录下来,把它变成 obj.a依赖。后续当 obj.a 被改变的时候,触发 setter,再取得这些依赖,挨个执行一遍就好啦。

大致思路就是这样。

代码实现

reactive

首先实现 reactive, 这个函数接受一个对象作为参数,返回一个响应式对象。其实就是把这个对象加一层 Proxy,设置好 getter,setter 等等。

export function reactive(target) {
return createReactiveObject(target, handlers);
}

reactive 内部调用 createReactiveObject ,传入目标对象 target 和处理函数 handlers,handles 是一个全局变量,一会儿再说。先看看 createReactiveObject

function createReactiveObject(target, handlers) {
const proxy = new Proxy(target, handlers);
return proxy;
}

用 Proxy 包装目标对象后,返回被代理后的 proxy 对象。那 handlers 长什么样:

const handlers = {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
track(target, 'get', key);
if (isObject(res)) {
return reactive(res);
}
return res;
},
set(target, key, value, receiver) {
let oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
trigger(target, 'set', key, value, oldValue);
return result;
},
deleteProperty,
has,
ownKeys
};

handlers 里面我们最关注 get 和 set,其他先忽略。先看 get 做了哪些事:

  1. 从 target 中取出 key 的值,这里的 Reflect.get(target, key, receiver) 等同于 target[key],区别是如果取值失败的话Reflect.get 不会中断程序执行。(Reflect API 常与 Proxy API 配合使用,因为它们的入参数是完全一样的)
  2. 调用 track 追踪依赖(“是谁在访问我呀,来登记一下谢谢”)
  3. 如果取出来的 target[key] 是一个对象,那么递归对其执行 reactive,也就是要把这个 key 属性下的所有嵌套子对象的值,全部加上 getter,setter 这些东西,变成响应式对象(”子子孙孙无穷尽也”)
  4. 最后,return 这个准备好的响应式对象

注意,调用 reactive 函数,仅仅是给 target 装备上 getter,setter 这些响应式「武器」,还没有执行呢,要等相关操作(访问,赋值)发生才会触发(“别人不惹我,我绝不会先开枪”)。

在 Vue3.x 中,响应式变量只有在被访问的时候,才会真正开始「响应」— 执行依赖追踪,递归子对象这些操作。在 Vue2 中,定义响应式变量(如申明 data)时,就会递归整个对象及其子对象,把整个对象变成响应式。相比 Vue2.x 的做法,Vue3.x 更节省性能,想象如果申明一个响应式对象,从头到尾都没有人理它,那它(会伤心吧)就不应该瞎努力了对吧。

整个 getter 的关键是 track 函数,它的作用是收集依赖:

let targetMap = new WeakMap() // 全局变量,用于记录所有响应式变量的依赖
let activeEffect = null; // 全局变量,暂存当前激活的副作用函数
function track(target, type, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = createDep()));
}
trackEffects(dep);
}
function createDep(effects) {
const dep = new Set(effects);
return dep;
}

track 的目标是在全局维护一个 Map,储存响应式对象及其所属的依赖。假如 target 对象长这样👇

let obj = {
a: 1,
b: 2,
c: 3
}

最终生成的 targetMap 就会长这样👇

targetMap.png

targetMap 是一个 WeakMap,它的 key 是一个个响应式对象,对应的value 是一个个 Map;这些 Map 由响应式对象 target 的 一个个 key 组成,value 是与这些一个个 key 所关联的依赖函数所组成的依赖集合

所以 track 的心路历程是:

  1. 从全局变量 targetMap 中取出当前对象(target)的依赖 Map,称之为 depsMap
  2. 如果 target 对象还没有建立过依赖 map — depsMap,说明是初次访问的情况,就以 target 整个对象作为 key,初始化一个 Map 作为 value,存入到 targetMap
  3. 接着,用目标 key 从 depsMap 中找出相关的依赖集合 (Set)— 称之为dep,如果没有,新建一个集合(Set),以目标属性 key 为 key,以新建的集合为 value,存入 depsMap
  4. 最后,调用 trackEffects,传入找到的依赖集合 dep

接着实现 trackEffects

function trackEffects(dep) {
dep.add(activeEffect);
}

就三行,实现完毕。

其实就干一件事:往 dep 集合(Set)中加入当前激活的副作用函数 - activeEffectactiveEffect 是一个全局变量,储存当前激活的副作用函数。activeEffect 初始化的时候没有值,在 effect 函数中会给它赋值,后面说到 effect 的时候再细究整个过程。

总结一下 getter 的作用:

  1. 取出响应式对象的某个值 target[key],如果结果是对象,递归执行reactive ,把整个属性值及其子对象全部变成响应式的
  2. 调用 track 追踪依赖,维护全局的依赖 Map — targetMap

接着实现 setter :

const handlers = {
...
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, 'set', key, value);
return result;
}
...
}

setter 在响应式对象的值被赋值的时候触发,干了两件事:

  1. 调用 Reflect.set(target, key, value, receiver), 把新值赋值给对应属性,Reflect.set(target, key, value, receiver) 可视为 target[key] = value
  2. 调用 trigger 方法,执行与之关联的依赖(effect)

轮到实现 trigger:

function trigger(target, type, key, newValue) {
// 从全局 targetMap 中取出当前对象的依赖 deptsMap
const depsMap = targetMap.get(target);
// 没有就返回,说明没有被追踪的依赖
if (!depsMap) {
return;
}
// 创建一个 deps 数组用于储存依赖
let deps = [];
// 如果 key 有效,从 depsMap 中取出依赖的集合,推入 deps 中
if (key !== void 0) {
deps.push(depsMap.get(key));
}
// 最终调用 triggerEffects
if (deps.length === 1) {
if (deps[0]) {
triggerEffects(deps[0]);
}
} else {
const effects = [];
for (const dep of deps) {
if (dep) {
effects.push(...dep);
}
}
triggerEffects(createDep(effects));
}
}

总的来说,trigger 的任务就是从全局 targetMap 中找到对应依赖,并且调用 triggerEffects

function triggerEffects(dep) {
for (const effect of isArray(dep) ? dep : [...dep]) {
if (effect !== activeEffect) {
effect.run();
}
}
}

遍历副作用 effect,逐个执行之。在执行每个 effect 之前,还须判断要执行的effect 是不是「当前激活的副作用 — activeEffect 」,如果当前要执行的 effect 正是当前激活的 activeEffect,那就需要跳过,避免死循环或错误依赖。

总结一下 setter 的作用:

  1. 设置新值
  2. 取出相应依赖,逐个执行

effect

假设我们创建了一个响应式对象 obj,随后在某个函数中访问了其中某个属性(如obj.a),于是触发了 getter,getter 运行track 去找全局变量 activeEffect ,发现是空值,那这次访问的动作就不会成为这个响应式对象的依赖,也就是说当响应式对象变化时,这个函数不会自动执行。

我们需要一个机制,能够「注册」某个函数为 当前激活的副作用,也就是把函数赋值给全局变量activeEffect,随后在 getter 中就能 track 到这次访问动作,把它变成依赖啦。

简单实现一版 effect:

export function effect(fn) {
activeEffect = fn
fn();
}

把传入的函数 fn 赋值给全局变量 activeEffect,然后执行 fn 函数。

我们用前文的例子来串一下整个过程:

const obj = reactive({
a: 0
})
effect(() => console.log(obj.a))
obj.a = 100

首先申明了一个响应式对象 obj,这时候 obj 已经具备 getter 和 setter 等响应式魔法了。接着,effect 函数执行,传入 () => console.log(obj.a) 函数,记得 effect 会做了哪两件事么:

  1. 把传入的函数 fn 赋值给全局变量 activeEffect

activeEffect = () => console.log(obj.a)

  1. 调用传入的 fn

() => console.log(obj.a)

由于 fn 中访问了 obj.a,触发了 obj 的 getter,getter 中运行 track 函数追踪依赖,去找全局的 activeEffect ,于是得到 () => console.log(obj.a),将其加入到了 obj.a 的依赖集合(Set)中。

接着,程序执行到了 obj.a = 100,这是一个赋值操作,触发了 setter,先把 obj.a 的值设置成 100,接着运行 trigger,从 targetMap中找到对应依赖,执行之。这时候的依赖是啥?正是() => console.log(obj.a) 啦,于是最终输出改变后的值 — 100。

示意图如下:

track.png

到此为止,整个响应式流程都走通了,如果你只是想了解响应式原理最核心的部分,看到这里就够啦。如果你愿意再深入一些些,深呼吸,我们继续。

effect 的嵌套

无法否认,有可能出现下面这种情况:

const obj = reactive({ a: 1, b: 2, c: 3 });
effect(() => {
effect(() => {
effect(() => {
console.log(obj.c);
})
console.log(obj.b);
});
console.log(obj.a);
});

一个 effect 中嵌套了另一个 effect,又嵌套了另一个 effect …俗称套娃🪆。这时候程序会依次输出3 2 1,没问题。重点来了,接着如果执行 obj.a = 100, 会输出什么呢?

effect(() => {
effect(() => {
effect(() => {
console.log(obj.c);
})
console.log(obj.b);
});
console.log(obj.a);
});
obj.a = 100

什么都不会输出,什么也不会发生。

~~最怕空气突然安静~~。

我们预想的是输出3 2 100,发生了什么?便于分析,不妨把三个 effect 从外到内叫做 fn1,fn2,fn3。我们知道 fn1 包含 fn2,fn2 包含 fn3。 一步一步过一下发生了什么:

  1. 首先 activeEffect 的值被设置为 fn1,随后执行 fn1()
  2. fn1 执行过程中,触发了 fn2 ,于是 activeEffect 的值被改写成了 fn2 ,随后执行 fn2()
  3. fn2 执行过程中,触发了 fn3 ,于是 activeEffect 的值被改写变成 fn3, 随后 fn3 执行 — 也就是console.log(obj.c) ,由于此函数访问了响应式变量 obj.c,于是其 getter 触发,track 到了 activeEffect — 即 fn3,把 fn3 加入到了 targetMap 中,作为 obj.c 的依赖
  4. fn3 执行完毕出栈,继续执行 fn2,输出 obj.b,触发 getter,track 找到了activeEffect — 此时依然是 fn3,于是 obj.b 的依赖也设置成了 fn3。(这里开始出错了:obj.b 改变时,我们希望自动响应执行的是 fn2,不是 fn3)
  5. fn2 执行完毕出栈,继续执行 fn1,输出 obj.a,触发 getter,track 找到了 activeEffect — 此时依然还是 fn3,于是 obj.a 的依赖也设置成了 fn3,错上加错
  6. fn1 执行完毕出栈,至此所有 effect 初始化执行完毕。现在的 targetMap 中,obj.aobj.bobj.c 的依赖全部是 fn3,且全局的 activeEffect 依然是 fn3
  7. 接着执行 obj.a = 100,触发 setter,从 targetMap 中找到 obj.a 的依赖 — fn3,在执行之前,会判断这个 effect 是否就是 activeEffect,碰巧的是,当前的 activeEffect 正是 fn3,所以跳过,不执行。于是什么事情都没有发生。因此这里的判断也避免了现在这种 activeEffect 错误的情况。

综上所述,在 effect 发生嵌套调用时,会发生 activeEffect 错误赋值的情况,从而导致响应式失效。我们需要一个机制,保证在嵌套调用时:

  1. activeEffect 能被正确赋值
  2. activeEffect 在 effect 结束后被置空

如果要保证 activeEffect 在嵌套的情况下被正确赋值,内层的 effect 需要保存外层 effect 的引用,在内层调用结束时,重新把外层 effect 的值「还原」给 activeEffect

我们申明一个 reactiveEffect 类,把每一个 effect 的属性和逻辑标准化:

class ReactiveEffect {
constructor(fn) {
this.fn = fn;
}
run() {
activeEffect = this;
return this.fn();
}
}

ReactiveEffect 的构造函数接收副作用函数 fn,且注册一个原型方法 run,负责把当前 effect 设置成 activeEffect,并且执行传入的副作用函数 fn,这和我们原来在 effect 函数中做的事情一模一样:

export function effect(fn) {
activeEffect = fn
fn();
}

现在我们用新朋友 ReactiveEffect 改写一下 effect 方法:

export function effect(fn) {
// activeEffect = fn;
// fn();
const _effect = new ReactiveEffect(fn);
_effect.run();
}

目前和原来没有任何区别,回到 ReactiveEffect

class ReactiveEffect {
constructor(fn) {
this.fn = fn;
// 内部维护一个parent,用来保存外层 effect 的引用
this.parent = undefined;
}
run() {
try {
// 执行当前fn时,先把当前 activeEffect 赋值给 parent,这时候如果 activeEffect 是有值的,就是外层 effect 的引用了
this.parent = activeEffect;
activeEffect = this;
return this.fn();
} finally {
// 当前 effect 执行完毕后,把 parent 的值(即外层effect)的值还原回 activeEffect。继续执行外层 effect,这时候 activeEffect 就不会出错了,且最终 activeEffect 会被置值为 undefined
activeEffect = this.parent;
// 把 this.parent 置空
this.parent = undefined;
}
}
}

首先在 ReactiveEffect 内部维护一个 parent 属性,用来保存外层 effect 的引用,当执行当前副作用函数 fn 时,先把当前的activeEffect(无论它有没有值)赋值给 parent,如果 activeEffect 是有值的,那它就是外层 effect 的引用了。当前 effect 执行完毕后,把 parent (即外层effect)的值「还原」给 activeEffect。这时外层 effect 在执行时,activeEffect 就是外层effect 了。

这相当于,在嵌套调用的 effect 之间,维护了一个单向链表,每个 effect 能够通过 parent 属性访问到外层的父级 effect,执行完当前 effect 后,找到 parent,把 activeEffect 还原成 parent,实现函数调用栈一样的效果。

effects.png

这时候,我们的 effect 函数就支持嵌套调用啦!

完整代码:

const isObject = (val) => val !== null && typeof val === 'object';
const isArray = (val) => Array.isArray(val);
const targetMap = new WeakMap();
let activeEffect = null;
const handlers = {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver);
track(target, 'get' /* GET */, key);
if (isObject(res)) {
return reactive(res);
}
return res;
},
set(target, key, value, receiver) {
let oldValue = target[key];
const result = Reflect.set(target, key, value, receiver);
trigger(target, 'set', key, value, oldValue);
return result;
},
};
export function reactive(target) {
return createReactiveObject(target, handlers);
}
function createReactiveObject(target, handlers) {
const proxy = new Proxy(target, handlers);
return proxy;
}
export function effect(fn) {
const _effect = new ReactiveEffect(fn);
_effect.run();
}
class ReactiveEffect {
constructor(fn) {
this.fn = fn;
this.parent = undefined;
}
run() {
try {
this.parent = activeEffect;
activeEffect = this;
return this.fn();
} finally {
activeEffect = this.parent;
this.parent = undefined;
}
}
}
function createDep(effects) {
const dep = new Set(effects);
return dep;
}
function track(target, type, key) {
let depsMap = targetMap.get(target);
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = createDep()));
}
trackEffects(dep);
}
function trigger(target, type, key, newValue, oldValue, oldTarget) {
const depsMap = targetMap.get(target);
if (!depsMap) {
// never been tracked
return;
}
let deps = [];
if (key !== void 0) {
deps.push(depsMap.get(key));
}
if (deps.length === 1) {
if (deps[0]) {
triggerEffects(deps[0]);
}
} else {
const effects = [];
for (const dep of deps) {
if (dep) {
effects.push(...dep);
}
}
triggerEffects(createDep(effects));
}
}
function triggerEffects(dep) {
for (const effect of isArray(dep) ? dep : [...dep]) {
if (effect !== activeEffect) {
effect.run();
}
}
}
function trackEffects(dep) {
dep.add(activeEffect);
}

当然,目前的实现依然非常粗糙,还有很多边界情况需要处理,很多特性需要实现。不妨鼓足勇气去读读源码,应该比之前胸有成竹了一些?

源码路径: vuejs/core/packages/reactivity 源码版本: 3.2.31