Vue3 – 实现 effect
实现响应式的核心 API – effect
使用
const obj = { name: "xm", age: 20 };
const state = reactive(obj);
effect(() => {
app.innerHTML = state.name + state.age;
console.log("yes");
});
特性
-
所有的渲染都是基于 effect 来实现的
-
默认叫响应式 effect,数据变化后会重新执行此函数
-
属性会收集 effect(数据的依赖收集)
数据会记录自己在哪个 effect 中使用了,稍后数据变化可以找到对应的 effect
ReactiveEffect
- effect 方法会创建一个响应式 effect,并且让 effect 执行
- 其中使用到了 ReactiveEffect 类来处理我们的入参函数
这么说有点抽象,我们通过代码来理解
function effect(fn) {
// 创建一个响应式 effect,并且让 effect 执行
const _effect = new ReactiveEffect(fn)
_effect.run()
}
let activeEffect = undefined
class ReactiveEffect {
// 默认会将 fn 挂载到类的实例上
constructor(private fn) { }
run() {
try {
activeEffect = this
return this.fn()
} finally {
activeEffect = null
}
}
}
可以看到,在 ReactiveEffect 之外定义了一个 activeEffect 全局变量,这个变量有什么作用呢?
- 执行 fn 之前会把当前的 effect 放到 activeEffect 上
- 这样 fn 中取属性的时候就可以访问到当前的 effect
比如在开篇的使用中取值 state.name
时,我们可以在 getter 中访问到 activeEffect(也就是当前属性对应的 effect)
除此之外,最后会将 activeEffect 置空,大家思考一下,如果不这么操作会产生什么后果呢?我们来看一个例子
effect(() => {
const name = state.name;
});
const myName = state.name
我们执行完 effect 后,activeEffect 会对应当前的 effect,当我们再进行属性读取时,发现 activeEffect 有值
此时问题就浮现了,我们会将 effect 外的属性对应到 effect 中,其实这是不对的
effect 嵌套问题
effect(() => {
app.innerHTML = state.age;
effect(() => {
app.innerHTML = state.name;
});
app.innerHTML = state.address;
});
我们分析一下上面这个嵌套 effect
- 第一个 effect,state 对应 e1
- 第二个 effect,state 对应 e2;此时要注意,第二个执行完毕后 activeEffect 是被置空了的
- 最后进行的
state.address
是找不到对应 effect 的
问题显而易见,那么 vue 是如何解决这个问题的呢?
vue2 和早期的 vue3 是使用栈来解决的,大致思想就是先入栈再执行,当前 effect 执行完毕后出栈
我们看到嵌套其实应该想到树结构,现在的 vue3 就是使用了树结构进行父子关系的维护(增加了一个 parent 标记)
class ReactiveEffect {
// 默认会将 fn 挂载到类的实例上
constructor(private fn) { }
parent = undefined
deps = [] // 依赖了哪些列表[effect]
run() {
try {
this.parent = activeEffect
activeEffect = this
return this.fn()
} finally {
activeEffect = this.parent
this.parent = undefined // 标记用完进行回收
}
}
}
我们来分析一下
- 第一个 effect,activeEffect => e1
- 第二个 effect,activeEffect => e2,e2.parent = e1
- 当第二个 effect 执行完毕后将 activeEffect 还原,activeEffect = e1
依赖收集 – track
所谓的依赖收集,其实就是记录属性和 effect 两者之间的关系:
- 多对多 – 一个属性对应多个 effect,一个 effect 对应多个属性
- 一个 effect 中相同属性执行多次 getter,只需要记录一次对应的 effect
- 结构:
{ name: 'xm' }: -> 'name': -> [effect, effect, ...]
这样,我们可以得到一个存储的结构:weakMap: => Map: => Set
根据结构梳理一下整个依赖收集的流程
- 判断用户是否是在 effect 中使用的这个数据
- 判断映射表(Map)是否存在当前属性
- 判断集合(Set)中有没有这个 effect
了解基本结构和流程后就可以进行代码编写了
const targetMap = new WeakMap()
export function track(target, key) {
if (activeEffect) {
// 1.说明用户是在 effect 中使用的这个数据
let depsMap = targetMap.get(target)
if (!depsMap) {
targetMap.set(target, (depsMap = new Map()))
}
// 2.在映射表中查找一下有没有这个属性
let dep = depsMap.get(key)
// 如果没有 Set 集合创建集合
if (!dep) {
depsMap.set(key, (dep = new Set()))
}
// 3.看一下 Set 中有没有这个 effect
let shouldTrack = !dep.has(activeEffect)
if (shouldTrack) {
dep.add(activeEffect)
activeEffect.deps.push(dep)
}
}
}
track 的使用
我们使用 reactive 创建响应式对象时,在 Proxy 中进行我们的依赖收集,如果不了解 reactive 原理,建议看上一篇文章[juejin.cn/post/735209…]
const mutableHandlers = {
get(target, key, receiver) {
if (key === ReactiveFlags.IS_REACTIVE) {
return true
}
const res = Reflect.get(target, key, receiver)
console.log(activeEffect, key);
// 做依赖收集,记录属性和当前 effect 的关系
track(target, key)
return res
}
}
const obj = { name: "xm", age: 20 };
effect(() => {
app.innerHTML = state.name + state.age;
});
我们最终输出的结构如下图所示:
依赖更新 – trigger
当我们属性更改时,要触发视图更新,也就是让当前属性对应的 effect 重新执行
const mutableHandlers = {
set(target, key, value, receiver) { // 更新
// 找到这个属性对应的 effect 让他执行
let oldValue = target[key]
const r = Reflect.set(target, key, value, receiver)
if (oldValue !== target[key]) {
trigger(target, key, value, oldValue)
}
return r
}
}
function trigger(target, key, value, oldValue) {
// 通过对象找到对应的属性 让这个属性对应的 effect 重新执行
const depsMap = targetMap.get(target)
if (!depsMap) {
return
}
let deps = depsMap.get(key)
if (deps) {
deps = [...deps]
deps.forEach((effect) => {
// 正在执行的 effect,不要多次执行
if (effect !== activeEffect) effect.run()
})
}
}
可能有同学注意到,我将获取到的 deps 进行了浅拷贝deps = [...deps]
,这是为了后面执行依赖清理的时候使用,大家可以先忽略
到这一步,我们已经实现了视图更新,但是我们还要考虑两种场景:
effect(() => {
state.age = Math.random();
app.innerHTML = state.name + state.age;
});
effect(() => {
app.innerHTML = state.age;
effect(() => {
state.age = Math.random();
app.innerHTML = state.name;
});
});
- 当我们在 effect 中进行属性更新时,会再次触发 effect,
- effect 中嵌套 effect,内层修改外层用到的属性,会触发外层,外层触发又会触发内层
这就造成了死循环,我们需要优化一下这种场景,其实就是做一层判断,正在执行的 effect 或者当前 activeEffect 的 parent 等于当前的 effect,不执行
dep && dep.forEach((effect) => {
let run = true
let parent = activeEffect && activeEffect.parent
while (parent && run) {
run = (effect !== parent)
parent = parent.parent ? parent.parent : null
}
if (effect !== activeEffect && run) effect.run()
})
依赖清理 cleanupEffect
为什么要进行依赖清理,我们先来看一种场景
const obj = { name: "xm", age: 20, flag: true };
const state = reactive(obj);
effect(() => {
console.log("触发");
app.innerHTML = state.flag ? state.name : state.age;
});
setTimeout(() => {
state.flag = false;
setTimeout(() => {
console.log("修改name不应该触发effect");
state.name = "gx";
state.age = 30;
}, 1000);
}, 1000);
以上例子中:
-
使用 flag 来决定展示 name 还是 age
-
当我们修改 flag 后再进行 name 的修改,原则上不应该触发 effect
因为此时 name 属性并没有使用到,所以更改 name 属性不应该触发 effect
针对这种情况,我们就需要进行依赖的清理,也就是在我们执行 effect 之前进行 clean
function cleanupEffect(effect) {
// 找到 deps 中的 set,清理掉里面的 effect
let deps = effect.deps
for(let i = 0; i < deps.length; i++) {
deps[i].delete(effect)
}
}
class ReactiveEffect {
constructor(private fn) { }
parent = undefined
deps = [] // 我依赖了哪些列表[effect]
run() {
try {
this.parent = activeEffect
activeEffect = this
cleanupEffect(this) // 依赖清理
return this.fn()
} finally {
activeEffect = this.parent
this.parent = undefined
}
}
}
看到这里,是不是感觉有点懵,清空之后我再修改 state 还会更新视图吗?我们逐步进行分析一下:
flag = true
=> track 收集 flag 和 name,此时activeEffect 中的deps: [Set(effect -> flag), Set(effect -> name)]
- 修改
flag = false
=> flag 触发 trigger 执行 effect.run() 清空依赖:循环 deps 删除每个 Set 中对应的 effect,此时 name 和 flag 对应的 Set 中就不存在对应的 effect 了- effect.run() 后执行 effet 碰到 state.flag 后触发 track 收集 flag 和 age
- 修改 name 触发 trigger,此时 Set 中没有对应的 effect,所以不执行
- 修改 age 触发 trigger,Set 中有对应的 effect,执行 effect.run()
实践是检验真理的唯一标准,还是不太明白的同学可以动手打个断点测试一下(想偷懒的图贴在下面了),输出过程如下:
冷知识
大家还记不记得,依赖更新中获取到 deps 执行前,我进行了一层浅拷贝,思考一下,为什么要做这个操作呢?
let deps = depsMap.get(key)
if (deps) {
deps = [...deps]
deps.forEach((effect) => {
if (effect !== activeEffect) effect.run()
})
}
我们来看一段代码:
let a = 1;
let s = new Set([a]);
s.forEach((item) => {
s.delete(item);
s.add(item);
console.log("kill");
});
可以看到,我们先进行了删除再添加到操作,最终导致的结果就是无限循环,因为我们添加后 s 是一直在增加的所以会一直循环
我们再回过头来看,
- 先执行 effect.run(),执行 effect 之前会进行依赖清理
cleanupEffect => deps[i].delete(effect)
- 执行 effect,触发 state 的 getter 进入到依赖收集 track
- 在 track 中会进行 effect 的收集
activeEffect.deps.push(dep)
这个过程中会先删除再添加,就出现无限循环的问题了,这也是我们为什么要进行浅拷贝的原因
总结
涉及响应式核心,所以本期内容比较多也比较杂,大家只要记住几个核心点就好
- 创建响应式 effect 的核心类 ReactiveEffect
- 获取属性触发 getter 进行依赖收集,记录属性和 effect 对应关系,形成多对多关系
- 属性修改触发 setter 进行视图更新,重新执行对应的 effect
- 依赖清理,视图未使用的属性不执行 effect
除此之外,我们还进行了一些优化:
比如 effect 嵌套问题、deps 浅拷贝无限循环问题、effect 嵌套引用触发外层 effect导致的无限循环问题
本节内容到这就结束了,如有遗漏或错误欢迎大家指正批评(我可以不接受hh🤓)
原文链接:https://juejin.cn/post/7353804180802945035 作者:海下钢琴师