聊到 Vue 的双向绑定,十个新手里有九个第一反应是 v-model。但这概念一旦往深了问,比如依赖收集是怎么触发的、为什么 Vue 2 改数组下标不生效、computed 为什么能缓存,很多人立刻就卡壳。原因其实不复杂:v-model 只是语法糖,真正支撑数据驱动视图的,是一整套“读取时收集依赖、写入时触发更新”的响应式机制。这篇文章我想把这条链路掰开揉碎,从最早的发布订阅模型讲起,手写一个足够上路的 reactive、ref、effect、computed 迷你实现,再回头对照 Vue 2 与 Vue 3 的实现差异。适合三类人看:准备啃源码但被各种模块绕晕的;面前端被连环追问响应式原理的;以及写过不少 Vue 但遇到性能问题只能靠“把 reactive 换成 ref”碰运气的。
先交代一句:下面的代码不是 Vue 源码,而是为了讲清楚“最小可用原理”写出来的教学版。理解了这个骨架,再去看 Vue 源码里的 effect.ts、reactive.ts,会觉得每个函数都眼熟。
1. 先搞清楚:双向绑定到底绑的是什么
1.1 双向绑定的完整链路拆解
“双向”这个说法很容易误导人,好像有一条魔法管道让数据和视图互通。实际上 Vue 里不存在什么“双向魔法”,它本质上是两条单向数据流的组合:
- 数据变化 -> 视图更新:这是响应式系统的职责。数据变成了响应式对象,被渲染函数读取时建立依赖,被修改时触发视图重新渲染。
- 视图交互 -> 数据变化:这是事件绑定的职责。用户在 input 里输入文字,触发 input 事件,事件处理器把新值写回数据。
两条单向流拼在一起,从使用者的视角看,就是“改数据页面变,改页面数据变”,所以叫双向绑定。
我经常用一个 Excel 公式的类比去解释这条链路:你在 A1 单元格输入 100,B1 单元格写的是=A1*2,改 A1,B1 会自动变。Excel 底层做的事情是:B1 公式执行时记住了“我引用了 A1”,A1 变化时会去通知所有引用它的单元格重新计算。Vue 的响应式系统和这个模型几乎一模一样,只不过把单元格换成了 DOM,把公式换成了渲染函数。
所以当你面试被问“双向绑定原理”时,不要只答 v-model,要把这条链路说出来:数据劫持、依赖收集、派发更新、视图重渲染。v-model 只是其中很小的一段。
1.2 为什么 v-model 不等于双向绑定机制
v-model 是 Vue 给开发者提供的一个语法糖。以原生 input 为例,下面这两段代码是等价的:
<input v-model="username" /><input :value="username" @input="username = $event.target.value" />看出来了吗,v-model 做的事情就是“绑 value + 监听 input 事件再写回数据”。也就是说,即使根本没有响应式系统,单靠手动事件监听也能实现“双向绑定”。jQuery 时代很多人不也这么干么:val() 读值,on('input') 赋值。
那响应式系统的价值在哪?在于第一段:当username被其他地方修改时,input 的 value 会自动跟着变。这才是真正的数据驱动。所以严谨地说,v-model 解决的是“视图到数据”那半条链路,而“数据到视图”那条路,是依赖收集和派发更新来铺的。
另外注意,Vue 3 里 v-model 在自定义组件上的等价形式也变了。Vue 2 是:value+@input,Vue 3 改成了:modelValue+@update:modelValue,并且支持一个组件上写多个 v-model,比如v-model:title、v-model:content。核心思想不变,只是名字更语义化。后面我会专门讲组件上的 v-model。
2. 依赖收集与派发更新:响应式系统的引擎
2.1 把响应式理解成“订阅登记”
为什么 Vue 能在数据变化时精准地只更新用到了这个数据的组件?靠的就是依赖收集。我一直觉得,搞懂 Vue 响应式,最关键的一步不是看 Proxy 怎么劫持,而是先搞懂“谁在依赖谁”这套模型。
把响应式数据想象成一家外卖店,把组件渲染函数想象成顾客。顾客第一次点餐时,店里会记下他的电话(track,依赖收集)。以后店里有新品到货(数据变更),店员挨个打电话通知“你关注的菜可以点啦”(trigger,派发更新)。
到代码层面,这个“电话本”就是依赖表。每个响应式属性都维护一个依赖集合,集合里存的是“谁在用我”。这个“谁”在 Vue 3 里叫effect(副作用函数,渲染函数、computed、watch 的回调都是 effect),在 Vue 2 里叫Watcher。
整个链路可以浓缩成四个动作:
- 读取属性:把当前的 effect 登记到属性的依赖列表里。
- 修改属性:取出这个属性的依赖列表,挨个重新执行。
- 重新执行 effect:effect 内部会再次读取属性,再次登记,形成闭环。
- 视图更新:渲染 effect 重新执行 => 组件重新渲染。
我最初看源码时犯过一个错误:以为依赖是渲染函数“绑定”上去的。其实不是,依赖是在 render 函数执行过程中,因为访问了响应式属性,被 getter 拦截后动态挂上去的。换句话说,渲染函数用到了哪些数据,就自动登记哪些数据;没用到的数据,改了也不会触发重渲染。
2.2 一次渲染如何建立依赖关系
把模板编译成 render 函数之后,组件渲染的第一步是执行 render 函数。我们用一个简化的渲染函数来模拟:
function render() { // 假设模板是 <div>{{ count }} {{ name }}</div> div.textContent = `count: ${state.count}, name: ${state.name}` }render 执行时,访问了state.count和state.name,这两个属性的 get 拦截器就会把“当前正在运行的 effect(也就是 render 函数)”记到它俩各自的依赖列表里。
以后执行state.count++,count 的 set 拦截器跑一遍依赖列表,发现有一个 effect 在用自己,就把这个 effect 扔进调度队列,浏览器空闲时重新执行 render。render 一跑,又从 get 拦截器走一遍,依赖关系被重新确认。这个过程每天都发生在每一个 Vue 应用里,肉眼看不见,但 DevTools 的 Performance 面板能录到。
这里有个很值得细品的细节:依赖关系是运行时动态建立的。如果模板里写了 v-if,那么条件为 false 时,被隐藏分支里的数据根本不会被读取,依赖也就不会挂上。等切回 true,分支里的数据重新被读取,依赖关系才建立。这就是为什么有人会遇到“明明数据变了,视图却不刷新”,多半是数据在条件分支之外,或者压根没被 render 函数访问到。
3. Vue 2 到 Vue 3:从 Object.defineProperty 到 Proxy 的进化
3.1 Vue 2 响应式方案的历史包袱
Vue 2 的响应式核心是Object.defineProperty,给对象的每个属性单独设置 getter 和 setter。这套方案在 2013 年那个时代是很聪明的,但它的限制也清清楚楚写在设计里:
- 只能劫持已经存在的属性。对象新增属性时,没有 getter/setter,直接就不是响应式的。
- 删除属性同样无法感知。
- 数组的多数操作无法被 defineProperty 拦截。直接
arr[0] = xxx、arr.length = 0都没反应。
于是 Vue 2 提供了一堆“补救 API”:Vue.set(obj, key, value)和Vue.delete(obj, key)就是手动给新属性加上响应式;数组方法push、pop、shift、unshift、splice、sort、reverse被整体重写,内部先执行原生方法,再手动触发依赖更新。
这些补丁能干,但容易忘。尤其是从后端接口拿数据后直接this.obj.newField = x,页面纹丝不动,排查半天才反应过来要 set。我踩过这个坑不止一次,后来干脆养成了习惯:Vue 2 项目里,所有动态字段一律先声明的默认值,或者统一走Vue.set。
至于为什么 Vue 2 不把每个数组索引都用 defineProperty 包一遍,官方当年的解释是性能和收益不成正比。对一个一万项的数组来说,给每一项都加 getter/setter,初始化开销是肉眼可见的卡。
3.2 Proxy 解决了哪些历史问题
Vue 3 把响应式核心换成了 Proxy,这不是换了个 API 那么简单,是整个“劫持模型”变了。
Object.defineProperty是给对象内部的某个 key 打补丁,所以新增 key 它管不了;Proxy是直接在对象外面套了一个拦截层,你对这个对象的任何操作,都要先经过代理的拦截器,不管这个 key 是原本就有的,还是后来新增的。
用 Proxy 之后,之前的所有坑基本消失:
- 新增属性、删除属性,都能被
set和deleteProperty拦截到,不再需要Vue.set。 in操作符、for...in遍历可以被has、ownKeys拦截。- 数组索引赋值、修改 length,都能被 set 拦截到,原生数组也不需要重写方法了。
代码层面的差别是这样的。Vue 2 劫持一个对象,等于给每个房间装监控:
// Vue 2 思路(简化):遍历 key,逐个 defineProperty Object.keys(obj).forEach((key) => { let value = obj[key] Object.defineProperty(obj, key, { get() { // 收集依赖 return value }, set(newValue) { value = newValue // 触发更新 }, }) })Vue 3 思路(简化):在房子门口装一道安检闸机:
// Vue 3 思路(简化) const proxy = new Proxy(target, { get(target, key, receiver) { // 收集依赖,支持任意 key return Reflect.get(target, key, receiver) }, set(target, key, value, receiver) { // 新 key 老 key 一视同仁 const result = Reflect.set(target, key, value, receiver) // 触发更新 return result }, })但 Proxy 也不是银弹。它代理的是整个对象,深度还是需要递归。Vue 3 的reactive在 get 拦截器里做了惰性代理:只有当某个属性被访问且值是对象时,才对这个子对象递归包一层 Proxy。这样初始化时不用深度遍历整棵对象树,首次性能反而比 Vue 2 好很多。
很多人不知道,Proxy 的receiver参数是有说法的,它指向实际调用代理方法的对象。在处理原型链和 getter 里的this时有坑,Vue 源码里就有针对 receiver 的toRaw处理。这种细节我们写业务代码基本碰不到,但面试官会拿它区分“看过文档”和“读过源码”。
4. 手写迷你响应式系统:reactive、ref、effect、computed
4.1 先搭最底层的 track 和 trigger
我不建议一上来就贴完整源码,那样容易劝退。咱们先写最小的依赖收集模型,再一步步加功能。
先准备一个“依赖存储桶”。Vue 3 用三层结构:targetMap(WeakMap)->depsMap(Map)->dep(Set)。
为什么要三层?因为我们需要存“哪个对象的哪个属性,被哪些 effect 依赖”。对象是一层,key 是一层,effect 集合是一层。
const targetMap = new WeakMap() let activeEffect = null const effectStack = []这里有两个关键变量:activeEffect表示当前正在运行的 effect;effectStack是为了处理嵌套 effect。如果你只用一个全局变量,嵌套场景下内层 effect 执行完,外层 effect 就找不回来了,依赖会挂错地方。这是初学者手写响应式最容易漏的细节。
track 的职责就是“登记依赖”:
function track(target, key) { if (!activeEffect) return // 拿到 target 对应的 depsMap let depsMap = targetMap.get(target) if (!depsMap) { depsMap = new Map() targetMap.set(target, depsMap) } // 拿到 key 对应的 dep 集合 let dep = depsMap.get(key) if (!dep) { dep = new Set() depsMap.set(key, dep) } // 把当前 effect 加进去 dep.add(activeEffect) }trigger 的职责是“派发更新”:
function trigger(target, key) { const depsMap = targetMap.get(target) if (!depsMap) return const dep = depsMap.get(key) if (!dep) return // 把依赖这个 key 的 effect 挨个重新执行 dep.forEach((effect) => { if (effect.scheduler) { effect.scheduler() } else { effect() } }) }scheduler是 Vue 3 里很重要的一环。默认情况下 trigger 是同步执行 effect 的,但如果每次都立即执行,一个事件里改了 10 次数据,渲染函数就要重跑 10 次。Vue 把 effect 传给调度器,调度器把 effect 放进微任务队列,同一轮里多个触发合并成一次执行,这就是异步批量更新的雏形。
WeakMap的选择也是刻意的。如果这里用普通 Map 保存 target 的依赖,即使业务代码里已经把对象释放了,Map 还强引用着它,垃圾回收永远不回收它,内存就泄漏了。WeakMap 的 key 是弱引用,target 失去引用后,整条依赖链跟着被回收。
4.2 手写 reactive 和 ref
有了 track 和 trigger,reactive 就是一个 Proxy 壳子:
function reactive(target) { return new Proxy(target, { get(target, key, receiver) { const res = Reflect.get(target, key, receiver) // 读取时收集依赖 track(target, key) // 惰性递归:如果取出来的还是对象,继续包一层 if (res !== null && typeof res === 'object') { return reactive(res) } return res }, set(target, key, value, receiver) { const oldValue = target[key] const res = Reflect.set(target, key, value, receiver) if (oldValue !== value) { // 写入时派发更新 trigger(target, key) } return res }, deleteProperty(target, key) { const res = Reflect.deleteProperty(target, key) // 删除属性同样要触发更新 trigger(target, key) return res }, }) }注意 set 里为什么要比较旧值?因为响应式系统最忌讳“无意义的重复渲染”。如果赋的是同一个值,说明业务层面没有任何变化,触发更新只会白白浪费一次渲染。Vue 3 源码里对 NaN 这种特殊情况还有额外判断,但核心思想就是“变了才通知”。
ref 的底层其实比 reactive 还简单。它把一个值包装成{ value: 任意值 },然后对这个包装对象走响应式:
function ref(value) { class RefImpl { constructor(value) { this._value = value } get value() { // 读取时收集依赖 track(this, 'value') return this._value } set value(newValue) { if (newValue !== this._value) { this._value = newValue // 写入时派发更新 trigger(this, 'value') } } } return new RefImpl(value) }所以 Vue 3 模板里ref明明是个值类型,却不用ref.value.xxx,因为模板编译时自动帮你解包了。而在 JS 逻辑里必须写.value,也是因为本质是一个属性叫 value 的对象。
4.3 effect 和 computed 的实现要义
effect 是响应式系统的执行单元。它的作用是:执行传入的函数,执行过程中如果访问了响应式属性,就把自己登记为依赖。
function effect(fn, options = {}) { const effectFn = () => { try { // 入栈 effectStack.push(effectFn) activeEffect = effectFn // 执行函数,收集依赖 return fn() } finally { // 出栈,恢复外层 effect effectStack.pop() activeEffect = effectStack[effectStack.length - 1] } } if (options.scheduler) { effectFn.scheduler = options.scheduler } effectFn() return effectFn }这里effectStack是关键,它不是可有可无的锦上添花。举一个场景:一个 effect 内部调用了另一个 effect(computed 就是这种情况),内层 effect 执行时会把activeEffect换成自己,执行完如果不恢复,外层的依赖收集就全挂到内层 effect 上了,整个依赖图就乱了。
computed 的实现则是在 effect 基础上加一个“懒执行 + 缓存”的逻辑:
function computed(getter) { let value let dirty = true // 用 effect 收集依赖 const runner = effect(getter, { scheduler() { // 依赖变化时不立即重算,只标记“脏了” dirty = true // 通知外层依赖 computed 的地方 trigger(computedRef, 'value') }, }) const computedRef = { get value() { // 只有 dirty 时才重新计算 if (dirty) { value = runner() dirty = false } // 读取 computed 时也要收集外层依赖 track(computedRef, 'value') return value }, } return computedRef }computed 这个实现细节是面试里很容易打出区分度的地方:它为什么快?不是因为它算得快,而是它有一套 dirty 标志位。如果依赖的数据没变,再读取 computed 时直接返回缓存值,不重跑 getter;只有依赖的数据变了,scheduler 被触发,dirty 变回 true,下次读取才重算。这个“懒计算 + 脏标记”的模式,在很多框架状态管理里都能看到影子。
4.4 手写版的局限与 Vue 源码里真正的处理
上面这个教程版能跑,但离 Vue 3 真正源码还有不少距离。源码里至少还处理了这些边缘情况:
- 依赖清理。每次 effect 执行前会先清除与 dep 的关系,执行后再重新收集,避免分支切换时旧依赖一直挂着,导致不必要的更新。
- 嵌套对象代理的缓存。同一个对象重复
reactive,应该返回同一个代理实例,而不是每访问一次包一个新代理,否则对象身份丢失、无限递归。 - 对
Map、Set、WeakMap等集合类型的代理。Vue 3 对 Map 的size、get、set等方法有特殊处理,因为 Proxy 无法直接拦截方法内部的属性读写。 - 只读代理和浅层代理。
readonly和shallowReactive在源码里是通过不同的 baseHandlers 实现的。
如果读者是进了 Vue 源码之后绕晕的,我建议优先读packages/reactivity/src/effect.ts,它是整个响应式系统的心脏。等把 track 与 trigger 看懂了,再回头看baseHandlers.ts里的createGetter和createSetter,基本就顺了。
5. v-model 不是双向绑定的全部:模板层的语法糖机制
5.1 v-model 在原生元素和组件上的翻译规则
把 v-model 写在原生 input 上,Vue 编译器会把它拆成:value和@input。但具体拆法视元素类型而定:
- 文本框、多行文本:value 属性 + input 事件。
- 复选框、单选框:checked 属性 + change 事件,值可能不是字符串而是布尔值或数组。
- 下拉选择框:value 属性 + change 事件。
Vue 3 在自定义组件上的 v-model 规范是modelValue+update:modelValue。子组件里这样写:
// 子组件 defineProps(['modelValue']) const emit = defineEmits(['update:modelValue'])模板里使用 props.modelValue 展示,需要变化时 emit('update:modelValue', newValue)。
Vue 3 还支持多个 v-model,比如日期范围选择器里的开始时间和结束时间:
<DateRangePicker v-model:start="startDate" v-model:end="endDate" />等价于:
<DateRangePicker :start="startDate" @update:start="startDate = $event" :end="endDate" @update:end="endDate = $event" />这个能力在 Vue 2 时代做不到,当时如果要传两个双向绑定值,只能用一个 v-model 加一个 .sync,非常割裂。Vue 3 统一之后,组件 API 的设计层面就顺畅多了。
5.2 为什么不能直接修改 prop,哪怕收到了 v-model
这个问题我被问过太多次,而且很多面试者答不全。组件收到 prop 后,能不能直接props.modelValue = '新值'?不能。抛开框架层面,单从设计模式上说,原因想一想就清楚了:
props.modelValue本质是父组件某个响应式数据的一份“快照”。如果子组件直接改 prop,相当于在不知道父组件意图的情况下,私自篡改了父组件的数据源。这个数据源可能同时被多个子组件引用,其中一个改了,其他几个显示的旧数据也得跟着变,但其他子组件根本不会收到通知,因为它们没有通过 emit 走那条正规链路。
响应式系统的“单向数据流”原则就是为了避免这种不可控的分叉状态:数据归属和修改行为必须明确。子组件不拥有这份数据,只能通过事件向上申请修改,修改权始终在数据所有者手里。
所以 v-model 的完整理解应该是:父组件通过 modelValue 把数据传给子组件,子组件展示;子组件要把数据改掉时,通过 update:modelValue 事件把新值发回父组件,父组件再更新自己的数据。整条数据流是一个干净的环路。
5.3 v-model 与 .sync 的异同:Vue 2 遗留问题
Vue 2 里v-model和.sync一直是很像但又不完全一样的两套东西。面试里也经常被放在一起问。
Vue 2 的 v-model 默认翻译成value+input;.sync默认翻译成组件名属性 +update:组件名事件,相当于“可传参版本的 v-model”。后者更灵活,可以给任意 prop 做双向同步,但只能有一个 key。Vue 3 把.sync的功能直接合并进 v-model 之后,.sync被废弃了。现在 Vue 3 的 v-model 就是加强版,既支持默认的 modelValue,也支持带参数绑定任意 prop。
如果项目里还有 Vue 2 老代码在维护,遇到表单类组件比如弹窗、表格、筛选器,我建议都统一走.sync,因为语义更清晰。但新项目,直接用 Vue 3 的多 v-model 就好,不需要再纠结。
6. 面试和线上问题:双向绑定最常见的坑与排查
6.1 爆栈现场:effect 内部修改自己依赖的属性
写自定义 effect 或者 watch 的时候,一个经典死循环我已经见过无数人踩了:
effect(() => { // 读取 count,然后又修改 count state.count = state.count + 1 })执行流程是这样的:effect 里读取 count,把自己登记成依赖;然后给 count 赋新值,set 拦截器触发 trigger,把这个 effect 重新执行;重新执行时又读又写,再触发 trigger……无限循环,直到浏览器爆栈。没加调度器的情况下这几乎是秒炸。
Vue 源码里对这种场景有防线吗?有,它是通过依赖清理和更新队列来避免同一个 effect 在同一轮循环里重复执行的。但我们自己写 watch 和自定义 hook 时,一定要有这个意识:回调函数里尽量避免修改自己监听的数据源。如果业务上确实需要,至少要加个标志位或者节流控制。
6.2 数组和新增属性:旧时代的大坑,新时代的注意点
Vue 2 里两个高频问题我要再拎出来提醒一下曾经维护老项目的人:
- 直接
array[0] = 'x',视图不更新,因为索引不是响应式的。 - 直接
obj.newKey = 'value',视图不更新,因为 defineProperty 根本没劫持过这个 key。
Vue 3 的 Proxy 把这两个问题都解决了,但新坑也随之而来:如果你给一个响应式对象整体赋了一个包含深层嵌套的大对象,内部依然可能经历一次较大的递归代理。数据量不大无所谓,但如果是从接口拉回来的几十兆嵌套数据,直接放进 reactive,初始化时的代理过程也会有一定耗时。遇到这种情况,可以评估一下该数据是否真的需要响应式,不需要的话用markRaw或者shallowReactive把它们排除在响应式追踪之外。
另外一个容易忽略的性能点是:reactive 是深层代理,而模板渲染通常只读取几层,没必要深度追踪的第三方实例,比如地图实例、富文本编辑器实例、播放器实例,一旦被包进 reactive,每次内部状态变化都可能触发 Vue 的依赖逻辑,造成诡异的卡顿。我见过一个项目把视频播放器的实例塞进 reactive 里,页面越来越卡,后来用markRaw包了一下,立刻恢复流畅。
6.3 排查响应式失效的标准思路
遇到“数据变了但视图没更新”的问题,先不要急着怀疑框架有 bug,按下面的顺序排查:
- 数据是不是真的变了?console.log 在修改前后各打一次,确认引用是否相同。
- 修改的是不是同一个响应式对象?如果是把整个对象重新赋值给 ref / reactive 容器,要确认赋值方式是否正确。
- 模板里依赖的是不是同一个属性?注意作用域问题,比如解构出来的变量已经失去了响应式追踪。
- 是否在 effect / computed 之外修改?如果数据在非响应式上下文里被改,可能根本没触发依赖关系。
- 依赖有没有被清理掉?比如用了 v-if,组件销毁重挂,缓存依赖被清空了。
响应式失效九成都是前三类问题。解构这个问题我多说一句:const { value } = refObj之后再改value,是不会触发视图更新的,因为解构出来的是普通变量的值拷贝,不是代理对象。Vue 3 里要用storeToRefs或者始终refObj.value去操作。
6.4 面试应答思路速查
| 常见问题 | 推荐回答要点 |
|---|---|
| 双向绑定原理是什么 | 分两段:数据到视图靠响应式依赖收集;视图到数据靠事件监听。v-model 只是语法糖 |
| 依赖收集怎么工作 | 读取时通过 getter 触发 track,把当前 effect 记录到目标属性的依赖集合中 |
| 派发更新怎么工作 | 修改时通过 setter 触发 trigger,取出依赖集合,逐个重新执行 effect |
| 为什么 Vue 2 新增属性不响应 | Object.defineProperty 是在对象初始化时逐 key 劫持,没有 key 就没有 getter/setter |
| Vue 3 Proxy 解决了什么 | 支持新增属性、删除属性、数组索引、for...in 等完整对象语义的拦截 |
| computed 为什么能缓存 | dirty 标志位机制,依赖没变时直接返回缓存值,不重跑 getter |
| v-model 在组件上怎么传值 | Vue 3 中是 modelValue 属性 + update:modelValue 事件;多个值可用 v-model:xxx |
| effect 与 watch 的区别 | effect 是响应式系统的基础执行单元;watch 是内置在 effect 之上的封装,且默认懒执行 |
我发现面试时回答这些问题的关键,不是背概念,而是能画出“渲染函数读取属性 -> get 拦截 -> 收集 effect -> 修改属性 -> set 拦截 -> 执行 effect -> 重新渲染”这条完整链路。只要这个链路清晰,任考官从哪个环节切入追问,都不会被卡住。
最后说一个我自己的习惯。遇到双向绑定相关的问题,我从来不去死背文档,而是先在控制台里把 effect 和 reactive 的小样例跑一遍,看依赖实际挂在了哪个对象、哪个 key 上。你能把 track 和 trigger 的执行时机讲清楚,说明你是真的懂了,而不是背了一堆名词。如果你也在啃 Vue 源码,建议从 effect.ts 开始,而不是从 Vue 的入口文件开始,因为 effect 是整个响应式系统的心脏,后面看 computed、ref、watch、render 的源码,全都是绕着这颗心脏转的。这是我在这个机制上踩坑近两年,最想告诉你的一句话。