先说一个几乎人人都会遇到的现象: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 收集进依赖列表。更新阶段:你修改某个属性,触发 setter,setter 会通知依赖列表里所有 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,不写 value;Vue 的响应式属性全部走这条路。2.3 三个默认 false:和字面量定义行为完全不一样这是新手最容易忽略的坑。用字面量定义对象属性时,三个开关默认都是 true;用 Object.defineProperty 新增属性时,默认全是 false:const obj = {}; Object.defineProperty(obj, 'a', { value: 1 }); for (const key in obj) { console.log(key); // 什么都不会打印,enumerable 默认 false } obj.a = 2; console.log(obj.a); // 1,writable 默认 false delete obj.a; console.log(obj.a); // 1,configurable 默认 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 数组下标为什么没有被逐项劫持理论上,数组下标也是对象的 key,Vue 初始化时完全可以用 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 通知 Watcher,Watcher 重新执行 render。Vue 2 源码里的 Dep、Watcher、defineReactive 就是按照这个逻辑展开的,只是多了队列调度、虚拟 DOM 等处理。5.3 数据变化到视图更新的时序与异步更新有了微型实现,再把完整时序梳理一遍,你就能把 Vue 的初始化、渲染、更新三件事串起来:Vue 初始化 data,递归调用 defineReactive,为每个 key 创建 Dep。创建渲染 Watcher,执行 render,render 读取 data 属性触发 getter,依赖收集到对应 Dep。用户修改属性,触发 setter,Dep.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 或空字符串。你预设了 key,Vue 就会在初始化时完成劫持。运行期确实要给对象加新字段时,统一用 this.$set,不要直接 this.obj.newKey = value。这是最省事也是最保险的写法。数组操作优先用 push、pop、splice 等方法,避免直接按下标赋值。必要时可以用 splice 代替下标赋值。不需要响应式的静态数据用 Object.freeze 包一层,尤其是大量只读字典数据,能明显减少初始化期间 defineProperty 的递归次数。排查"数据变了视图不更新"的 bug 时,先问自己三个问题:这个 key 初始化时存在吗?我用的是赋值还是方法调用?有没有被 Object.freeze 冻结?基本都能定位到根因。6.3 换到 Vue 3 不等于可以忘掉 defineProperty即便 Vue 3 全面转向 Proxy,Object.defineProperty 在 JavaScript 生态里依然没有过时。Vue 3 的 reactive 实现里,某些需要精确控制属性定义的场景仍会用到它;很多工具库、表单框架、状态管理库也在用它实现私有属性或不可变约束。更别说面试、源码阅读、写自己的响应式 demo 时,你总归要回来把这段逻辑弄明白。所以我的建议是:不要因为 Vue 3 存在就跳过 Object.defineProperty 的学习,反而是先吃透这套老方案,再看 Proxy 方案,你才能真正理解 Vue 3 做对了哪些事、牺牲了什么。响应式编程的底层思路是通用的,区别只在于拦截手段。最后分享一个个人体会。早年在 Vue 2 项目里排查一个"tab 切换后表格数据不更新"的 bug,折腾了一下午,最后发现是后端返回的数据里少了一个字段,前端初始化后动态塞进去,导致没有走响应式链路。当时如果早一点理解 Object.defineProperty 的劫持时机,这个问题十五分钟就能定位。从那之后我给自己定了个规矩:凡是用了框架,一定要先把它最底层的魔法弄清楚——不是自己实现一遍,而是至少要在头脑里把它的运行链路走通。Vue 的这条链路,起点就是 Object.defineProperty。
先说一个几乎人人都会遇到的现象: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 收集进依赖列表。更新阶段:你修改某个属性,触发 setter,setter 会通知依赖列表里所有 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,不写 value;Vue 的响应式属性全部走这条路。2.3 三个默认 false:和字面量定义行为完全不一样这是新手最容易忽略的坑。用字面量定义对象属性时,三个开关默认都是 true;用 Object.defineProperty 新增属性时,默认全是 false:const obj = {}; Object.defineProperty(obj, 'a', { value: 1 }); for (const key in obj) { console.log(key); // 什么都不会打印,enumerable 默认 false } obj.a = 2; console.log(obj.a); // 1,writable 默认 false delete obj.a; console.log(obj.a); // 1,configurable 默认 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 数组下标为什么没有被逐项劫持理论上,数组下标也是对象的 key,Vue 初始化时完全可以用 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 通知 Watcher,Watcher 重新执行 render。Vue 2 源码里的 Dep、Watcher、defineReactive 就是按照这个逻辑展开的,只是多了队列调度、虚拟 DOM 等处理。5.3 数据变化到视图更新的时序与异步更新有了微型实现,再把完整时序梳理一遍,你就能把 Vue 的初始化、渲染、更新三件事串起来:Vue 初始化 data,递归调用 defineReactive,为每个 key 创建 Dep。创建渲染 Watcher,执行 render,render 读取 data 属性触发 getter,依赖收集到对应 Dep。用户修改属性,触发 setter,Dep.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 或空字符串。你预设了 key,Vue 就会在初始化时完成劫持。运行期确实要给对象加新字段时,统一用 this.$set,不要直接 this.obj.newKey = value。这是最省事也是最保险的写法。数组操作优先用 push、pop、splice 等方法,避免直接按下标赋值。必要时可以用 splice 代替下标赋值。不需要响应式的静态数据用 Object.freeze 包一层,尤其是大量只读字典数据,能明显减少初始化期间 defineProperty 的递归次数。排查"数据变了视图不更新"的 bug 时,先问自己三个问题:这个 key 初始化时存在吗?我用的是赋值还是方法调用?有没有被 Object.freeze 冻结?基本都能定位到根因。6.3 换到 Vue 3 不等于可以忘掉 defineProperty即便 Vue 3 全面转向 Proxy,Object.defineProperty 在 JavaScript 生态里依然没有过时。Vue 3 的 reactive 实现里,某些需要精确控制属性定义的场景仍会用到它;很多工具库、表单框架、状态管理库也在用它实现私有属性或不可变约束。更别说面试、源码阅读、写自己的响应式 demo 时,你总归要回来把这段逻辑弄明白。所以我的建议是:不要因为 Vue 3 存在就跳过 Object.defineProperty 的学习,反而是先吃透这套老方案,再看 Proxy 方案,你才能真正理解 Vue 3 做对了哪些事、牺牲了什么。响应式编程的底层思路是通用的,区别只在于拦截手段。最后分享一个个人体会。早年在 Vue 2 项目里排查一个"tab 切换后表格数据不更新"的 bug,折腾了一下午,最后发现是后端返回的数据里少了一个字段,前端初始化后动态塞进去,导致没有走响应式链路。当时如果早一点理解 Object.defineProperty 的劫持时机,这个问题十五分钟就能定位。从那之后我给自己定了个规矩:凡是用了框架,一定要先把它最底层的魔法弄清楚——不是自己实现一遍,而是至少要在头脑里把它的运行链路走通。Vue 的这条链路,起点就是 Object.defineProperty。