响应系统的作用与实现
除了前面提到的编译器和渲染器,响应系统也是 Vue.js 的核心组成部分。响应系统的主要作用是让 Vue.js 应用程序在数据发生变化时,自动更新视图。想要提响应系统,就要从响应式数据和副作用函数这两个概念开始。
响应式数据和副作用函数
副作用函数是指那些会产生副作用的函数,即函数执行会直接或间接影响其他函数的执行或修改了全局变量。
const obj = { text: 'hello' };
function effect() {
document.body.innerText = obj.text;
}
var a = 1;
function effect() {
a = 2;
}
在上述代码两个例子中,effect
函数就是一个副作用函数,例子一修改了 body
的内容导致其他函数读取 body
内容时会受到影响,例子二的函数会直接修改全局变量 a
的值。
说完副作用函数,来看看什么是响应式数据。响应式数据是指那些能够自动追踪依赖并响应数据变化的值。例如上述代码组的例子一,当 obj.text
的值发生变化时,期望 effect
函数会自动重新执行,从而更新 body
的内容。
obj.text = 'hello world';
实现这个目标后对象 obj
就是响应式数据。
响应式数据的基本实现
JavaScript 中,读取对象会触发其 get
方法,修改对象会触发其 set
方法。因此,我们可以通过重写对象的 get
和 set
方法来实现响应式数据。读取对象属性时,将副作用函数与属性进行关联;修改对象属性时,执行所关联的全部副作用函数。在 ES5 之前,只能通过 Object.defineProperty
来实现,ES6 之后可以使用 Proxy
代理。
const bunketSet = new Set();
const data = { text: 'hello' };
const obj = new Proxy(data, {
// 读取操作
get(target, key) {
bunketSet.add(effect);
return target[key];
},
// 修改操作
set(target, key, value) {
target[key] = value;
set.forEach(fn => fn());
return true;
}
});
const effect = () => {
document.body.innerText = obj.text;
}
effect()
setTimeout(() => {
obj.text = 'hello world';
}, 1000);
效果实现了,不过有一定的缺陷,如目前写死函数名为 effect
,如果使用者不传 effect
,传其他名称的函数,甚至匿名函数,功能就不生效了。因此需要去掉这些硬编码机制。
设计一个完善的响应系统
基础实现的响应系统有如下几个缺陷:
- 响应式数据和副作用函数硬编码,用户只能传
effect
函数,传其他的函数则无法生效。 - 副作用函数与被操作的目标字段没有建立联系,如上方示例代码,如果为对象
obj
新增一个otherText
属性,effect
也会触发
想要解决第一个问题,可以设计一个 effect
函数,该函数用于注册副作用函数,用户可以传入任意名称的函数,如 effectFn
或匿名函数,effect
函数内部会调用用户传入的函数,并将用户传入的函数作为副作用函数,保存在全局变量中。最后在 get
方法中直接把该变量收集保存到 Set
中即可,这样就不需要关注该函数是匿名函数还是什么名称。
let activeEffect;
function effect(fn) {
// 当调用 effect 注册副作用函数时,将副作用函数 fn 赋值给 activeEffect
activeEffect = fn;
fn(); // 执行副作用函数
}
effect(() => {
console.log('effect run'); // 执行了两次
document.body.innerText = obj.text;
})
setTimeout(() => {
obj.text = 'hello world';
}, 1000);
setTimeout(() => {
obj.otherText = 'hello vue3';
}, 1000);
const bunketSet = new Set();
const data = { text: 'hello' };
const obj = new Proxy(data, {
// 读取操作
get(target, key) {
if (activeEffect) bunketSet.add(activeEffect); // 把全局保存的副作用函数收集保存
return target[key];
},
// 修改操作
set(target, key, value) {
target[key] = value;
set.forEach(fn => fn());
return true;
}
});
接下来看第二个问题,上方 use.js
代码 obj.text
只修改了一次,但是 effect
函数执行了两次。理论上副作用函数没有读取到 otherText
,不应该执行。因此需要修改副作用函数收集的代码,将副作用函数与被操作的目标字段进行关联。
根据前面的代码可以得知,副作用函数涉及到以下三点:
target
: 被操作(读取)的对象obj
key
: 被操作(读取)的属性text
effect
: 副作用函数effectFn
那么可以为他们三者建立联系,它们之间是一个树形结构。
effect (function effectFn1() {
obj.key;
})
/**
|-target
|-key
|-effectFn1
*/
effect (function effectFn1() {
obj.key;
})
effect (function effectFn2() {
obj.key;
})
/**
|-target
|-key
|-effectFn1
|-effectFn2
*/
effect (function effectFn1() {
obj.key1;
obj.key2;
})
/**
|-target
|-key1
|-effectFn1
|-key2
|-effectFn1
*/
effect (function effectFn1() {
obj1.key1;
})
effect (function effectFn2() {
obj2.key2;
})
/**
|-target1
|-key1
|-effectFn1
|-target2
|-key2
|-effectFn2
*/
可以得出,根据这个树型结构建立联系后,只需要触发对应的副作用函数即可。想要实现这个功能,要用 WeakMap
替代 Set
;然后收集对象的全部属性字段 Map
,每个 Map
对应一个属性字段;最后用 Set
收集属性字段对应的副作用函数。
const bunketWeakMap = new WeakMap();
const data = { text: 'hello' };
const obj = new Proxy(data, {
// 读取操作
get(target, key) {
track(target, key);
return target[key];
},
// 修改操作
set(target, key, value) {
target[key] = value;
trigger(target, key); // 获取并执行对应的副作用函数
}
});
const track = (target, key) => {
if (!activeEffect) return;
let depsMap = bunketWeakMap.get(target); // 获取WeakMap映射对应的target对象
if (!depsMap) bunketWeakMap.set(target, (depsMap = new Map())); // 如果没有,则为该target对象新建一个Map映射用于保存所有属性字段key
let deps = depsMap.get(key); // 获取Map映射对应的key属性字段
if (!deps) depsMap.set(key, (deps = new Set())); // 如果没有,则为该key属性字段新建一个Set映射用于保存所有副作用函数
deps.add(activeEffect); // 把全局保存的副作用函数收集保存
}
const trigger = (target, key) => {
const depsMap = bunketWeakMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
effects && effects.forEach(effect => effect());
}
目前的完整代码
<body>
<div></div>
<script>
const divEle = document.querySelector('div');
const data = {name: '张三'};
let activeEffect
const bunketWeakMap = new WeakMap(); // 存储依赖函数
// track函数收集依赖
const track = (target, key) => {
if (!activeEffect) return
let depsMap = bunketWeakMap.get(target);
if(!depsMap) bunketWeakMap.set(target, depsMap = new Map());
let deps = depsMap.get(key);
if(!deps) depsMap.set(key, deps = new Set());
deps.add(activeEffect)
}
// trigger函数触发依赖
const trigger = (target, key) => {
const depsMap = bunketWeakMap.get(target);
if(!depsMap) return
const effects = depsMap.get(key);
effects && effects.forEach(fn => fn());
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, value) {
target[key] = value;
trigger(target, key);
}
})
function effect(fn) {
activeEffect = fn;
fn();
}
effect (() => {
divEle.innerHTML = obj.name;
})
setTimeout(() => {
obj.name = '李四';
}, 1000);
</script>
</body>
分支切换与 cleanup
看下方一段代码示例:
const data = { ok: true, text: 'hello' };
const obj = new Proxy(data, { /* ... */ });
effect(() => {
document.body.innerText = obj.ok ? obj.text : 'is false';
})
obj.ok = false;
obj.text = 'hello world';
这段代码中,obj.ok
的值从 true
变为 false
后,再次修改 obj.text
的值,会发现副作用函数依旧触发了。理想情况是 obj.ok
值为 false
时,没有用到 obj.text
,因此不应该触发副作用函数。但目前代码会有遗留的副作用函数导致不必要的更新。
解决思路是每次执行副作用函数时,先把它从所有关联的依赖集合中删除,然后在它执行完毕后重新建立联系,新的联系不会包含遗留的副作用函数。
想要实现这个功能,需要改写 effect
函数,为其添加 deps
数组属性,用于存储所有包含当前副作用函数的 key
依赖。在 track
函数中,将当前副作用函数添加到副作用函数的 deps
中,新建一个 cleanup
函数,将当前副作用函数从 deps
中删除。
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 调用 cleanup 函数完成清除工作
cleanup(effectFn);
activeEffect = fn;
fn(); // 执行副作用函数
}
effectFn.deps = []; // 收集当前副作用函数有关联的依赖集合
effectFn()
}
const track = (target, key) => {
if (!activeEffect) return;
let depsMap = bunketWeakMap.get(target); // 获取WeakMap映射对应的target对象
if (!depsMap) bunketWeakMap.set(target, (depsMap = new Map())); // 如果没有,则为该target对象新建一个Map映射用于保存所有属性字段key
let deps = depsMap.get(key); // 获取Map映射对应的key属性字段
if (!deps) depsMap.set(key, (deps = new Set())); // 如果没有,则为该key属性字段新建一个Set映射用于保存所有副作用函数
deps.add(activeEffect); // 把全局保存的副作用函数收集保存
activeEffect.deps.push(deps); // 把当前副作用函数添加到依赖集合中
}
const cleanup = (effectFn) => {
// 遍历 effectFn.deps 数组
effectFn.deps.forEach(deps => {
// 从依赖 Map 集合 deps 中把当前副作用函数 effectFn 删除
deps.delete(effectFn);
})
effectFn.deps.length = 0; // 清空 deps 数组
}
此时运行会发现代码程序陷入了死循环,排查后发现问题出在 trigger.js
函数中 effects && effects.forEach(fn => fn())
这个命令。前面 effect.js
函数中已经改写了 effectFn
函数,添加了 cleanup
函数用于清除遗留的副作用函数,但是执行了副作用函数后又会导致其重新被收集到集合中。相当于下方代码示例:
const set = new Set([1])
set.forEach(item => {
set.delete(1)
set.add(1)
console.log('遍历中')
})
一边删除一边添加,自然就会造成死循环。解决方法很简单,再新建一个 Set
即可。
const trigger = (target, key) => {
const depsMap = bunketWeakMap.get(target);
if (!depsMap) return;
const effects = depsMap.get(key);
const newEffects = new Set(effects);
newEffects.forEach(effectFn => effectFn());
effects && effects.forEach(effect => effect());
}
目前完整代码
<body>
<div></div>
<script>
const divEle = document.querySelector('div');
const data = {name: '张三'};
let activeEffect
const bunketWeakMap = new WeakMap(); // 存储依赖函数
// track函数收集依赖
const track = (target, key) => {
if (!activeEffect) return
let depsMap = bunketWeakMap.get(target);
if(!depsMap) bunketWeakMap.set(target, depsMap = new Map());
let deps = depsMap.get(key);
if(!deps) depsMap.set(key, deps = new Set());
deps.add(activeEffect)
activeEffect.deps.push(deps)
}
// trigger函数触发依赖
const trigger = (target, key) => {
const depsMap = bunketWeakMap.get(target);
if(!depsMap) return
const effects = depsMap.get(key);
const newEffects = new Set(effects);
newEffects.forEach(effectFn => effectFn());
}
const obj = new Proxy(data, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, value) {
target[key] = value;
trigger(target, key);
}
})
function effect(fn) {
const effectFn = () => {
cleanup(effectFn)
activeEffect = effectFn;
fn();
}
effectFn.deps = [];
effectFn();
}
function cleanup(effectFn) {
effectFn.deps.forEach(dep => {
dep.delete(effectFn);
})
effectFn.deps.length = 0;
}
effect (() => {
divEle.innerHTML = obj.name;
})
setTimeout(() => {
obj.name = '李四';
}, 1000);
</script>
</body>