开头就用一个实际场景带入模板语法学得差不多了写页面也顺手了但脑子里开始冒出一个问题——Vue 实例上那些this.xxx、模板里的{{ xxx }}背后到底是靠什么串起来的。答案也就是这节标题里写的两个词MVVM 和数据代理。这篇文章适合所有正在学 Vue 2 基本语法、想知道“Vue 为什么能直接读 data 里的字段”的同学我会从设计思路讲到源码行为再带你手写一个简化版的数据代理最后把常见坑列一遍。1. MVVM 到底是啥先弄清楚 Vue 在视图和模型之间安插了什么1.1 Model你的数据就是最大的 ModelMVVM 的全称是 Model-View-ViewModel翻译过来就是“模型-视图-视图模型”。很多人一看到这种缩写就头大实际上它描述的事情特别朴素你的网页上有一块区域要显示数据数据本身存起来了中间需要有个东西把数据“翻译”成页面能渲染的内容。Model 层在 Vue 2 里就是data。你写的data: { msg: hello }不管这个 data 是对象还是函数返回的对象它就是整个应用的数据源。Model 不关心消息怎么显示、按钮怎么布局它只负责保存状态。这一点很重要Model 是纯数据的不应该直接操作 DOM。1.2 View模板编译后呈现出来的 DOMView 层就是你写的模板template里的那部分最终会被 Vue 编译成 render 函数再转换成真实的 DOM。说人话View 是用户看到的界面是数据“长”出来的样子。这里有个容易误解的点模板本身不是 View模板经过编译渲染之后的 DOM 才是 View。Vue 拿到模板字符串以后会先解析成 AST再优化、生成代码最后通过 render 函数产出虚拟 DOM。所以你在{{ msg }}里写的那个 msg实际上是在 render 函数执行时通过with(this) { return _c(div, {}, [_v(_s(msg))]) }这种形式读取的。这个this就是 Vue 实例也就是 ViewModel。1.3 ViewModelVue 实例就是那个“中间人”ViewModel 在 Vue 2 里就是每个组件对应的实例。它的职责很清晰把 Model 里的数据传递给 View让模板能渲染出来再把 View 上的交互事件转成对 Model 的修改。Vue 实现这一层主要靠两个机制一个是数据代理让实例可以直接访问 data 里的字段另一个是响应式劫持让数据变化能触发视图更新。二者是配合关系缺一不可。数据代理解决的是“怎么好写代码”的问题响应式劫持解决的是“数据变了视图怎么知道”的问题。1.4 那 Vue 到底算不算严格的 MVVM说实话Vue 2 并不是教科书意义上严格的 MVVM。经典 MVVM 强调 View 和 ViewModel 之间是双向绑定的View 变了 VM 跟着变VM 变了 View 也跟着变。Vue 实际做的是单向数据流数据变化驱动视图更新而视图变化这件事必须通过事件、方法调用等显式方式去修改数据没有天然的“视图反向改数据”通道。你写个v-model看起来像双向绑定本质也是语法糖它内部帮你监听 input 事件然后把值写回 data。我自己当时学这块有个感觉MVVM 不一定要严格做到但理解了这个模式再看 Vue 的设计就会顺很多。你会明白为什么this.msg x页面会变为什么this.$el.innerHTML xxx页面不会变——前者走的才是 ViewModel 的正规流程。2. 数据代理为什么存在Object.defineProperty 才是地基2.1 没有代理之前你要写什么假设没有数据代理你 new 一个 Vue 实例之后数据都存在vm._data里那访问数据就只能写成vm._data.msg修改数据要写vm._data.msg new value。模板里也得写_data.msg又丑又容易漏。Vue 的设计者显然不想让你这么难受所以它做了一件事把 data 里每个字段都在 vm 实例上重新定义一遍让vm.msg读写操作自动转发到vm._data.msg。这就是“数据代理”四个字的意思——实例上的属性不存真实数据只是负责转发。2.2 defineProperty 的五个配置项实现数据代理用到的 API 是Object.defineProperty它可以给对象新增或者修改一个属性并且精确控制这个属性的行为。它的第三个参数是属性描述符有五个关键配置value属性的初始值。writable是否能赋值改写。enumerable是否可以被for...in或Object.keys()枚举出来。configurable是否能删除该属性能否再次修改描述符。get读取属性时调用的函数有它就不能有 value 和 writable。set给属性赋值时调用的函数有它同样不能有 value 和 writable。2.3 get 和 set 是关键数据代理只用到了 get 和 set。你可以把这两个函数理解成属性门口的两个体检站读属性时先过 get写属性时先过 set。比如const target {}; const source { name: zhangsan }; Object.defineProperty(target, name, { enumerable: true, configurable: true, get() { return source.name; }, set(val) { source.name val; } }); console.log(target.name); // zhangsan target.name lisi; console.log(source.name); // lisi这个例子里target.name本身不存数据但它能读 source 的 name、写 source 的 name。Vue 对 vm 实例做的几乎就是这件事把vm.msg和vm._data.msg之间建立一条读写通道。2.4 数据流动方向数据代理让代码里的数据流向变得很清晰你在组件里写this.msg abc实际发生的是this._data.msg abc。然后在响应式系统里这个赋值又会触发_data上 msg 属性的 setter进而通知依赖更新视图。代理这一层是“入口约定”响应式劫持是“内部监控”两个层次叠加才是完整的 Vue 数据机制。我见过不少初学者混淆这两个概念一听到代理就以为它负责更新视图。其实代理不更新视图它只转发读写操作。真正忙的是 Object.defineProperty 的另外一套 getter/setter那套东西在_data上不在实例属性上。3. 从零复刻一个简化版数据代理3.1 明确三个目标要验证自己是不是真懂了最好动手写一个简化版实现。我先定义目标第一new Vue 之后vm上要能直接访问options.data里的字段。第二修改vm.msgvm._data.msg要同步变化。第三修改vm._data.msgvm.msg也要能读到最新值。不用考虑模板编译、依赖收集、diff 算法只做数据代理这一件事。3.2 代码实现function Vue(options) { this.$options options || {}; this._data options.data || {}; // 模拟源码里的 observe先占个位 observe(this._data); // 遍历 data 的所有 key在 vm 实例上做代理 const keys Object.keys(this._data); keys.forEach((key) { proxy(this, _data, key); }); } function proxy(target, sourceKey, key) { Object.defineProperty(target, key, { enumerable: true, configurable: true, get() { return this[sourceKey][key]; }, set(val) { this[sourceKey][key] val; } }); } function observe(data) { // 真正源码里这里是给 data 的每个属性做响应式劫持 // 简化版先直接返回 if (!data || typeof data ! object) return; Object.keys(data).forEach((key) { defineReactive(data, key, data[key]); }); } function defineReactive(target, key, val) { Object.defineProperty(target, key, { enumerable: true, configurable: true, get() { console.log(读取 data.${key} ${val}); return val; }, set(newVal) { if (newVal val) return; console.log(设置 data.${key} ${newVal}); val newVal; } }); } const vm new Vue({ data: { msg: hello, count: 0 } }); console.log(vm.msg); // hello走代理 get再去读 _data.msg vm.msg world; // 走代理 set写入 _data.msg console.log(vm._data.msg); // world vm._data.count 10; // 直接改 _data.count触发响应式 setter console.log(vm.count); // 10代理 get 读到 _data.count这段代码跑完你会发现代理和响应式是两层独立的机制但配合得像榫卯一样。vm.msg world这个动作先经过的是proxy里定义的 set然后在 set 内做this._data.msg val这个赋值才会进入defineReactive的 set 拦截。3.3 几个容易踩的实现细节实现里面有个细节值得单独拿出来说proxy 里的 configurable。我把configurable设成了 true因为要模拟真实 Vue 的行为。如果你把它设成 false后面想再改描述符或者重新代理就会报错。还有一个细节是 enumerable。真实 Vue 源码里_data上的属性默认是可枚举的而实例属性vm.$data等默认是不可枚举的。我在简化版里刻意没有加enumerable: false是为了打印起来直观。你在实际项目里写类似工具时要注意属性枚举带来的副作用比如for...in意外遍历到不该出现的字段。3.4 代理的真正价值在于“统一入口”写完这段代码你会感受到代理设计的一个好处不管代码里读的是 vm 还是 _data最终都汇到同一条数据通道上。这保证了后续要做响应式跟踪时只需要在一处拦截。如果没有代理你就要在模板里到处写_data.xxx又容易漏掉某个读写路径。有人会问响应式劫持已经能把 data 的读写监控起来了为什么还需要代理因为模板和组件逻辑里大量使用this.xxx如果只劫持_data那this.msg和_data.msg就成了两个地方你改了其中一个另一个不会自动同步数据会出现“分身”。代理保证this.msg就是_data.msg的别名永远一致。4. 在真实 Vue 实例里看数据代理4.1 vm._data 到底是什么我建议你在浏览器里开一个 Vue 2 项目随便找一个组件在 mounted 里打印一下this。你会看到实例上有一堆以$或_开头的属性比如$attrs、$children、_data。其中_data指向的就是 data 选项那个对象并且它和$data是同一个对象。拿一个简单的配置举例new Vue({ data: { a: 1, b: { c: 2 }, arr: [1, 2, 3] } });Vue 初始化时大致会经历这几个步骤先拿到 data 对应的值校验它是不是纯对象然后observe(data)给这个对象及其嵌套对象做响应式。再做proxy(vm, _data, key)把 data 的每个 key 代理到 vm 实例上。所以vm.a和vm._data.a读出来的值相同vm.a vm._data.a为 truevm._data vm.$data也为 true。很多人以为 _data 是数据副本其实不是它是同一个对象的引用。这个认识对后面排查“为什么改了 data 里对象属性模板没更新”这类问题至关重要因为引用是一样的问题一定出在是不是走了 setter。4.2 代理 getter 和响应式 getter 的分工在控制台里可以用Object.getOwnPropertyDescriptor分别查看实例属性和 _data 属性会发现差别非常大。const vm new Vue({ data: { a: 1 } }); const d1 Object.getOwnPropertyDescriptor(vm, a); const d2 Object.getOwnPropertyDescriptor(vm._data, a); console.log(d1); console.log(d2);d1的 get 和 set 是代理函数它的 get 内部会去vm._data.a取值d2的 get 和 set 是响应式函数get 里会做依赖收集set 里会触发依赖更新。同一个属性在两层分别被监控各司其职。这也解释了一个问题为什么 Vue DevTools 里看到一个组件的数据时instance 选项下的$data是响应式拦截过的而组件实例上同名属性只是透明的读写窗口。4.3 一次完整访问链路拿模板里{{ a }}来说render 函数执行时读取vm.a触发代理 get代理 get 内部读vm._data.a触发响应式 get响应式 get 发现当前正在渲染的 Watcher就把这个 Watcher 收集起来。之后你在控制台vm._data.a 100响应式 set 被触发它通知 WatcherWatcher 重新执行 render。这就是完整链路。如果直接给vm.a 30也一样只是多了第一步代理 set把 30 写到_data.a然后触发响应式 set。整个链路从 4 个环节变成 5 个环节但始终是单向贯通。4.4 新属性、数组下标到底去哪里了你可能会想既然代理把 data 的 key 都在实例上定义了一遍那vm.xxx 3这种新加属性Vue 能监控到吗很遗憾不行。原因很简单代理是初始化时遍历Object.keys(data)做的事新增的 key 在遍历时根本不存在自然没有 getter/setter。所以你在实例上动态加一个属性它只是一个普通实例属性不会和_data同步更不会触发视图更新。数组下标也有类似问题。代理层能读到this.arr但它只代理了arr这个引用没有代理数组里的每个索引。this.arr[0] 99这个操作走的是数组对象内部的下标赋值没有走 Vue 给对象属性定义的 setter所以视图不刷新。但this.arr [99]是可以触发的因为整个 arr 属性有代理 set写到_data.arr时又有响应式 set新数组还会被递归观察。这类问题不是 bug而是“没有提前代理就没有监听入口”的自然结果。官方给的替代方案是Vue.set或者$set它会在运行时补上响应式处理。5. 常见坑与排障技巧实测记录5.1 实例上新增属性视图没反应这是我和周边同事踩得最多的一次。场景通常是接口返回后往一个对象上塞新字段然后发现页面不更新。去查原因十有八九是新增字段在 data 初始化时不存在。排查思路先确认你是用this.xxx newVal还是this.dataObj.xxx newVal如果对象本身是 data 里定义过的字段是新挂的就可以锁定为未代理问题。解决方式有两种一是初始化时把字段提前声明成 null二是用this.$set(this.dataObj, xxx, newVal)。我在一个老项目里处理过一批表格列字段后端返回格式随时变最后统一封装了一个assignData方法内部判断字段是否存在不存在就$set规避了一堆隐藏问题。5.2 数组按下标修改视图没反应this.arr[0] x不响应是 Vue 2 老生常谈的坑。原因前面说过数组索引没有走对象属性代理。解决方式// 使用数组方法 this.arr.splice(0, 1, x); // 或者整体替换 const newArr [...this.arr]; newArr[0] x; this.arr newArr;我个人的习惯是优先用 splice尤其在做多选、列表编辑时语义清晰而且 Vue 重写过的 splice 参与了响应式通知。整体替换适合需要 immutable 风格的场景但要注意别直接this.arr[0] x; this.arr this.arr;这样既改了原数组又骗代理 set处理起来容易乱。5.3 代理对象与 data 对象是同一个引用吗是。vm._data vm.$data并且vm._data和初始化传入的 data 是同一个对象。这代表一个隐藏风险如果你在代理上改了嵌套对象的属性原 data 对象里的值也会变因为它们本来就是一个引用。曾经有个同事在工具函数里直接改动传入的 data 对象以为只是改了“额外副本”结果组件状态被污染。排查了很久才发现引用关系。所以记住一个原则不要在组件外部直接改动 data 对应的对象除非你明确知道自己在做什么。5.4 初始化顺序和命名冲突data 里的字段如果和 Vue 实例自带属性冲突可能引起警告或者覆盖。源码里对 props、methods、data 做了检查data 的 key 如果和 props 重名会有 warning和 methods 重名会有 warning 且行为可能不符合预期。我自己踩过一个例子data 里定义了一个$route结果和 Vue Router 注入的$route冲突调试了半天发现路由信息不对。后来养成习惯data 字段名以业务语义命名不用$或者_开头不覆盖实例属性。5.5 常用调试手段遇到响应式相关的问题我一般用几个工具快速定位。第一招打印属性描述符。不确定某个属性是不是被代理了就执行Object.getOwnPropertyDescriptor看 get 和 set 是否存在。第二招在 getter 里临时 console。可以用 Object.defineProperty 临时给某个属性加一个 log getter看读取时机。开发环境调试完再删掉不要留在生产代码里。第三招看 Vue DevTools。数据面板里能直观看到哪些字段有响应式标识、值有没有变化。第四招watch 一个函数作为中间步骤。比如watch: { obj: { handler() { console.log(obj changed) }, deep: true } }可以帮你确认深层字段修改时有没有触发依赖。下面整理一个速查表平时排查可以直接对号入座现象常见原因解决/排查方向新增属性视图不更新属性初始化时不存在没走代理和劫持初始化声明或使用$set数组下标赋值不更新数组索引未定义 setter使用 splice或整体替换数组修改 nested 对象整个对象都变_data是同一引用深拷贝处理外部传入对象data 属性名冲突和实例属性或 methods 重名避免$、_开头业务化命名异步回调里数据变了视图没变修改的方式不响应或回调绑定上下文错误检查 this 指向检查赋值方式6. 延伸想象数据代理的边界就是 Vue 2 的能力边界Object.defineProperty 这套方案设计得再精巧也有它天然的墙角。一次只能代理一个 key所以对象有多少属性就得循环多少次 defineProperty。数组索引没办法逐个代理所以 Vue 只能重写数组方法弥补。新增和删除属性没有提前定义的 setter所以只能靠$set和$delete补刀。Vue 3 换成 Proxy 之后这些问题从机制上就消失了。Proxy 可以代理整个对象新增属性、删除属性、数组下标天然能被监听到。但你回头看 Vue 2 的这套设计会发现它并不是简单的“添加属性转发的工具”而是一整套配合响应式的体系。理解数据代理核心不是背下来proxy这个函数而是搞清楚“读写入口和监控机制分离、再协同工作”这个思路。这节内容写到这里我觉得最有价值的一个实操习惯是在学一个新框架机制时先用 console 输出属性描述符和内部_data把黑盒变成白盒。你能亲眼看到代理 get、响应式 get、依赖收集的触发顺序比死记一堆结论有用得多。下次再遇到 Vue 2 数据不更新的问题你至少能先判断是哪一层出了问题而不是把代码翻来覆去改一遍。