面试里问「Vue 的响应式原理是什么」,多数人能背出 Object.defineProperty 劫持 getter/setter 这句话。再往下追一层,「那 getter 里到底收集了什么」「Dep 和 Watcher 谁持有谁」「视图为什么会自己更新」,能答清楚的就少了。这些细节光看文章记不住,得自己敲一遍。这篇就是把 Vue 2 的响应式链路拆成五个类手写一遍,从最底层的数据劫持开始,一路搭到指令编译和视图更新,中间穿插 Vue 3 换成 Proxy 之后到底改善了什么。代码不长,跑通之后你对「数据变了页面为什么会动」会有一个能站得住的解释。
在本篇文章中,我们将从浅入深,和大家一起学习以下知识:
- 数据响应式、双向绑定、数据驱动这三个词各自指什么
- Vue 2 的
Object.defineProperty方案,以及它绕不过去的四个局限 - Vue 3 的
Proxy方案补上了哪些能力,代价是什么 - 发布订阅模式和观察者模式的真实区别,不是同一个东西
- 手写 Vue、Observer、Compiler、Dep、Watcher 五个类,把整条链路跑通
- 这套模拟实现和真实 Vue 之间还差什么
一、数据驱动到底在说什么
数据响应式、双向绑定、数据驱动,这三个词经常被混着用,但它们说的不是一件事。
数据响应式说的是,数据模型仅仅是普通的 JavaScript 对象,而当我们修改数据时,视图会进行更新,避免了繁琐的 DOM 操作,提高开发效率。这是单向的,数据到视图。
双向绑定说的是,数据改变视图改变,视图改变数据也随之改变。我们可以使用 v-model 在表单元素上创建双向数据绑定。它是在响应式的基础上,反向再加了一条从 DOM 事件到数据的通路。
数据驱动是 Vue 最独特的特性之一。开发过程中仅需要关注数据本身,不需要关心数据是如何渲染到视图的。
顺序上,响应式是地基,双向绑定是它的一个应用,数据驱动是使用者的体感。后面手写的时候你会看到,双向绑定无非就是在 v-model 的更新函数里多绑了一个 input 事件监听而已。
二、数据响应式的核心原理
Vue 2.x 的 Object.defineProperty
- Vue 2.x深入响应式原理
- MDN - Object.defineProperty
- 浏览器兼容 IE8 以上(不兼容 IE8)
先用最小的代码把「劫持」这件事演示出来。下面这段模拟了 Vue 实例代理 data 的过程,读 vm.msg 走 getter,写 vm.msg 走 setter,在 setter 里顺手改一下 DOM:
// 模拟 Vue 中的 data 选项 |
跑一下就会发现,vm.msg = 'Hello World' 这一句普通的赋值,顺带把页面也改了。响应式的全部魔法,起点就是这里。
那问题来了,如果有一个对象中多个属性需要转换 getter/setter 如何处理?答案是遍历。Object.keys(data).forEach(...) 挨个定义一遍,遇到属性值还是对象就递归下去。这也是 Vue 2 方案最尴尬的地方之一。
defineProperty 绕不过去的四个局限
先说结论,Vue 2 里那些「为什么我改了数据页面不动」的经典问题,根子都在这个 API 的能力边界上。
第一,它劫持的是属性,不是对象。所以必须在初始化的时候把 data 递归遍历一遍,对象层级越深、字段越多,初始化开销越大,而且这些开销不管你用不用得上都得先付。
第二,新增和删除属性感知不到。data.user.age = 18 这个 age 如果初始化时不存在,它就没被 defineProperty 处理过,页面不会更新。Vue 2 只能额外提供 Vue.set 和 Vue.delete 这一对补丁 API。
第三,数组的索引赋值和 length 修改也拦不住。arr[0] = 1 和 arr.length = 0 都不会触发更新。Vue 2 的做法是把数组的原型换掉,重写 push、pop、shift、unshift、splice、sort、reverse 这七个会改变原数组的方法,在里面手动触发通知。所以你用这七个方法改数组是响应式的,用下标不是。
第四,Map、Set 这类新的集合类型完全没辙。
这四条不是实现得不好,是 Object.defineProperty 的设计本来就只面向单个属性。要根治,得换一个能代理「整个对象」的东西。
Vue 3.x 的 Proxy
- MDN - Proxy
- 直接监听对象,而非属性
ES 6中新增,IE不支持,性能由浏览器优化
同样的功能用 Proxy 写是这样:
// 模拟 Vue 中的 data 选项 |
代码量差不多,但能力天差地别。注意 get 和 set 的第二个参数是 key,也就是说这一套拦截器能处理这个对象上的任意属性,包括初始化时不存在的。数组索引、length、delete 操作、in 判断,Proxy 都有对应的拦截钩子,一共十三种。
Vue 3 还在这基础上做了一件很划算的事:惰性代理。reactive 只代理最外层,某个嵌套对象只有在被真正访问到的时候才会被包成 Proxy。初始化不再需要深度遍历,大对象的启动成本一下就降下来了。
代价只有一个,Proxy 是 ES6 特性且无法被 polyfill,所以 Vue 3 不支持 IE11。这也是 Vue 3 敢直接换掉底座的原因,当时 IE 已经可以放弃了。
顺便提一句实现细节的差异。Vue 3 的依赖不再挂在每个属性的闭包里,而是用一个全局的 WeakMap 做三层映射,从 target 找到它的属性表,再从属性名找到对应的依赖集合。用 WeakMap 是为了不阻止对象被垃圾回收。这套结构在 Vue 3 的后续版本里还被优化过几轮,具体实现以官方仓库为准。
另外,Vue 3 里 setter 必须返回 true,否则严格模式下会抛 TypeError。上面这段模拟代码为了突出主线省掉了这一点,真写业务代码时别照抄。
三、发布订阅模式和观察者模式
手写之前得先把这两个模式分清楚,因为后面 Dep 和 Watcher 的关系正是观察者模式,而 Vue 的自定义事件是发布订阅。很多文章把它们当成同一个,其实不是。
发布订阅模式
发布订阅模式有三个角色:
- 订阅者
- 发布者
- 信号中心
我们假定存在一个「信号中心」,某个任务执行完成,就向信号中心「发布」(publish)一个信号,其他任务可以向信号中心「订阅」(subscribe)这个信号,从而知道什么时候自己可以开始执行。这就叫做发布订阅模式。
Vue 的自定义事件就是这个模式:
let vm = new Vue() |
兄弟组件通信过程也是同一套东西,中间那个 eventHub 就是信号中心:
// eventBus.js |
把这个信号中心自己实现一遍,也就三十行。核心是一个 { 事件名: [回调数组] } 的字典:
class EventEmitter { |
要提一句,Vue 3 已经把实例上的 $on / $off / $emit 这套 API 移除了,事件总线的写法需要换成 mitt 这类第三方库,或者就用上面这个手写版本。
观察者模式
观察者模式里只有两个角色,没有中间人:
- 观察者(订阅者)就是
Watcherupdate():当事件发生时,具体要做的事情
- 目标(发布者)就是
Depsubs数组:存储所有的观察者addSub():添加观察者notify():当事件发生,调用所有观察者的update()方法
- 没有事件中心
// 目标(发布者) |
两者的区别
观察者模式是由具体目标调度的。事件触发时 Dep 直接去调用观察者的方法,所以观察者模式的订阅者与发布者之间是存在依赖的,Dep 手里握着 Watcher 的引用。
发布订阅模式由统一的调度中心调用,因此发布者和订阅者不需要知道对方的存在,两边都只认识那个信号中心。

耦合度不同带来的取舍也不同。观察者模式耦合更紧但链路短、可追踪;发布订阅完全解耦但事件满天飞,出问题不好查。Vue 内部用观察者模式做响应式,是因为 Dep 和 Watcher 本来就是一体设计的,没必要再加一层中间人。
四、动手实现一个迷你Vue
整体分析
先看要做成什么样。目标是支持一个最小的 Vue 使用形态,能声明 data,能解析双大括号插值和 v-text、v-model 指令,数据变了视图跟着变,输入框输入了数据也跟着变。

五个类各管一摊:
Vue:把data中的成员注入到Vue实例,并且把data中的成员转成getter/setterObserver:能够对数据对象的所有属性进行监听,如有变动可拿到最新值并通知DepCompiler:解析每个元素中的指令和插值表达式,并替换成相应的数据Dep:添加观察者(watcher),当数据变化通知所有观察者Watcher:数据变化更新视图
Vue
功能:
- 负责接收初始化的参数(选项)
- 负责把
data中的属性注入到Vue实例,转换成getter/setter - 负责调用
observer监听data中所有属性的变化 - 负责调用
compiler解析指令/插值表达式
结构:

class Vue { |
_proxyData 这一步做的事情很具体:让你能写 this.msg 而不是 this.$data.msg。它只是把 $data 上的属性代理到实例上,真正的响应式还没开始,那是下一个类的活。这里的 getter/setter 用了 Object.defineProperty 的第三个参数对象,get 和 set 都是方法简写形式,注意别用箭头函数,会拿不到正确的 this。
Observer
功能:
- 负责把
data选项中的属性转换成响应式数据 data中的某个属性也是对象,把该属性转换成响应式数据- 数据变化发送通知
结构:

// 负责数据劫持 |
这段有三处细节值得停一下。
defineReactive 把 val 作为参数传进来,而不是在 getter 里写 data[key]。为什么?因为 getter 里读 data[key] 会再次触发这个 getter,直接栈溢出。把值捞进函数参数当闭包变量存着,读写都操作这个闭包变量,就绕开了递归。
const that = this 是为了在 setter 里还能调到 Observer 实例的 walk。setter 是普通函数,this 指向的是被代理的那个 data 对象,不是 Observer。
setter 里那句 that.walk(newValue) 处理的是「给属性赋了一个新对象」的情况。新对象是从外面来的,身上没有任何劫持,得重新走一遍。这也解释了 Vue 2 为什么对深层对象的赋值有性能顾虑。
Compiler
功能:
- 负责编译模板,解析指令/插值表达式
- 负责页面的首次渲染
- 当数据变化后重新渲染视图
结构:

1. compile()
入口方法,遍历所有子节点,分文本节点和元素节点两条路处理,有子节点就递归下去:
// 负责解析指令/插值表达式 |
childNodes 返回的是一个动态的 NodeList,所以先 Array.from 转成静态数组再遍历,避免遍历过程中节点变动带来的意外。
2. compileText()
负责编译插值表达式。用正则把双大括号里的属性名抠出来,去实例上取值,再替换回文本:
// 编译文本节点 |
这里有个坑要注意:(.+) 是贪婪匹配,一个文本节点里如果写了两个插值,正则会从第一个左括号一路吃到最后一个右括号,抠出来的 key 就全乱了。改成非贪婪的 (.+?) 并配合 g 标志循环替换才严谨。真实的 Vue 编译器当然不是靠正则硬啃,它有完整的模板解析器会先把模板转成 AST,这个我在 虚拟DOM原理分析 里聊过一部分。这份模拟实现保留正则写法,是因为它足够短,能让主线不被解析器细节淹没。
3. compileElement()
负责编译元素的指令,处理 v-text 和 v-model 的首次渲染:
// 编译属性节点 |
this[dir + 'Updater'] 这个写法挺聪明,把指令名映射成方法名,加新指令只要多写一个 xxxUpdater 方法,update 一行都不用改。
但这里埋了个 bug:updaterFn(node, this.vm[key]) 是直接调用,函数里的 this 会丢。后面创建 Watcher 时要用到 this.vm,所以必须改成 updaterFn.call(this, ...),下面就会看到修正版。
Dep(Dependency)

功能:
- 收集依赖,添加观察者(
watcher) - 通知所有观察者
结构:

class Dep { |
Dep 要接进 defineReactive 里才有意义。每个响应式属性对应一个 Dep 实例,它活在闭包里,getter 时收集,setter 时通知:
// defineReactive 中 |
Dep.target 是整套设计里最巧的一处,也是最容易看晕的一处。
问题是这样的:getter 被触发的时候,它只知道「有人在读我」,但不知道读的人是谁。JavaScript 没有办法在函数里反查调用者。那怎么把「当前正在求值的那个 Watcher」传进 getter 里?答案是用一个全局变量当传声筒。Watcher 在读数据之前,先把自己挂到 Dep.target 上,读完立刻清掉。这样 getter 执行的那一瞬间,Dep.target 里躺着的一定就是它。
真实的 Vue 2 源码里这个变量配了一个栈,pushTarget 和 popTarget 成对使用。因为组件是嵌套渲染的,父组件渲染到一半会去渲染子组件,子组件渲染完必须把 Dep.target 还原成父组件的 Watcher,一个变量存不下这种嵌套关系。这份模拟实现里直接置 null,是简化写法。
Watcher

功能:
- 当数据变化触发依赖,
dep通知所有的Watcher实例更新视图 - 自身实例化的时候往
dep对象中添加自己
结构:

class Watcher { |
构造函数里那句 this.oldValue = vm[key] 是整段代码的关键,它看着像是在存旧值,实际的目的是主动触发一次 getter。因为只有触发 getter,dep.addSub(Dep.target) 才会执行,这个 Watcher 才能被收进对应属性的依赖列表里。存旧值只是顺带。
再看 update,它做了一次新旧值对比,相等就直接返回。这一步省掉了大量无意义的 DOM 操作,把同一个值重复赋十次也只会渲染一次。
在 compiler.js 中为每一个指令和插值表达式创建 watcher 对象,监视数据的变化:
// 因为在 textUpdater 等中要使用 this |
这里就是前面说的 .call(this, ...) 修正。Watcher 的第三个参数是个回调闭包,它捕获了当前这个 node,所以数据一变,改的就是这个具体的 DOM 节点,不需要重新查找。
视图变化更新数据
到这里数据到视图的通路已经通了,双向绑定还差反方向那一半。做法就是在 v-model 的更新方法里额外监听一次 input 事件:
// v-model 指令的更新方法 |
原文这段少了 new Watcher(...) 的右括号,上面补上了。这个方法里两条通路是对称的:Watcher 负责数据到视图,事件监听负责视图到数据。v-model 的全部秘密就这么多。
五、整体回顾
通过下图回顾整体流程:

- Vue
- 记录传入的选项,设置
$data/$el - 把
data的成员注入到Vue实例 - 负责调用
Observer实现数据响应式处理(数据劫持) - 负责调用
Compiler编译指令/插值表达式等
- 记录传入的选项,设置
Observer- 数据劫持
- 负责把
data中的成员转换成getter/setter - 负责把多层属性转换成
getter/setter - 如果给属性赋值为新对象,把新对象的成员设置为
getter/setter
- 负责把
- 添加
Dep和Watcher的依赖关系 - 数据变化发送通知
- 数据劫持
Compiler- 负责编译模板,解析指令/插值表达式
- 负责页面的首次渲染过程
- 当数据变化后重新渲染
Dep- 收集依赖,添加订阅者(
watcher) - 通知所有订阅者
- 收集依赖,添加订阅者(
Watcher- 自身实例化的时候往
dep对象中添加自己 - 当数据变化
dep通知所有的Watcher实例更新视图
- 自身实例化的时候往
一句话串起来:Observer 在 getter 里把 Watcher 收进 Dep,在 setter 里让 Dep 挨个通知 Watcher,Watcher 拿到通知去改自己那个 DOM 节点。
这套模拟和真实 Vue 还差什么
得说清楚,上面这份实现是为了讲原理,离能用还差好几层。
差的第一层是异步更新队列。真实的 Vue 里 setter 触发的不是立即渲染,而是把 Watcher 推进一个去重的队列,等本轮同步代码跑完在微任务里统一刷新。所以连着改十次数据只会渲染一次,this.$nextTick 拿到的就是这次刷新之后的 DOM。上面的模拟是同步改 DOM,改一次渲染一次。
差的第二层是虚拟 DOM 和 diff。这份实现里每个指令绑一个 Watcher,粒度细到属性级别,数据一多 Watcher 数量会爆炸。Vue 2 的实际做法是一个组件一个渲染 Watcher,数据变了先生成新的虚拟 DOM 树,再和旧树 diff 出最小的真实 DOM 操作。粒度粗了,但内存省得多。
差的第三层是 Vue 3 的重构。Vue 3 用 Proxy 换掉了 Object.defineProperty,用 effect 换掉了 Watcher 类,依赖收集从对象闭包搬进了全局 WeakMap。而且响应式这块被单独拆成了 @vue/reactivity 包,可以脱离 Vue 单独使用。ref、reactive、computed、watch 这些组合式 API 就是这层能力的对外出口,用法我写在 Vue3之Composition API详解 里了。
总结
把这一圈过下来,几个能直接带走的结论:
- 响应式的起点是数据劫持,Vue 2 用
Object.defineProperty劫持属性,Vue 3 用Proxy代理整个对象 - Vue 2 那些「改了不更新」的坑,新增属性、删除属性、数组下标赋值、修改 length,全都源于
defineProperty只能作用于已存在的单个属性 - Proxy 一次解决这四类问题,还顺带支持了惰性代理和 Map/Set,代价是放弃 IE
- 发布订阅有信号中心、双方解耦,观察者模式没有中间人、目标直接持有观察者;Vue 的自定义事件是前者,Dep 和 Watcher 是后者
Dep.target是把「谁在读数据」传进 getter 的唯一手段,真实源码里它是一个栈,因为组件渲染是嵌套的- Watcher 构造函数里读一次
vm[key],目的是触发 getter 完成依赖收集,不是为了存旧值 - 双向绑定不神秘,就是在
v-model的更新方法里多加一个input事件监听
我的建议是把这五个类照着敲一遍再跑通,比读十篇源码解析都有用。至少下次面试被追问「getter 里到底收集了什么」的时候,你脑子里浮现的会是自己写过的那几行代码。响应式这块如果还想再看一个角度的讲法,可以对照我之前那篇 vue响应式原理。