跳到主要内容

MVVM 模式详解(以 Vue 为例)

在学习 Vue 的过程中,你大概率听过一句话:Vue 是一个 MVVM 框架。但 MVVM 到底是什么?它解决了什么问题?v-model 为什么能做到“数据变、页面就变”,甚至还能“输入框改了、数据也跟着改”?这篇文档会用通俗的例子把 MVVM 讲清楚,并把它和 Vue 的关键机制串起来。

1. 从一个问题开始:为什么“手动同步 UI”很痛苦?

假设我们只用原生 DOM 写一个最简单的需求:页面显示 count,点击按钮 count++

<div>
<p id="countView"></p>
<button id="btn">+1</button>
</div>
let count = 0

function render() {
document.querySelector('#countView').textContent = String(count)
}

document.querySelector('#btn').addEventListener('click', () => {
count++
render() // 你必须记得手动更新 UI
})

render()

这种写法的问题不在于“不能用”,而在于它很难维护

  • 状态一多,你要到处调用 render()(或更细的更新函数),很容易漏掉
  • 多个地方都能改数据,UI 更新时机变得不可控
  • UI 和数据互相“拉扯”,代码会越来越像一团线

MVVM 的核心目标,就是让你尽量把心思放在“数据是什么、业务规则是什么”,而不是“哪里要手动更新 UI”。

2. 什么是 MVVM?

MVVM 是一种 UI 架构模式,通常拆成三块:

  • Model(模型):数据与业务状态(例如 countuserlist
  • View(视图):界面展示(DOM、模板)
  • ViewModel(视图模型):连接 Model 与 View 的“桥梁”,负责把数据变成可渲染状态,并响应用户操作

用一句话概括:

MVVM = 数据驱动视图 + 事件驱动数据(View 不直接操作 Model,交给 ViewModel 统一协调)

你可以这样记

MVC 更像“Controller 接管用户输入”;MVVM 更像“ViewModel 让 View 和 Model 通过绑定自动同步”。

3. 单向绑定 vs 双向绑定:不要把 MVVM 等同于“v-model”

很多同学一提 MVVM 就想到“双向绑定”。这其实只说对了一半:

  • 单向绑定(数据 -> 视图):数据变了,界面跟着变(这几乎是现代前端框架的标配)
  • 双向绑定(数据 <-> 视图):界面输入变了,数据也自动更新(典型例子:输入框)

MVVM 强调的是“通过 ViewModel 做绑定与协调”,双向绑定只是 MVVM 可能具备的一种能力,并不是 MVVM 的全部。

在 Vue 里,单向绑定很好理解:

<template>
<p>{{ msg }}</p>
</template>

而双向绑定常见于表单输入:

<template>
<input v-model="msg" />
<p>{{ msg }}</p>
</template>

4. Vue 里的 Model / View / ViewModel 分别是什么?

虽然 Vue 官方很少用“MVVM”这个词去做严格定义,但从“视图层框架”的角度看,我们可以这样对应:

  • Model:组件中的响应式状态(data / reactive / ref / store 中的 state)
  • View:模板(template)最终渲染出来的 DOM
  • ViewModel:组件实例(以及它暴露给模板的那部分能力:状态、计算属性、方法、事件处理)

你写的 Vue 组件,往往就是在写一个 ViewModel:它不直接操作 DOM,而是通过状态变化来驱动 DOM 变化。

关键点

“ViewModel”并不等于“某个固定对象”,它更像一个角色:负责把数据和视图的同步机制封装起来

5. 以 v-model 为例:一次“双向绑定”到底发生了什么?

先记住结论:v-model 本质是 语法糖

5.1 原生表单元素:展开后就是 :value + @input

<input v-model="msg" />

等价于(简化版):

<input :value="msg" @input="msg = $event.target.value" />

5.2 组件上的 v-model:展开为 modelValue + update:modelValue

<MyInput v-model="msg" />

等价于(简化版):

<MyInput :modelValue="msg" @update:modelValue="val => (msg = val)" />

5.3 从“输入”到“更新界面”的流程图

常见误区

v-model 并不是“绕过单向数据流”。它只是把“输入事件 -> 更新数据”这段样板代码封装成了语法糖,数据仍然是唯一可信来源

6. MVVM 背后的两项核心技术:响应式 + 渲染

要让“数据变 → 视图自动变”成立,框架通常需要两件事:

  1. 响应式系统:知道“哪个视图依赖了哪些数据”,并在数据变化时通知更新
  2. 渲染系统:把“当前数据状态”映射成 UI(真实 DOM 或虚拟 DOM)

在 Vue 3 中,你可以用一个极简版的思路理解它(不是源码,只是帮助你建立心智模型):

// 极简示意:不要用于生产
let activeEffect = null
const bucket = new WeakMap()

function track(target, key) {
if (!activeEffect) return
let depsMap = bucket.get(target)
if (!depsMap) bucket.set(target, (depsMap = new Map()))
let deps = depsMap.get(key)
if (!deps) depsMap.set(key, (deps = new Set()))
deps.add(activeEffect)
}

function trigger(target, key) {
const depsMap = bucket.get(target)
const deps = depsMap?.get(key)
deps?.forEach((fn) => fn())
}

function effect(fn) {
activeEffect = fn
fn()
activeEffect = null
}

function reactive(obj) {
return new Proxy(obj, {
get(target, key, receiver) {
const res = Reflect.get(target, key, receiver)
track(target, key)
return res
},
set(target, key, value, receiver) {
const ok = Reflect.set(target, key, value, receiver)
trigger(target, key)
return ok
},
})
}

6.1 把响应式和渲染串起来:就有了“数据驱动视图”

const state = reactive({ msg: 'Hello MVVM' })

effect(() => {
// 这个函数在渲染时会读取 state.msg,因此会被 track 记录依赖
document.querySelector('#app').textContent = state.msg
})

setTimeout(() => {
state.msg = '更新后自动渲染'
}, 1000)

当你执行 state.msg = ... 时,trigger 会把依赖了 msg 的渲染逻辑重新执行,于是页面自动更新。

7. 实战:手写一个“迷你 MVVM”(只做最核心的绑定)

下面我们做一个教学版 Mini-MVVM:实现两种最常见的绑定能力:

  • data-text="msg":把 msg 渲染到文本节点
  • data-model="msg":把 msg 绑定到输入框(类似 v-model

HTML:

<div id="app">
<p>你输入的是:<span data-text="msg"></span></p>
<input data-model="msg" />
</div>

JS(极简示意):

function createMiniMVVM({ el, data }) {
const root = document.querySelector(el)
const textNodes = [...root.querySelectorAll('[data-text]')]
const modelNodes = [...root.querySelectorAll('[data-model]')]

const state = new Proxy(data, {
set(target, key, value) {
target[key] = value
render()
return true
},
})

function render() {
textNodes.forEach((node) => {
const key = node.getAttribute('data-text')
node.textContent = String(state[key] ?? '')
})
modelNodes.forEach((node) => {
const key = node.getAttribute('data-model')
if (node.value !== String(state[key] ?? '')) {
node.value = String(state[key] ?? '')
}
})
}

modelNodes.forEach((node) => {
const key = node.getAttribute('data-model')
node.addEventListener('input', (e) => {
state[key] = e.target.value
})
})

render()
return state
}

createMiniMVVM({
el: '#app',
data: { msg: 'Hello' },
})

你会发现:这段代码已经具备了 MVVM 的味道——View 不直接改 Model,而是通过“绑定 + 事件”让 ViewModel 去协调

思考一下

这套 Mini-MVVM 还缺什么?(提示:依赖收集、局部更新、模板编译、组件化、异步批量更新……)这些“缺的部分”,就是 Vue 这种框架的价值所在。

8. MVVM 的边界:什么时候会“反噬”?

MVVM 很强,但也可能被滥用。常见坑包括:

  1. 到处双向绑定:表单很爽,但复杂业务里“谁改了数据”会变得难追踪,建议把状态收敛到明确的地方(例如 store、组件内部单一入口)
  2. ViewModel 变成“巨石”:所有业务逻辑都塞进组件,最终组件难测试、难复用;可以用组合式函数(Composable)/ 领域层抽象来拆分
  3. 把 DOM 当成数据源:例如通过 document.querySelector 读值再写回状态,等于绕开了 MVVM 的核心思想

9. 总结

一句话复盘:

  • MVVM 不是“必须双向绑定”,而是“用 ViewModel 管理绑定与同步
  • Vue 组件大体符合 MVVM 的思路:状态(Model)驱动模板(View),组件实例承担 ViewModel 的职责
  • 读懂 v-model 的展开规则,你就能在面试里把“双向绑定”讲得很清楚

10. 面试高频问答

Q1:MVVM、MVC、MVP 的核心区别是什么?

参考回答:

  • MVC:Controller 负责处理用户输入与业务流转,View 通常更“被动”
  • MVP:Presenter 更强调把 View 抽象成接口,方便测试
  • MVVM:用 ViewModel 做绑定与同步,强调数据驱动视图,View 尽量不直接操作 Model

面试时抓住关键词:职责划分、数据流向、可测试性、是否有数据绑定


Q2:为什么说 Vue 是 MVVM?严格吗?

参考回答:

Vue 具备 MVVM 的典型特征:数据驱动视图事件驱动数据模板与状态通过绑定自动同步。但它不一定是“教科书式 MVVM”,因为:

  • Vue 关注的是“View Layer”,并不强制你的业务分层方式
  • 组件既可能扮演 ViewModel,也可能混入较多业务逻辑

所以更准确的说法是:Vue 借鉴并实现了 MVVM 的核心思想


Q3:v-model 的本质是什么?

参考回答:

v-model 是语法糖:

  • 在原生表单上,展开为 :value + @input
  • 在组件上,展开为 :modelValue + @update:modelValue

它把“取值 + 监听输入 + 回写数据”的样板代码封装了起来。


Q4:Vue 2 和 Vue 3 的响应式实现有什么差异?为什么 Vue 3 用 Proxy?

参考回答:

  • Vue 2:Object.defineProperty 做属性级劫持,无法天然监听新增/删除属性,数组也需要额外 hack
  • Vue 3:Proxy 做对象级代理,能覆盖更多操作(get/set/delete/has/ownKeys),并且支持“懒代理”,性能更好

选择 Proxy 的核心原因是:弥补 defineProperty 的天然缺陷,并提升能力与性能上限


Q5:双向绑定有什么风险?项目里如何避免“状态失控”?

参考回答:

风险是“数据的修改入口太多”,导致难追踪、难排查。常见治理方式:

  • 限制双向绑定的范围(表单场景使用即可)
  • 重要状态收敛到 store 或父组件,子组件通过 props + emit 形成明确的数据流
  • 复杂输入用“受控组件”思路:显式的 valueonChange(或自定义 v-model)统一入口

Q6:ViewModel 里 computedwatch 怎么选?

参考回答:

  • computed:用来声明“由状态推导出来的状态”,强调有返回值、可缓存、偏“表达式”
  • watch:用来处理“副作用”,比如请求、写本地存储、手动同步第三方库,强调执行动作

一句话:computed 管结果,watch 管过程(副作用)