前两天和组里一个刚转前端的同事聊代码,他指着项目里的 Vue 3 问我:MVC 和 MVVM 到底怎么理解?为什么大家总说 Vue 是“最接近 MVVM 思想”的前端框架?我一下子想起自己刚接触这些概念时的状态:文档刷了好几遍,课也听了,可一到写页面就分不清哪块是 Model、哪块是 ViewModel。如果你也有这种困惑,这篇内容的定位就很清楚:不背定义,从设计意图和实际代码出发,把 MVC、MVVM 以及 Vue 3 和它们的关系一次性捋顺。适合刚入门前端、准备面试、或者已经在用 Vue 但没细想过架构的同学。内容里会尽量用“为什么这么做”的角度来讲,而不是堆术语。
1. MVC 先别急着背定义,先想清楚它在解决什么问题
很多前端同学第一次接触 MVC 是从课本或面试题里看到的,但 MVC 真正解决的问题其实是“代码开始变乱”的时候。理解了这个初衷,后面很多东西就顺了。
1.1 MVC 的老家不在网页,在桌面程序
MVC 早在上世纪七十年代末就已经出现。当时某实验室在做面向对象语言和图形界面编程环境,桌面应用开始多起来:一个窗口里同时有表单、按钮、列表、菜单,用户点一个按钮,可能要更新好几个区域的数据。这种场景下,把数据和界面写在一起,代码很快乱成一团。MVC 就是在这种背景下提出来的分层思路。
- Model:负责数据和业务规则,比如一条订单记录、一个用户对象,以及“计算订单总额”这类逻辑。
- View:负责展示,把 Model 的数据渲染成用户看得懂的界面。
- Controller:负责接收用户输入,调用 Model 的方法去改数据,再告诉 View 去刷新。
用餐厅的例子类比:Model 是后厨的食材和菜谱,View 是端上桌的菜,Controller 是服务员。顾客(用户)告诉服务员要什么菜,服务员去后厨下单,后厨按菜谱做好菜,服务员再把菜端出来。整个过程是单向流动的,Controller 是那个“接单调度”的角色。
这里有个关键点:Controller 不是“业务逻辑的集合”,它更像调度员。它不直接关心数据怎么存,也不关心界面长什么样,只负责“收到用户指令,然后协调两边”。一旦 Model 更新了,Controller 还需要主动告诉 View“你该重绘了”。在早期桌面框架里,这个流程是可控的,因为用户输入基本都来自窗口系统的事件循环,入口很集中。
1.2 前端套 MVC 为什么会别扭
等前端工程化走到单页应用阶段,浏览器里的 JavaScript 要自己管理视图和数据了。这时候有人想把 MVC 搬进来,设想过一套对应关系:View 是模板,Model 是数据集合,Controller 是路由和事件回调。但问题很快就暴露了。
后端 MVC 的 Controller 可以决定整个 HTTP 请求的响应,因为它只有一个入口;前端的 Controller 则要面对无数个事件源:点击按钮、输入框键入、滚动页面、拖拽上传,每个交互都可能触发逻辑。于是 Controller 被不断切碎,最后变成了“路由配置 + 一堆监听函数”,原本“调度 Model 并刷新 View”的职责反而被架空了。更麻烦的是数据同步:Model 一变,View 不会自己跟着变,你得手动在 View 上监听 Model 的 change 事件,然后手动重建 DOM。写过这段代码的人都知道,这种手动同步逻辑一旦多了,bug 数量和“同步失败”的次数基本成正比。
所以不是说 MVC 不好,而是它假设的“集中输入入口”在浏览器里根本不存在。前端需要另一种思路来解决“数据变了,界面跟着变”这件事。MVVM 就是在这种需求下出现的。
2. MVVM:把“手动同步”变成“自动同步”
MVVM 和 MVC 最大的区别,不是多了一个字母,而是取消了对 Controller 的依赖,用一套绑定机制把 View 和 Model 之间的同步自动化了。
2.1 ViewModel 的本质是“视图的状态与行为代理”
MVVM 的完整结构是 Model、View、ViewModel。这里面最容易理解错的是 ViewModel。它不是一个普通的中间层,它保存的是“视图当前需要的全部状态”和“用户触发视图操作时应该执行的逻辑”。
- 状态:比如列表是否加载中、当前选中了哪一行、搜索框里的关键字。
- 行为:用户点击加载更多时触发什么函数、提交表单时处理什么数据。
ViewModel 不关心 DOM,不关心选择器,不关心节点什么时候插进去。它只负责把 Model 里的数据转换成视图展示需要的形态,同时接收来自视图的操作指令,反过来更新 Model。
可以说 View 像是一块联动图表,ViewModel 是翻译器加同步器,Model 是底层数据库。图表自己不直接查数据库,它只订阅同步器给它的数据,用户在图表上拖拽修改数值,同步器再把修改写回数据库。核心收益就是:你不再需要手动调用刷新方法去同步两个地方,绑定机制替你做了这件事。
2.2 MVC 和 MVVM 的对照:不是替代关系,是取舍不同
很多人把 MVC 和 MVVM 理解成“旧方案”和“新方案”,其实不完全对。它们解决的是类似的问题,但取舍不同。MVC 强调通过 Controller 做显式调度,数据流一眼能看清,但同步代码写得多;MVVM 强调由 ViewModel 承担状态和绑定,View 可以非常薄,但 ViewModel 的设计质量直接影响项目维护难度。
| 对比点 | MVC | MVVM |
|---|---|---|
| 视图同步方式 | Controller 主动调用 View 刷新 | 数据绑定自动同步 |
| View 的职责 | 展示 + 接收 Controller 的刷新指令 | 投影 ViewModel 的状态 |
| 中间层职责 | Controller 调度 Model 和 View | ViewModel 持有状态和行为 |
| 前端适配度 | 需要手动处理大量事件和同步 | 绑定机制天然适合组件化 UI |
| 适用场景 | 后端服务端渲染、桌面传统应用 | 数据驱动 UI 的前端应用 |
我在实际项目里的体会是:MVVM 并不是“消灭 MVC”,而是把 MVC 里 Controller 最容易被写乱的同步部分,用机制换掉了。Controller 不是被删了,而是它的“重绘 View”职责被绑定系统接管了。这也是很多从 MVC 转 MVVM 的人一开始觉得“不知道为什么数据一变页面就变了”的原因——你已经不需要自己写那一步了。
3. Vue 3 为什么够格说“最接近 MVVM 思想”
Vue 3 被称为“最接近 MVVM 思想”的前端框架之一,不是没有依据的。它至少在四个层面和 MVVM 的核心思路高度吻合:View 的声明式模板、响应式数据模型、setup 作为 ViewModel、以及 v-model 的双向绑定语法。
3.1 模板即 View:声明式描述界面,而不是命令式操作 DOM
用 Vue 3 写界面的时候,template 里写的是“界面应该长成什么样”,而不是“你该怎么一步步操作 DOM 让它长成这样”。这是一个很本质的区别。
<template> <div v-if="loading">加载中...</div> <ul v-else> <li v-for="item in list" :key="item.id">{{ item.name }}</li> </ul> </template>这段代码描述的是一个条件渲染结果:loading 为真时显示“加载中”,为假时渲染列表。你完全没有写 createElement、appendChild、removeChild 这类命令。模板最终会被 Vue 编译器转换成渲染函数,渲染函数的作用是“根据当前状态返回虚拟节点”,由框架负责后续的 DOM 更新。
这就是 MVVM 里 View 的定义:View 是视图状态的投影。它不是“界面上的按钮和输入框”那堆实物,而是“由当前状态推导出来的界面描述”。模板里没有 document.getElementById,也没有 innerHTML,这是它在思想上靠近 MVVM 的第一个表现。
3.2 响应式系统:Model 发生变化,View 自动同步
MVVM 的绑定机制,在 Vue 3 里靠的是基于 Proxy 的响应式系统。当你用 reactive 或 ref 创建响应式数据时,组件渲染过程中如果读取了这个数据,就会自动收集依赖;之后数据变了,框架就会通知对应的组件重新渲染。
Vue 3 的内部流程可以简化为三步:
- 读取数据时:组件的渲染 effect 被注册为这个数据的依赖。
- 修改数据时:Proxy 拦截 set 操作,触发依赖更新。
- 重新渲染:框架执行渲染函数,生成新的虚拟 DOM,并高效地更新真实 DOM。
这就不再是 Controller 主动调用 view.render() 了。而是 View 订阅了 ViewModel 的状态,状态一变,View 自动知道。这正是 MVVM 说的“数据驱动视图”。
有一点要特别注意:Vue 2 时代用的是 Object.defineProperty 做响应式,它对数组下标修改、对象新增属性这类操作天然检测不到,开发者很容易踩坑。Vue 3 换成 Proxy 之后,对这些操作都能做到原生拦截。这不是实现细节的差别,而是响应式完整度的提升。MVVM 的“绑定”要能正常工作,前提是“任何状态变化都能被系统感知”,Proxy 比 defineProperty 更接近这个理想状态。
3.3 setup() 就是 ViewModel 的活体样本
如果说 template 是 View,那 setup 函数里返回的那一堆方法和状态,就是“当前这个视图的 ViewModel”。
import { ref, computed } from 'vue' export default { setup() { const keyword = ref('') const list = ref([]) const filteredList = computed(() => list.value.filter(item => item.name.includes(keyword.value)) ) async function load() { const data = await fetchData() list.value = data } return { keyword, list, filteredList, load } } }- keyword、list 是 View 当前需要的状态。
- filteredList 是基于状态派生出来的视图展示数据。
- load 是用户操作时要执行的逻辑。
模板里只需要拿到 keyword、filteredList、load,就能完成展示和交互。这就是一个完整的 ViewModel。它不碰任何 DOM 节点,只负责描述“这个视图现在是什么状态、能做什么事情”。
Vue 3 的组合式 API(composables)让这个设计更明显。你可以把一组状态和行为抽到一个函数里,组件只用这个函数返回的东西去渲染。这其实就是一个可复用的 ViewModel 单元。
模板里的双向绑定 v-model 则是 ViewModel 和 View 之间“同步”的语法外壳:
<input v-model="keyword" />它展开之后等价于把 value 绑定到 keyword,同时在 input 事件里把用户输入写回 keyword。MVVM 的 View 和 ViewModel 之间要求的双向绑定,在 Vue 3 里用这个语法糖表示是相当直接的。
3.4 为什么是“最接近”而不是“就是 MVVM”
那就有人要问了:既然这么像,为什么不干脆说 Vue 3 就是 MVVM 框架?因为 Vue 的组件模型比经典 MVVM 更复杂,它在 MVVM 之外还做了很多事。
- 组件不只是一个 View,它还承载了生命周期、局部状态、指令、插槽等机制。一个 Vue 组件其实是 View + ViewModel + 部分 Controller 的混合体。
- v-model 是语法糖,不是框架底层的双向绑定机制。框架底层的数据流依然是“状态变化触发视图更新”的单向流,v-model 只是帮你写好了“用户输入写回状态”的监听逻辑。
- 事件回调里仍然可以写命令式代码,比如调用原生 DOM API。MVVM 理论里 View 应该尽量声明式和无状态,但 Vue 允许你在需要时跳出约束。
所以准确的说法是:Vue 3 的设计思路高度贴近 MVVM,它实现了 MVVM 的核心收益,但没有刻意追求“纯 MVVM”。它吸收了组件化和响应式编译的思想,更像“MVVM + 组件体系”的进化体。这也是为什么社区里说它“最接近”而不是“就是”。
4. 三个好用的认知模型,把 MVC / MVVM 落到代码里
概念讲再多,不如一个能直接在代码里套用的模型。下面分享三个我在项目里觉得特别实用的理解方式。
4.1 按职责拆组件:Model、ViewModel、View 分别是什么
拿一个常见的记账页面举例。页面要展示交易列表、总支出、总收入和添加一条记录的表单。
- Model:交易数据本身。它可能是接口返回的数组、本地存储里的记录集合,以及“计算总支出”这类纯逻辑函数。
- ViewModel:一个组合式函数,比如 useAccount()。它内部维护 transactions、total、addRecord(),对外暴露视图需要的所有东西。
- View:组件下的 template。它只从 useAccount() 拿数据并渲染,不直接改接口数据。
function useAccount() { const transactions = ref([]) const total = computed(() => transactions.value.reduce((sum, item) => sum + item.amount, 0) ) async function load() { transactions.value = await fetch('/api/transactions').then(res => res.json()) } function addRecord(record) { transactions.value.push(record) } return { transactions, total, load, addRecord } }组件里就是:
<template> <p>总额:{{ total }}</p> <button @click="load">加载</button> </template> <script> export default { setup() { return useAccount() } } </script>这个模型的好处是:你可以把 ViewModel 单独抽出来测试,因为它不依赖 DOM。这对单元测试来说非常友好。MVC 里 Controller 想测试必须模拟输入事件,而 ViewModel 直接调用返回的方法就能验证状态变化。
4.2 按数据流看方向:单向与双向如何共存
MVVM 常被说成“双向绑定”,这容易让人误以为整个应用的数据流都是双向的。实际上在一个 Vue 应用里,向上的数据流动和向下的数据流动是分开的。
- 父组件向子组件传参,使用 props。数据流向是单向的,从上往下。
- 子组件想改父组件的状态,必须通过 emit 发送事件。数据流向是单向的,从下往上。
- v-model 把“从下往上”的事件和“从上往下”的值合并成一个简写语法。
所以 v-model 本质上是两条单向数据流的组合,而不是一种神奇的“双向管道”。MVVM 里的双向绑定,描述的是 View 与 ViewModel 之间“一改俱改”的关系,但它落到实际工程里,依然需要明确的更新路径。理解了这一点,你在排查“为什么子组件改了 props 页面没更新”的时候就不会被误导。
4.3 按决策场景看:什么时候用 MVVM 思维就够,什么时候需要更重的架构
不是每个页面都需要严格套 MVVM。一个小按钮组件,状态就一个 isLoading,直接写在组件内部判断就行了,硬拆一个 ViewModel 出来反而多此一举。建议按下面的场景来决策:
- 单个组件内部状态较少:直接在 setup 里定义 ref 足够,不需要额外抽象。
- 一段状态逻辑被多个组件复用:抽成 composable 函数,这就是你的 ViewModel 单元。
- 跨页面共享的用户登录状态、全局配置:引入全局 store,它承担的是 Model 层和跨组件通信。
- 业务逻辑特别复杂,需要多人协作:MVVM 思维是基础,还要配合模块划分、数据契约、状态管理方案。
MVVM 不是一个“必须套用”的规范,而是一个“帮你把关注点分开”的思考工具。做到了状态和视图分离,你就已经拿到了它的大部分收益。
5. 踩坑记录:把 MVVM 理解“歪”的常见姿势
这里整理一些我在实际项目和带新同学时经常遇到的误解。每一条背后都踩过真实的坑。
5.1 “v-model 就是双向绑定”遇到自定义组件就露馅
v-model 在原生 input 上很顺,是因为框架帮你处理好了 value 和 input 事件的绑定关系。但如果你写一个自定义组件:
<my-input v-model="name" />这个组件不会自动获得“双向绑定”能力。它必须手动声明 modelValue prop,并向外 emit update:modelValue 事件,v-model 才能工作。这恰好证明了 v-model 是语法糖,不是框架底层的黑魔法。理解这一点,对排查“为什么自定义组件双向绑定不生效”特别有帮助。
5.2 “MVVM 之后就不用碰 DOM 了”是想当然
MVVM 消灭的是“手动同步数据的 DOM 操作”,不是消灭所有 DOM 操作。你在集成第三方图表库、使用文件上传、处理Canvas 绘制时,依然会用到 ref 获取真实节点、监听原生事件、操作第三方 API。
我见过有人为了“绝对不碰 DOM”,把图表库的初始化强行塞进 computed 里,结果数据一变就重复初始化,性能惨不忍睹。正确做法是:把这类操作放到 onMounted 生命周期里,通过 ref 拿到节点后初始化,并做好清理。MVVM 让你少写低价值的同步代码,但该写的命令式代码还是得老老实实写。
5.3 响应式丢了?大概率不是框架的问题
Vue 3 的响应式比 Vue 2 强很多,但依然有固定的使用姿势。最常见的坑是解构 reactive 对象后,变量变成普通值,绑定失效。
const state = reactive({ count: 0 }) const { count } = state // count 失去了响应式遇到这种场景,应该用 toRefs 解构,或者干脆用 ref 定义独立状态。另一个坑是直接把整个响应式对象重新赋值。比如 list.value = await fetchList() 这种写法在 ref 上是正常的,但如果你用 reactive 包一层再整体替换,之前建立的依赖关系就会被切断。理解了 Proxy 拦截的是“对象属性的读写”,而不是“变量引用的替换”,这些问题其实都很好定位。
5.4 面试被问到“MVC 和 MVVM 区别”,这样回答最稳
面试里碰到这个问题,不要一上来就背定义。比较好的回答思路是分三步走。
- 第一步:MVC 的三层职责。Controller 接收输入、调用 Model、刷新 View,同步逻辑是手动的。
- 第二步:MVVM 的改进。ViewModel 持有视图状态和行为,View 声明式渲染状态,绑定机制自动完成同步,不需要手动刷新。
- 第三步:拉回到 Vue 3。template 是 View,setup 里维护的 ref/computed/方法组成了 ViewModel,响应式系统就是绑定机制,v-model 是双向绑定的语法糖。
这样做的好处是,既说明你理解了历史方案的局限,也说明你能把理论映射到实际框架里。如果面试官继续追问“为什么 Vue 不完全是 MVVM”,把组件生命周期、指令、事件回调这些超出经典 MVVM 的部分讲清楚,就是一个很好的加分点。
我个人把 MVC 和 MVVM 彻底想通,不是在看文档的时候,而是某次把老项目里几百行手动拼接同步 DOM 的代码换成 Vue 之后:那些数据一变页面跟着变、完全不需要操心刷新顺序的感觉,让我一下子反应过来,MVVM 的精华不是“多了一个层”,而是“用声明和订阅替代命令式同步”。如果你学这两个概念时觉得绕,建议动手把一个很小的数据列表分别用原生 JS 和 Vue 3 各写一遍,对比之下,“原来如此”的感觉会非常明显。希望这篇内容能把你的理解往前推一步。