ARTICLE · INTELLIGENCE

战地情报 · 详情页

来自尧图项目组的一线实战观察与深度解析

Vue 2 数据代理与 MVVM:从设计思路到源码实现解析

Vue 2 数据代理与 MVVM:从设计思路到源码实现解析

开头就用一个实际场景带入:模板语法学得差不多了,写页面也顺手了,但脑子里开始冒出一个问题——Vue 实例上那些this.xxx、模板里的{{ xxx }},背后到底是靠什么串起来的。答案也就是这节标题里写的两个词:MVVM 和数据代理。这篇文章适合所有正在学 Vue 2 基本语法、想知道“Vue 为什么能直接读 data 里的字段”的同学,我会从设计思路讲到源码行为,再带你手写一个简化版的数据代理,最后把常见坑列一遍。

1. MVVM 到底是啥:先弄清楚 Vue 在视图和模型之间安插了什么

1.1 Model:你的数据就是最大的 Model

MVVM 的全称是 Model-View-ViewModel,翻译过来就是“模型-视图-视图模型”。很多人一看到这种缩写就头大,实际上它描述的事情特别朴素:你的网页上有一块区域要显示数据,数据本身存起来了,中间需要有个东西把数据“翻译”成页面能渲染的内容。

Model 层在 Vue 2 里就是data。你写的data: { msg: 'hello' },不管这个 data 是对象还是函数返回的对象,它就是整个应用的数据源。Model 不关心消息怎么显示、按钮怎么布局,它只负责保存状态。这一点很重要:Model 是纯数据的,不应该直接操作 DOM。

1.2 View:模板编译后呈现出来的 DOM

View 层就是你写的模板,<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 ViewModel:Vue 实例就是那个“中间人”

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.msg,vm._data.msg要同步变化。第三,修改vm._data.msg,vm.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为 true,vm._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 被触发,它通知 Watcher,Watcher 重新执行 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 数据不更新的问题,你至少能先判断是哪一层出了问题,而不是把代码翻来覆去改一遍。

RELATED READING

延伸阅读

更多一线实战笔记与深度复盘,助您持续精进