02 · MVVM 思想(MVVM Architecture)

📅 预计 70 分钟 | ⭐ = 高频考点 | 📌 中英术语见文末
✍️ 配套练习:网页练习 shengxia.dev/quiz?module=mv02


1.0 先给直觉:从"手动发传单"到"自动订阅"

想象你经营一家餐厅。传统做法是:每次菜单价格变动,你都得手动跑遍每张桌子,撕掉旧传单、贴上新的,客人点了已下架的菜才发现。这就是最原始的前端——用 JS 手动操作 DOM,数据一变,你得亲自找到页面上的每一处并更新。

MVVM 的做法是:给菜单贴上"价格一改,桌上自动更新"的标签。你只管改数据,页面自己知道怎么变。这就叫数据驱动视图(data-driven view)——视图是数据的函数,数据变了,视图跟着变,不需要你指挥。

💡 记忆口诀:传统前端"人拉车",MVVM"自动驾驶";改数据,页面自己动。


1.1 前端演进:为什么需要框架

从静态页到交互页

早期网页是静态的——服务器返回写死的 HTML,浏览器显示,用户只能看不能改。后来 JS 普及,页面能响应用户操作,但所有逻辑都是"找元素 → 改内容"的命令式写法。

// 命令式:每一步都亲自指挥
const list = document.querySelector('#list');
const newLi = document.createElement('li');
newLi.textContent = '新项目';
list.appendChild(newLi);

这种写法在小页面没问题。但页面一大,问题就来了:数据和 DOM 之间的同步散落在各处。改了数据忘了改某处 DOM,页面就出现"数据和显示对不上"的 bug,而且很难排查。

框架解决的核心问题

前端框架(Vue、React)统一解决一件事:如何高效地把数据变成页面,并保持同步。它们提供一套"声明式"写法——你声明"页面上应该显示什么",框架负责"怎么去更新 DOM"。就像你告诉厨师"来一份番茄炒蛋",厨师自己完成买菜、切菜、下锅,你不必指挥每一步。

⚠️ 常见错误

  1. 以为框架能取代 HTML/CSS:框架最终生成的还是 DOM,基础三件套是地基,不能跳。
  2. 命令式思维硬套框架:拿到框架还是用 querySelector 一顿猛改 DOM,等于开自动挡还忍不住踩离合。
  3. 忽视"数据是唯一事实来源":数据和 DOM 各存一份,改了数据却不去同步,bug 由此而生。

1.2 MVC:最初的"分工方案"

三个角色各管一段

MVC 把一个应用拆成三层:

  • Model(模型):数据与业务规则,比如"用户列表""计算总分"。它不知道页面长什么样。
  • View(视图):用户看到的界面。它只管展示。
  • Controller(控制器):接收用户输入,指挥 Model 更新数据、再让 View 刷新。

流程是"用户操作 → Controller → 改 Model → 刷新 View"。Controller 是"中间人",View 和 Model 通常不直接对话。

用户点击  -->  Controller  -->  Model 改数据  -->  View 刷新

问题:Controller 越来越臃肿

MVC 适合后端(服务器),Controller 在服务端处理请求、组装数据很自然。但搬到前端后,View 也是 JS 写的,Controller 里要写大量"数据变了 → 手动更新哪块 DOM"的代码,同步逻辑依旧没人替你管,Controller 变成一个大杂烩。

⚠️ 常见错误

  1. 把逻辑全塞进 View:页面里堆满数据和业务判断,想改需求要翻遍模板。
  2. Controller 里直接写死 DOM 操作:Model 一改就要手动查 DOM,同步靠人肉维护,必然漏。
  3. 混淆职责:Model 里写 UI 逻辑("如果金额大于100就显示红色"),数据层不该知道"红色"。

1.3 MVP:让 View 变"哑巴"

Presenter 接管全部逻辑

MVP(Model-View-Presenter)把 Controller 换成 Presenter(主持人),并且做得更彻底:View 只负责被动展示,所有交互逻辑都进 Presenter。View 通过接口暴露方法(如 showName(name)),Presenter 调用这些方法更新界面。

View 点击  -->  Presenter  -->  Model
    ^                              |
    +-------- 调 showName() -------+

好处与代价

好处是 View 极轻、逻辑集中,测试时能"只测 Presenter 不碰界面"。代价是接口方法写起来繁琐——每个界面变化都要在 View 上定义方法,代码量不小。而且 View 和 Presenter 的关联代码仍是手写的同步。

⚠️ 常见错误

  1. 把 View 接口写宽:为每个小变化都加一个方法,接口数量爆炸。
  2. Presenter 持有太多 View 引用:一个 Presenter 操作多个 View,耦合失控。
  3. 同步仍靠手动:Presenter 里 modelChanged() 后挨个调 View 方法,本质还是命令式。

1.4 MVVM:ViewModel 登场 ⭐

从 Controller 到 ViewModel

MVVM 由三个角色构成:

  • Model:数据与业务逻辑,和 MVC 中的一致。
  • View:用户界面(模板)。
  • ViewModel(VM):View 与 Model 之间的"桥梁",持有页面所需的状态(state),并提供"把状态转成视图"的能力。

关键区别在于:View 与 ViewModel 之间建立了数据绑定(data binding),ViewModel 状态一变,View 自动更新;View 上的用户输入,也会自动写回 ViewModel。你不再需要手写"同步"。

View  <--数据绑定-->  ViewModel  <-->  Model

一个最小 MVVM 例子

用 Vue 写一个计数器,直观感受"状态变了页面自动动":

<template>
  <p>当前计数:{{ count }}</p>          <!-- 插值:count 变化时这里自动更新 -->
  <button @click="count++">加一</button> <!-- @click 绑定点击事件 -->
</template>

<script>
export default {
  data() {
    return { count: 0 };   // 状态 count 就是 ViewModel 的数据
  },
};
</script>

用户点击按钮 → 触发 count++count 一变,插值处自动重渲染。全程没有一行 querySelector,这就是数据绑定带来的声明式体验。

⚠️ 常见错误

  1. 把 ViewModel 当 Model:在组件 data 里放大量业务规则,数据层和表现层混在一起。
  2. 以为 MVVM 是框架专属:MVVM 是思想/架构模式,Vue/React/Angular 是它的具体实现,别把"Vue"和"MVVM"画等号。
  3. 双向绑定滥用:不是所有状态都该双向绑定,后面 1.6 会讲何时用单向。

1.5 单向绑定与双向绑定 ⭐

单向绑定:数据 → 视图

v-bind 只把数据"灌"给视图,视图不会反向改数据:

<p v-bind:title="tooltip">悬停看提示</p>

tooltip 变了,title 属性跟着变;但用户没法通过 <p>tooltip。数据流向是单向的:ViewModel → View。适合展示类内容,流向清晰、好调试。

双向绑定:数据 ⇄ 视图

v-model 在表单元素上实现双向:数据变,输入框变;用户输入,数据也变。

<input v-model="username" />
<p>你好,{{ username }}</p>

用户在输入框打字 → username 更新 → 下面的 {{ username }} 立刻回显。两条路都自动同步。

什么时候用哪个

  • 展示/单向变化:用 v-bind 单向绑定,数据流可控。
  • 表单输入:用 v-model 双向绑定,省掉手动监听 input 再赋值。

原则:双向绑定只用于"用户能输入"的地方。强行给展示元素也搞双向,会引入隐式的数据流,出 bug 难查。

⚠️ 常见错误

  1. v-model 用在非表单元素:如 <p v-model="x">,没意义也不会同步,该用 v-bind
  2. 双向绑定用在只读展示:数据流变双向后,任何来源的改变都可能改到数据,逻辑不透明。
  3. 忘了 v-model 本质是"语法糖":它等价于"value 绑定 + input 事件更新",理解底层才不会在 v-model 上犯晕。

1.6 数据驱动视图:视图是数据的函数

一句话核心

视图 = f(状态)。状态(state)是唯一的"事实来源"(single source of truth),视图只是状态的一种投影。只要保证状态正确,视图就正确。

// 视图就是"读状态 → 生成描述"
function view(state) {
  return `<p>共 ${state.todos.length} 条待办</p>`;
}

框架在此基础上加一层"当状态变化时,重新计算视图并精准更新 DOM"。你写代码时只关心状态怎么变,不关心 DOM 怎么改。

为什么这样更好

  • 逻辑集中:业务就是"改状态",不用分散在各处同步 DOM。
  • 可预测:给定一份状态,必然得到同一份视图,测试方便。
  • 易维护:新增功能 = 新增状态与模板片段,不碰其他部分的同步代码。

⚠️ 常见错误

  1. 视图之外另存一份"镜像数据":比如把列表内容同时在 data 和某个全局变量各存一份,两处不同步就出错。一切数据都进状态。
  2. 直接改 DOM 绕过框架document.querySelector(...).textContent = ... 会破坏"状态是唯一事实"的约定,下次状态更新时框架可能把改动覆盖掉。
  3. 在模板里写复杂逻辑:状态到视图的"函数"应保持简单,复杂计算放进"计算属性"(后续讲义专讲),模板里塞一堆判断难以维护。

1.7 MVVM 的生态位置:Vue、React 与双向绑定

框架是 MVVM 思想的落地

MVVM 是架构思想,不是某个库的专利。三大主流框架各有侧重:

  • Vue:官方文档明确采用 MVVM 风格的设计。data + 模板 + 指令直接体现 ViewModel 与数据绑定,对初学者最友好,本课程以它为例。
  • React:更接近"单向数据流"的变体。它没有 v-model 这种语法糖,而是要求显式地"用函数更新状态、再重新渲染",数据流向更直白。
  • Angular:把 MVVM 写进核心概念,依赖注入、双向绑定([(ngModel)])都是框架级功能,结构更重。

学通 MVVM 思想后,换框架只是换"语法外壳"——思想不变

双向绑定的代价:数据从哪来,变得难追

双向绑定省事,也带来一个隐性成本:任何用户输入都可能改数据。如果页面有几十个输入框,每个都双向绑定,出了 bug 时你很难判断"这个数据是被谁改的"。单向绑定(数据 → 视图)的调试路径更短,因为数据只能有一个来源。

工程上常见的取舍:

  • 表单场景:双向绑定收益大,普遍使用 v-model
  • 展示与业务逻辑:坚持单向,让数据流清晰可读。
  • 大型团队项目:往往约定"业务数据单向、表单局部双向",兼顾效率与可维护性。

再谈"声明式"与"命令式"

回到本讲开头的直觉:命令式像"手动发传单",声明式像"自动订阅"。MVVM 把**"怎么更新页面"这件重复劳动交给框架,开发者只描述"页面应该长什么样、数据是什么"**。这带来的实际改变是:代码里不再有一堆 querySelector + textContent = ...,取而代之的是清晰的数据与模板。

⚠️ 常见错误

  1. 以为 React 不是 MVVM 就"不好":架构模式没有绝对优劣,只有适不适合;理解各框架的取舍比站队更重要。
  2. 把"双向绑定"当万能解:非表单区域滥用双向绑定,数据流混乱,调试成本上升。
  3. 只记 API 不记思想:会写 v-model 却说不清它解决了什么问题,换技术栈就归零;先想清楚"数据驱动视图"这个本质。

1.8 思维转变:从"操作页面"到"管理数据"

一个习惯的迁移

传统写法里,你的注意力在DOM:哪个元素该改、改什么。MVVM 写法里,注意力转移到数据:这份状态对不对、要变成什么样。代码的组织方式也随之变化。

对比同一件事——点击按钮把文字变红:

// 命令式(传统):直接操作 DOM
btn.addEventListener('click', () => {
  textEl.style.color = 'red';
});
<!-- 声明式(MVVM):改状态,样式由数据驱动 -->
<template>
  <p :style="{ color: isRed ? 'red' : 'black' }">文字</p>
</template>

<script>
export default {
  data() { return { isRed: false }; },
  methods: {
    turnRed() { this.isRed = true; },
  },
};
</script>

前者的核心动作是"改元素",后者的核心动作是"改状态"。渲染结果的正确性由"数据 + 模板"整体保证,而不是靠一条条 DOM 操作拼出来。

这对排错方式的影响

命令式代码出 bug,你顺着 DOM 操作逐条看"哪一步没执行";声明式代码出 bug,你查"状态是不是预期的、模板是否读对了状态"。两种思维方式不同,排查路径也不同。习惯后,你会自然地先看数据再看模板。

为什么值得掌握

不只是 Vue——这个"状态驱动界面"的思想贯穿现代前端(React 的 state、Flutter 的 Widget 重建都是同一套逻辑)。学一次,换任何框架都能快速迁移,这是比记住某个 API 更值钱的能力。

⚠️ 常见错误

  1. 习惯性去模板里找元素改样式:忘掉"状态是唯一事实",又退回命令式,等于放弃框架优势。
  2. 把状态存在 DOM 属性里:用 data-* 属性存业务值,绕过了响应式系统,改了不更新。
  3. 只学 API 不体会思想:换框架就懵。思想是"数据驱动视图",API 只是表达方式。

📌 记忆口诀

  • MVVM 三件套:Model 管数据,View 管展示,ViewModel 当桥梁。
  • 数据绑定:状态一变,视图自动跟着变;用户一输入,状态自动更新。
  • 单向 vs 双向:展示用单向 v-bind,表单用双向 v-model
  • 视图 = f(状态):状态是唯一事实来源,视图只是投影。

📌 双语术语表(本讲)

中文 English 记忆点
模型 Model 数据与业务规则
视图 View 用户看到的界面
控制器 Controller MVC 里的中间人
主持人 Presenter MVP 里的逻辑层
视图模型 ViewModel MVVM 的桥梁
数据驱动 data-driven 数据决定视图
声明式渲染 declarative rendering 声明"是什么"
命令式 imperative 指挥"怎么做"
单向绑定 one-way binding 数据 → 视图
双向绑定 two-way binding 数据 ⇄ 视图
状态 state 视图的依据

⭐ 本讲考点清单

  1. 前端演进:命令式 DOM 操作的痛点 → 框架的声明式方案
  2. MVC 三角色职责与协作流程
  3. MVP 中 Presenter 与 MVC 中 Controller 的区别
  4. MVVM 三角色与"数据绑定"的含义
  5. 单向绑定 v-bind 与双向绑定 v-model 的适用场景
  6. 视图是状态的函数、"单一事实来源"的含义
  7. 双向绑定为何只用于表单等可输入元素
  8. 框架与 MVVM 的关系(思想 vs 实现)