一个学员拿着代码来找我说他明明在mounted里改了this.todo.title页面上的标题就是纹丝不动可console.log打出来的数据明明已经是新值了。这种问题我几乎每个月都能遇到几次而每次解释都绕不开同一个根源Vue 2 的 MVVM 架构和数据代理机制没有真正被理解。如果你只是照着文档写模板、填数据可能一年半载都碰不到这类问题但只要你开始写组件、动业务数据早晚会被它绊一脚。这篇就把 Vue 2 里 MVVM 和数据代理这两件事彻底拆开讲清楚数据究竟是怎么从 JS 对象流到页面 DOM 上的又是在哪一环断掉的。1. 先从最简单的表象说起为什么改了数据页面会跟着变1.1 一行模板背后的完整事件链我们写 Vue 2 的第一行代码通常是这样的div idapp{{ msg }}/divnew Vue({ el: #app, data: { msg: hello vue } })页面打开显示“hello vue”。然后你在控制台敲一行app.msg hello world几乎同时页面上变成了“hello world”。全程没有手动操作过任何 DOM没有document.getElementById(app).innerHTML ...。你可能会觉得这很自然但站在原生 JS 的角度这句话背后藏着一整个链路而且每一环都在 Vue 安装的“机关”里兜了一圈。这条链路大致可以拆成四步app.msg hello world不是简单给普通对象赋值因为msg已经被 Vue 定义成了带 setter 的“代理属性”。setter 被触发后会同步修改 Vue 内部真实存放数据的位置app._data.msg。_data.msg本身也不是普通字段它同样被挂上了 getter/setter修改动作会通知一个叫 Dep 的依赖中心。Dep 把“这条数据变了”的消息广播给所有依赖它的 Watcher其中就包括负责渲染当前组件的 render Watcher于是组件重新执行渲染函数对比出差异更新到页面上。你看到的结果是“改个变量刷新页面”实际上是一套“读取拦截 写入广播”的系统在跑。而其中的第 1 步“代理属性”以及第 3 步“依赖收集”正是这篇要讲的两个核心。1.2 没有框架的年代这段流程怎么写我印象很深早年写页面最痛的不是逻辑而是“数据与视图同步”。一个列表后端返回 50 条数据你用 jQuery 拼好 HTML 塞进页面过两分钟数据变了你得再写一次拼接逻辑然后把新的 HTML 再塞一次。后来出来 MVC 模式把数据处理和界面拆开了但数据一旦发生变化仍然要靠你手动告诉视图层“该更新了”。MVVM 的思路是反过来你不去管视图怎么更新你只需要保证数据是正确的、完整的。视图层会自己盯着数据数据一变它自己就动手。想实现“自己动手”前提就是框架必须能“看见”数据被读取了、被修改了。Vue 2 靠的就是数据代理先让所有属性都变成可控的才能谈后续的自动更新。2. MVVM 到底是谁在干活View / Model / ViewModel 的边界2.1 传统 MVC 和 MVVM 的差别MVVM 全称是 Model-View-ViewModel很多人第一次听觉得抽象。我给个容易记的对应关系Model 是数据View 是页面ViewModel 是中间那个“牵线的人”。要说清楚 ViewModel 的价值最好先和 MVC 比一下。传统 MVC 的分工是Model 管数据View 管界面Controller 作为控制器接收用户操作、调用 Model 修改数据、再手动刷新 View。也就是说Controller 干了很多“指挥交通”的活而 View 并不知道 Model 什么时候变化Model 也不会主动通知 View。MVVM 的差异在于 View 和 Model 之间完全不再直接通信所有交互都通过 ViewModel。ViewModel 负责监听 Model 的变化并及时反映到 View 上同时接收用户在 View 上的操作转换成对 Model 的修改。从结果看Model 变化到 View 更新的这条路被框架自动化了。我在实际判断代码结构时习惯用一张表快速区分对比项MVCMVVMView 是否直接读取 Model可以常见于服务端模板不能只跟 ViewModel 打交道数据变化后谁负责更新视图手动刷新ViewModel 自动通知用户操作入口Controller 接收ViewModel 通过事件绑定接收典型场景PHP 模板、早期桌面 GUIVue、Angular、小程序框架等2.2 ViewModel 在 Vue 实例中的具体形态放到 Vue 2 里MVVM 的落地并不复杂View你写的模板即template或render函数最终渲染出来的 DOM。Model你定义在data里的业务数据对象。ViewModelVue 实例本身准确说是实例自带的响应式系统Observer、Dep、Watcher加上数据代理之后的那个“代理层”。有一个常见的认知误区是觉得 ViewModel 就是vm._data。我在早期也被误导过后来调源码才明白_data指向的是 Model而 ViewModel 是包裹在 Model 外面、承载了响应式能力的整个 Vue 实例。你平时写的this.xxx实际上是实例上的代理属性代理到this._data.xxx再进一步触发响应式系统。这正好解释了为什么app.msg hello world能把页面改掉因为你在跟 ViewModel 说话ViewModel 转告给 ModelModel 的变化又被 ViewModel 反馈给 View。还有一点值得留意MVVM 不等同于双向绑定。Vue 里的v-model确实是 MVVM 思想下的实用产物但 MVVM 本身只是架构模式双向绑定只是它对表单交互的一种具体封装。我在项目里会把“数据更新视图由响应式系统负责”和“视图操作更新数据由事件绑定负责”这两件事分开理解排查问题会果断很多。3. 数据代理的底层机关Object.defineProperty 是怎么被用来“偷梁换柱”的3.1 一个极简的代理 DemoVue 2 的数据代理底层就是Object.defineProperty。我说个很朴素的实现思路。假设你手头有一个真实数据对象_data你想让外部访问vm.msg的时候实际上读到的是_data.msg而且你还想在读取和赋值的时候偷偷干点别的事。那么可以这样写const _data { msg: hello } const vm {} Object.defineProperty(vm, msg, { configurable: true, enumerable: true, get() { console.log(有人读取了 vm.msg实际返回 _data.msg) return _data.msg }, set(val) { console.log(有人给 vm.msg 赋了新值, val) _data.msg val } }) console.log(vm.msg) // 打印 get 日志结果是 hello vm.msg hi // 打印 set 日志然后 _data.msg 变成 hi这段代码就是 Vue 2 数据代理的最小原型访问vm.msg真正读写的是_data.msg。你在实例上看到的属性并不是真正的仓库只是一个“门卫”。Vue 2 在初始化实例时做的事情就是把data里的每个 key 都通过同样的方式定义到vm上。你可以打开控制台打印一个 Vue 实例展开__proto__或者实例属性列表会发现每个数据属性都带有 getter 和 setter 的标记而不是普通的 value。3.2 为什么不能用普通赋值模拟代理有人会问代理不就是转发吗那我直接在初始化时循环一遍for (const key in data) { vm[key] data[key] }这不也能做到vm.msg访问吗能但只做到了“拷贝”没做到“代理”。关键在于vm.msg data.msg是一次值拷贝值类型拷贝了值引用类型拷贝了引用拷贝完之后vm.msg和data.msg之间就再也没有任何“接线”了。你改vm.msgdata.msg不知道你改data.msgvm.msg也不知道。更别谈在这个转发的过程中插入 getter/setter 逻辑。用Object.defineProperty定义的属性最关键的点是访问行为完全由 getter/setter 接管。它不是一个固定的老值而是每一次访问都会重新计算、重新转发的活动通道。所以我在设计封装类的时候也养成了一个习惯需要“实时映射”的属性尽量用访问器属性而不是同步赋值。3.3 getter/setter 之外的第三层含义依赖收集的入口数据代理看起来只是转发但 Vue 2 真正高明的地方在于它把转发和响应式系统绑在了一起。你读一个代理属性的时候光转发是不够的Vue 还会顺便问一句“谁在用这个数据”然后把使用者记下来。这就是依赖收集。简化理解可以想象每个数据属性旁边挂了一个小本子本子上记录着那些“正在显示我”的 Watcher。模板里用到{{ msg }}渲染函数执行时访问了vm.msg触发 getterVue 就把当前的这个组件 Watcher 写进msg的小本子。等你有一天vm.msg xxx触发 setterVue 就翻开小本子挨个通知里面的 Watcher“我变了你重新渲染一下自己。”也就是说getter 不只是转发也负责收集依赖。setter 不只是转发也负责派发更新。这两件事缺一不可。理解了这一点你再看 Vue 3 的 Proxy 响应式会发现设计思路其实是一致的只是底层拦截的粒度从“单个属性”变成了“整个对象”能力的边界彻底不一样了。4. Vue 2 初始化时数据代理的真实执行顺序4.1 initData 阶段到底做了什么看源码是最枯燥也最踏实的学习方式Vue 2 的初始化逻辑里initState会调用initData。我不打算把完整源码贴出来但核心顺序值得用简化版讲清楚function initData(vm) { // 1. 取到用户定义的 data let data vm.$options.data data vm._data typeof data function ? data.call(vm, vm) : data || {} // 2. 遍历 data 的每一个 key const keys Object.keys(data) let i keys.length while (i--) { const key keys[i] // 3. 把 key 代理到 vm 实例上 proxy(vm, _data, key) } // 4. 把 data 变成响应式对象 observe(data, true /* asRootData */) }注意等一下顺序很有意思先做代理再做 observe。源码里observe(data)是对整个 data 对象做响应式劫持而proxy(vm, _data, key)是在实例上建立访问通道。这两步缺一不可。代理负责让你能通过this.xxx访问observe 负责让数据本身变得“可通知”。proxy函数本身也很简单function proxy(target, sourceKey, key) { Object.defineProperty(target, key, { enumerable: true, configurable: true, get() { return this[sourceKey][key] }, set(val) { this[sourceKey][key] val } }) }这里有个容易忽略的细节代理 setter 里写的this[sourceKey][key] val是在给_data的属性赋值而_data上的属性本身经过observe之后也有了 setter所以一次vm.msg vue实际会触发两层 setter实例代理的 setter 先执行把值写进_data.msg随后_data.msg的 setter 再执行通知依赖更新。4.2 观察 vm.msg 与 vm._data.msg 的关系理解了这个顺序你在调试时就能看懂很多“诡异”现象。比如你在控制台执行app.msg a app._data.msg b这两个操作最终结果一样页面都会更新因为不管从哪个口子进最终都会落到同一个_data.msg的 setter 上。区别只在于走代理 setter 会多一层转发。反过来读也一样const x app.msg // 走代理 getter - _data.msg 的 getter const y app._data.msg // 直接走 _data.msg 的 getter所以在组件内部直接操作this.xxx就够了完全没必要碰_data。我唯一主动用_data的场景是在加载 Vue 实例调试时看它内部结构比如确认某个属性是否真的已经被 observe 过了——如果打印出来的对象里没有__ob__标记说明它根本没有走进响应式系统。4.3 数组的响应式是另一套玩法有一点必须单独拎出来说observe遇到数组时并不会对数组的每一个索引做Object.defineProperty。这正是 Vue 2 很多“坑”的来源。Vue 2 的做法是如果数据属性是数组它会把数组实例的原型链替换成一个经过增强的arrayMethods对象重写push、pop、shift、unshift、splice、sort、reverse这七个方法。在这些方法内部调用原生方法后找到数组身上的__ob__然后手动通知依赖更新const methodsToPatch [push, pop, shift, unshift, splice, sort, reverse] methodsToPatch.forEach(method { const original Array.prototype[method] Object.defineProperty(arrayMethods, method, { value: function (...args) { const result original.apply(this, args) const ob this.__ob__ ob.dep.notify() return result } }) })这也是为什么this.list.push(item)能更新视图而this.list[0] item不能。数组索引不是被 defineProperty 监视的属性所以直接按索引赋值时Vue 根本收不到通知。这一点到后面讲坑的时候还会展开。5. 数据代理的边界与坑为什么有的数据改了不生效5.1 新增属性为什么没有 setter数据代理是在初始化时遍历data已有的 key 做的。它只能管“当时存在的属性”管不了“以后新增的属性”。所以你在组件里写this.user.age 18如果user初始化时只有name没有age那么age就是一个普通的、没有 getter/setter 的新属性。你赋值它没有代理 setter没有依赖收集视图自然也不会动。正确做法是this.$set(this.user, age, 18) // 或 Vue.set(this.user, age, 18)$set的核心逻辑并不复杂如果目标是数组就调用splice如果对象上没有这个属性就用defineReactive把新属性变成响应式的然后找到对象身上的__ob__.dep手动通知一次更新。这也是我在项目里强烈建议团队统一使用$set加动态字段的原因——不给“字段漏了响应式”留机会。5.2 通过索引改数组为什么失效上面已经说了数组的响应式机制这里再补一个实际案例。假设你有data() { return { list: [{ name: a }, { name: b }] } }然后执行this.list[0].name new这个操作是能更新视图的因为list[0]是一个对象而这个对象本身已经被递归observe过它的name属性有 setter你改的是对象属性自然走通了响应式链路。真正失效的是this.list[0] { name: new }或者this.list.length 0因为这两个操作改的是数组本身的结构而数组索引没有 setter 监听。正确方式是splicethis.list.splice(0, 1, { name: new }) this.list.splice(0) // 清空数组或者用$setthis.$set(this.list, 0, { name: new })5.3 整体替换对象为什么又能生效这是一个很多人觉得“矛盾”的点新增属性不行但整体替换对象却可以。其实并不矛盾。因为整体替换触发的是对象本身的 setter// 假设 this.user 初始化时有 name 字段 this.user { name: new, age: 18 }这里的user是初始化时已经代理过、并且被defineReactive保护过的顶层属性赋值会触发它的 setter。setter 收到新对象后会先对新对象执行observe(newVal)把新对象里的所有字段重新做成响应式然后通知依赖更新。所以新对象里的age虽然当初不存在但在整体替换的那一刻被补上了完整的响应式能力。5.4 排查“数据变了但页面没反应”的实用思路结合上面的坑我总结了一套在实际项目中很有用的排查路径遇到这类问题可以按顺序检查确认改的是不是vm实例 / 组件实例上的属性而不是组件外部的普通对象。确认这个字段在初始data里就存在。如果不存在改用$set。确认改的是不是数组索引或length。如果是改用splice、重写方法或$set。确认是否因为代码时序问题在data都还没有初始化完之前就赋值了比如在created早期用了一个尚未存在的数据。最后再看是不是不小心在某个对象上覆盖了整个data引用导致组件持有的响应式对象被换了而视图 Watcher 仍然盯在旧对象上。这几条还不能解决基本就能把问题定位到是你自己代码里“丢掉了数据引用”再通过打断点检查实际赋值语句来进一步锁定。6. 从 Vue 2 数据代理看 Vue 3 的升级Object.defineProperty 的三个天花板6.1 Object.defineProperty 的三个明显局限Vue 2 的数据代理方案很巧妙但它确实是戴着镣铐跳舞。最明显的是三个天花板无法监听新增属性。这导致必须用$set打补丁。無法监听数组索引和 length 变化。这导致必须重写数组方法。初始化时必须深度递归。observe会递归遍历 data 所有层级的对象当数据量很大或嵌套很深时初始化会明显变慢而且一旦对象在被 observe 之后新增了很深层的字段还是要靠$set一层层处理。这也是我后来跨到 Vue 3 项目时最直观的感受Vue 3 用Proxy代理整个对象读取和赋值都走拦截新增属性、删除属性、数组索引通通不在话下连in运算符、Object.keys都能被拦截到。数据代理从“监听属性”变成了“监听对象”边界问题被根治了一大半。6.2 面试和项目里的实际借鉴面试的时候MVVM 和数据代理是高频题但很多人只会背概念。我的建议是在纸上手写一遍上面的“极简代理 Demo”然后把“为什么 Vue 2 需要$set”作为引子把 Object.defineProperty 的局限和 Vue 3 的 Proxy 方案讲一遍基本就能答出区分度。在项目里如果团队还在维护 Vue 2 代码有一条经验很实用不要把复杂业务数据塞进一个又大又深的嵌套对象里。一方面是初始化递归慢另一方面是后续维护动态字段时到处都是$set代码可读性会很差。更推荐把数据拆成扁平的小对象每个字段在初始化时就定义好变化时用整体替换的方式更新。这个习惯既绕开了 Vue 2 的短板也会让数据流更清晰。最后再分享一个我自己的小习惯遇到视图不更新第一反应不是去看模板代码而是先在控制台打印实例观察目标字段的 setter 是否存在。setter 在问题多半在触发链路上setter 不在基本就是数据代理没有覆盖到直接用$set或调整数据结构就能解决。Vue 2 的这套设计看懂了会觉得巧妙看不懂只会觉得处处是坑而它恰恰是所有响应式框架在“如何监听数据”这件事上的第一课。