什么是副作用函数?
如果一个函数只是接收输入,然后输出,没有影响外部(比如修改外部变量),那么我们就说这个函数没有副作用,反之就是有副作用。理想的情况下,我们希望所有的函数都很纯粹,没有副作用。
举个例子:
1 2 3
| function effect() { document.body.innerText = "hello vue3"; }
|
Proxy
我们都知道 vue.js 这类框架是声明式的,当数据发生变化,它可以监听到并重新渲染,那么这种响应系统是如何实现的呢?首先一个问题就是怎么监听到数据的变化。答案是 Proxy,vue2 用了Object.defineProperty()函数,这是 es2015 之前的做法,vue3 则是用了 es2015 带来的新特性:Proxy。
通过 Proxy 我们可以监听到数据的变化(set),那就可以调用副作用函数更新 dom 了。
最简单的响应系统设计如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18
| function effect(data) { document.body.innerText = data.text; } const data = { text: "hello vue3" }; const obj = new Proxy(data, { get(target, key) { return target[key]; }, set(target, key, value) { target[key] = value; effect(target); return true; }, });
effect(data);
obj.text = "11";
|
下面开始,都是需求变化产生的代码变化了。
- 如果有多个副作用函数需要注册,怎么办?
- 如果这些多个副作用函数,监听的是这个对象的不同 key,怎么办?
- 如果这些多个副作用函数,监听的是多个对象的不同 key,怎么办?
- 分支切换
如果有多个副作用函数需要注册,怎么办?
代码如下:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| function effect1(data) { document.body.innerText = data.text; } function effect2(data) { document.body.setAttribute(data.text); } const data = { text: "hello vue3" }; const obj = new Proxy(data, { get(target, key) { return target[key]; }, set(target, key, value) { target[key] = value; effect1(target); effect2(target); return true; }, });
effect1(obj); effect2(obj);
obj.text = "11";
|
这样写,显然很不灵活(因为要去修改已经写好的代码),那么怎么设计才能不去动已经写好的代码呢?答案是传回调函数:
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
| const bucket = new Set(); function useEffect(fn, data) { bucket.add(fn); return fn(data); }
const data = { text: "hello vue3" }; const obj = new Proxy(data, { get(target, key) { return target[key]; }, set(target, key, value) { target[key] = value; bucket.forEach((fn) => fn(target)); return true; }, });
useEffect((data) => { document.body.innerText = data.text; }, data); useEffect((data) => { document.body.setAttribute("a", data.text); }, data);
obj.text = "11";
|
这样的写法,是不是很像 react 的 useEffect()了。
如果这些多个副作用函数,监听的是这个对象的不同 key,怎么办?
用个 Map,让 key 和回调函数一一对应即可,但如何知道副作用函数用了哪个 key 呢?如果是在 useEffect 里面显然是不知道的,但用没用 key,用了哪个 key,Proxy 里面的 get 拦截函数是一清二楚的,所以我们应该在这里添加副作用函数!但 get 拦截函数里面又不知道,当前获取数据的函数是哪个,很简单,我们设置一个 activeEffect 来记录当前的函数。但需要注意的是,一但我们开始在 get 里面注册响应,每次调用副作用函数就会调用注册响应,这个时候需要更新 activeEffect。
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
| let activeEffect; const bucket = new Map(); function useEffect(fn, data) { function effectFn(data) { activeEffect = effectFn; return fn(data); } return effectFn(data); }
const data = { text: "hello vue3", a: "11" }; const obj = new Proxy(data, { get(target, key) { if (activeEffect) { const effects = bucket.get(key); if (!effects) { bucket.set(key, new Set([activeEffect])); } else { effects.add(activeEffect); } } return target[key]; }, set(target, key, value) { target[key] = value; const effects = bucket.get(key); effects && effects.forEach((fn) => fn(target)); return true; }, });
useEffect((data) => { document.body.innerText = data.text; }, obj); useEffect((data) => { document.body.setAttribute("a", data.a); }, obj);
obj.text = "hello aaaa"; obj.a = "222";
|
这样就实现了对单个对象的多个不同 key 注册多个副作用函数,进行响应式渲染。
而且,我们可以把其中处理副作用函数的逻辑抽出来:
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
| let activeEffect; const bucket = new Map(); function useEffect(fn, data) { function effectFn(data) { activeEffect = effectFn; return fn(data); } return effectFn(data); }
const data = { text: "hello vue3", a: "11" }; const obj = new Proxy(data, { get(target, key) { track(key); return target[key]; }, set(target, key, value) { target[key] = value; trigger(target, key); return true; }, }); function track(key) { if (activeEffect) { const effects = bucket.get(key); if (!effects) { bucket.set(key, new Set([activeEffect])); } else { effects.add(activeEffect); } } } function trigger(target, key) { const effects = bucket.get(key); effects && effects.forEach((fn) => fn(target)); }
useEffect((data) => { document.body.innerText = data.text; }, obj); useEffect((data) => { document.body.setAttribute("a", data.a); }, obj);
obj.text = "hello aaaa"; obj.a = "222";
|
如果这些多个副作用函数,监听的是多个对象的不同 key,怎么办?
再加一个 Map 即可,而且由于键是个对象,最好用 WeakMap:
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
| let activeEffect; const bucket = new WeakMap(); function useEffect(fn, data) { function effectFn(data) { activeEffect = effectFn; return fn(data); } return effectFn(data); }
const data1 = { text: "hello vue3", a: "11" }; const data2 = { text2: "hello vue2", b: "22" }; const obj1 = createProxyObj(data1); const obj2 = createProxyObj(data2);
function createProxyObj(data) { return new Proxy(data, { get(target, key) { track(target, key); return target[key]; }, set(target, key, value) { target[key] = value; trigger(target, key); return true; }, }); } function track(target, key) { if (activeEffect) { let effectsOnTarget = bucket.get(target); if (!effectsOnTarget) { bucket.set(target, (effectsOnTarget = new Map())); } let effects = effectsOnTarget.get(key); if (!effects) { effectsOnTarget.set(key, (effects = new Set())); } effects.add(activeEffect); } } function trigger(target, key) { const effectsOnTarget = bucket.get(target); if (effectsOnTarget) { const effects = effectsOnTarget.get(key); effects && effects.forEach((fn) => fn(target)); } }
useEffect((data) => { document.body.innerText = data.text; }, obj1); useEffect((data) => { document.body.setAttribute("b", data.b); }, obj2);
obj1.text = "hello aaaa"; obj2.b = "333";
|
分支切换
目前来说:
- 首次执行副作用函数会触发 get,get 会把副作用函数添加为响应函数。
- 然后更新值的时候会触发 set,set 会执行响应函数,执行响应函数又会触发 get,get 会把副作用函数添加为响应函数。
也就是说每次更新值都会重新添加响应函数,似乎很冗余,能否只执行一次呢?答案是不能,因为可能存在分支,例如:
1 2 3 4 5 6 7 8
| const data = { ok: true, text: "hello world" }; const obj = new Proxy(data, { });
useEffect(function effectFn() { document.body.innerText = obj.ok ? obj.text : "not"; }, obj);
|
当 obj.ok 为 true 的时候,obj.text 的响应函数会记录下副作用函数,但如果 obj.ok 为 false 的时候,修改 obj.text 其实不用执行副作用函数了,但由于之前添加过,所以还是会执行。
所以我们不仅要重新添加响应函数,还需要清理之前添加的。这样每次添加的响应函数才会是准确无误的。
为了清理响应函数,比较粗暴一点的是遍历 bucket 中的每个 target 的每个 key,然后对其 set 集合执行 delete()方法,但这样显然不太好,我们可以记录一下哪些集合存了当前副作用函数,只对这些集合执行 delete()方法。
代码如下:
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
| function useEffect(fn, data) { effectFn.records = []; return effectFn(data);
function effectFn(data) { cleanup(effectFn); activeEffect = effectFn; return fn(data);
function cleanup(effectFn) { for (let i = 0; i < effectFn.records.length; i++) { effectFn.records[i].delete(fn); } } } }
function track(target, key) { if (activeEffect) { let effectsOnTarget = bucket.get(target); if (!effectsOnTarget) { bucket.set(target, (effectsOnTarget = new Map())); } let effects = effectsOnTarget.get(key); if (!effects) { effectsOnTarget.set(key, (effects = new Set())); } effects.add(activeEffect); activeEffect.records.push(effects); } }
|
我们改写了 useEffect,增加了一个 records 来记录需要清理哪些集合,在每次重新添加响应之前,清理掉旧的响应。并在 track 函数里面对这个 records 进行填充。
但还存在一个问题,这个问题比较隐秘,那就是遍历的时候对遍历对象进行 add 和 delete 操作,trigger 中我们执行响应函数,响应函数会对桶先 cleanup 再重新添加,但这个时候我们还在遍历桶啊,所以就相当于这样:
1 2 3 4 5 6 7
| const set = new Set([1]);
set.forEach((item) => { set.delete(1); set.add(1); console.log("遍历中"); });
|
这会造成死循环,解决的办法,自然是不对遍历的对象增删,新搞个对象进行增删。修改后的代码如下:
1 2 3 4 5 6 7
| function trigger(target, key) { const effectsOnTarget = bucket.get(target); if (effectsOnTarget) { const effects = effectsOnTarget.get(key); effects && [...effects].forEach((fn) => fn(target)); } }
|