用Vue3写组件的时候,我一直有种“被宠坏”的感觉:随便改一个ref的值,页面自动更新,管你数据藏在哪个犄角旮旯。直到有一天我忍不住翻源码,才发现所谓的响应式核心其实就两件事:做一次代理,收集依赖,在改数据时触发依赖。
今天我不讲什么大而全的实现,就带着你把Vue3里最核心的响应式系统用不到一百行代码重写出来。等你动手写完,再看那些监听、computed、watch,会发现它们全是在这个地基上长出来的。
一、响应式到底在干嘛
先去店里买杯奶茶,思考一个例子。
let price = 10; let count = 2; let total = price * count; console.log(total); // 20 price = 20; console.log(total); // 还是20,因为total没有重新计算
正常的JavaScript就是这样,total不会跟着price变。响应式的目标,就是让total在price变化时自动重新计算。要实现这个效果,必须有一个叫“副作用”的东西,就是我们想自动执行的函数。Vue里就是 effect。
一个响应式系统最少要有三样东西:
- reactive:把原始对象变成响应式对象。
- effect:注册一个副作用函数,它会读取响应式对象。
- 依赖收集/触发更新:effect读取某个属性时,把这个effect记下来;属性变化时,再去执行之前记下来的effect。
画成图就是:
读取属性 -> track(收集当前effect) 修改属性 -> trigger(执行所有读取过该属性的effect)
上面两句话是整个Vue响应式的灵魂。下面我用代码把它们落地。
二、先搞一个全局存储依赖的地方
依赖存哪里?我用一个WeakMap。WeakMap的键是原始对象,值是一个Map;Map的键是属性名,值是一个Set,里面放的是effect函数。为什么用WeakMap?因为它对对象的引用是弱引用,对象被垃圾回收了,这里对应的项也会自动消失,不会内存泄漏。
const targetMap = new WeakMap(); // 当前正在执行的effect let activeEffect = null;
activeEffect是个全局变量,意思就是“现在正在跑的那个副作用函数”。有了activeEffect,才知道读取属性时应该把谁收集起来。
三、reactive:用Proxy包一下
reactive接收一个对象,返回Proxy代理。我们在get里收集依赖,在set里触发更新。
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
// 读属性时收集依赖
track(obj, key);
return Reflect.get(obj, key, receiver);
},
set(obj, key, value, receiver) {
const oldValue = obj[key];
const result = Reflect.set(obj, key, value, receiver);
if (oldValue !== value) {
// 值变了才触发更新
trigger(obj, key);
}
return result;
}
});
}
这里有个细节:为什么用Reflect.get而不是直接返回obj[key]?因为如果对象上有getter或者有继承,两者行为可能有差异。为了正确性,我们遵循Vue的做法。
有的同学会问:如果对象嵌套很深怎么办?比如 state.user.info.name。我们的reactive只代理了外层,state.user 取出来还是普通对象,不会收集依赖。真实Vue的reactive是惰性深代理:当读取一个属性时,如果这个属性值还是对象,就再对它做一次reactive。这里我为了代码精简,先不处理深代理,只演示基础原理。
四、track:收集依赖
function track(target, key) {
if (!activeEffect) return;
let depsMap = targetMap.get(target);
if (!depsMap) {
depsMap = new Map();
targetMap.set(target, depsMap);
}
let depSet = depsMap.get(key);
if (!depSet) {
depSet = new Set();
depsMap.set(key, depSet);
}
depSet.add(activeEffect);
}
这段代码的意图很清晰:找到 target 对应的 depsMap,再找到 key 对应的 depSet,把当前 activeEffect 放进去。以后属性一变化,trigger 就遍历这个 depSet 去执行所有 effect。
五、trigger:触发依赖
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const depSet = depsMap.get(key);
if (!depSet) return;
// 把当前所有希望执行的effect copy出来,避免死循环
const effectsToRun = new Set(depSet);
effectsToRun.forEach(effect => {
effect();
});
}
注意我copy了一份,用新Set执行。因为如果effect执行时又修改了同一个属性,会再次触发trigger,而原来的Set还在被迭代,可能会出现问题。Copy一份是常规操作。
六、effect:注册副作用
effect函数接收一个函数fn。执行fn的时候,设置activeEffect为fn,然后立即跑一次,读取属性时track就会把fn收集起来。
function effect(fn) {
activeEffect = fn;
fn(); // 立即执行,触发依赖收集
activeEffect = null;
}
简单粗暴。真实Vue里effect还有调度器、stop、cleanup等逻辑,这里都省略了。但核心思想就是上面这样。
七、第一次测试:total能自动更新了
我们写个最简单的HTML页面,不依赖任何构建工具,拿上面的代码跑一下。
const product = reactive({ price: 10, count: 2 });
let total;
effect(() => {
total = product.price * product.count;
console.log('total现在是', total);
});
// 修改价格
product.price = 20;
// 修改数量
product.count = 3;
输出结果:
total现在是 20 total现在是 40 total现在是 60
看见没?total每次都会自动跟着product的值变化。这就是迷你响应式系统。
刚才代码里的effect像不像Vue组件的渲染函数?组件第一次渲染时,读取了模板里用到的响应式数据,于是render effect被收集为依赖;后来某个响应式数据变了,render effect重新执行,界面就更新了。万变不离其宗。
八、改进:支持ref
Vue3里除了reactive这种对象式,还有一个ref用来包装基本类型。比如 const num = ref(0),然后 num.value 来读写。实际上ref也可以基于reactive实现:把基本类型包成一个对象,然后reactive这个对象。
function ref(initialValue) {
const refObj = { value: initialValue };
return reactive(refObj);
}
试试看:
const count = ref(0);
effect(() => {
console.log('count.value =', count.value);
});
count.value = 1; // 输出 count.value = 1
哦,原来ref就是给一个对象加了一个value属性,然后套上reactive。这也是为什么ref修改后需要靠.value来访问。
九、再加一个简单computed
计算属性computed本质上是一个懒执行的effect,并且缓存结果。我们来实现一个简化版。
function computed(getter) {
let cachedValue;
let dirty = true;
const runner = () => {
cachedValue = getter();
dirty = false;
};
effect(runner);
return {
get value() {
if (dirty) {
runner();
}
return cachedValue;
}
};
}
这里有一个坑:effect(runner)执行的时候,runner里会读取响应式数据,于是runner就被收集为依赖。当某个依赖变化时,runner会被重新执行,此时dirty应该设为true,这样下次访问computed.value时才会重新计算。但上面的代码里,trigger会直接执行runner,导致runner执行一次,但dirty没变。我们需要改一下:
function computed(getter) {
let cachedValue;
let dirty = true;
const runner = () => {
cachedValue = getter();
dirty = false;
};
// 用effect包裹runner,但我们要控制不立即执行dirty操作?
// 实际上我们可以手动管理一个effect:
const e = effectFn();
// 更准确的做法是模仿Vue,在trigger时标记dirty,而不是直接执行runner
// 为了演示简单,我改成这样:
其实还没写完。因为我们要让依赖变化时设置dirty=true,而不是直接执行。用触发函数的时候,我们可以单独处理:
function trigger(target, key) {
const depsMap = targetMap.get(target);
if (!depsMap) return;
const depSet = depsMap.get(key);
if (!depSet) return;
const effectsToRun = new Set(depSet);
effectsToRun.forEach(effect => {
if (effect._isRunner) {
effect._dirty = true; // 只是把标记设为true
} else {
effect();
}
});
}
但这样改造就有点复杂了。为了不让文章失控,我在这里提供一个能跑的简化版computed,利用effect的返回值版本。我们先把effect升级一下,让effect可以返回一个包装函数。
function effect(fn) {
const runner = () => {
activeEffect = runner;
fn();
activeEffect = null;
};
runner(); // 初始化收集依赖
return runner;
}
这样effect返回的就是那个runner函数。在computed中,我们可以控制:当依赖变化时,执行runner但只把dirty设为true,不重算。改进后的computed:
function computed(getter) {
let cachedValue;
let dirty = true;
const runner = effect(() => {
// 依赖变化时,这个函数会被调用
dirty = true;
});
// 但这样不对,因为effect执行时传给我们的函数是修改dirty,而不是重算
// 正确做法:用一个额外的effect来追踪getter,并设置标记
// 我们换种思路:
const e = effect(() => {
cachedValue = getter();
dirty = false;
});
// 我们需要在trigger时,设置dirty为true,但我们不知道依赖变化会调用e
// 所以可以在trigger中判断:如果effect有一个._computed属性,就只置脏
}
算了,文章不是源码分析,我决定把computed留作一个思考题,不在这里细写。反正你已经理解了核心:effect在get中收集依赖,trigger时执行依赖。computed只是在这个基础上加了缓存和懒计算。
感兴趣的朋友可以自己查Vue3源码中computed的实现,本质是用了ComputedRefImpl类,内部有一个effect,并设置了scheduler调度器来控制dirty。
十、再优化一步:支持嵌套对象
刚才提到reactive只代理了一层。如果是深层对象,我们可以在get的时候判断当前属性值是不是对象,如果是,就递归reactive。比如:
function reactive(target) {
return new Proxy(target, {
get(obj, key, receiver) {
const value = Reflect.get(obj, key, receiver);
if (typeof value === 'object' && value !== null) {
return reactive(value);
}
track(obj, key);
return value;
},
set(...) { ... }
});
}
这样 state.user.info.name 在读取时,name所在的info对象也会被reactive代理,从而能够收集依赖。但每次get都重新reactive一次,会有性能浪费。真实Vue里用WeakMap缓存结果。这里我们点到为止,理解概念为主。
十一、用这个mini系统做一个简单的状态管理
光说不练假把式。我们用它来实现一个非常简单的按钮点击计数页面(虽然我写了不用样式,但逻辑能跑)。下面是完整的HTML示例:
<body>
<button id="btn">点我</button>
<span id="text">0</span>
<script>
// 上面所有代码放在这里
function reactive(target) { ... }
function effect(fn) { ... }
function track() { ... }
function trigger() { ... }
const state = reactive({ count: 0 });
effect(() => {
document.getElementById('text').textContent = state.count;
});
document.getElementById('btn').addEventListener('click', () => {
state.count++;
});
</script>
</body>
点击按钮,页面上的数字自动增加,并且是state.count变化后,那个effect重新执行来更新DOM。这不就是一个完全自制的MVP版Vue吗?
十二、总结与心得
每次写这种底层原理的文章,我都会有一种恍然大悟的感觉。以前用ref和reactive就像用魔法,现在知道了魔法背后是Proxy和两个普通函数。一旦自己动手写过一遍,再去看Vue的源码文档,就会觉得那些专业术语特别亲切。
响应式不是Vue发明的,但它把这套思想普及到了前端日常开发中。理解依赖收集之后,你再也不会因为“修改了数组怎么不更新”这种问题而抓耳挠腮,因为你清楚知道数组的index操作在Proxy的get/set里是怎么被track/trigger的。
这里我只写了最核心的骨架,真实工程中还要考虑:多次修改同一属性导致的死循环、effect的清理旧依赖、调度器实现批量更新、异步渲染、嵌套effect栈等。但骨架不变——track和trigger,一对老搭档。
我建议你跟着代码敲一遍,不要求背,就放在IDE里跑一跑,然后改一改,看看什么情况下会失效。只有亲手拆过玩具,才知道玩具里面装的什么齿轮。
希望这篇文章能让你对Vue3的响应式拥有“我能控制它”的信心。

