很多前端初学者接触Vue时,最先听到的两个词就是“MVVM”和“数据代理”。网上的教程大多一笔带过,要么说“Vue是MVVM框架”,要么说“data里的数据会被代理到vm上”,但很少有人把这两件事拆开讲透:MVVM到底解决了什么问题?数据代理又是怎么实现的?为什么Vue2和Vue3的代理方式完全不同?
这篇文章我会从一个实际开发者的角度,把MVVM的架构思想、数据代理的底层原理、以及手写一个迷你版MVVM的完整过程都过一遍。最后再聊聊真实项目里因为不理解这两个概念而踩过的坑,以及面试时怎么回答才能让面试官觉得你是真懂,而不是背了八股文。适合刚学完Vue基础、准备进阶源码,或者正在复习前端面试的朋友。
1. 先从MVVM说起:Vue凭什么“响应式”
1.1 MVC到MVVM:前端架构的演变
要理解MVVM,先得看看它从哪来。早期做网页,大家用的都是MVC模式:Model(数据模型)、View(视图)、Controller(控制器)。用户操作页面,Controller接收事件,修改Model,然后手动调用DOM操作把最新的数据渲染到View上。反过来,如果Model自己变了,也还是得靠Controller去同步View。
这听起来没什么问题,但实际写起来很痛苦。一个稍微复杂点的页面,可能有几十个状态散落在不同的Controller里,每个状态变化都要手动找到对应的DOM节点去更新。代码写了一堆document.getElementById,改一个输入框的值,得同时维护三四处逻辑。时间一长,代码就和意大利面一样,牵一发而动全身。
前端发展的早期,JQuery统治时代,大家的核心工作就是“操作DOM”。但在业务逻辑越来越复杂的单页应用面前,这种手动同步的方式成了瓶颈。于是MVVM出现了,它把“手动同步”这件事从开发者手里拿走了,让“数据”和“视图”之间自动保持同步。
MVVM的全称是Model-View-ViewModel。Model还是数据模型,View还是视图模板,但中间的Controller换成了ViewModel。ViewModel是View和Model之间的桥梁,它负责监听Model的变化并更新View,同时监听View上的交互事件并更新Model。开发者不再需要写任何DOM操作代码,只需要操作数据,视图就会自动变化。
1.2 三个角色各司其职:View、Model、ViewModel
在Vue里,这三个角色的分工非常清晰:
- View:对应我们的模板(template),也就是
<template>里的那部分HTML结构。它不是一个纯粹的静态页面,里面会有插值表达式{{ message }}、指令v-if、v-for等,这些是View层的“骨架”。 - Model:对应组件里的
data返回的对象。它里面存放的是页面要展示、要使用的原始数据,本身不包含任何UI逻辑。 - ViewModel:对应Vue实例(也就是
vm),它是整个响应式系统的核心。ViewModel把Model里的数据“代理”到自己身上,同时负责依赖收集和派发更新,让Model变化时View能自动刷新。
举个例子,你写了一个<span>{{ count }}</span>,然后data里定义了count: 0。当你点击按钮执行this.count++时,页面上的数字会立刻变成1。这个过程中,你完全没有操作DOM,Vue的ViewModel帮你完成了所有工作。
很多人会混淆“双向绑定”和“MVVM”。其实双向绑定是MVVM的一个特性,指的是View和ViewModel之间的双向同步:View的变化会反映到Model,Model的变化也会反映到View。在Vue中,v-model就是双向绑定的语法糖,它本质上是一个value绑定加上一个input事件监听。但MVVM的核心不只是双向绑定,而是“数据驱动视图”这种思想。
1.3 双向绑定的本质:不是魔法,是监听+通知
很多新手觉得Vue很“魔法”,写了数据就能自动渲染。其实拆开来看,核心就两件事:监听数据变化和通知视图更新。
在Vue2中,它通过Object.defineProperty给每个属性添加getter和setter。当组件初始化时,Vue会遍历data里的所有属性,把它们转换成响应式属性。当读取某个属性时,getter会被触发,Vue把当前的“依赖”(哪个组件、哪个渲染函数在用这个数据)收集起来;当修改这个属性时,setter被触发,Vue通知所有收集到的依赖去更新。
这个过程里,数据是被“劫持”的。你表面上访问this.count,实际上访问的是对象上那个被重写过的count属性。你给它赋值,实际上走的是Vue为你安装的setter函数。这个“劫持”就是数据代理的一部分。
到了Vue3,Object.defineProperty换成了ES6的Proxy,这个问题我在后面会细说。但不管怎么换,底层逻辑没变:读取时收集依赖,修改时触发更新。搞懂这一句话,MVVM的响应式原理就通了。
2. 数据代理:Vue实例如何接管你的data
2.1 什么是数据代理,为什么需要它
Vue官方文档里有句话:每个Vue实例都会代理其data对象中的所有属性。什么意思?就是你data里定义的message,可以直接通过this.message访问,不需要写this.$data.message。
这个“代理”就是数据代理。它把data对象上的属性,映射到了Vue实例(vm)上。好处很明显:
- 访问方便:模板里写
{{ message }},本质是访问vm.message,而不是vm.$data.message。少写了一层路径,代码更简洁。 - 统一入口:所有数据操作都可以通过
vm这个入口来访问,不需要去注意数据到底是存在data里还是computed里。 - 隐藏实现细节:开发者只需要面向
vm操作,不需要关心Vue内部把数据保存在哪里。
但要明确一点:数据代理不等于响应式。代理解决的是“能否通过vm访问到数据”的问题,响应式解决的是“数据变了视图能否更新”的问题。它们是两件事,但经常被放在一起讨论。
2.2 Object.defineProperty:一切代理的地基
Vue2的数据代理核心就是Object.defineProperty。这个方法可以直接在一个对象上定义一个新属性,或者修改一个已有属性,并且可以给这个属性设置getter和setter。
来看一个最简版的实现。假设我们有一个Vue类,构造函数接收一个options,里面有data。我们要做两件事:把data里的属性代理到vm上,并且让这些属性变成响应式的。
class Vue { constructor(options) { this.$data = options.data(); // 遍历data的key,代理到vm上 Object.keys(this.$data).forEach(key => { this.proxyKey(key); }); } proxyKey(key) { Object.defineProperty(this, key, { enumerable: true, configurable: true, get() { return this.$data[key]; }, set(newVal) { this.$data[key] = newVal; } }); } } const vm = new Vue({ data() { return { name: '张三' }; } }); console.log(vm.name); // 输出:张三 vm.name = '李四'; console.log(vm.$data.name); // 输出:李四这段代码就是Vue2数据代理的最小模型。vm.name的getter返回this.$data.name,setter设置this.$data.name,从而实现了代理。
但光有代理还不够,数据还得是响应式的。所以Vue2在initData的时候,会对data里的每个属性调用observe,用Object.defineProperty再次重写getter和setter,在getter里收集依赖,在setter里触发更新。注意,这两次定义是分开的:代理是用defineProperty定义在vm上,响应式是用defineProperty定义在data对象本身上。
这里有个著名的坑:Vue2无法检测对象属性的添加和删除。因为Object.defineProperty只能针对已有的key做拦截,你给data里的对象新增一个属性时,Vue根本没有对这个新属性做过响应式处理,所以不会触发更新。
2.3 Vue3的Proxy又改了什么
Vue3把响应式系统整个重写了,用Proxy替代了Object.defineProperty。Proxy可以代理整个对象,而不是对象的单个属性。
Proxy会创建一个“代理对象”,所有对目标对象的操作都会先经过代理对象上的“拦截器”。看个简单对比:
// Vue2风格:需要遍历key,且新增key无法监听 const obj = { a: 1 }; Object.defineProperty(obj, 'a', { get() { console.log('读取a'); return 1; }, set(v) { console.log('设置a'); } }); // Vue3风格:直接代理整个对象 const target = { a: 1 }; const proxy = new Proxy(target, { get(obj, key) { console.log('读取', key); return Reflect.get(obj, key); }, set(obj, key, value) { console.log('设置', key); return Reflect.set(obj, key, value); } });Proxy不仅能拦截get和set,还能拦截has、deleteProperty、ownKeys、getOwnPropertyDescriptor等操作。这意味着:
- 新增属性、删除属性都能被监听到,不再需要
Vue.set和Vue.delete。 - 代理的是整个对象,不需要像Vue2那样初始化时就递归遍历所有属性,性能上更有优势。
Reflect配合Proxy使用,可以保证原始操作的行为不变,比如Reflect.set返回布尔值表示是否设置成功,这样就不会因为拦截器抛错导致原始对象状态不一致。
但Proxy也不是完美的。它要求目标对象必须是对象类型,不能直接代理原始值(字符串、数字、布尔),所以Vue3对基本类型的数据会封装成ref,用对象包裹一层。另外,Proxy的兼容性不如Object.defineProperty,但现代浏览器基本都支持,也不用太纠结。
Vue3的数据代理逻辑也因此简化了:vm本身不再需要逐个defineProperty,而是直接通过proxy访问data。不过我们在组件里写this.xxx的时候,实际上访问的是组件代理对象,它内部会进一步通过proxy去取data里的值。整体路径更清晰了。
3. 从零手写一个迷你MVVM:把原理变成代码
3.1 观察者模式:依赖收集和派发更新
要手写MVVM,光有数据代理不够,还得有“响应式系统”。响应式系统的核心是观察者模式,里面有三个角色:
- Dep(依赖):一个收集器,维护一个数组,存放所有依赖某个数据的“订阅者”。
- Watcher(观察者):订阅数据变化的“订阅者”。在Vue中,一个组件渲染函数会创建一个Watcher,当它读取了某个响应式属性时,这个属性对应的Dep就会把这个Watcher收进去。
- Observer(观察者):把普通对象转换成可响应对象的工具。在Vue2中,它通过
Object.defineProperty遍历每个属性并重写getter/setter。
整个流程是这样运转的:
- 初始化时,Observer把data变成响应式,每个属性都有了自己的Dep。
- 组件渲染时,会执行渲染函数,读取data中的属性,触发getter。getter里执行
dep.depend(),把当前正在渲染的Watcher收集进Dep。 - 当你修改属性值,触发setter。setter里执行
dep.notify(),遍历所有收集到的Watcher,调用它们的update方法。 - Watcher的
update方法会重新执行渲染函数,生成新的虚拟DOM,然后对比旧DOM,最终更新页面。
我们写一个极简版,去掉虚拟DOM和模板编译,只保留最核心的依赖收集和派发更新:
class Dep { constructor() { this.subs = new Set(); } depend() { if (Dep.target) { this.subs.add(Dep.target); } } notify() { this.subs.forEach(w => w.update()); } } Dep.target = null; class Watcher { constructor(updateFn) { this.updateFn = updateFn; } update() { this.updateFn(); } }然后定义observe函数,把对象转成响应式:
function observe(obj) { Object.keys(obj).forEach(key => { const dep = new Dep(); let value = obj[key]; Object.defineProperty(obj, key, { enumerable: true, configurable: true, get() { dep.depend(); // 收集当前Watcher return value; }, set(newVal) { value = newVal; dep.notify(); // 通知更新 } }); }); }依赖收集的前提是:在触发getter之前,必须设置好Dep.target。Vue的做法是,在渲染前把当前Watcher赋值给Dep.target,渲染结束后再置为null。这样getter执行时,dep.depend()才能收集到正确的Watcher。
3.2 模板编译:把“胡子语法”变成真实DOM
真正的Vue有完善的模板编译器,会把{{ message }}、v-if这些语法编译成渲染函数。这里我们不写那么复杂,只实现一个最简单的插值表达式解析,让你看清楚模板和数据是怎么关联起来的。
思路是:拿到DOM元素,查找所有包含{{ }}的文本节点,解析出表达式,然后创建一个Watcher,当依赖的数据变化时,重新计算表达式的值,并更新文本节点。
function compileText(node, vm) { const text = node.textContent; const reg = /\{\{(.*?)\}\}/g; if (reg.test(text)) { const render = () => { // 用vm作为作用域,计算表达式的值 const result = text.replace(reg, (_, expr) => { return eval(`with(vm){ ${expr} }`); }); node.textContent = result; }; // 执行第一次,触发getter完成依赖收集 render(); const watcher = new Watcher(render); // 注意:这里需要在observe的时候把watcher收集进去 } }这块有个细节:eval + with是Vue作者最早在模板编译里用的思路,后来出于性能和安全性考虑被放弃了。但我们手写demo时用它最简单,能让你直观看到“表达式”和“vm上的属性”是如何绑定的。
真正Vue的做法,是把{{ message }}编译成_s(message)这样的函数调用,然后在渲染函数的闭包里传入了vm作为代理,所以你能直接访问message。原理和我们这里用with(vm)一样,都是改变表达式求值时的作用域链。
完整的迷你MVVM还要处理v-model、v-on等指令,但核心逻辑已经清楚了:数据代理让你能通过vm.xxx访问数据,响应式系统让数据变化能触发Watcher,Watcher再更新DOM。
3.3 一个50行的数据代理示例
很多朋友问我,能不能用最短的代码演示数据代理?我整理了一个50行左右的版本,虽然简陋,但每一步都有注释,适合在本地跑着玩:
function isObject(v) { return v !== null && typeof v === 'object'; } class Vue { constructor(options) { this.$data = options.data(); this._proxy(this.$data); // 数据代理 observe(this.$data); // 响应式处理 } _proxy(data) { Object.keys(data).forEach(key => { Object.defineProperty(this, key, { get: () => data[key], set: (v) => { data[key] = v; } }); }); } } function observe(data) { if (!isObject(data)) return; Object.keys(data).forEach(key => { defineReactive(data, key, data[key]); }); } function defineReactive(obj, key, value) { observe(value); // 递归处理嵌套对象 const dep = new Dep(); Object.defineProperty(obj, key, { get() { Dep.target && dep.depend(); return value; }, set(newVal) { if (newVal !== value) { value = newVal; dep.notify(); } } }); }配合前面定义的Dep和Watcher,这个迷你Vue已经能做到:初始化后,在控制台修改vm.message,所有依赖它的地方都会自动更新。你可以在这个基础上继续加数组的响应式处理、事件绑定等,慢慢它就长成了一个真Vue的骨架。
4. 真实项目中的MVVM实践:常见坑与排查实录
4.1 为什么我改了data,页面没反应?
这是新手最常问的问题。大多数情况不是Vue坏了,而是你改数据的方式没有触发setter。
典型场景一:直接给对象新增属性。
data() { return { user: { name: '张三' } } } // 错误写法 this.user.age = 20; // 页面不会显示age // 正确写法(Vue2) this.$set(this.user, 'age', 20); // 或者重新赋值整个对象 this.user = { ...this.user, age: 20 };原因就是Object.defineProperty无法拦截新增属性,age没有经过响应式定义,所以不会有Watcher收集它。
典型场景二:通过索引修改数组。
data() { return { list: [{ name: 'a' }, { name: 'b' }] } } // 错误写法 this.list[0] = { name: 'c' }; // 页面不更新 // 正确写法 this.$set(this.list, 0, { name: 'c' }); this.list.splice(0, 1, { name: 'c' });Vue2对数组做了特殊处理,重写了7个会修改原数组的方法(push、pop、shift、unshift、splice、sort、reverse),但直接通过索引赋值是不会被监听的。
典型场景三:修改了某个属性的值,但这个属性在模板中被深层引用。
// 模板:{{ obj.a.b.c }} // 错误:在某个异步回调里改了 obj.a.b.c,但页面没变 // 排查:检查你的层级是否每一层都已经响应式化了,如果 obj.a 本身就没有被 Vue 处理(比如在created时后加的),那 c 的变化自然无法触发更新。排查思路很简单:连点三下Vue DevTools,看一下数据是否已经改变。如果数据变了但页面没变,说明是响应式链路断了;如果数据压根没变,那就是你改的压根不是同一个对象。
4.2 数组的索引修改和length变更为什么失效?
上面提到了Vue2的数组缺陷,这里展开说说原理。
Object.defineProperty是可以拦截数组索引的,比如arr[0],Vue2在技术上是能做到的。但为什么官方选择不做?有一个说法是性能和收益的权衡:数组元素数量一般较多,如果每个索引都做响应式,开销会很大,而且多数业务场景下,数组是整体替换或者用push等方法修改,很少直接按索引改。另一个原因是JavaScript的数组是可以随意设置length的,你arr.length = 0,等于删除了所有元素,Object.defineProperty无法拦截这种操作。
所以Vue2采用了折中方案:拦截数组的变更方法,同时为了兼容arr[0] = xxx这种写法,提供$set。到了Vue3,因为Proxy可以拦截set操作,不管你是修改已有属性还是新增属性,不管你是修改数组索引还是改变length,都能被监听到,这个问题就彻底解决了。
如果你还在用Vue2维护老项目,记住一个原则:只要是修改对象或数组,尽量用$set或者重新赋值一个新对象/数组。比如:
// 推荐:用展开运算符创建新对象 this.user = { ...this.user, age: 20 }; // 推荐:用filter/map生成新数组 this.list = this.list.map((item, i) => i === 0 ? { ...item, name: 'c' } : item);这样写一定不会触发响应式丢失的问题,而且代码语义清晰。
4.3 深度监听和性能:别把整个data都放进watch
理解了MVVM的依赖收集机制后,你会发现一个性能隐患:如果你的Watcher过多,每次数据变化都会优先通知所有相关Watcher,如果某个Watcher的操作很重(比如复杂计算、大量DOM操作),页面就会卡顿。
很多新手写watch喜欢这样:
watch: { user: { handler() { // 深拷贝、重渲染等操作 }, deep: true } }deep: true会递归遍历user对象的所有属性,把依赖都收集到这个Watcher上。一旦user下任何一个属性变了,都会触发handler。如果你的user是个很大的对象,这个开销不容忽视。
更好的做法是精确监听你真正需要的字段:
watch: { 'user.name': function(newVal, oldVal) { // 只有name变化时触发 } }或者用computed替代复杂的watch逻辑。computed本身有缓存机制,只有依赖的变化时才重新计算,性能上比watch + deep好很多。
另外,尽量别在模板里写复杂的表达式。比如:
{{ list.filter(item => item.visible).map(item => item.name).join(', ') }}每次list变化,这个表达式都要重新执行,而且它内部的filter和map会产生新数组,这会让Vue的依赖收集变得非常细碎,严重拖慢渲染。正确做法是移到computed里:
computed: { visibleNames() { return this.list.filter(...).map(...).join(', '); } }这样模板只依赖一个visibleNames,数据变化时只有这个computed重新计算,依赖收集也简短,性能会好很多。
5. 面试考点与学习路线:从入门到源码
5.1 高频面试题:MVVM和数据代理怎么答才加分
关于MVVM,最常见的问题是“说一下你对MVVM的理解”。很多人的回答是“M是数据,V是视图,VM是连接它们的桥梁”,然后就没了。这种回答拿不到高分,因为面试官想听的是“为什么需要它”和“它是怎么工作的”。
我建议按这个思路答:
- 背景:先说明传统MVC模式下,前端需要手动操作DOM来同步数据,业务复杂后维护成本高,代码耦合严重。
- 概念:MVVM是Model-View-ViewModel的缩写,ViewModel是核心,它数据绑定View和Model的关系。Vue的VM就是Vue实例,负责监听数据变化并更新视图,监听视图变化并更新数据。
- 核心机制:数据代理(让你通过vm访问data属性)和响应式系统(依赖收集+派发更新)一起构成了MVVM在Vue中的落地。
- 结合业务:说说你实际项目中用到了哪些MVVM特性,比如v-model、计算属性、侦听器,遇到过哪些问题,如何解决的。
关于数据代理,面试官可能会问“Vue2和Vue3的数据代理有什么区别”。回答要点:
- Vue2用
Object.defineProperty,只能代理已有属性,新增/删除属性需要$set/$delete。 - Vue3用
Proxy,可以代理整个对象,能监听新增、删除、变更等所有操作。 Proxy还有Reflect配合,语义更标准,性能更好。- Vue3的响应式系统分成了
reactive和ref,基本类型用ref包裹,对象用reactive。
再深层一点,面试官可能追问“Proxy为什么可以监听数组”?因为Proxy的set拦截器能拦截所有写操作,包括设置数组索引、修改length,而Object.defineProperty做不到对未定义索引的拦截。
5.2 推荐的学习路径:从会用到会写
如果你不只是想应付面试,而是真心想把MVVM搞懂,建议按下面这条路走:
- 先熟练Vue基础:模板语法、计算属性、侦听器、生命周期。这个阶段不用关心原理,重点是会用。
- 手写一个响应式demo:用
Object.defineProperty实现一个最简单的reactive函数,能监听对象属性变化并执行回调。然后再用Proxy写一遍,对比两种方式的差异。这个过程能帮你彻底理解依赖收集和派发更新。 - 读Vue2源码的响应式部分:建议从
src/core/observer目录开始,关注dep.js、watcher.js、index.js三个文件。代码量不大,但逻辑非常经典。 - 读Vue3源码的响应式部分:在
packages/reactivity/src下,看reactive.ts、effect.ts、ref.ts。Vue3的响应式系统是独立于框架的,甚至可以单独拿来用。 - 做个小项目:用原生JS实现一个简单的MVVM库,支持
{{ }}插值、v-model、v-on:click。不需要完整,但要做到输入框里改数据,页面上其他绑定同步更新。完成这个项目后,你再看Vue源码的任何一部分都不会觉得吃力了。
学习过程中,强烈推荐一个方法:在浏览器控制台里调试。打开Vue2的项目,打印vm.$data的属性描述符,你能亲眼看到get和set被重写成了什么样子。打开Vue3的项目,打印reactive返回的代理对象,你会发现它和原对象不是同一个引用。这些直观体验比看十篇文章都管用。
5.3 一个案例:从数据代理到组件通信
数据代理的另一个应用场景是组件通信。父组件给子组件传prop时,子组件通过this.propName访问,实际上也是通过组件实例代理去读取的。如果你理解了代理的机制,就能明白为什么子组件不能直接修改prop:因为prop是父组件传递下来的数据引用,子组件修改它,本质上修改的是父组件的数据,这种非受控操作会破坏单向数据流。
再比如,provide/inject的实现也和数据代理有关。Vue会在实例上维护一个provides对象,后代组件注入时,实际上是从当前的实例链上逐层向上查找。这个“查找”过程用到了代理吗?并没有,它只是纯对象遍历。但如果你看过Vue3的源码,会发现组件实例setupState、data、props都是通过proxy访问的,在render函数中使用的this.xxx就是从不同代理对象上拿取的。
还有v-model本质上也涉及代理的“写”操作。v-model展开后,其实是:value和@input的语法糖。:value走的是数据代理的读,@input把输入值通过事件回调赋给data,走的是数据代理的写。整个过程就是在MVVM框架下完成的。所以你会看到很多面试题问“v-model的实现原理”,答案就是:一个value prop + 一个input事件监听,再加数据代理作为桥梁。
把数据代理理解成“一个能让外部以更简洁方式访问内部数据的封装层”,它不仅在Vue里有,在其他框架、工具库中也很常见。比如状态管理库Redux的connect、React的useState某种程度上也是在做类似的事情。这样横向对比,你就能站在更高的维度看这些框架了。
最后分享一点个人经验
我刚开始学Vue时,总觉得数据代理和MVVM是两套独立的“理论”,背了一堆概念但写代码时完全用不上。直到有一次项目里遇到一个极其诡异的bug:一个数组通过索引修改了,页面不刷新,我折腾了一个下午才想起来Vue2的响应式缺陷。从那之后,我才彻底意识到,底层原理不是面试题,而是排查问题的指南针。
如果你也想真正掌握这部分,我的建议非常简单粗暴:关掉IDE,拿出一张白纸,从写一个Dep类开始,到observe函数,再到Watcher,然后把它们串起来。写完你就会发现,Vue官网最核心的那张响应式原理图,其实只是这个流程的加强版。数据代理也是一样,自己写一遍proxyKey,比看十遍源码更管用。
MVVM听起来高大上,数据代理听起来玄乎,但拆到底层,无非是对象属性的读写拦截、事件的发布订阅、以及视图模板的解析。Vue只是把这些事情组织得更优雅了一些。搞懂这些基础,你再看Vue的虚拟DOM、组件化、编译优化,都会觉得顺理成章。希望这篇文章能帮你把那层窗户纸捅破。