上一篇我们讲了reactive的核心实现,这次来讲整个响应式系统的核心球员:副作用函数(Effect)
1、 什么是 Effect?
在 Vue3 中,Effect副作用函数
是实现响应式非常关键的一环。
它代表一段“副作用”代码,当响应式数据变化时,这段代码会被重新执行。
有的同学要问了,什么是副作用?
副作用就是“对外部环境产生了影响”的函数,例如
let a = 0;
fn1(){ a++ }
;与之相反的就是“纯函数”,也就是“对外部不会产生影响的”,例如
fn2 (a,b) { return a + b}
const obj = reactive({ count: 0 });
// 定义一个 Effect
effect(() => {
console.log('count 的值变为:', obj.count); // 立即执行,控制台输出:count 的值变为:0
});
obj.count = 1; // 控制台输出:count 的值变为:1
这里的 effect
会立即执行一次传入的函数,并在 obj.count
变化时再次执行。
2、实现一个基础 Effect
2.1 基本实现与问题分析
核心机制
Effect 的本质是建立副作用函数与响应式数据间的联系。当响应式数据变化时,能自动重新执行副作用函数。
let activeEffect = null; // 当前激活的副作用函数
function effect(fn) {
activeEffect = fn; // 标记为激活状态
fn(); // 执行函数触发 getter 收集依赖
activeEffect = null; // 重置防止无效收集
}
3. 依赖收集的完善
如上我们完成了effect
函数的基本,传给他函数后他可以执行,但当前他仅仅可以再传入的时候执行一次,我们的响应式可是只要数据变化就该重新执行才对,要着呢么办呢?
依赖收集!收集是哪些地方引用着响应式的数据,这些数据变化后才知道来重新来执行effect
3.1 依赖收集的核心逻辑
依赖收集的核心是:在读取响应式数据时,将当前活跃的 Effect
函数(即 activeEffect
)与响应式数据的属性关联起来。
为了实现这一点,我们需要:
- 在读取响应式数据时,触发
getter
,收集当前活跃的Effect
。 - 在修改响应式数据时,触发
setter
,通知所有关联的Effect
重新执行。
3.2 实现依赖收集
1. 依赖存储结构
我们需要一个全局的 WeakMap
来存储响应式对象与属性之间的依赖关系:
WeakMap
的键是响应式对象。WeakMap
的值是一个Map
,其中键是对象的属性,值是一个Set
,存储所有依赖该属性的effect
函数。
const targetMap = new WeakMap(); // 全局依赖存储
2. 依赖收集函数
在 getter
中,我们需要将当前活跃的 Effect
函数与属性关联起来:
function track(target, key) {
if (!activeEffect) return; // 没有活跃的 Effect,直接返回
// 获取 target 对应的依赖 Map
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
// 获取 key 对应的依赖 Set
let deps = depsMap.get(key);
if (!deps) {
deps = new Set();
depsMap.set(key, deps);
}
// 将当前活跃的 effect 添加到依赖集合中
deps.add(activeEffect);
// 将依赖集合添加到 effect 的 deps 中(用于 cleanup)
activeEffect.deps.push(deps);
}
3. 触发更新函数
在 setter
中,我们需要通知所有依赖该属性的 Effect
函数重新执行:
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return; // 没有依赖,直接返回
const deps = depsMap.get(key);
if (!deps) return; // 没有依赖该属性的 effect,直接返回
// 遍历依赖集合,执行所有 effect
const effectsToRun = new Set(deps); // 避免无限循环
effectsToRun.forEach(effect => effect());
}
3.3 与响应式系统结合
联系我们上节已经实现的 Vue 3 响应式系统(一): Reactive ,将依赖收集和触发更新与 getter
和 setter
结合起来:
function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
track(target, key); // 读取时收集依赖
return Reflect.get(target, key, receiver);
},
set(target, key, value, receiver) {
const result = Reflect.set(target, key, value, receiver);
trigger(target, key); // 修改时触发更新
return result;
},
});
}
3.4 测试依赖收集
以下是一个完整的测试用例,验证依赖收集和触发更新的逻辑:
const obj = reactive({ count: 0 });
effect(() => {
console.log(`count is: ${obj.count}`);
});
obj.count++; // 触发 effect 重新执行
obj.count++; // 再次触发 effect 重新执行
输出结果
count is: 0
count is: 1
count is: 2
3.5 依赖收集的流程解析
-
初始化阶段:
- 调用
Effect
,执行传入的函数。 - 读取
obj.count
,触发getter
,调用track
。 - 将当前
effect
与obj.count
关联起来。
- 调用
-
更新阶段:
- 修改
obj.count
,触发setter
,调用trigger
。 - 找到所有依赖
obj.count
的Effect
函数,重新执行。
- 修改
-
清理阶段:
- 每次执行
Effect
前,调用cleanup
清理旧依赖。 - 确保依赖集合不会无限增长。
- 每次执行
4、 嵌套 Effect 解决方案
上面的实现的effect有个问题,那就是当双层effect时,会有如下问题:
示例:
let activeEffect = null;
function effect(fn) {
activeEffect = fn;
fn();
activeEffect = null;
}
// 测试嵌套 effect
effect(() => {
console.log('外层 effect 执行');
effect(() => {
console.log('内层 effect 执行');
});
console.log('外层 effect 继续执行');
});
输出结果:
外层 effect 执行
内层 effect 执行
可以看到,外层 effect
在执行完内层 effect
后,activeEffect
被置为 null
,导致外层 effect
的后续逻辑无法正确收集依赖。
解决办法:使用栈结构管理嵌套 Effect
为了解决嵌套 effect
的问题,我们可以使用一个栈(effectStack
)来保存当前执行的 effect
函数。每次执行 effect
时,将其推入栈中;执行完毕后,将其弹出栈,并恢复上一个 effect
。
const effectStack = []; // 用于管理嵌套 effect 的栈
function effect(fn) {
const effectFn = () => {
activeEffect = effectFn; // 标记当前活跃的 effect
effectStack.push(effectFn); // 推入栈
try {
fn(); // 执行副作用函数
} finally {
effectStack.pop(); // 执行完毕后弹出栈
activeEffect = effectStack[effectStack.length - 1]; // 恢复上一个 effect
}
};
effectFn.deps = []; // 存储该 effect 依赖的集合
effectFn(); // 立即执行一次
}
测试嵌套 effect
effect(() => {
console.log('外层 effect 执行');
effect(() => {
console.log('内层 effect 执行');
});
console.log('外层 effect 继续执行');
});
输出结果
外层 effect 执行
内层 effect 执行
外层 effect 继续执行
执行流程解析
- 外层
effect
开始执行,effectStack
变为[外层 effect]
。 - 内层
effect
开始执行,effectStack
变为[外层 effect, 内层 effect]
。 - 内层
effect
执行完毕,effectStack
弹出内层effect
,恢复为[外层 effect]
。 - 外层
effect
继续执行后续逻辑。
5、 调度系统
嵌套解决完了,但此时发现一个新的问题:
const obj = reactive({ count: 0 });
effect(() => {
console.log(`count is: ${obj.count}`);
});
// 连续多次修改响应式数据
obj.count++;
obj.count++;
obj.count++;
输出结果:
count is: 0
count is: 1
count is: 2
count is: 3
我们发现:
- 每次修改
obj.count
时,都会立即触发effect
函数的执行。 - 在这个例子中,
effect
函数被连续执行了 3 次,但实际上我们只关心最终的结果(count is: 3
),中间的两次执行是多余的。 - 如果
effect
函数的逻辑非常复杂(例如涉及大量计算或 DOM 操作),这种频繁的执行会导致严重的性能问题。
问题的根源
问题的根源在于:当前的响应式系统在每次数据变化时都会立即执行副作用函数,而没有对执行时机进行优化。我们需要一种机制,能够将多次数据变化合并为一次副作用函数的执行,从而避免不必要的重复计算。
解决方案:引入调度系统
为了解决这个问题,我们可以引入一个调度系统(Scheduler) 。调度系统的核心思想是:将副作用函数的执行推迟到合适的时机,而不是在每次数据变化时立即执行。通过这种方式,我们可以:
- 合并多次更新:将短时间内多次数据变化合并为一次副作用函数的执行。
- 优化性能:减少不必要的重复计算,提升响应式系统的性能。
实现调度系统
我们可以通过以下步骤实现一个简单的调度系统:
- 任务队列:使用一个队列来存储需要执行的副作用函数。
- 延迟执行:利用 JavaScript 的微任务机制(如
Promise.resolve().then()
)将副作用函数的执行推迟到当前任务结束后。 - 批量执行:在合适的时机一次性执行队列中的所有任务。
以下是调度系统的具体实现:
let isFlushing = false; // 是否正在刷新队列
const queue = new Set(); // 任务队列
function flushQueue() {
if (isFlushing) return; // 如果正在刷新队列,直接返回
isFlushing = true; // 标记为正在刷新队列
// 创建一个新的 Set 避免无限循环
const effectsToRun = new Set(queue);
queue.clear(); // 清空队列
// 执行所有任务
effectsToRun.forEach(effect => effect());
isFlushing = false; // 标记为刷新完成
}
function scheduler(effect) {
queue.add(effect); // 将副作用函数加入队列
Promise.resolve().then(flushQueue); // 使用微任务延迟执行
}
将调度系统与 effect 结合
我们可以将调度系统与 effect
函数结合,使得副作用函数的执行可以被调度器控制。具体来说,可以在 trigger
函数中调用调度器,而不是立即执行副作用函数。
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return; // 没有依赖,直接返回
const deps = depsMap.get(key);
if (!deps) return; // 没有依赖该属性的 effect,直接返回
// 创建一个新的 Set 避免无限循环
const effectsToRun = new Set();
deps.forEach(effect => {
// 避免重复触发当前正在执行的 effect
if (effect !== activeEffect) {
effectsToRun.add(effect);
}
});
// effectsToRun.forEach(effect => scheduler(effect));
}
测试调度系统
我们再次运行之前的例子:
const obj = reactive({ count: 0 });
effect(() => {
console.log(`count is: ${obj.count}`);
});
// 连续多次修改响应式数据
obj.count++;
obj.count++;
obj.count++;
console.log('测试完成');
输出结果:
count is: 0
测试完成
count is: 3
执行流程解析:
effect
首次执行,读取obj.count
,触发track
,收集依赖。- 连续修改
obj.count
三次,触发trigger
,但由于调度器的存在,副作用函数不会立即执行。 - 所有同步代码执行完毕后,调度器开始工作,将队列中的副作用函数一次性执行。
- 最终输出
count is: 3
,避免了中间的重复执行。
通过引入调度系统,我们解决了:
- 性能优化:避免了短时间内多次数据变化导致的重复执行。
- 执行时机控制:可以将副作用函数的执行推迟到合适的时机,例如 DOM 更新后或异步任务完成后(后续的computed等,就要依赖这个)
6、 依赖清理机制
又一个问题,在条件分支中,依赖可能会动态变化。例如:
const obj = reactive({ show: true, text: 'hello' });
effect(() => {
if (obj.show) {
console.log(obj.text); // 初次执行收集 text 依赖
}
});
obj.show = false; // 条件分支切换
obj.text = 'modified'; // 此时不应触发 effect,但基础实现仍会触发
在effect中,虽然依赖了obj.show
和obj.text
,
但如果obj.show为false
的情况下,obj.text
再怎么变化也不会影响effect中的执行的,但我们的例子中,text属性
的再次变化,依然引起了effect的再次执行,这与我们期望不同且造成了浪费。
解决方案:清理旧依赖
每次执行 effect
前,清理其旧依赖,避免冗余更新。
function cleanup(effectFn) {
for (const dep of effectFn.deps) { // 遍历所有依赖集合
dep.delete(effectFn); // 从集合中移除当前 effect
}
effectFn.deps.length = 0; // 重置依赖集合
}
更新后的 effect
实现
function effect(fn) {
const effectFn = () => {
cleanup(effectFn); // 清理旧依赖
activeEffect = effectFn;
effectStack.push(effectFn);
try {
fn(); // 在执行过程中,访问数据时又会触发track函数,又会将effect重新
} finally {
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
};
effectFn.deps = []; // 初始化依赖集合
effectFn();
}
测试条件分支
const obj = reactive({ show: true, text: 'hello' });
effect(() => {
console.log(obj.show ? obj.text : 'nothing');
});
obj.show = false; // 条件分支切换
obj.text = 'modified'; // 不应触发 effect
输出结果
hello
nothing
可以看到,当 obj.show
为 false
时,修改 obj.text
不会触发 effect
,因为旧依赖已被清理。
7、 避免无限循环
在实现依赖收集和触发更新的过程中,可能会遇到无限循环的问题。例如,当一个 effect
函数在执行时修改了某个响应式属性,而这个属性又依赖了当前 effect
,就会导致 effect
不断重新执行,形成无限循环。
7.1 问题复现
以下是一个典型的无限循环场景:
const obj = reactive({ count: 0 });
effect(() => {
obj.count++; // 在 effect 中修改依赖的属性
console.log(`count is: ${obj.count}`);
});
执行流程
effect
首次执行,读取obj.count
,触发track
,收集依赖。- 修改
obj.count
,触发trigger
,通知所有依赖的effect
重新执行。 effect
重新执行,再次修改obj.count
,触发trigger
。- 重复上述过程,导致无限循环。
7.2 解决方案
为了避免无限循环,我们需要在 trigger
中避免重复触发当前正在执行的 effect
。具体来说,可以通过以下方式实现:
- 在
trigger
中,使用一个新的Set
来存储需要执行的effect
函数。 - 如果当前
effect
已经在执行中,则跳过。
7.3 实现避免无限循环
修改 trigger
函数
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return; // 没有依赖,直接返回
const deps = depsMap.get(key);
if (!deps) return; // 没有依赖该属性的 effect,直接返回
// 创建一个新的 Set 避免无限循环
const effectsToRun = new Set();
deps.forEach(effect => {
// 避免重复触发当前正在执行的 effect
if (effect !== activeEffect) {
effectsToRun.add(effect);
}
});
// 执行所有需要运行的 effect
effectsToRun.forEach(effect => effect());
}
修改 effect
函数
在 effect
函数中,确保 activeEffect
在执行期间不会被重复触发:
function effect(fn) {
const effectFn = () => {
cleanup(effectFn); // 清理旧依赖
activeEffect = effectFn; // 标记当前活跃的 effect
effectStack.push(effectFn); // 推入栈
try {
fn(); // 执行副作用函数
} finally {
effectStack.pop(); // 执行完毕后弹出栈
activeEffect = effectStack[effectStack.length - 1]; // 恢复上一个 effect
}
};
effectFn.deps = []; // 初始化依赖集合
effectFn(); // 立即执行一次
}
7.4 测试避免无限循环
以下是一个测试用例,验证避免无限循环的逻辑:
const obj = reactive({ count: 0 });
effect(() => {
obj.count++; // 在 effect 中修改依赖的属性
console.log(`count is: ${obj.count}`);
});
console.log('测试完成');
输出结果
count is: 1
count is: 2
测试完成
执行流程解析
effect
首次执行,读取obj.count
,触发track
,收集依赖。- 修改
obj.count
,触发trigger
,但由于当前effect
正在执行,跳过重复触发。 effect
执行完毕,输出count is: 1
。- 由于
obj.count
被修改,effect
再次执行,但不会进入无限循环。 - 最终输出
count is: 2
和测试完成
。
7.5 总结
通过避免重复触发当前正在执行的 effect
,我们成功解决了无限循环的问题。具体实现包括:
- 在
trigger
中,使用新的Set
存储需要执行的effect
。 - 跳过当前正在执行的
effect
,避免重复触发。
这种机制确保了响应式系统在复杂场景下的稳定性,例如:
- 在
effect
中修改依赖的属性。 - 多个
effect
相互依赖的场景。
8、总结
至此,我们实现了一个基础的响应式系统:
reactive
通过 Proxy 拦截对象操作。effect
注册副作用函数,并在依赖变化时重新执行。- 依赖收集(
track
)和触发更新(trigger
)通过全局的targetMap
管理。 - 调度器机制避免无限循环并控制执行时机。