实现双向绑定:Proxy 比 defineProperty 有哪些优劣?
原文主要讨论数据劫持、
Object.defineProperty、Proxy、Observer/Dep/Watcher 和数组监听。本文尽量保留这条学习路线,并补充 Vue 2.7、Vue 3.5 的现状。示例是为了理解机制的教学代码,不是 Vue 源码的完整实现。
前言:先区分响应式和双向绑定
“双向绑定”常被用来描述表单场景,但它不是响应式系统的同义词:
状态 → 视图:响应式渲染
视图 → 状态:事件处理或 v-model 约定
Vue 的响应式系统负责在状态变化后调度视图更新;v-model 再把输入事件和状态赋值组合成方便的语法。组件之间仍然推荐 props 下行、emits 上行的单向数据流。
Vue 的核心可以从三个角度理解:
- 响应式系统:如何追踪读取、检测写入并触发 effect;
- 模板编译:如何把模板转换为 render function;
- 渲染器:如何把 VNode 挂载、更新到 DOM 或其他宿主环境。
不同框架也有不同的状态更新模型。React 默认强调显式触发 state 更新,Angular、Vue 提供更强的模板绑定能力;但这些框架都可以实现单向或双向的数据流,不能简单概括为“某个框架只能单向、另一个只能双向”。
本文讨论两类 JavaScript 属性拦截方式:
- Vue 2.7 主要使用
Object.defineProperty将已知属性转换为 getter/setter; - Vue 3.5 使用 Proxy 处理 reactive 对象,使用 getter/setter 处理 ref 的
.value。
严格说,Proxy 是“代理”,不必把所有拦截都称为“数据劫持”。Object.observe 曾经是提案,但已经废弃,不能作为现代方案。

一、什么是数据劫持
当对象属性被读取或写入时,通过 getter/setter 或 Proxy trap 执行额外逻辑,就可以收集依赖、校验数据或通知视图。
1. Object.defineProperty 的最小示例
const data = { name: '' }
function say(name) {
if (name === '古天乐') {
console.log('给大家推荐一款超好玩的游戏')
} else if (name === '渣渣辉') {
console.log('戏我演过很多,但游戏我只玩贪玩蓝月')
} else {
console.log('来做我的兄弟')
}
}
Object.keys(data).forEach(key => {
let value = data[key]
Object.defineProperty(data, key, {
enumerable: true,
configurable: true,
get() {
console.log('get')
return value
},
set(newValue) {
if (Object.is(newValue, value)) return
value = newValue
console.log(`大家好,我系${newValue}`)
say(newValue)
},
})
})
data.name = '渣渣辉'
// 大家好,我系渣渣辉
// 戏我演过很多,但游戏我只玩贪玩蓝月
原文的 getter 没有返回旧值,会导致读取 data.name 得到 undefined;这里补上了闭包中的 value。实际 Vue 还要处理嵌套对象、数组、依赖收集、批量调度和组件更新。
2. 数据劫持能解决什么问题
如果 setter 能够知道具体是哪个对象、哪个属性被修改,就可以直接通知相关依赖,而不必每次都对整棵状态树做全量比较。这是精确依赖更新的一个重要基础。
但“可以精确知道属性变化”不代表一定比 Diff 更快:
- 依赖收集本身有运行时成本;
- 大量细粒度 effect 仍需要调度和去重;
- 渲染器最终还要处理 VNode、属性、布局和绘制;
- 数组和集合操作可能一次触发多个底层读写。
二、Vue 2 风格的 Observer、Dep 和 Watcher
一个简化的 Vue 2 响应式链路是:
Observer:把对象属性转换为 getter/setter
Dep:保存某个属性对应的订阅者
Watcher:读取数据时收集依赖,收到通知后重新执行
Compile:把模板指令转换为具体的 DOM 更新

1. Dep:依赖管理器
class Dep {
constructor() {
this.subscribers = new Set()
}
depend() {
if (activeWatcher) activeWatcher.addDep(this)
}
addSub(watcher) {
this.subscribers.add(watcher)
}
notify() {
for (const watcher of [...this.subscribers]) {
watcher.update()
}
}
}
let activeWatcher = null
Vue 2 源码使用 Dep.target 表示当前正在收集依赖的 watcher。真实实现还要用栈处理嵌套计算、清理旧依赖和批量调度,这里用一个变量只展示核心概念。
2. Observer:转换对象属性
function observe(value) {
if (!value || typeof value !== 'object') return
for (const key of Object.keys(value)) {
defineReactive(value, key, value[key])
}
return value
}
function defineReactive(object, key, initialValue) {
let value = initialValue
const dep = new Dep()
observe(value)
Object.defineProperty(object, key, {
enumerable: true,
configurable: true,
get() {
dep.depend()
return value
},
set(nextValue) {
if (Object.is(nextValue, value)) return
value = nextValue
observe(value)
dep.notify()
},
})
}
这段代码只处理初始化时已经存在的普通对象属性。Vue 2 还需要:
- 为数组改写部分变更方法;
- 处理嵌套对象的 child observer 依赖;
- 通过
Vue.set/this.$set和Vue.delete弥补新增、删除属性; - 对 watcher 进行队列化、去重和异步刷新。
3. Watcher:读取时收集依赖
function getPath(object, path) {
return path.split('.').reduce((value, key) => value?.[key], object)
}
class Watcher {
constructor(vm, getter, callback) {
this.vm = vm
this.getter = getter
this.callback = callback
this.deps = new Set()
this.value = this.get()
}
addDep(dep) {
if (this.deps.has(dep)) return
this.deps.add(dep)
dep.addSub(this)
}
get() {
const previous = activeWatcher
activeWatcher = this
try {
return this.getter.call(this.vm)
} finally {
activeWatcher = previous
}
}
update() {
const nextValue = this.get()
if (Object.is(nextValue, this.value)) return
const oldValue = this.value
this.value = nextValue
this.callback.call(this.vm, nextValue, oldValue)
}
teardown() {
for (const dep of this.deps) {
dep.subscribers.delete(this)
}
this.deps.clear()
}
}
读取 this.vm._data.user.name 时,会依次触发对象属性 getter,当前 watcher 因此订阅相关依赖。真实 Vue 2 watcher 还区分渲染 watcher、computed watcher、user watcher,并在更新时进入 scheduler 队列。
4. 简化的 Vue 实例
class MiniVue {
constructor(options = {}) {
this.$data = typeof options.data === 'function'
? options.data()
: (options.data || {})
this._watchers = []
for (const key of Object.keys(this.$data)) {
Object.defineProperty(this, key, {
enumerable: true,
configurable: true,
get: () => this.$data[key],
set: value => {
this.$data[key] = value
},
})
}
observe(this.$data)
}
$watch(path, callback) {
const watcher = new Watcher(
this,
() => getPath(this.$data, path),
callback,
)
this._watchers.push(watcher)
return () => {
watcher.teardown()
const index = this._watchers.indexOf(watcher)
if (index >= 0) this._watchers.splice(index, 1)
}
}
}
const vm = new MiniVue({
data: { user: { name: 'Vue' } },
})
vm.$watch('user.name', (value, oldValue) => {
console.log(`${oldValue} -> ${value}`)
})
vm.user.name = 'Vue 3'
这里还没有实现模板编译和 DOM 更新。完整的双向绑定需要 Compiler 将 v-model、插值和事件绑定到 watcher,再在输入事件中修改 Model。
三、Object.defineProperty 的优缺点
优点
- Vue 2 时代浏览器支持范围广;
- 可以在不改变调用方式的情况下拦截已有属性的读写;
- setter 能够精确知道某个属性的新值;
- 与 Dep/Watcher 结合后可以实现依赖收集。
局限
1. 只能转换已知属性
Object.defineProperty 是针对单个属性设置描述符。初始化后直接新增属性不会自动拥有 getter/setter:
const state = { user: { name: 'Vue' } }
observe(state)
state.user.age = 3 // 这个新增属性不会被上面的简化 observe 捕获
Vue 2 应使用:
this.$set(this.user, 'age', 3)
// 或 Vue.set(this.user, 'age', 3)
Vue 3 的 Proxy 可以拦截新增和删除属性,但响应式对象仍应遵循清晰的状态设计。
2. 数组需要额外处理
Vue 2 能够检测以下七种数组变更方法:
push、pop、shift、unshift、splice、sort、reverse
它通过替换响应式数组的部分原型方法,在调用原生方法后通知依赖;这不是 defineProperty 自动监听了数组的所有变化。以下写法在 Vue 2 中仍需要特殊处理:
vm.items[index] = nextValue
vm.items.length = 0
可以使用 splice 或 $set:
vm.items.splice(index, 1, nextValue)
vm.$set(vm.items, index, nextValue)
示意性的数组方法改写如下:
const methods = ['push', 'pop', 'shift', 'unshift', 'splice', 'sort', 'reverse']
const arrayMethods = Object.create(Array.prototype)
for (const method of methods) {
const original = Array.prototype[method]
Object.defineProperty(arrayMethods, method, {
value(...args) {
const result = original.apply(this, args)
console.log(`数组方法 ${method} 被调用`)
// 真实框架会在这里通知依赖并批量调度更新
return result
},
enumerable: false,
writable: true,
configurable: true,
})
}
const list = [1, 2]
Object.setPrototypeOf(list, arrayMethods)
list.push(3)
3. 初始化遍历成本和覆盖范围
为了转换嵌套对象,Vue 2 需要递归遍历已知属性。属性很多或对象很深时,初始化会有成本;不可扩展对象、原型链属性、Map/Set 和某些外部对象也需要额外处理。
因此,“defineProperty 无法深度监听对象”并不严谨:递归遍历后,它可以监听已存在的嵌套属性;准确的说法是它对新增/删除属性、数组索引和集合的覆盖不完整。
四、Proxy 的特点
Proxy 在目标对象外部建立代理,可以拦截 get、set、has、deleteProperty、ownKeys 等多种操作。它返回的是一个代理对象,不是目标对象的复制品:
const raw = { count: 0 }
const proxy = new Proxy(raw, {})
proxy !== raw // true
proxy.count = 1
raw.count // 1
1. 拦截对象而不是逐个声明属性
const input = document.querySelector('#input')
const output = document.querySelector('#output')
const state = new Proxy({ text: '' }, {
get(target, key, receiver) {
return Reflect.get(target, key, receiver)
},
set(target, key, value, receiver) {
const updated = Reflect.set(target, key, value, receiver)
if (key === 'text') {
input.value = String(value)
output.textContent = String(value)
}
return updated
},
})
input.addEventListener('input', event => {
state.text = event.target.value
})
这里展示的是“输入事件修改状态、setter 更新 DOM”的最小双向绑定。生产实现不应把 DOM 操作直接写进 Proxy,而应通过 effect、组件渲染器和 scheduler 解耦。

2. 对数组和集合有更完整的拦截能力
Proxy 可以观察数组索引、length 以及 push 等方法内部触发的属性操作,也可以为 Map/Set 的 get、set、add、delete、clear 建立专门的依赖规则:
const list = new Proxy([], {
set(target, key, value, receiver) {
const oldLength = target.length
const result = Reflect.set(target, key, value, receiver)
console.log('changed:', key, value)
if (target.length !== oldLength) {
console.log('length changed:', target.length)
}
return result
},
})
list.push('Vue')
list[2] = 'Proxy'
一次数组方法可能触发多个 trap,因此真实 Vue 不会在每个 trap 中立即完整重渲染,而是把组件更新和 watcher 放入 scheduler 队列进行去重、批量处理。
3. Proxy 的局限
- Proxy 不能拦截普通局部变量的读写,只能代理对象操作;
- 不能用 polyfill 完整模拟旧浏览器缺少的 Proxy;
- 代理对象和原对象的 identity 不同,和第三方库交互时要注意;
- 必须遵守 Proxy invariant,某些不可配置属性不能随意伪造结果;
- 代理并不会自动产生依赖关系,仍需要
track/trigger或其他订阅机制; - 代理层本身也有运行时开销,不能简单宣称“Proxy 永远更快”。
五、Vue 2.7 与 Vue 3.5 的对应关系
| 方面 | Vue 2.7 | Vue 3.5 |
|---|---|---|
| 普通对象 | Object.defineProperty | Proxy/Reflect |
| 基础值 | 通常通过实例属性代理 | ref() 使用 .value getter/setter |
| 依赖模型 | Dep、Watcher | ReactiveEffect、依赖集合、scheduler |
| 新增属性 | $set/Vue.set | Proxy 可拦截 add |
| 数组 | 改写部分变更方法 | Proxy + 数组操作 instrumentation |
| Map/Set | 支持有限 | 有专门的集合响应式处理 |
| 组件更新 | render watcher 入队 | 组件 render effect 入队 |
| 浏览器兼容 | 可支持更老环境 | 需要 Proxy,不能兼容 IE11 |
Vue 3 的 reactive() 主要返回 Proxy,ref() 主要通过 getter/setter 追踪 .value。因此不要把“Vue 3 完全不使用 getter/setter”作为结论。
一个更接近 Vue 3 的响应式伪代码是:
const subscribers = new WeakMap()
let activeEffect
function track(target, key) {
if (!activeEffect) return
// WeakMap<target, Map<key, Set<effect>>>,这里只省略初始化细节
}
function trigger(target, key) {
// 找到 target/key 对应的 effects,并交给 scheduler
}
function reactive(target) {
return new Proxy(target, {
get(object, key, receiver) {
const value = Reflect.get(object, key, receiver)
track(object, key)
return value
},
set(object, key, value, receiver) {
const oldValue = object[key]
const result = Reflect.set(object, key, value, receiver)
if (!Object.is(oldValue, value)) trigger(object, key)
return result
},
})
}
伪代码省略了嵌套代理、删除、迭代依赖、数组、Map/Set、effect 清理和 scheduler。Vue 官方文档中的 track/trigger 示例同样是为了说明核心概念,不能直接替代框架实现。
六、总结
- 响应式是状态变化通知依赖;双向绑定通常还包含 View → Model 的输入事件;
- Vue 2.7 以
defineProperty + Dep/Watcher为主要实现,并通过数组方法改写和$set弥补限制; - Vue 3.5 以 Proxy 处理 reactive 对象,以 getter/setter 处理 ref,并通过 effect 和 scheduler 驱动更新;
- Proxy 能拦截新增、删除、数组索引和集合操作,但不会自动解决依赖收集、批量更新和兼容性问题;
- 不要把 Proxy 称为
defineProperty的“全方位无条件加强版”,应结合目标浏览器、数据类型和更新模型选择方案。
参考资料
原文作者:寻找海蓝 96。原文关于数据劫持、Observer/Dep/Watcher、数组方法改写和 Proxy 示例的主线予以保留;抓取格式、失效推广内容、错误的 getter、错误的“八种数组方法”和过度绝对化表述已清理或修正。