一. 响应式系统
1) 实现一个基本的响应式数据
基本思路如下:
- 使用Proxy监听数据的读写操作
- 当发生数据读取的时候将副作用函数存进一个buckets中
- 当发生数据写入的时候从buckets中取出副作用函数执行
缺陷有很多,列举如下几点,是接下来的优化方向:
- 副作用函数名称不确定
- 只要有读取操作就会尝试在buckets中添加副作用函数。无关属性的读取也会添加副作用函数
```javascript
const buckets = new Set();
const data = {
text: ‘想要设计一个基本的reactive系统’
};
const objRef = new Proxy(data, {
get(target, key) {
}, set(target, key, value) {buckets.add(effect);
return target[key];
} });target[key] = value;
buckets.forEach((fn) => {
fn();
});
return true;
function effect() {
document.querySelector(‘#app’).innerHTML = objRef.text;
}
<a name="crbgD"></a>
#### 2) 设计一个完善的响应系统
1. 首先设计一个副作用函数的注册函数;它将副作用函数**统一命名**为一个全局变量并**执行**
1. 重新设计buckets,使`object -> key -> effectFn`对应起来;使用`WeakMap -> Map -> Set`
1. 分离装桶和出桶的逻辑,装桶叫track,出桶叫trigger
```javascript
// 设计一个合理的注册副函数机制
let activeEffect;
function effect(fn) {
activeEffect = fn;
fn();
}
// 代理操作,分离出track和trigger逻辑
function reactive(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;
}
});
}
// track就是找到对象target对应的key的桶,将副作用函数装进去
const buckets = new WeakMap();
function track(target, key) {
if (!activeEffect) {
return;
}
let depsMap = buckets.get(target);
if (!depsMap) {
depsMap = new Map();
buckets.set(target, depsMap);
}
let effectFnSet = depsMap.get(key);
if (!effectFnSet) {
effectFnSet = new Set();
depsMap.set(key, effectFnSet);
}
effectFnSet.add(activeEffect);
}
// trigger同理,找到对应的key的副作用函数桶,执行里面的所有副作用函数
function trigger(target, key) {
let depsMap = buckets.get(target);
if (!depsMap) {
return;
}
let effectFnSet = depsMap.get(key);
if (!effectFnSet) {
return;
}
effectFnSet.forEach((fn) => {
fn();
});
}
3) 分支切换与cleanup
考虑这样一种情况,副作用函数中有一个分支选项,当我们切换分支的时候,某些收集了副作用函数的属性(如obj.text)应该不再收集该副作用函数。
function changeDOM() {
document.querySelector('#app').innerText = obj.ok ? obj.text : 'none'
}
当设置obj.ok = false
,触发副作用函数;再修改obj.text不应该触发副作用函数;这个副作用函数应该再obj.text的依赖中剔除;
可以设计在执行副作用函数前,将副作用函数从所有依赖收集中剔除;如修改obj.ok = false
,将changeDOM副作用函数从obj.ok
和obj.text
中删除;执行的时候重新添加到obj.ok
,obj.text
中就没有该副作用函数了。达到目的。
- 首先在注册副作用函数的时候,在副作用函数上添加一个数组
deps
属性用于表示哪些集合(Set)收集了这个副作用函数; - 在执行前从
deps
中全部删除该副作用函数 - 在入桶的track函数中为副作用函数收集依赖的集合
- 由于Set的forEach函数在删除一个元素后再添加这个元素时,forEach依然会访问它,造成无限循环;可以创建一个新的Set遍历解决
```javascript
// 注册副作用函数的时候先删除,再重新收集
let activeEffect;
function effect(fn) {
const effectFn = () => {
} /* 重新收集副作用依赖 / effectFn.deps = []; effectFn(); }/** 调用前清除副作用依赖 */
cleanup(effectFn);
activeEffect = effectFn;
fn();
function cleanup(effectFn) { for (let i = 0; i < effectFn.deps.length; i++) { const deps = effectFn.deps[i]; deps.delete(effectFn); } /* 重置副作用依赖集合 / effectFn.deps.length = 0; }
// track中收集副作用函数的依赖 function track(target, key) { … deps.add(activeEffect); /* 在track时收集依赖 / activeEffect.deps.push(deps); }
// trigger函数中创建新的Set,避免陷入死循环 function trigger(target, key) { … /* Set.forEach 先删除后添加元素会导致无限循环,创建一个新的Set / const newSet = new Set(deps); newSet.forEach((fn) => { fn(); }); }
完整的代码参考:分支切换与cleanup
<a name="BGZZZ"></a>
#### 4) 嵌套的effect与effect栈
effect函数的使用是可以嵌套的。对象的属性应该只收集读取它的那个副作用函数。<br />有如下一个嵌套的effect的例子:
```javascript
const objRef = reactive({
text: 'hello',
ok: true
});
effect(function fn1() {
console.log('在fn1中执行');
effect(function fn2() {
console.log('在fn2中执行');
t2 = objRef.ok;
});
t1 = objRef.text;
});
理想情况下objRef.ok -> [fn2]
, objRef.text -> [fn1]
。但是修改objRef.text却发现只执行了fn2,我们知道这是依赖收集的副作用函数指向发生了变化。当执行fn1时,fn2也被触发;执行完fn2时我们发现正在活跃的activeEffect永远指向了fn2,无法指回fn1导致objRef.text -> [fn2]
。
解觉方案:可以使用保存一个栈effectStack,栈顶始终指向正在执行的副作用函数activeEffect,保证依赖收集正确;当嵌套内的函数执行完成后,栈顶元素弹出;activeEffect重新指向正在执行的外层函数。
// ! 感叹号是新增的代码
let activeEffect;
/**
* ! 添加一个栈,栈顶指向正在执行的副作用函数
* ! 防止嵌套的副作用函数导致依赖收集错误
*/
const effectStack = [];
function effect(fn) {
const effectFn = () => {
/** 调用前清除副作用依赖 */
cleanup(effectFn);
activeEffect = effectFn;
/**
* ! 将副作用函数的引用压入栈顶
*/
effectStack.push(effectFn);
fn();
/**
* ! 内层函数的执行完毕后,栈顶的副作用弹出
* ! 正在活跃的副作用函数activeEffect指向外层的函数
*/
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
/** 重新收集副作用依赖 */
effectFn.deps = [];
effectFn();
}
5) 避免无限递归循环
当我们在同一个副作用函数中同时读取和写入数据的时候。读取触发track收集副作用函数,写入触发trigger执行副作用函数,执行副作用函数的过程中又track了一次导致无限调用。
// 堆栈溢出
effect(function fn1() {
objRef.ok++;
});
解决方案:trigger的时候判断触发的副作用函数是否是当前正在执行的副作用函数activeEffect;如果是则选择不trigger这个副作用函数
function trigger(target, key) {
...
/** Set.forEach 先删除后添加元素会导致无限循环,创建一个新的Set */
const newSet = new Set(deps);
newSet.forEach((fn) => {
/**
* ! 判断要trigger的函数是否是正在执行的副作用函数;避免无限递归循环
*/
if (fn !== activeEffect) {
fn();
}
});
}
6) 调度执行
为了可以让副作用函数选择在什么时候执行,即副作用函数具有可调度性;在副作用函数注册器effect中添加一个可选的options,指定一个调度器。这样我们就可以决定副作用的执行时机
effect(function fn1() {
objRef.ok++;
}, {
// 这个调度器就是将函数放在计时器中执行
scheduler(fn) {
setTimeout(fn)
}
});
为了支持调度器,在副作用函数注册器effect中将options挂载到函数上;在trigger中决定是立即执行函数fn还是把函数放在调度器中
function effect(fn, options) {
...
/**
* ! 将options挂载到函数上
*/
effectFn.options = options;
/** 重新收集副作用依赖 */
effectFn.deps = [];
effectFn();
}
function trigger(target, key) {
...
newSet.forEach((fn) => {
if (fn !== activeEffect) {
/**
* ! 有调度器存在的时候将fn交给调度器scheduler执行
*/
if (fn.options && fn.options.scheduler) {
fn.options.scheduler(fn);
} else {
fn();
}
}
});
}
7) 计算属性computed与lazy
目前而言,我们的副作用函数都是在注册的时候立即执行的,但是我们需要它不立即执行;我们可以选择手动执行等。需要我们effect注册器中添加
options.lazy
。不执行副作用函数而是将它返回给我们。function effect(fn, options) {
...
effectFn.options = options;
effectFn.deps = [];
/**
* ! 如果options为true,不选择执行而是return出去
*/
if (options.lazy) {
effectFn();
}
return effectFn;
}
如果把副作用函数fn当作一个getter,需要获得它的返回值;就需要在我们生成的effectFn中把原来函数的返回值返回回来
function effect(fn, options) {
const effectFn = () => {
...
/**
* ! 将结果保存起来
*/
const res = fn();
...
/**
* ! 将原函数执行的结果返回
*/
return res;
}
接下来我们利用目前实现的响应实现一个computed函数,他会收集传入的getter的依赖,返回一个对象obj,在访问
obj.value
时返回getter的值/**
* ! 实现vuejs中的computed函数,收集依赖在访问value的时候返回计算值
*/
function computed(getter) {
const fn = effect(getter, {
lazy: true
});
const obj = {
get value() {
return fn();
}
}
return obj;
}
我们还知道,vue的computed是可以缓存的,当依赖的响应数据没有发生变化;computed的副作用回调函数是不会执行的。所以我们可以添加
value
缓存值,添加dirty
是否需要更新。
当响应数据更新的时候,可以在调度器中将脏值dirty改回true
/**
* ! 实现vuejs中的computed函数,收集依赖在访问value的时候返回计算值
*/
function computed(getter) {
/**
* ! value缓存值,dirty脏值检测
*/
let value;
let dirty = true;
const fn = effect(getter, {
lazy: true,
/**
* ! 调度器会在数据更新的时候调用,可以用它更新dirty值
*/
scheduler() {
if (!dirty) {
dirty = true;
}
}
});
const obj = {
get value() {
/**
* ! 如果脏值为true,则调用副函数回调更新
*/
if (dirty) {
value = fn();
dirty = false;
}
return value;
}
}
return obj;
}
- 如果计算属性bar也嵌套在注册的副函数中,那么bar依赖的响应数据obj2Ref发生变化的时候;bar的值发生了改变;bar的副函数应该触发。但实际上bar的副函数没有触发
那么怎么在这种情况下更新bar.value
呢。就是当获取value的时候主动track跟踪副函数,在调度器中设置dirty=true
,即计算属性bar的值需要更新的时候主动触发trigger。触发计算属性bar的副作用函数。让响应式数据objRef2和bar的副函数联系起来,形成obj2Ref -> bar -> effectFn
的响应链条
/**
* ! 实现vuejs中的computed函数,收集依赖在访问value的时候返回计算值
*/
function computed(getter) {
/**
* ! value缓存值,dirty脏值检测
*/
let value;
let dirty = true;
let obj;
const fn = effect(getter, {
lazy: true,
/**
* ! 调度器会在数据更新的时候调用,可以用它更新dirty值
*/
scheduler() {
/**
* ! 在依赖的响应值修改的时候手动trigger
*/
if(!dirty) {
dirty = true;
trigger(obj, 'value');
}
}
});
obj = {
get value() {
/**
* ! 如果脏值为true,则调用副函数回调更新
*/
if (dirty) {
value = fn();
dirty = false;
}
/**
* ! 在获取值的时候手动track
*/
track(obj, 'value');
return value;
}
}
return obj;
}
完整的代码参考:调度执行