Vue 2 的响应式原理是什么?
Vue 2 在初始化时用 Object.defineProperty 把 data 的属性递归转换为 getter/setter。getter 中由 Dep 收集当前 Watcher,setter 中通知 Watcher 重新执行;组件的渲染 watcher 因此在依赖变化时重新渲染。
参考答案
简答
- Vue 2 已于 2023-12-31 停止维护,本题以 Vue 2.7 源码为准。
- 初始化时
Observer遍历 data,用defineReactive通过Object.defineProperty把每个属性改成 getter/setter,嵌套对象递归处理。 - 每个属性闭包里有一个
Dep;getter 执行时,若存在Dep.target(正在求值的 Watcher),就互相登记依赖。 - setter 发现新值与旧值不同时调用
dep.notify(),通知所有订阅的 Watcher 更新。 - 每个组件有一个渲染 watcher,求值函数就是渲染并 patch,它被通知后进入异步队列,统一重新渲染。
三个角色
Observer:挂在对象的__ob__上,负责把对象的属性转换为响应式;数组则替换原型并逐项 observe。它自身也带一个dep,供Vue.set和数组方法通知使用。Dep:依赖的容器,subs保存订阅它的 Watcher。Dep.target是全局唯一的"当前正在求值的 Watcher",用pushTarget/popTarget维护成栈。Watcher:一段需要追踪依赖的求值逻辑。渲染 watcher、computed(lazywatcher)、watch/$watch(user watcher)都是它的实例。
官方文档的描述:Vue 遍历 data 的所有属性并用 Object.defineProperty 转为 getter/setter,每个组件实例有一个对应的 watcher,在渲染时把"接触过"的属性记录为依赖,依赖的 setter 触发后通知 watcher 重新渲染。
依赖收集
Watcher.get() 的流程:
pushTarget(this),把自己设为Dep.target。- 执行 getter(渲染 watcher 就是
vm._update(vm._render())),途中读到的每个响应式属性都会调用dep.depend()。 dep.depend()调用Dep.target.addDep(dep),Watcher 用newDepIds去重后把自己加入dep.subs。- 求值结束
popTarget(),再cleanupDeps():本轮没再用到的旧 dep 会把这个 Watcher 移除,所以v-if切走的分支不会再触发更新。
属性值是对象或数组时,getter 还会让 childOb.dep.depend(),数组再递归 dependArray。这样 Vue.set 新增属性或调用数组变异方法时,通过 __ob__.dep 也能通知到读过这个对象的 Watcher。
派发更新
setter 中,新值与旧值相同(含 NaN 的情况)直接返回;否则写入新值,对新值重新 observe,然后 dep.notify()。notify 遍历 subs 调用每个 Watcher 的 update():
lazywatcher(computed)只把dirty设为 true,下次读取时再求值。syncwatcher 立即执行。- 其他 Watcher 交给
queueWatcher去重入队,在nextTick中按 id 排序后批量执行,所以同一轮里改多次只渲染一次。
示例
JavaScript
// 用 Node 运行,vue@2.7.16
const Vue = require('vue');
const vm = new Vue({ data: { count: 0 } });
vm.$watch('count', (v, old) => console.log(old, '->', v));
vm.count = 1;
vm.count = 2;
// 输出:0 -> 2(同一轮多次修改合并为一次回调)
常见追问
- 为什么组件的
data要写成函数?每个实例需要独立的数据对象,否则多个实例共享同一份被 observe 的对象。 - 与 Vue 3 的实现有何不同?见《Vue 3 响应式依赖是怎样收集和触发的?》。
- 新增属性为什么不触发更新?见《Vue 2 响应式有哪些限制?如何绕过?》。