0%

Vue3响应式原理

Vue3 的稳定版发布了很久了,去阅读官网的文档时发现推荐的一个优秀的 Vue 教程网站:Vue Mastery,里面的一篇Vue 3 Reactivity(Vue3 响应式)讲的是真的不错。跟着学完收获很多,顺着课程的思路总结一篇 Vue3 响应式的笔记,手动还原响应式的原理。

手动实现响应式

响应式,就是一个变量依赖了其他变量,当被依赖的其他变量更新后该变量也要响应式的更新。所以从零开始,先实现手动的更新。

单个变量的手动响应

先看几个单词的意思,

  • depdependence 的缩写,也就是依赖。
  • effect,指因某种原因导致产生结果,着重持续稳定的影响。
  • track,指追踪、踪迹。
  • trigger,指触发。

再看手动实现的代码,这里用到了 Set,不熟悉的话可参考 MDN:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
let price = 5;
let quantity = 2;
let total = 0;

// dep是一个“依赖”集合,用来存放众多的effect
let dep = new Set();

/**
* effect就是“影响”,其实反过来说就是单个的“依赖”
* total “依赖”的就是 price 和 quantity,也就是被 price 和 quantity "影响"
*/
let effect = () => {
total = price * quantity;
};

/**
* track就是“追踪”,其实就是留下记录
* 做的事情就是将 effect函数 添加到 dep 这个集合中
*/
function track() {
dep.add(effect);
}

/**
* trigger是触发
* 就是执行了保存在 dep 这个集合中的所有 effect函数
*/
function trigger() {
dep.forEach((effect) => effect());
}

track();
effect();
console.log(total); // output: 10

上述代码是最最简单的实现,流程就是三步:

  1. 通过 effect 来表明影响 total 的依赖
  2. 通过 track 来保存 effect
  3. 通过 trigger 来执行 effect

最后输出的 total 肯定就是计算后的 10

对对象的多个属性手动响应

上个例子中 pricequantity 都是放在了不同的变量里,现在更进一步,把他们放到同一个对象里 let product = { price: 5, quantity: 2 },现在如果想让 product 对象变为响应式,就需要指定每个键的响应。

depsMap 的意思就是 dependencemap。也就是一个 map 中,每个键都对应某个属性的 dep

这里用到了 Map,不熟悉的话可参考 MDN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
// 新建一个Map来存储deps
const depsMap = new Map();

/**
* 与上例中的变化是,指定了个参数key,表明effect存储到对象哪个键对应的的dep中
*/
function track(key) {
let dep = depsMap.get(key);
// 对应dep不存在时就new一个
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
// 把effect添加进去
dep.add(effect);
}
/**
* 同样,触发的时候也要指定一个key,表明执行的是对象哪个键的对应的dep中的effect
*/
function trigger(key) {
let dep = depsMap.get(key);
if (dep) {
dep.forEach((effect) => {
effect();
});
}
}

let product = { price: 5, quantity: 2 };
let total = 0;

let effect = () => {
total = product.price * product.quantity;
};

// 首次调用计算出total
effect();
console.log(total); // output: 10

// 将effect保存给 quantity 键对应的dep
track('quantity');
product.quantity = 3;
// 执行 quantity 键对应 dep 保存的 effect
trigger('quantity');
console.log(total); // output: 15

在上述代码中,实现了对整个 product 对象的手动响应。

对多个对象的多个属性进行手动响应

继续升级上述代码,如果有多个对象需要响应式,那么就需要给不同的对象设置不同 depsMap。所以创建一个 WeakMap 类型的变量,命名为 targetMap 来存储多个对象的 depsMaptarget 就是指的需要被响应式的对象。

而之所以用 Map 类型是因为 Map 可以用“对象”作为键,用 WeakMap 方便对键(也就是被响应式的对象)进行垃圾回收。不熟悉 WeakMap 的话可参考 MDN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
// 新建一个 WeakMap 来存储 depsMap
const targetMap = new WeakMap();

/**
* 与上例中的变化是,多指定了个参数 target,表明 effect 存储到哪个对象对应的的 depsMap 中
*/
function track(target, key) {
// 新增👇
let depsMap = targetMap.get(target);
// 对应depsMap不存在时就new一个
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
// 👆
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
dep.add(effect);
}

/**
* 同样,触发的时候也要指定一个 target,表明执行的是哪个对象的对应的depsMap中的effect
*/
function trigger(target, key) {
// 新增👇
const depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
// 👆
let dep = depsMap.get(key);
if (dep) {
dep.forEach((effect) => {
effect();
});
}
}
let product = { price: 5, quantity: 2 };
let total = 0;
let effect = () => {
total = product.price * product.quantity;
};

// 首次调用计算出total
effect();
console.log(total); // output: 10
// 将 effect 保存给对应 product对象 的 depsMap 中的对应 quantity键 的 dep
track(product, 'quantity');
product.quantity = 3;
// 执行对应 product对象 的 depsMap 中的对应 quantity键 的 dep 中保存的 effect
trigger(product, 'quantity');
console.log(total); // output: 15

上述代码实现了对不同对象的不同键进行手动响应。到了这一步,可以用课程中的一张图来清楚的表示下 targetMapdepsMapdep 之间的关系:

变为自动响应

继续升级代码,给上述代码添加自动响应。在这里用到了 ProxyReflect, 不熟悉的话可参考 MDN

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
const targetMap = new WeakMap();
function track(target, 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 = new Set()));
}
dep.add(effect);
}

function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) {
return;
}
let dep = depsMap.get(key);
if (dep) {
dep.forEach((effect) => {
effect();
});
}
}
// 新增 👇
/**
* @description: 例用Proxy和Reflect实现自动响应式
* @param {Object} target 要响应的对象
* @return {Proxy} 返回要响应对象的代理
*/
function reactive(target) {
const handlers = {
get(target, key, receiver) {
let result = Reflect.get(target, key, receiver);
// 在访问这个target对象的key键之前,先把effect保存下
track(target, key);
return result;
},
set(target, key, value, receiver) {
let oldValue = target[key];
// 下面两步的顺序不能颠倒,很关键
// 这一步其实就已经赋值成功了
let result = Reflect.set(target, key, value, receiver);
// 到这里再执行get时获取的是新设的值
if (result && oldValue != value) {
// 如果把这个target对象的key键的值改了,就得执行一遍对应的effect
trigger(target, key);
}
return result;
},
};
return new Proxy(target, handlers);
}
// 👆
let product = reactive({ price: 5, quantity: 2 });
let total = 0;

var effect = () => {
total = product.price * product.quantity;
};

// 首次调用计算出total
effect();
console.log(total); // output: 10

// 注意:在这里与前面代码的不同就是我们没有手动调用trigger,实现的自动响应
product.quantity = 3;
console.log(total); // output: 15

这段代码实现了自动响应,最关键的核心部分就是 reactive 函数,它返回了一个 Proxy,代理了对 target 对象的存取。首先在在 get 返回之前,自动调用 trackeffect 存到对应的位置。

巧妙的地方是 set,当我们执行 product.quantity = 3; 时,会先将 quantity 设为 3,再自动触发 trigger。这时最关键的地方来了,trigger 调用了存储的对应的 effect,计算出最新的 total15,实现了自动响应。

优化自动响应过程

上述代码实现了自动响应,但是现在还有两个明显不如人意的地方:

  1. 不能设置多种 effect
  2. 在我们设置 quantity3 的时候,trigger 调用了对应的 effect,这里的 effect 函数执行来计算 total 时,会再走一遍 proxyget 的流程。所以就会触发 track 的流程,但是这里我们并不需要触发 track 再保存一遍 effect

下面来优化上述两个问题:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
const targetMap = new WeakMap();
let activeEffect = null; // 👈 新增,是否需要添加effect的标志

function track(target, key) {
// 👇 新增,只有再activeEffect为真时才执行保存的操作
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); // 👈 修改,有直接添加effect改为了添加activeEffect
}
}

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

function reactive(target) {
const handler = {
get(target, key, receiver) {
let result = Reflect.get(target, key, receiver);
track(target, key);
return result;
},
set(target, key, value, receiver) {
let oldValue = target[key];
let result = Reflect.set(target, key, value, receiver);
if (result && oldValue != value) {
trigger(target, key);
}
return result;
},
};
return new Proxy(target, handler);
}

// 👇 新增
// 为了用统一的方式,把eff添加到对应的dep中,顺便还执行了一遍设置了初始值
// 这样以后,只有我们手动调用effect那次才会保存dep,用trigger触发的get就不会再保存一遍了
function effect(eff) {
activeEffect = eff;
activeEffect();
activeEffect = null;
}
// 👆

let product = reactive({ price: 5, quantity: 2 });
let salePrice = 0;
let total = 0;

// 👇 同理也要对effect函数改造,把每一个要保存的dep变为了effect函数的参数
// 手动设定了total呃salePrice的初始值
effect(() => {
total = product.price * product.quantity;
});
// 所以这里就不会把这个eff添加给quantity
effect(() => {
salePrice = product.price * 0.9;
});
// 👆

console.log(total, salePrice); // output: 10, 4.5

// 设置quantity只会重新计算total
product.quantity = 3;
console.log(total, salePrice); // output: 15, 4.5

// 设置price后,total和salePrice对应的effect都会执行,都会被重新计算
product.price = 10;
console.log(total, salePrice); // output: 30, 9

上述代码通过添加了一个 activeEffect 的标志位,解决了无效的重复执行保存的缺点;并把 effect() 变为 effect(eff) 的带参数形式,解决了多个 effect 的问题。

到这里为止,Vue3 Composition APIreactive 方法的实现流程已经被我们手动大致实现了一遍!

实现 ref

Vue3 Composition API 设计中,reactive 主要用于引用类型,另外专门提供了一个 ref 方法实现对原始类型的响应式。

大部分的地方都不用变,基本上就是添加了个 ref 方法,实现的方式就是对象访问器 getter/setter 来模仿 Proxy

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
const targetMap = new WeakMap();
let activeEffect = null;

function track(target, key) {
if (activeEffect) {
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()));
}
let dep = depsMap.get(key);
if (!dep) {
depsMap.set(key, (dep = new Set()));
}
dep.add(activeEffect);
}
}

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

function reactive(target) {
const handler = {
get(target, key, receiver) {
let result = Reflect.get(target, key, receiver);
track(target, key);
return result;
},
set(target, key, value, receiver) {
let oldValue = target[key];
let result = Reflect.set(target, key, value, receiver);
if (result && oldValue != value) {
trigger(target, key);
}
return result;
},
};
return new Proxy(target, handler);
}

// 👇 新增
/**
* @description: ref使用getter和setter实现,模仿了Proxy的get和set
* @param {Primary} raw
* @return {Object} 返回响应对象
*/
function ref(raw) {
const r = {
get value() {
// 在get之前,先保存到targetMap中
track(r, 'value');
return raw;
},
set value(newVal) {
raw = newVal;
// set了之后,触发effect更新
trigger(r, 'value');
},
};
return r;
}
// 👆

function effect(eff) {
activeEffect = eff;
activeEffect();
activeEffect = null;
}

let product = reactive({ price: 5, quantity: 2 });
let salePrice = ref(0); // 👈 修改 此时的salePrice自身也是个响应式对象
let total = 0;

// 此时的salePrice自身也是个响应式对象
effect(() => {
salePrice.value = product.price * 0.9; // 👈 修改
});

// 注意这里计算总价的方式变了,使用的是打折后的值来计算
effect(() => {
total = salePrice.value * product.quantity; // 👈 修改
});

console.log(total, salePrice); // output: 9, 4.5

product.quantity = 3;
console.log(total, salePrice); // output: 13.5, 4.5

product.price = 10;
console.log(total, salePrice); // output: 27, 9

其实可以发现,reactive 也能实现对原始类型的响应式,为什么还要专门提供一个 ref 方法?看对尤大的访谈中,尤大回答的是 reactive 还会添加更多处理流程,对于原始类型来说,是一种无用的负担。

实现 computed

跟响应式相关的最后一部分内容就是 computed 了,继续来手动实现它:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
// 👇 代码不变
const targetMap = new WeakMap();
let activeEffect = null;

function track(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);
}
}

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

function reactive(target) {
const handler = {
get(target, key, receiver) {
let result = Reflect.get(target, key, receiver);
track(target, key);
return result;
},
set(target, key, value, receiver) {
let oldValue = target[key];
let result = Reflect.set(target, key, value, receiver);
if (result && oldValue != value) {
trigger(target, key);
}
return result;
},
};
return new Proxy(target, handler);
}

function ref(raw) {
const r = {
get value() {
track(r, 'value');
return raw;
},
set value(newVal) {
raw = newVal;
trigger(r, 'value');
},
};
return r;
}

function effect(eff) {
activeEffect = eff;
activeEffect();
activeEffect = null;
}
// 👆 代码不变

// 👇 新增
/**
* @description: computed实现,其实就是封装了ref
* @param {Function} getter 取值函数
* @return {Object} ref返回的对象
*/
function computed(getter) {
// 创建一个响应式的引用
let result = ref();
// 用effect封装着调用getter,将结果设给result.value的同时,也将eff保存在了targetMap中的对应位置
effect(() => (result.value = getter()));
// 最后把result返回
return result;
}
// 👆

let product = reactive({ price: 5, quantity: 2 });

// 👇 修改
// 此时的salePrice自身也是一个响应式对象
let salePrice = computed(() => {
return product.price * 0.9;
});

// total也是个响应式对象
let total = computed(() => {
return salePrice.value * product.quantity;
});
// 👆

console.log(total.value, salePrice.value); // output: 9, 4.5

product.quantity = 3;
console.log(total.value, salePrice.value); // output: 13.5, 4.5

product.price = 10;
console.log(total.value, salePrice.value); // output: 27, 9

可以发现,computed 本质上就是封装了 ref 方法,用 effect 封装着来调用 getter,将结果设给 result.value 的同时,也将 eff 保存在了 targetMap 中的对应位置,实现了 computed 的响应式。

Vue3 源码中响应式的实现

Vue3 整体是用 Typescript 写的,reactivity 是一个独立的模块,源代码位于packages/reactivity/src目录下,几个不同的方法分别位于不同文件中:

source code

  • effecttracktrigger 方法,位于 effect.ts
  • Proxygetset 这些 handler 方法,位于 baseHandlers.ts
  • reactive 方法位于 reactive.ts,使用了 Proxy
  • ref 方法位于 ref.ts,使用了对象访问器。
  • computed 方法位于 computed.ts,使用了 effectref

关于 Vue Mastery 课程

Vue Mastery 课程是收费的,25% 的收入会捐给 Vue 项目,所以大家对课程感兴趣的话可以开会员支持一波。不过它的会员很贵,可以有一些取巧的方法跳过收费验证,可以关注“林景宜的记事本”公众号发送“Vue3 响应式”获取方法试看一波。


前端记事本,不定期更新,欢迎关注!


👆 全文结束,棒槌时间到 👇