响应式系统的作用与实现
0.写在前面:
- 写了mini-vue之后的疑惑更多了,比如为什么要这样设计?这样做的好处是啥?为什么我想不出来?(我真菜
- 于是决定去看霍春阳大佬的Vue.js设计与实现。一些参考资料:官方的参考资料,前置知识非常有必要看一下。mini-vue的实现
1.响应式:
Vue2的响应式实现是借助 Object.defineProperty
通过重写getter和setter方法来进行的数据劫持,Vue3通过Proxy代理拦截对象中任意属性的变化,通过Reflect反射对源对象的属性进行操作,然后再在get里收集依赖在set里派发更新。
2.副作用函数与响应式数据:
上面中的这句话应该都知道,那我们先来了解一下什么是副作用函数,以及它有什么用?它和响应式数据又有什么关系?
- 副作用函数:会产生副作用的函数,它的执行会直接或间接影响其他函数的执行。
举例:当一个函数修改了全局变量。
let a = 1;
function effect(){
a = 2;
}
- 响应式数据:当值发生变化后,副作用函数会自动重新执行。
那么怎么才能让数据变成响应式数据呢?
- 我们需要对数据的读取和设置操作进行拦截,把副作用函数存储到一个"桶"中;
- 即当读取属性时将副作用函数effect添加到桶里,然后返回属性值;
- 当设置属性值时先更新原始数据,再将副作用函数从桶里取出并重新执行。
3.响应式系统的实现:
上代码前请看两个地方:
- WeakMap 由 target – > Map构成
- 这里用WeakMap的原因:它的键是弱引用对象,防止内存泄漏。官网解释的很详细
- Map 由 key --> Set 构成
上图中Set数据结构所存储的副作用函数集合我们称为 key的依赖集合。
// 存储副作用函数的桶
const bucket = new WeakMap();
// 原始数据
const data = { text: "hello world" };
const obj = new Proxy(data, {
get(target, key) {
if (!activeEffect) {
return ;
}
// 根据target从桶中取得depsMap,它也是一个Map类型: key -->effects
let depsMap = bucket.get(target);
// 如果depsMap不存在,那么新建一个 Map 与 target关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 根据key从depsMap中取得 deps(对应着:key --> effects),它是一个Set类型
let deps = depsMap.get(key);
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 添加到桶里
deps.add(activeEffect);
return target[key];
},
set(target, key, newValue) {
target[key] = newValue;
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
ettects && ettects.forEach((fn) => fn());
},
});
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// effect函数用于注册副作用函数
function effect(fn) {
// 将副作用函数fn 赋值给 activeEffect
activeEffect = fn;
// 执行副作用函数
fn();
}
4.抽离代码:
上面的get 和 set里面的逻辑太长了,我们把它抽离到track和trigger函数中(学习官方的做法)。
function track(target, key) {
if (!activeEffect) {
return ;
}
// 根据target从桶中取得depsMap,它也是一个Map类型: key -->effects
let depsMap = bucket.get(target);
// 如果depsMap不存在,那么新建一个 Map 与 target关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 根据key从depsMap中取得 deps(对应着:key --> effects),它是一个Set类型
let deps = depsMap.get(key);
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 添加到桶里
deps.add(activeEffect);
}
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
effects && effects.forEach((fn) => fn());
}
此时的get和set:
const obj = new Proxy(data, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, newValue) {
target[key] = newValue;
trigger(target, key);
},
});
这时一个稍微完善的响应式系统(破产版)就实现了!下面就对它继续完善。
5.分支切换与cleanup:
分支切换:比如下面的代码,随着obj.ok的值不同而执行不同的分支。
const data = { ok: true, text: 'hello world' }
const obj = new Proxy(data, {/* */ })
effect(function effectFn() {
document.body.innerHTML = obj.ok ? obj.text : 'not'
})
-
它可能会产生遗留的副作用函数。obj.ok== true,读取obj.text的值,所以当副作用函数执行时会触发两个属性的读取操作。
-
理想状态下,副作用函数只会收集一个依赖放入依赖集合中。
- 并且遗留的副作用函数会导致不必要的更新。对应上面中的例子就是,当我们修改obj.ok的值为false后,我们再修改obj.text的值仍然会导致副作用函数重新执行。
解决思路:
-
每次当副作用函数执行时,先把它从所有与之关联的依赖集合中删除(如下图)。
-
当副作用函数执行完毕后,重新建立联系,新的联系中不包含遗留的副作用函数(对应上面的理想状态)。
-
那么怎么做呢?要将一个副作用函数从所有和它关联的依赖集合中删除,我们需要明确知道哪些依赖集合中包含它。
重新设计副作用函数:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
fn();
};
// 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
在track函数中完成依赖集合的收集:新增一行代码即可
function track(target, key) {
if (!activeEffect) {
return;
}
// 根据target从桶中取得depsMap,它也是一个Map类型: key -->effects
let depsMap = bucket.get(target);
// 如果depsMap不存在,那么新建一个 Map 与 target关联
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 根据key从depsMap中取得 deps(对应着:key --> effects),它是一个Set类型
let deps = depsMap.get(key);
// 如果 deps 不存在,同样新建一个 Set 并与 key 关联
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 添加到桶里
deps.add(activeEffect);
// deps就是与当前副作用函数存在联系的依赖集合,将其添加到数组中
activeEffect.deps.push(deps); // 新增
}
完成了对依赖集合的收集,接下来就是将副作用函数从依赖集合中删除:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
function effect(fn) {
const effectFn = () => {
// 调用cleanup 函数完成清除工作
cleanup(effectFn); // 新增
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
fn();
};
// 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
cleanup函数的实现:
function cleanup(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
// deps 是依赖集合
const deps = effectFn.deps[i];
// 将effectFn从依赖集合中移除
deps.delete(effectFn);
}
// 最后将数组进行重置
effectFn.deps.length = 0;
}
修改trigger函数避免无限执行:
出现原因:因为在trigger函数内部,遍历effects集合时,它是Set数据结构的,里面存储着副作用函数。当执行时调用cleanup进行清除,但是副作用函数的执行会导致其重新被收集到集合中,而此时遍历仍在进行。
- 说了那么多,简单来说就是当我们修改时,会触发set,此时不断修改不断触发,一边删除一边往里添加,就会造成无限执行。
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
// 新增两行代码
const effectsToRun = new Set(effects);
effectsToRun.forEach(effectFn => effectFn());
// effects && effects.forEach(effectFn => effectFn()) // 删除
}
具体原理可以查看mdn
6.嵌套的effect与effect栈:
Vue.js中什么时候会发生effect嵌套?
举个例子:当组件发生嵌套时,例如Foo组件渲染了Bar组件。
const Bar = {
render() {/* */ }
}
// Foo组件渲染了Bar组件
const Foo = {
render() {
return <Bar /> // jsx 语法
}
}
// 等价于
effect(() => {
Foo.render()
// 嵌套
effect(() => {
Bar.render()
})
})
这就是effect要设计成可嵌套的原因。但是我们上面设计的很明显不符合这种情况,那么要怎么做呢?
- 我们可以借助副作用函数栈
effectStack
来解决,当副作用函数执行时,将当前副作用函数压入栈中,执行完毕后弹出,并始终让activeEffect
指向栈顶的副作用函数。 - 这样就能做到一个响应式数据只会收集直接读取其值的副作用函数,不会出现互相影响的情况。
- 因为之前我们是通过全局变量
activeEffect
来存储通过effect函数注册的副作用函数,这意味着着同一时刻activeEffect
所存储的副作用函数只能有一个。当发生嵌套时,会出现覆盖的情况。
如下代码:
// 用一个全局变量存储被注册的副作用函数
let activeEffect;
// 新增栈
const effectStack = [];
function effect(fn) {
const effectFn = () => {
// 调用cleanup 函数完成清除工作
cleanup(effectFn); // 新增
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn); // 新增
fn();
// 执行完毕后,出栈并还原activeEffect之前的值
effectStack.pop(); // 新增
activeEffect = effectStack[effectStack.length - 1]; // 新增
};
// 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
这样一来便实现了响应式数据只会收集直接读取其值的副作用函数为依赖,避免发生混乱。
7.避免无线递归循环:
举个例子:下面这段代码中,既读取了obj.foo的值,又修改了它的值,就造成了无限递归调用自身。
effect(() => obj.foo++);
// 等价于
effect(() => {
obj.foo = obj.foo + 1;
})
此时的执行流程:
- 读取值,触发track操作,将当前副作用函数收集到"桶"中。
- 修改值,触发trigger操作,执行"桶"中的副作用函数。
- 那么就会造成,副作用函数正在执行中,就要开始下一次执行,于是就造成了上面的结果,产生栈溢出。
解决方法:
- 读取和设置值在同一个副作用函数内进行,并且要收集和触发的副作用函数都是
activeEffect
,我们可以在trigger动作发生时增加守卫条件:如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行。
如下代码:
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
const effectsToRun = new Set(effects);
effects && effects.forEach(effectFn => { // 新增
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
})
effectsToRun.forEach(effectFn => effectFn());
// effects && effects.forEach(effectFn => effectFn()) // 删除
}
8.调度执行:
- 可调度性:当trigger动作触发副作用函数重新执行时,能决定副作用函数执行的时机、次数以及方式。
我们可以为effect函数设计一个选项参数 options,允许用户指定调度器,来实现控制函数的执行顺序以及次数。
function effect(fn, options = {}) {
const effectFn = () => {
// 调用cleanup 函数完成清除工作
cleanup(effectFn);
// 当effectFn执行时,将其设置为当前激活的副作用函数
activeEffect = effectFn;
// 在调用副作用函数之前将当前副作用函数压入栈中
effectStack.push(effectFn);
fn();
// 执行完毕后,出栈并还原activeEffect之前的值
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
};
// 将 options 挂载到 effectFn 上
effectFn.options = options; // 新增
// 用来存储所有与该副作用函数相关联的依赖集合
effectFn.deps = [];
// 执行副作用函数
effectFn();
}
修改trigger函数,当触发副作用函数重新执行时,如果用户传了调度器,则直接调用。
function trigger(target, key) {
// 根据 target 从桶中取得 depsMap,它是Map类型: key --> effects
const depsMap = bucket.get(target);
if (!depsMap) return;
// 根据 key 取出所有副作用函数
const effects = depsMap.get(key);
const effectsToRun = new Set(effects);
effects && effects.forEach(effectFn => {
// 如果 trigger 触发执行的副作用函数与当前正在执行的副作用函数相同,则不触发执行
if (effectFn !== activeEffect) {
effectsToRun.add(effectFn);
}
})
effectsToRun.forEach(effectFn => { // 修改这部分
// 如果一个副作用函数存在调度器,则调用该调度器,并将副作用函数作为参数传递
if (effectFn.options.scheduler) {
effectFn.options.scheduler(effectFn);
} else {
effectFn();
}
});
}
9.计算属性computed与lazy:
先上结论:计算属性实际上是一个懒执行的副作用函数,我们通过lazy选项使得副作用函数可以懒执行。
懒执行:
- 有些场景下,我们希望副作用函数不立即执行,而是需要的时候才执行(例如计算属性)。
- 我们可以像调度执行那样来完成,通过在options中添加lazy属性来达到目的,当
options.lazy
为true时,不立即执行副作用函数。 - 并且我们想要在手动执行副作用函数时,能拿到其返回值。
function effect(fn, options = {}) {
const effectFn = () => {
cleanup(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
const res = fn(); // 新增
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
return res; // 新增
};
effectFn.options = options;
effectFn.deps = [];
// 只有非lazy的时候才执行副作用函数
if (!options.lazy) { // 新增
// 执行副作用函数
effectFn();
}
return effectFn; // 新增
}
接下来就可以实现计算属性了:
function computed(getter) {
const effectFn = effect(getter, {
lazy: true
})
const obj = {
get value() {
return effectFn()
}
}
return obj;
}
测试:
const data = { foo: 1, bar: 2 }
const obj = new Proxy(data, /* 复制之前的即可 */);
const sumRes = computed(() => obj.foo + obj.bar);
console.log(sumRes.value); // 3
现在计算属性能正常工作了,但是还做不到对值进行缓存。(即obj.foo+obj.bar的值没有发生变化,但还是会进行多次计算)
修改后的computed:
- 添加调度器的原因:当obj.foo或obj.bar的值发生变化,需要重新计算;
- 并且计算属性发生变化,就要重新进行渲染。(借助track + trigger就可以解决这个问题)
function computed(getter) {
// value用来缓存上一次计算的值
let value;
// dirty标志用来标识是否需要重新计算值,为true则意味"脏",需要重新计算
let dirty = true;
const effectFn = effect(getter, {
lazy: true,
// 添加调度器,在调度器中将dirty重置为true
scheduler() {
if (!dirty) {
dirty = true;
// 当计算属性依赖的响应式数据变化时,手动调用trigger函数触发响应式
trigger(obj, 'value');
}
}
})
const obj = {
get value() {
if (dirty) {
value = effectFn();
dirty = false;
}
// 当读取value时,手动调用track函数进行追踪
track(obj, 'value');
return value;
}
}
return obj;
}
至此,一个稍微完善的computed就实现了。
10.watch的实现原理:
watch本质就是观测一个响应式数据,当数据发生变化时通知并执行相应的回调函数。它的本质其实就是利用了effect
以及options.sheduler
选项。
最简单的watch实现:
function watch(source, cb) {
effect(
() => source.foo,
{
scheduler(){
// 当数据变化时,调用回到函数cb
cb()
}
}
)
}
但是上面其实硬编码了对source.foo
的读取,我们可以封装一个通用的读取操作:
function watch(source, cb) {
effect(
() => traverse(source),
{
scheduler() {
// 当数据变化时,调用回到函数cb
cb()
}
}
)
}
function traverse(value, seen = new Set()) {
// 如果要读取的数据是原始值,或者被读取过了,则什么都不做
if (typeof value !== 'object' || value === null || seen.has(value)) {
return;
}
// 将数据添加到seen中,代表遍历读取过了,避免循环引用引起死循环
seen.add(value);
// 暂时不考虑数组等其他结构
// 假设value就是一个对象,使用for...in 读取对象的每一个值,并递归调用处理
for (const k in value) {
traverse(value[k], seen);
}
return value;
}
watcher函数除了可以观测响应式数据之外,还可以接收一个getter函数:
function watch(source, cb) {
let getter;
// 如果用户传递进来的是函数,则直接使用
if (typeof source === 'function') {
getter = source;
// 如果不是函数类型,则保留之前的做法,即调用traverse函数递归读取
} else {
getter = () => traverse(source);
}
effect(
() => getter(),
{
scheduler() {
cb();
}
}
)
}
其实上面的代码还缺少一个非常重要的能力,即在回调函数中拿不到旧值与新值,我们可以充分利用effect函数的lazy选项来解决这个问题:
function watch(source, cb) {
let getter;
// 如果用户传递进来的是函数,则直接使用
if (typeof source === 'function') {
getter = source;
// 如果不是函数类型,则保留之前的做法,即调用traverse函数递归读取
} else {
getter = () => traverse(source);
}
// 定义新值与旧值
let oldValue, newValue;
// 使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中方便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
// 在scheduler中重新执行副作用函数,得到的是新值
scheduler() {
newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;
}
}
)
// 手动调用副作用函数,拿到的值就是旧值
oldValue = effectFn();
}
11.立即执行的watch:
- 在上面中,我们了解到watch的本质其实是对effect的二次封装,但是watch的特性我们还没有实现完,即:立即执行的回调函数。
在Vue.js中我们有通过选项参数 immediate
来指定回调是否需要立即执行。
- 仔细思考就发现,回调函数的立即执行与后续执行本质上没有差别,所以我们可以把scheduler调度函数封装为一个通用函数,在初始化和变更时执行它。
function watch(source, cb, options = {}) {
let getter;
// 如果用户传递进来的是函数,则直接使用
if (typeof source === 'function') {
getter = source;
// 如果不是函数类型,则保留之前的做法,即调用traverse函数递归读取
} else {
getter = () => traverse(source);
}
// 定义新值与旧值
let oldValue, newValue;
// 提取scheduler调度函数为一个独立的 job函数
const job = () => {
newValue = effectFn();
cb(newValue, oldValue);
oldValue = newValue;
}
// 使用effect注册副作用函数时,开启lazy选项,并把返回值存储到effectFn中方便后续手动调用
const effectFn = effect(
() => getter(),
{
lazy: true,
// 使用job函数作为调度器函数
scheduler: job
}
)
if (options.immediate) {
// 当immediate为true时立即执行job,从而触发执行回调
job();
} else {
oldValue = effectFn();
}
}
如此一来便实现了watch的立即执行功能。因为此时的回调函数第一次执行时没有oldValue
,故此时oldValue
的值为undefined。
12.总结:
我们先来回顾一下,
- 响应式数据的基本实现就是:依赖于对 get和set操作的拦截,从而在副作用函数与响应式数据之间建立联系。
- 响应系统的根本实现原理:
- 当进行get操作时,将当前执行的副作用函数存储到"桶"中;
- 当进行set操作时,再将副作用函数从"桶"中取出并执行。("桶"的数据结构使用WeakMap配合Map构建了新的"桶"结构)
我们还解决了分支切换导致的冗余副作用问题,以及嵌套的effect函数(常发生在父子组件中)、如何避免副作用函数无限递归调用自身,相应系统的调度执行,computed和watch的实现原理等。后面将继续对响应式数据进行完善。