当我们修改一个响应式数据时,Vue 是如何知道要更新哪些地方的?这一切都始于 effect 函数。理解副作用和 effect,是掌握 Vue3 响应式系统的钥匙。
前言:一个看似简单的问题
我们先看看这段代码,你认为 Vue 应该如何工作?
javascript
const state = reactive({ count: 0 });
// 这里访问了 state.count,Vue 需要记录这个依赖
function render() {
return `<div>${state.count}</div>`;
}
// 这里修改了 state.count,Vue 需要触发更新
state.count++;
问题来了:Vue 如何知道 render() 依赖于 state.count ?又如何知道当 count 变化时需要重新执行什么?
这就是副作用追踪系统要解决的核心问题。
什么是副作用
副作用的定义
在计算机科学中,副作用是指在函数执行过程中,除了返回值之外,对外部环境产生的任何影响,即产生了副作用,如下面代码所示:
示例1:修改 DOM
javascript
function updateDOM() {
document.body.innerHTML = 'Hello'; // 副作用:修改 DOM
}
当 updateDOM 方法执行时,它会设置 body 的文本内容,但除了 updateDOM 方法外,还有很多方法都可以读取或设置 body 的文本内容。也就是说,updateDOM 方法的执行,会直接或间接影响其他函数的执行,此时 updateDOM 方法产生了副作用。
实际开发中,副作用很容易产生,我们可以看看以下几个示例:
示例2:修改外部变量
javascript
let total = 0;
function addToTotal(value) {
total += value; // 副作用:修改了外部变量 total
}
示例3:控制台输出
javascript
function showMessage(msg) {
console.log(msg); // 副作用:控制台输出
}
示例4:网络请求
javascript
function fetchData() {
fetch('/api/data'); // 副作用:发起网络请求
}
有一个与副作用函数对应的概念:纯函数。纯函数是指,当前函数操作不会产生任何副作用,不会改变外部任何状态。
副作用的特征
- 修改函数外部的变量或状态
- 执行输入/输出操作(控制台、网络、文件)
- 修改文档对象模型
- 抛出异常(改变了程序的控制流)
- 产生随机数(结果不可预测)
Vue 的上下文中,最常见的副作用
- 渲染 DOM
- 更新响应式数据
- 执行 watch 回调
- 计算 computed 属性
- 调用生命周期钩子
为什么副作用对 Vue 如此重要?
我们以前言中的代码为例,如果没有副作用追踪,Vue 怎么知道需要重新渲染呢?不追踪副作用,我们无法知道 render() 函数依赖于 count 。
所以,Vue 需要追踪哪些函数(副作用)访问了哪些响应式数据,这样当数据变化时,就能自动重新执行这些函数。
Vue为什么需要追踪副作用
声明式渲染的需求
副作用追踪让声明式成为可能:我们只需要关心要如何显示数据;至于数据是如何更新的,我们其实并不关心:
javascript
<template>
<div>{{ count }}</div>
<div>{{ doubleCount }}</div>
</template>
<script setup>
const count = ref(0);
const doubleCount = computed(() => count.value * 2);
</script>
自动追踪依赖
javascript
// 假设我们有这样的响应式对象
const obj = reactive({
foo: 1,
bar: 2
});
// 定义一些副作用
function effect1() {
console.log('effect1:', obj.foo);
}
function effect2() {
console.log('effect2:', obj.bar);
}
function effect3() {
console.log('effect3:', obj.foo + obj.bar);
}
// 我们需要建立这样的依赖关系图:
const dependencyGraph = {
'obj.foo': ['effect1', 'effect3'],
'obj.bar': ['effect2', 'effect3']
};
// 当 obj.foo 变化时,自动执行 effect1 和 effect3
// 当 obj.bar 变化时,自动执行 effect2 和 effect3
精细化的更新控制
如果没有依赖追踪,只能暴力更新:
javascript
function withoutTracking() {
// 每次数据变化都重新渲染整个应用
// 性能极差!
fullRender();
}
有了依赖追踪,可以实现精准更新:
javascript
function withTracking() {
// 只重新执行依赖于变化数据的副作用
// 性能优秀!
const deps = getDeps(changeProperty);
deps.forEach(effect => effect());
}
effect 函数的定位与作用
effect 是什么?
effect 是 Vue3 响应式系统的核心函数,它的作用是自动追踪函数内部对响应式数据的访问,并在这些数据变化时重新执行函数。其概念模型如下:
javascript
function effect(fn) {
// 1. 记录当前正在执行的 effect
// 2. 执行 fn,期间所有对响应式数据的访问都会被记录
// 3. 建立依赖关系:响应式数据 → 这个 effect
// 4. 当数据变化时,重新执行 fn
}
effect 在 Vue3 中的定位
effect 是整个 Vue3 中,响应式系统的基石:
在 Vue3 底层基础中,effect 可以:
- 创建响应式对象
reactive - 创建响应式引用
ref - 实现副作用追踪系统
effect。
在 Vue3 中层应用中,基于 effect 可以:
- 实现计算属性
computed - 实现监听器
watch - 实现渲染函数
render
在 Vue3 上层暴露中,可以
- 组合式 API:CompositionAPI
- 选项式 API:OptionsAPI
手写实现简易版 effect
effect 系统的工作流程
- 调用 effect(fn)
- 设置 activeEffect = fn
- 执行 fn,期间访问响应式数据
- 在 get 拦截器中调用 track,将 activeEffect 添加到依赖集合
- fn 执行完毕,重置 activeEffect = null
- 当数据变化时,在 set 拦截器中调用 trigger
- 从依赖集合中取出所有 effect,依次执行
基础版本:activeEffect 模式
javascript
// 1. 依赖存储:使用 WeakMap 存储对象 → 属性 → effect 的映射
const targetMap = new WeakMap();
// 2. 当前激活的 effect
let activeEffect = null;
// 3. effect 函数实现
function effect(fn) {
const effectFn = () => {
try {
activeEffect = effectFn; // 设置当前激活的 effect
console.log(` [effect] 执行 ${fn.name || 'anonymous'}`);
fn(); // 执行原始函数,期间会触发 track
} finally {
activeEffect = null; // 重置激活的 effect
}
};
effectFn(); // 立即执行一次,完成依赖收集
return effectFn;
}
思考题:如果没有 effect 系统,响应式如何实现?
方案1:手动依赖管理(Vue 1.x 的做法)
javascript
function withoutEffectV1() {
const watchers = [];
class Watcher {
constructor(updateFn) {
this.updateFn = updateFn;
watchers.push(this);
}
update() {
this.updateFn();
}
}
// 数据变化时,手动通知所有 watcher
function notifyAll() {
watchers.forEach(w => w.update());
}
// 使用
const w1 = new Watcher(() => console.log('更新视图1'));
const w2 = new Watcher(() => console.log('更新视图2'));
console.log('数据变化,手动通知:');
notifyAll();
}
缺点:无法精确知道哪个 watcher 依赖哪个数据
方案2:发布订阅模式(全局事件总线)
javascript
function withoutEffectV2() {
const events = {};
function on(event, callback) {
if (!events[event]) events[event] = [];
events[event].push(callback);
}
function emit(event, data) {
events[event]?.forEach(cb => cb(data));
}
// 使用
on('countChange', (newVal) => {
console.log('count 变化了:', newVal);
});
console.log('数据变化,手动触发:');
emit('countChange', 1);
}
缺点:需要手动维护事件名,容易出错
方案3:计算属性风格(类似 Vue 2 的 computed)
javascript
function withoutEffectV4() {
let data = { count: 0 };
let dirty = true;
let cachedValue = null;
function computed(getter) {
return {
get value() {
if (dirty) {
cachedValue = getter();
dirty = false;
}
return cachedValue;
},
setDirty() {
dirty = true;
}
};
}
const doubleCount = computed(() => data.count * 2);
console.log('第一次访问:', doubleCount.value); // 计算
console.log('第二次访问:', doubleCount.value); // 缓存
data.count = 2;
doubleCount.setDirty(); // 手动标记为脏
console.log('数据变化后:', doubleCount.value); // 重新计算
}
缺点:需要手动标记脏数据
上述方案的共同问题
- 无法自动建立依赖关系:需要开发者手动声明依赖,容易遗漏或多余
- 更新粒度粗:无法精确知道哪个数据依赖哪个 effect,往往需要全量更新
- 内存泄漏风险:手动管理订阅时容易忘记取消,导致内存泄漏
- 扩展性差:添加新功能需要修改现有代码,违反开闭原则
- 测试困难:依赖关系分散在代码各处,难以单元测试
effect 系统的优势
- 自动依赖收集:执行 effect 时自动追踪访问的属性,建立精确依赖关系
- 精确更新:数据变化时只重新执行真正依赖它的 effect,避免无效计算
- 生命周期管理:effect 可以自动清理,避免内存泄漏
- 可组合性:effect 可以嵌套,支持复杂的依赖关系
- 测试友好:副作用集中管理,便于测试和调试
结语
本文简单介绍了 Vue3 中的副作用函数 effect,对于文章中错误的地方或者有任何问题,欢迎在评论区留言讨论!