Vue 的响应式系统是其核心魅力之一,它能够在你改变数据时,自动更新依赖这些数据的视图。本文将从零开始,带你深入剖析 Vue 响应式系统的核心机制,逐步构建一个精简版的响应式系统。
一、什么是副作用函数(Effect Function)?
在编程世界中,副作用函数 (effect function)是指那些会间接或直接改变外部状态的函数。在 Vue 的响应式系统中,它扮演着至关重要的角色,特指那些"依赖于响应式数据,并在数据变化时会自动重新执行"的函数。
举个例子,下面的 effect
函数就是一个典型的副作用函数。它通过读取响应式对象 obj.title
的值,从而改变了外部的 document.body.innerHTML
。
JavaScript
scss
const obj = {
title: "hello world",
};
// 这是一个副作用函数,因为它依赖于 obj.title 并改变了外部的 DOM
function effect() {
document.body.innerHTML = obj.title;
}
// 另一个函数,它的执行结果间接依赖于 effect 的执行
function showResult() {
console.log(document.body.innerHTML);
}
effect(); // 执行 effect,此时 document.body.innerHTML 被设置为 "hello world"
showResult(); // 输出 "hello world"
二、初步实现响应式系统雏形
为了实现"当数据变化时,依赖于该数据的副作用函数能自动重新执行"的神奇效果,Vue 的响应式系统遵循两大核心步骤:依赖收集 与依赖触发。
- 依赖收集(Track) :当副作用函数执行时,它会访问响应式数据。此时,系统就像一个"侦探",默默追踪这个访问行为,并将该副作用函数"记住"下来,作为该数据的依赖。
- 依赖触发(Trigger) :当响应式数据发生变化时,系统会"通知"所有之前收集到的依赖(也就是那些副作用函数),让它们重新执行,从而更新视图或执行其他操作。
下面,我们使用 Proxy
来实现一个简化的响应式系统,模拟这个过程。
javascript
const obj = {
title: "hello world",
};
// 存储所有副作用函数的桶(依赖集合)
const bucket = new Set();
function effect() {
document.body.innerHTML = obj.title;
}
// 使用 Proxy 创建响应式对象
const proxyObj = new Proxy(obj, {
// get 拦截器:进行依赖收集
get(target, key) {
// 将 effect 函数添加到依赖桶中
bucket.add(effect);
return target[key];
},
// set 拦截器:进行依赖触发
set(target, key, value) {
// 设置新值
target[key] = value;
// 遍历依赖桶,执行所有副作用函数
bucket.forEach((fn) => fn());
},
});
// 在 1 秒后修改数据,这会触发依赖更新
setTimeout(() => {
proxyObj.title = "hello vue";
// 此时,document.body.innerHTML 将自动更新为 "hello vue"
}, 1000);
三、硬编码与不精准触发的优化
在上面的初步实现中,我们遇到了两个明显的问题:
- 副作用函数硬编码 :
bucket.add(effect)
这种写法将副作用函数effect
的名称写死了,这无法灵活处理多个副作用函数。 - 不精准的依赖触发 :
set
拦截器会无差别地执行bucket
中的所有副作用函数,即使修改的属性与它们无关,这会造成不必要的性能开销。
优化后的数据结构
为了解决这些问题,我们需要对存储依赖的数据结构进行升级。我们将用一个多层嵌套的数据结构来存储依赖关系,就像一个精心组织的档案库:
WeakMap
(bucket
) :最顶层的结构,它的键是响应式对象 (target
)。使用WeakMap
是一个聪明的选择,因为它的键是弱引用,当对象没有其他引用时,垃圾回收器会自动清理它,有效防止内存泄漏。Map
(depsMap
) :中层结构,它的键是属性名 (key
),值是一个Set
。Set
(deps
) :最底层结构,它存储了所有依赖于该属性的副作用函数 。使用Set
可以确保每个副作用函数只被存储一次,避免重复。

通过这种结构,我们实现了精准的依赖触发 。当你只修改 proxyObj.age
时,trigger
函数会因为 depsMap.get('age')
返回 undefined
而直接返回,effect
函数将不会被触发。只有当你修改了 proxyObj.title
时,才会精准地执行与它关联的副作用函数,这解决了之前不精准触发的问题,实现了更高效、健壮的响应式系统。
ini
const bucket = new WeakMap();
const obj = {
title: "hello world",
};
let activeEffect = null;
function effect(fn) {
activeEffect = fn;
fn();
}
const proxyObj = new Proxy(obj, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, value) {
target[key] = value;
trigger(target, key);
},
});
// 依赖收集
function track(target, key) {
// 如果没有正在执行的副作用函数,则直接返回
if (!activeEffect) return;
// 获取 depsMap
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.set(target, (depsMap = new Map()));
}
// 获取 deps 集合
let deps = depsMap.get(key);
if (!deps) {
depsMap.set(key, (deps = new Set()));
}
// 将当前 activeEffect 添加到 deps 集合中
deps.add(activeEffect);
}
// 触发依赖
function trigger(target, key) {
// 获取 depsMap
let depsMap = bucket.get(target);
if (!depsMap) return;
// 获取 deps 集合
let deps = depsMap.get(key);
// 遍历并执行所有副作用函数
deps && deps.forEach((fn) => fn());
}
effect(() => {
document.body.innerHTML = proxyObj.title;
console.log("trigger effect");
});
setTimeout(() => {
// 修改一个不相关的属性,不会触发 effect
proxyObj.age = 18;
}, 1000);
四、解决"分支"导致的依赖遗留问题
想象一下这样的场景:
- 第一次执行 :
proxyObj.check
为true
,effect
函数会读取proxyObj.check
和proxyObj.title
。此时,proxyObj.check
和proxyObj.title
都收集了该effect
函数作为依赖。 - 修改数据 :当
proxyObj.check
被修改为false
时,trigger
函数会执行effect
。 - 第二次执行 :
effect
函数再次执行,由于proxyObj.check
为false
,它现在只访问proxyObj.check
,而不再访问proxyObj.title
。然而,proxyObj.title
的依赖集合中仍然残留着这个effect
函数。
ini
effect(() => {
document.body.innerHTML = proxyObj.check ? proxyObj.title : "hahaha";
});
这会导致一个"幽灵依赖":当 title
改变时,这个本不应再执行的函数却被错误地触发了。
解决方案:先清理,再收集
为了解决这个问题,我们引入一个核心思想:在每次执行副作用函数之前,先将它从所有旧的依赖集合中移除,然后再重新收集新的依赖。
为此,我们引入了两个关键机制:
effectFn.deps
数组 :在effect
函数内部,我们为每一个副作用函数实例effectFn
创建一个deps
数组,用来存储它所关联的所有依赖集合(Set
)。这样,我们就能反向追踪该函数都存在于哪些依赖集合中。clean
函数 :在副作用函数重新执行前,clean
函数会遍历effectFn.deps
数组,将该副作用函数从所有它关联的依赖集合中移除,并清空deps
数组。
scss
function effect(fn) {
function effectFn() {
clean(effectFn); // 对所有依赖集合中抹除该副作用函数
activeEffect = effectFn; // 依赖收集需要通过 activeEffect 拿到副作用函数
fn(); // 执行函数体
}
effectFn.deps = []; // 初始化 deps
effectFn();
}
function clean(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i];
deps.delete(effectFn);
}
effectFn.deps.length = 0; // delete 不会改变 length,需要手动处理
}
// 依赖收集
function track(target, key) {
if (!activeEffect) return;
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.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); // 将属性值的依赖集合添加到 activeEffect 中,用于反向追踪
}
// 触发依赖
function trigger(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) return;
let deps = depsMap.get(key);
// 新建一个 Set 是为了避免在遍历时因副作用函数执行而导致 Set 改变
const newSet = new Set(deps);
newSet && newSet.forEach((fn) => fn());
}
五、处理"嵌套"的 Effect 与 Effect 栈问题
在之前的实现中,我们使用一个全局变量 activeEffect
来存储当前正在执行的副作用函数。当存在 Effect 嵌套 (例如,一个组件内部渲染另一个子组件)时,这会导致一个严重的问题:内部的 Effect 可能会覆盖 activeEffect
,导致外部的 Effect 无法正确收集到依赖。
scss
// 假设这是外部组件的渲染函数
effect(() => {
// 假设这是内部组件的渲染函数
effect(() => {
document.body.innerHTML = proxyObj.title; // 内部 effect 读取 title
});
// 此时,activeEffect 已经被内部 effect 覆盖
// 如果这里有读取操作,比如 proxyObj.someOtherProp,它将错误地被收集到内部 effect 中
console.log(proxyObj.check); // 外部 effect 读取 check
});
解决方案:引入 Effect 栈
为了解决这个问题,我们需要一个副作用函数栈 ( effectStack
) 。
- 执行时:将当前副作用函数压入栈中。
- 执行后:将其从栈中弹出。
activeEffect
:始终指向栈顶的副作用函数。
通过这种方式,我们可以保证每一个响应式数据只会收集直接读取它的副作用函数,避免了相互干扰,让依赖关系变得清晰而精准。
ini
const bucket = new WeakMap();
const obj = {
title: "hello world",
check: true,
};
let activeEffect = null;
const effectStack = [];
function effect(fn) {
function effectFn() {
clean(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
effectFn.deps = [];
effectFn();
}
六、解决"读写"自身属性导致的栈溢出
如果在一个副作用函数内部,我们同时读取 并写入同一个响应式属性,就会陷入一个无限递归的死循环:
- 读取属性 :
get
拦截器触发track
,将当前副作用函数收集到"桶"中。 - 写入属性 :
set
拦截器触发trigger
,从"桶"中取出副作用函数并执行。
问题在于,这个副作用函数正在执行中 ,但又被 trigger
再次调用,这会导致它无限递归地调用自己,最终引发栈溢出。
scss
// 触发依赖
function trigger(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) return;
let deps = depsMap.get(key);
const newSet = new Set(deps);
newSet &&
newSet.forEach((fn) => {
// 避免无限递归调用(读写同一个属性)
if (fn !== activeEffect) {
fn();
}
});
}
effect(() => {
proxyObj.title += "2222"; // 读和写发生在同一个副作用函数中
});
为了解决这个问题,在 trigger
函数中,我们增加一个判断:当要执行的副作用函数与当前正在执行的副作用函数是同一个 时,就跳过本次执行。
七、可调度性(Scheduler)
可调度性 是响应式系统非常重要的特性。它赋予我们决定副作用函数执行时机、次数以及方式的能力。
通过引入调度器 (scheduler
),我们可以将副作用函数的执行权交给用户。例如,我们可以设置在数据变化时,不是立即执行副作用函数,而是将它放入一个任务队列中,等待下一个"tick"再执行,从而实现批量更新,提高性能。
ini
function effect(fn, options = {}) {
function effectFn() {
clean(effectFn);
activeEffect = effectFn;
effectStack.push(effectFn);
fn();
effectStack.pop();
activeEffect = effectStack[effectStack.length - 1];
}
effectFn.deps = [];
effectFn.options = options; // 存储用户传入的 options
effectFn();
}
// 触发依赖
function trigger(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) return;
let deps = depsMap.get(key);
const newSet = new Set(deps);
newSet &&
newSet.forEach((fn) => {
if (fn !== activeEffect) {
// 如果有调度器,则调用调度器
if (fn.options.scheduler) {
fn.options.scheduler(fn);
} else {
// 否则直接执行副作用函数
fn();
}
}
});
}
总结
至此,我们已经构建了一个功能强大、健壮、且支持嵌套的响应式系统雏形。它能够处理复杂的依赖关系,解决循环引用和依赖遗留问题,并通过调度器提供了高度的可控性。以下是完整的代码实现,你可以直接复制运行。
ini
const bucket = new WeakMap();
const obj = {
title: "hello world",
check: true,
};
let activeEffect = null;
const effectStack = [];
function effect(fn, options = {}) {
function effectFn() {
// 1. 在执行前,先清理旧的依赖
clean(effectFn);
// 2. 将当前 effectFn 设置为 activeEffect
activeEffect = effectFn;
// 3. 将当前 effectFn 压入栈
fn();
// 4. 执行完毕后,将当前 effectFn 弹出栈
effectStack.pop();
// 5. 恢复 activeEffect 为栈顶的 effectFn
activeEffect = effectStack[effectStack.length - 1];
}
// 在 effectFn 上添加一个数组,用于反向存储它所在的 deps 集合
effectFn.deps = [];
// 存储用户传入的 options
effectFn.options = options;
// 首次执行
effectFn();
}
/**
* 清理副作用函数的所有依赖
*/
function clean(effectFn) {
for (let i = 0; i < effectFn.deps.length; i++) {
const deps = effectFn.deps[i];
// 从每个依赖集合中移除 effectFn
deps.delete(effectFn);
}
// 清空 effectFn 的依赖数组
effectFn.deps.length = 0;
}
const proxyObj = new Proxy(obj, {
get(target, key) {
track(target, key);
return target[key];
},
set(target, key, value) {
target[key] = value;
trigger(target, key);
},
});
/**
* 依赖收集
*/
function track(target, key) {
if (!activeEffect) return;
let depsMap = bucket.get(target);
if (!depsMap) {
bucket.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);
}
/**
* 触发依赖
*/
function trigger(target, key) {
let depsMap = bucket.get(target);
if (!depsMap) return;
let deps = depsMap.get(key);
const newSet = new Set(deps);
newSet &&
newSet.forEach((fn) => {
if (fn !== activeEffect) {
if (fn.options.scheduler) {
fn.options.scheduler(fn);
} else {
fn();
}
}
});
}
// 示例:触发响应式
effect(() => {
proxyObj.title += "2222";
});
setTimeout(() => {
console.log(bucket);
}, 1000);