本篇要回答的问题:把前几讲抽象的 MVC/MVMP 理念落到一个真实案例——浏览器端的"画图"程序,它的 Model、ViewModel、Controller 三层各自该怎么写、怎么解耦?
这是"画图"程序实战四讲的第一讲。前面 22~25 讲讲的都是普适设计理念,显得抽象。本讲选一个需求好懂的"画图"程序(源码 github.com/qiniu/qpaint),把 B/S 程序在浏览器端的三层结构走一遍——本版本暂未连服务端,仍是单机版。
带读(26~30 讲连续实战的预备课):这五讲全部代码是 JavaScript + 浏览器。
不熟前端就先过 带读-00-读懂qpaint需要的前端底子.md,
里面有 26~30 的完整路线。
展开精读:原文只给接口规格、不给一行实现,最关键的三个方法(
Controller.onpaint、stop()、
invalidateRect())读起来全是黑盒。把 qpaint 源码拉下来对着读了一遍,逐条回答"为什么是这样、
以及作者自己做到了没有"——见同目录 精读-26-把MVC按在代码上验一遍.md,
配套可跑代码 代码/qpaint-mini.html。
B/S Web 程序的三层回顾:
| 层 | 本质 |
|---|---|
| Model 层 | 多用户 Model + 单租户 Session-based Model;浏览器端是完整的单租户 DOM 模型 |
| View 层 | 实为 ViewModel 层,真正 View 被浏览器实现;只有数据和可委托事件 |
| Controller 层 | 多个相互解耦的 Controller;切忌让 Controller 互知,更别让 View 知道具体 Controller |
Model 层
代码即 dom.js,是一棵以 QPaintDoc 为根的 DOM 树。各类规格:
| 类 / 接口 | 关键内容 |
|---|---|
QLineStyle |
width、color |
QLine |
pt1/pt2、lineStyle,onpaint |
QRect |
x/y/width/height、lineStyle,onpaint |
QEllipse |
x/y/radiusX/radiusY、lineStyle,onpaint |
QPath |
points[]、close、lineStyle,onpaint |
interface Shape |
onpaint |
QPaintDoc(根) |
addShape、onpaint |
Model 层主要支持两种能力:添加图形(addShape) 与 绘制(onpaint)。
关键判断:绘制有两种做法——让 View 来画(需 Model 把数据暴露给 View),或让 Model 层自己画(避免暴露 DOM 实现细节)。作者选后者:虽然 Model 因此和 GDI 耦合、有点"不纯粹",但耦合 GDI 比暴露 DOM 数据细节更好,因为 GDI 接口更稳定。
依赖选择原则:考虑耦合时,更倾向依赖接口更稳定的组件——因为这意味着你自己的接口也更稳定。
ViewModel 层
代码是 index.htm(总控:界面布局 Layout + 应用初始化 InitApplication)与 view.js(核心 QPaintView 类)。QPaintView 内容归类:
| 归属 | 成员 | 作用 |
|---|---|---|
| Model 相关 | doc: QPaintDoc |
唯一与 Model 的连接,操作 Model |
| ViewModel 自身 | properties(lineWidth/lineColor) |
当前用户选择的样式,典型 ViewModel 数据 |
drawing(DOMElement) |
操作 HTML DOM 的抽象 | |
| 绘制 | onpaint、invalidateRect |
重绘;当前为简单起见总是整个 View 全部重绘 |
| Controller 相关 | registerController / invokeController / stopController | 登记 / 激活 / 停止当前 Controller |
| 事件委托(onmousedown/move/up、ondblclick、onkeydown) | 让 Controller 选择感兴趣的事件响应 | |
| getMousePos | 辅助方法,取鼠标位置 |
关键洞见:Web 开发本不用自己处理局部更新,为何这里又要管?因为我们没有用浏览器的 Virtual View,整个 DOM 数据组织完全自己管理(画在 canvas 上),于是面临的问题与传统桌面开发完全一致。
View 层即便把绘制交给 Model 后只剩"胶水层",仍承担两个重要责任:
| 责任 | 说明 |
|---|---|
| 屏蔽平台差异 | Model 易做平台无关,Controller 大部分是事件响应也跨平台,差异收敛到 View |
| 定义界面布局 | 不同尺寸设备的整体布局在 View 层控制最妥当 |
Controller 层
文件较多(相近的合并):menu.js(Menu/PropSelectors/MousePosTracker)、path.js、freepath.js、rect.js。两点要注意:
| # | 注意点 | 说明 |
|---|---|---|
| 1 | 菜单不直接和创建图形的 Controller 打交道 | 而是调 qview.invokeController 激活对应 Controller,避免两类 Controller 互相耦合 |
| 2 | Model 4 种图形 ↔ 界面 6 种表现 | Model 只有 QLine/QRect/QEllipse/QPath,界面有 Line/Rect/Ellipse/Circle/Path/FreePath——同一 DOM API 在 Controller 层常有多条实现路径 |
几个特殊点:选择 lineWidth/lineColor 操作的是 ViewModel 数据而非 Model(可看作某种 Selection);MousePosTracker 极简但特殊——它不操作 Model/ViewModel,而是操作输入事件。
以 QRectCreator 为例看创建型 Controller 的工作机理:
| 环节 | 动作 |
|---|---|
| 构造 | 传入 shapeType——它复用于 Line/Rect/Ellipse/Circle(凡用两个 point 构建的图形) |
| mousedown | 记录第一个 point,开启数据收集 |
| mouseup | 收集第二个 point,创建 Shape 并加入 DOM |
| keydown | 支持按 ESC 放弃创建 |
架构思维上我们学到什么
关键判断:这个程序裸写 JavaScript、不依赖第三方库——不是鼓励裸写,而是为消除框架喜好差异。更要强调:别被框架绑架,框架不应增加代码耦合,否则就该丢掉;很可能你用的是好框架,但用没用好取决于你自己的思维。
需求分析之后进入架构第二步:概要设计(系统设计),核心是分解子系统,关心三个问题:
| 问题 |
|---|
| 每个子系统负责什么? |
| 它依赖哪些子系统?能否少知道一些子系统的存在? |
| 它们靠什么接口耦合?接口是否自然体现业务关系、是否足够稳定? |
桌面程序一致的套路:
| 层 | 套路要点 |
|---|---|
| Model | 接口自然体现业务逻辑 |
| View | 连接 Model 与 Controller,提供事件委托,但不知道任何具体 Controller |
| Controller | 每个彼此独立,职责就是响应事件→调 Model/ViewModel 改数据 |
总结
本讲把 MVC 落到画图程序:Model 自己画(耦合更稳的 GDI、不暴露 DOM 细节)、ViewModel 当胶水并屏蔽平台/布局差异、Controller 间靠 invokeController 解耦。当前仍是单机版,没连服务端。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| QPaintDoc | Model 层 DOM 树的根,支持 addShape 与 onpaint |
| Model 自绘 | 让 Model 画图,耦合更稳定的 GDI,胜过暴露 DOM 数据细节 |
| 依赖选择原则 | 优先依赖接口更稳定的组件,使自身接口也更稳定 |
| QPaintView | ViewModel 核心,含 doc/properties/drawing + Controller 管理 + 事件委托 |
| invokeController | 菜单通过它激活 Controller,避免 Controller 间耦合 |
一句话速记
Model 自己画图(耦合更稳的 GDI、不暴露 DOM)、ViewModel 当胶水并管布局、Controller 间只通过 invokeController 解耦——抽象的 MVC 在画图程序里就长这样。
几条值得记住的判断
- 依赖更稳定的组件:耦合 GDI 好过暴露 DOM 数据细节。
- 同一 DOM API 在 Controller 层常有多条实现路径(4 种 Model 图形 → 6 种界面表现)。
- 别被框架绑架——框架不该增加耦合,用没用好取决于你的思维。
思考题
作者让 Model 自己绘制(耦合 GDI),理由是"GDI 接口更稳定"。回到你的代码:你在做依赖选择时,是按"接口稳定度"权衡,还是仅凭"哪个库顺手"?有没有因为依赖了一个高频变动的组件,把不稳定传染给了自己的接口?
