
先说一个几乎人人都会遇到的现象Vue 2 项目里data 中声明好的属性随便改视图同步刷新但你运行中给某个对象新塞一个此前没有定义过的属性页面纹丝不动。网上查到的答案是用 Vue.set照着写能跑就行。至于背后为什么这么设计、Vue.set 补了什么、数组方法为什么要重写、computed 和 watch 为什么能生效这一长串问题其实都指向同一个老 API——Object.defineProperty。这篇文章就来彻底回顾 Vue 中的 Object.defineProperty 方法的使用。我会从 API 本身的参数细节讲起再到 Vue 2 如何利用它实现响应式、它有哪些边界局限最后对比 Vue 3 的 Proxy 方案。适合刚学完 Vue 基础、想搞懂响应式原理的读者也适合准备面试、或者正在维护 Vue 2 老项目的同学。看完之后你会对数据变了视图自动更新这句话有完全不同的理解。1. 为什么 Vue 2 会选中一个 2009 年的老 API 当响应式基石1.1 响应式到底在解决什么问题在没有框架的年代数据变化要带动页面更新是纯手工活你改一个变量就要自己去查对应的 DOM 节点然后手动更新。业务简单时还能接受一旦页面复杂起来漏更新、重复更新、更新顺序不对的 bug 会层出不穷。响应式想做的事情是把数据变化 - 视图更新这条链路自动化。难点在于JavaScript 语言本身并没有提供属性被读取时执行回调或属性被修改时执行回调这样一等公民的监听机制。所以框架必须想一个办法在读写对象属性的瞬间做点额外的事情——读的时候记录一下谁在用这个数据写的时候把数据变了广播出去。Object.defineProperty 恰好提供了这种能力。它允许你精确定义对象某个属性的行为甚至可以用 getter/setter 完全接管属性的读取和赋值逻辑。Vue 2 就是看中了这一点不侵入业务代码不需要你手动调用什么 update 方法只要你用普通方式读写对象属性底层就会自动触发拦截。1.2 为什么不是轮询、不是代理而是属性劫持有人可能会问能不能用定时轮询每隔一段时间扫描一遍数据有没有变化能但效率太差而且无法精确知道变化的粒度只能做整棵数据树的 diff。AngularJS 时期的脏检查就是类似思路它的问题在于需要显式触发检查性能开销也不小。能不能彻底用 Proxy 代理理论上 Proxy 能覆盖更多场景当时也已经是 ES6 标准的一部分但 Vue 2 发布时需要考虑浏览器兼容性Proxy 无法在低版本浏览器上 polyfill而 Object.defineProperty 从 ES5 开始就存在IE9 都能用。对一个面向大众开发者的框架来说兼容性直接决定了能走到多远。另外属性劫持这种方案还有一个好处你读属性时监听写属性时触发两者都能精确到某个 key。相比脏检查需要比对整份数据粒度更细代码实现也更直观。所以 Vue 2 选择 Object.defineProperty 不是拍脑袋而是当时条件下性能和兼容性的最优解。1.3 先建立整体链路data 到视图之间发生了什么在深入 API 之前先把 Vue 2 响应式的整体链路画在脑子里后面所有代码细节都是在给这条链路补零件。初始化阶段Vue 拿到 data递归遍历所有属性对每个属性调用 Object.defineProperty为它定义 getter 和 setter。渲染阶段render 函数执行时会读取 data 上的属性触发 getter这时把当前正在渲染的 Watcher 收集进依赖列表。更新阶段你修改某个属性触发 settersetter 会通知依赖列表里所有 Watcher 更新。视图变化Watcher 重新执行 render生成新虚拟 DOM最终由 diff 算法更新真实 DOM。注意这里有两个关键环节getter 里做依赖收集setter 里做派发更新。这两件事是理解整个响应式系统的重点后面我专门讲。接下来先把 Object.defineProperty 本身讲透因为它是一切的地基。2. Object.defineProperty 参数拆解value、getter 与三个默认 false 的坑2.1 基本语法与最简单的用例Object.defineProperty 的语法非常简洁三个参数目标对象、属性名、描述符对象。描述符对象决定了这个属性的行为。const person {}; Object.defineProperty(person, name, { value: 小A, writable: false, enumerable: true, configurable: true }); console.log(person.name); // 小A person.name 小B; console.log(person.name); // 小A因为 writable 为 false赋值被静默忽略这个例子展示了最基本的写法。value 表示属性值writable 控制能否被赋值修改。如果你希望属性可枚举、可配置、可写就得把对应开关全部显式打开否则会掉进后面的默认值陷阱。这里我可以先给 Vue 用一句话Vue 内部几乎不用 value 型描述符它用的是 getter/setter 型因为只有在 getter/setter 里才能插入收集依赖和派发更新的额外逻辑。所以你去看 Vue 源码几乎不会见到{ value: xxx }这种写法清一色都是 get/set。2.2 数据描述符和访问器描述符不能混用描述符分两类数据描述符和访问器描述符。数据描述符包含 value、writable、enumerable、configurable访问器描述符包含 get、set、enumerable、configurable。两类都可以控制 enumerable 和 configurable但 value/writable 和 get/set 不能同时出现否则直接抛异常const obj {}; Object.defineProperty(obj, name, { get() { return x; }, value: y // TypeError: Invalid property descriptor });这个限制背后的逻辑很容易理解属性要么是直接存一个值要么是由函数动态计算。前者用 value后者用 get/set两套机制并存会让行为变得无法预测。实际使用中如果你想让属性具备自定义的读写行为你的描述符里就只写 get 和 set不写 valueVue 的响应式属性全部走这条路。2.3 三个默认 false和字面量定义行为完全不一样这是新手最容易忽略的坑。用字面量定义对象属性时三个开关默认都是 true用 Object.defineProperty 新增属性时默认全是 falseconst obj {}; Object.defineProperty(obj, a, { value: 1 }); for (const key in obj) { console.log(key); // 什么都不会打印enumerable 默认 false } obj.a 2; console.log(obj.a); // 1writable 默认 false delete obj.a; console.log(obj.a); // 1configurable 默认 false很多人第一次用 Object.defineProperty 添加属性后发现 for-in、Object.keys 都看不到赋值也改不动就是这个原因。Vue 源码里凡是调用 defineReactive 的地方都会显式声明 enumerable: true、configurable: true正是因为不能依赖默认值。这个细节也提醒我们如果自己封装响应式工具描述符的每个开关都要写清楚别省。3. 从 API 到响应式getter/setter 劫持的关键一步3.1 一个最简的 defineReactive 实现理解了描述符的基本规则下面用 Object.defineProperty 实现一个最简化的 Vue 响应式属性定义函数。Vue 源码里对应的函数名就叫 defineReactive我按它的核心逻辑简化来看function defineReactive(obj, key, val) { Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { console.log(读取了 ${key}); return val; }, set(newVal) { if (newVal val) { return; } console.log(修改了 ${key}); val newVal; } }); } const data { name: 小A }; // 初始化时遍历 data对每个 key 调用 defineReactive Object.keys(data).forEach((key) { defineReactive(data, key, data[key]); }); data.name; // 读取了 name data.name 小B; // 修改了 name data.name; // 读取了 name返回 小B到这里你已经能拦截属性的读写动作了。虽然还没做任何有用的通知但从 API 层面讲一个属性已经从被直接存储变成了被函数接管。这是 Vue 响应式的地基读和写这两条路径都被你控制住了之后想在 getter 里加依赖收集、在 setter 里加通知都是顺手的事。3.2 闭包里保存的值为什么返回 obj[key] 会死循环很多同学第一次模仿这段代码时会写错最常见的问题是 getter 里面直接返回 obj[key]setter 直接写 obj[key] newVal然后发现浏览器直接爆栈。原因很简单getter 里访问 obj[key] 会再次触发 getter形成无限递归setter 里赋值 obj[key] 也一样会再次触发 setter。正确做法是把真实值保存在闭包里。function defineReactive 的参数 val 就是这个闭包变量getter 返回 val、setter 修改 val完全不经过对象本身。对象上挂的只是一个性格偏执的属性它的 getter/setter 被完全重写真实值反而藏在外层作用域里。这是 Object.defineProperty 实现响应式时最核心、也最容易出错的细节。3.3 从能读能写到能通知defineProperty 只是开始上面的 defineReactive 只能打印日志离真正的响应式还差两步。Vue 源码里这个函数的完整逻辑要比我的简化版本复杂不少主要体现在三处如果 val 本身是对象需要递归调用 Observer保证深层属性也被劫持。每个 key 都对应一个 Dep 实例getter 里调用 dep.depend() 收集依赖setter 里调用 dep.notify() 通知更新。数组类型需要走单独的数组方法拦截逻辑因为 defineProperty 对数组下标的监听能力有限。字符串打印那两步换成 dep 的收集与通知响应式闭环就通了。所以我说 defineProperty 只是开始它提供了拦截读写的能力而 Dep 和 Watcher 提供了谁在用、通知谁的逻辑。这两套系统在 Vue 里是严格配合的。4. 边界与教训新增属性、数组下标、深层递归的真相4.1 新增属性不响应Vue.set 到底补了什么回到开头的现象。初始化时 Vue 会遍历 data 已有的 key 并对它们做 defineReactive后续你往对象上新增一个 key 时这个过程不会执行于是新属性没有 getter/setter自然不会出现在依赖系统里。这就是为什么 this.user.age 20 页面不更新。Vue.set 的解决思路是当目标对象是普通对象时它手动调用 defineReactive 给新 key 补上 getter/setter然后手动触发一次依赖通知当目标是数组时它调用 splice 相关逻辑。本质上 Vue.set 就是把你初始化阶段的劫持步骤在运行期手动执行了一遍。// 页面不更新 this.user.age 20; // 页面更新 this.$set(this.user, age, 20);所以解决方案的本质不是调一个神奇 API而是补上 Object.defineProperty 应该做但没有机会做的事。如果你在做自己的响应式封装应该把初始化劫持和动态添加属性后重新劫持都封装好避免调用者踩同样的坑。4.2 数组下标为什么没有被逐项劫持理论上数组下标也是对象的 keyVue 初始化时完全可以用 Object.defineProperty 对索引 0、1、2……逐个劫持。为什么 Vue 2 没这么做一是性能负担数组元素可能非常多而且经常被整体替换或增删对每个下标做 defineProperty开销远超收益。二是实际开发中大家修改数组的主流方式是 push、pop、splice、shift、unshift、sort、reverse 这些方法而不是直接改下标。针对方法做拦截比针对下标做拦截覆盖更广、维护成本更低。于是 Vue 2 采用了另一种策略用拦截器包住数组原型上的 7 个变更方法在调用这些方法时执行原方法然后再做通知。这也是为什么 Vue 2 直接 this.list[0] x 不响应而 this.list.splice(0, 1, x) 能响应。4.3 深层对象递归与 Object.freeze 的提速思路Object.defineProperty 只能处理一层属性。如果 data 里的某个值仍然是对象Vue 会递归地把所有嵌套属性全部劫持。递归的好处是一旦初始化完成深层数据修改也能精确触发响应坏处是初始化开销随着数据规模线性增长数据很深很宽时首屏会卡一下。顺着这个机制Vue 2 留下了一个很实用的优化点对于完全不需要响应式的数据可以用 Object.freeze 冻结对象。Vue 初始化时会检查这个对象是否被冻结如果已被冻结就跳过 defineProperty 流程直接按普通值处理。比如接口返回的大字典表、枚举映射、静态配置用 Object.freeze 能明显降低初始化耗时。不过要注意冻结后的对象无法被任何运行时逻辑修改包括 Vue.set。如果你既要部分响应式、又要优化性能应该把需要响应的字段和不需要响应的静态数据拆成两个对象分别处理。5. 另一半系统getter 收集依赖setter 派发更新5.1 依赖为什么要在 getter 里收集而不是 setter 里这是 Vue 响应式设计里一个很反直觉的点。直觉上数据变了才知道谁需要更新所以依赖应该发生在 setter但如果 setter 时才去查谁在用你只能通过全局扫描或手动注册无法精确知道每个属性和哪些渲染函数有关。换到 getter 就不同了。渲染函数执行时一定会读取它依赖的 data 属性读取必然触发 getter此时显然当前正在执行的这段代码就是这个属性的消费者。把这段代码封装成一个 Watcher在 getter 里把它登记到当前属性的依赖列表就能做到精确一对一。这也是读时收集、写时通知的由来读的时候把谁在用的关系记下来写的时候才找得准通知谁。5.2 一个微型 Dep Watcher 的完整闭环我把上一节的思路用代码表达出来。先写 Dep它负责管理某个属性的一堆订阅者class Dep { constructor() { this.subs []; } depend() { if (Dep.target) { this.subs.push(Dep.target); } } notify() { this.subs.forEach((watcher) watcher.update()); } } Dep.target null;再写一个极简 Watcher它代表一段需要在数据变化时重新执行的代码。为了让依赖收集时知道当前正在执行的 Watcher 是谁get() 执行前把自身挂到 Dep.target 上执行完再清掉class Watcher { constructor(getter) { this.getter getter; this.get(); } get() { Dep.target this; this.getter(); Dep.target null; } update() { this.getter(); } }接着把 defineReactive 升级成带 Dep 的版本function defineReactive(obj, key, val) { const dep new Dep(); Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { dep.depend(); return val; }, set(newVal) { if (newVal val) { return; } val newVal; dep.notify(); } }); }执行一下完整流程const data { info: { name: 小A } }; Object.keys(data).forEach((key) defineReactive(data, key, data[key])); let viewCount 0; function render() { viewCount; console.log(data.info.name, 渲染了 ${viewCount} 次); } new Watcher(render); data.info.name 小B; // 控制台输出 // 小A 渲染了 1 次 // 小B 渲染了 2 次这是整个响应式最核心的微型闭环首次 render 通过 getter 收集依赖修改属性后 setter 通知 WatcherWatcher 重新执行 render。Vue 2 源码里的 Dep、Watcher、defineReactive 就是按照这个逻辑展开的只是多了队列调度、虚拟 DOM 等处理。5.3 数据变化到视图更新的时序与异步更新有了微型实现再把完整时序梳理一遍你就能把 Vue 的初始化、渲染、更新三件事串起来Vue 初始化 data递归调用 defineReactive为每个 key 创建 Dep。创建渲染 Watcher执行 renderrender 读取 data 属性触发 getter依赖收集到对应 Dep。用户修改属性触发 setterDep.notify 被调用。Watcher.update 触发重新执行 render生成新虚拟 DOM。diff 算法对比新旧虚拟 DOM更新真实 DOM。实际 Vue 2 中还有一步关键优化多个数据变化可能在同一轮事件循环里发生如果每次都立刻重新渲染性能会很差。所以 Watcher.update 会把更新任务推入队列等本轮同步代码跑完再统一执行配合 nextTick 把 DOM 更新延迟到下一次事件循环。理解了这个机制你就能明白为什么修改多个响应式属性后页面只会更新一次。6. Vue 3 换成 Proxy 之后这套老 API 的价值还在哪6.1 Proxy 解决的问题新增属性、数组、懒代理Vue 3 用 Proxy 重写了响应式系统核心改进可以总结成三个词全量、自动、懒。全量Proxy 拦截的是整个对象新增、删除属性都天然被覆盖不再需要 Vue.set 这样的补丁 API。自动数组下标赋值、length 变化也能被监听不需要重写数组方法。懒Proxy 可以在 getter 里才代理子对象数据没被访问到的深层属性不初始化性能开销大幅下降。用表格对比会更直观对比维度Vue 2 Object.definePropertyVue 3 Proxy新增属性不响应需 $set天然响应删除属性无法检测天然响应数组下标赋值不响应响应数组方法需要重写 7 个方法天然拦截初始化开销递归全量劫持懒代理按需劫持浏览器兼容IE9不支持老 IE这套升级带来的开发体验提升是明显的少记了很多必须用 $set 才能更新的规则代码心智负担一下子降下来。但代价也很直接Proxy 无法在低版本浏览器上 polyfill所以 Vue 3 的浏览器底线直接拉到现代浏览器。6.2 还在维护 Vue 2 项目这些响应式边界值得注意如果你手上还有 Vue 2 项目下面几条是我在实际维护中总结出来的实操建议可以帮你少踩坑接口返回的数据如果包含动态字段尽量在后端或数据处理层把最终会出现的 key 全部预设出来哪怕初始值是 null 或空字符串。你预设了 keyVue 就会在初始化时完成劫持。运行期确实要给对象加新字段时统一用 this.$set不要直接 this.obj.newKey value。这是最省事也是最保险的写法。数组操作优先用 push、pop、splice 等方法避免直接按下标赋值。必要时可以用 splice 代替下标赋值。不需要响应式的静态数据用 Object.freeze 包一层尤其是大量只读字典数据能明显减少初始化期间 defineProperty 的递归次数。排查数据变了视图不更新的 bug 时先问自己三个问题这个 key 初始化时存在吗我用的是赋值还是方法调用有没有被 Object.freeze 冻结基本都能定位到根因。6.3 换到 Vue 3 不等于可以忘掉 defineProperty即便 Vue 3 全面转向 ProxyObject.defineProperty 在 JavaScript 生态里依然没有过时。Vue 3 的 reactive 实现里某些需要精确控制属性定义的场景仍会用到它很多工具库、表单框架、状态管理库也在用它实现私有属性或不可变约束。更别说面试、源码阅读、写自己的响应式 demo 时你总归要回来把这段逻辑弄明白。所以我的建议是不要因为 Vue 3 存在就跳过 Object.defineProperty 的学习反而是先吃透这套老方案再看 Proxy 方案你才能真正理解 Vue 3 做对了哪些事、牺牲了什么。响应式编程的底层思路是通用的区别只在于拦截手段。最后分享一个个人体会。早年在 Vue 2 项目里排查一个tab 切换后表格数据不更新的 bug折腾了一下午最后发现是后端返回的数据里少了一个字段前端初始化后动态塞进去导致没有走响应式链路。当时如果早一点理解 Object.defineProperty 的劫持时机这个问题十五分钟就能定位。从那之后我给自己定了个规矩凡是用了框架一定要先把它最底层的魔法弄清楚——不是自己实现一遍而是至少要在头脑里把它的运行链路走通。Vue 的这条链路起点就是 Object.defineProperty。