☰
Vue 2 数据代理与 MVVM 原理:从 Object.defineProperty 到响应式更新
2026/10/14 5:07:25 网站建设 项目流程

一个学员拿着代码来找我,说他明明在mounted里改了this.todo.title,页面上的标题就是纹丝不动,可console.log打出来的数据明明已经是新值了。这种问题我几乎每个月都能遇到几次,而每次解释都绕不开同一个根源:Vue 2 的 MVVM 架构和数据代理机制没有真正被理解。如果你只是照着文档写模板、填数据,可能一年半载都碰不到这类问题;但只要你开始写组件、动业务数据,早晚会被它绊一脚。这篇就把 Vue 2 里 MVVM 和数据代理这两件事彻底拆开,讲清楚数据究竟是怎么从 JS 对象流到页面 DOM 上的,又是在哪一环断掉的。

1. 先从最简单的表象说起:为什么改了数据,页面会跟着变

1.1 一行模板背后的完整事件链

我们写 Vue 2 的第一行代码通常是这样的:

<div id="app">{{ msg }}</div>
new Vue({ el: '#app', data: { msg: 'hello vue' } })

页面打开,显示“hello vue”。然后你在控制台敲一行:

app.msg = 'hello world'

几乎同时,页面上变成了“hello world”。全程没有手动操作过任何 DOM,没有document.getElementById('app').innerHTML = ...。你可能会觉得这很自然,但站在原生 JS 的角度,这句话背后藏着一整个链路,而且每一环都在 Vue 安装的“机关”里兜了一圈。

这条链路大致可以拆成四步:

  1. app.msg = 'hello world'不是简单给普通对象赋值,因为msg已经被 Vue 定义成了带 setter 的“代理属性”。
  2. setter 被触发后,会同步修改 Vue 内部真实存放数据的位置app._data.msg。
  3. _data.msg本身也不是普通字段,它同样被挂上了 getter/setter,修改动作会通知一个叫 Dep 的依赖中心。
  4. 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 更新的这条路,被框架自动化了。

我在实际判断代码结构时习惯用一张表快速区分:

对比项MVCMVVM
View 是否直接读取 Model可以,常见于服务端模板不能,只跟 ViewModel 打交道
数据变化后谁负责更新视图手动刷新ViewModel 自动通知
用户操作入口Controller 接收ViewModel 通过事件绑定接收
典型场景PHP 模板、早期桌面 GUIVue、Angular、小程序框架等

2.2 ViewModel 在 Vue 实例中的具体形态

放到 Vue 2 里,MVVM 的落地并不复杂:

  • View:你写的模板,即template或render函数最终渲染出来的 DOM。
  • Model:你定义在data里的业务数据对象。
  • ViewModel:Vue 实例本身,准确说是实例自带的响应式系统(Observer、Dep、Watcher)加上数据代理之后的那个“代理层”。

有一个常见的认知误区是:觉得 ViewModel 就是vm._data。我在早期也被误导过,后来调源码才明白,_data指向的是 Model,而 ViewModel 是包裹在 Model 外面、承载了响应式能力的整个 Vue 实例。你平时写的this.xxx,实际上是实例上的代理属性,代理到this._data.xxx,再进一步触发响应式系统。这正好解释了为什么app.msg = 'hello world'能把页面改掉:因为你在跟 ViewModel 说话,ViewModel 转告给 Model,Model 的变化又被 ViewModel 反馈给 View。

还有一点值得留意:MVVM 不等同于双向绑定。Vue 里的v-model确实是 MVVM 思想下的实用产物,但 MVVM 本身只是架构模式,双向绑定只是它对表单交互的一种具体封装。我在项目里会把“数据更新视图由响应式系统负责”和“视图操作更新数据由事件绑定负责”这两件事分开理解,排查问题会果断很多。

3. 数据代理的底层机关:Object.defineProperty 是怎么被用来“偷梁换柱”的

3.1 一个极简的代理 Demo

Vue 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.msg,data.msg不知道;你改data.msg,vm.msg也不知道。更别谈在这个转发的过程中插入 getter/setter 逻辑。

用Object.defineProperty定义的属性,最关键的点是:访问行为完全由 getter/setter 接管。它不是一个固定的老值,而是每一次访问都会重新计算、重新转发的活动通道。所以我在设计封装类的时候也养成了一个习惯:需要“实时映射”的属性,尽量用访问器属性,而不是同步赋值。

3.3 getter/setter 之外的第三层含义:依赖收集的入口

数据代理看起来只是转发,但 Vue 2 真正高明的地方在于,它把转发和响应式系统绑在了一起。你读一个代理属性的时候,光转发是不够的,Vue 还会顺便问一句:“谁在用这个数据?”然后把使用者记下来。这就是依赖收集。

简化理解,可以想象每个数据属性旁边挂了一个小本子,本子上记录着那些“正在显示我”的 Watcher。模板里用到{{ msg }},渲染函数执行时访问了vm.msg,触发 getter,Vue 就把当前的这个组件 Watcher 写进msg的小本子。等你有一天vm.msg = 'xxx',触发 setter,Vue 就翻开小本子,挨个通知里面的 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 监听。正确方式是splice:

this.list.splice(0, 1, { name: 'new' }) this.list.splice(0) // 清空数组

或者用$set:

this.$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 排查“数据变了但页面没反应”的实用思路

结合上面的坑,我总结了一套在实际项目中很有用的排查路径,遇到这类问题可以按顺序检查:

  1. 确认改的是不是vm实例 / 组件实例上的属性,而不是组件外部的普通对象。
  2. 确认这个字段在初始data里就存在。如果不存在,改用$set。
  3. 确认改的是不是数组索引或length。如果是,改用splice、重写方法或$set。
  4. 确认是否因为代码时序问题,在data都还没有初始化完之前就赋值了,比如在created早期用了一个尚未存在的数据。
  5. 最后再看是不是不小心在某个对象上覆盖了整个data引用,导致组件持有的响应式对象被换了,而视图 Watcher 仍然盯在旧对象上。

这几条还不能解决,基本就能把问题定位到是你自己代码里“丢掉了数据引用”,再通过打断点检查实际赋值语句来进一步锁定。

6. 从 Vue 2 数据代理看 Vue 3 的升级:Object.defineProperty 的三个天花板

6.1 Object.defineProperty 的三个明显局限

Vue 2 的数据代理方案很巧妙,但它确实是戴着镣铐跳舞。最明显的是三个天花板:

  1. 无法监听新增属性。这导致必须用$set打补丁。
  2. 無法监听数组索引和 length 变化。这导致必须重写数组方法。
  3. 初始化时必须深度递归。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 的这套设计,看懂了会觉得巧妙,看不懂只会觉得处处是坑,而它恰恰是所有响应式框架在“如何监听数据”这件事上的第一课。

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询