⚠️ 前置:本篇默认你能读 JavaScript。如果读前端代码吃力,先看同目录的
带读-00-读懂qpaint需要的前端底子.md——
只讲读懂这份代码所需的最少前端知识(以 Python 为对照),一小时能过完。
本篇要回答的七个问题:
① 让 Model 自己画,和 22 讲"Model 层不该依赖 UI"不是打架吗?“GDI 更稳定"这句怎么验?
② 一个 Web 程序,凭什么反过来要自己写invalidateRect?(连带:dom.js那棵树为什么必须存在)
③ 原文把onpaint列进了 Controller 接口,然后一个字没解释——它是干嘛的?
④ Controller 为什么必须是对象、还必须有stop()?做成几个函数不行吗?
⑤ 一个数据到底该放 Model、ViewModel 还是 Controller?有没有能当场判定的标准?
⑥ 4 种图形对 6 种界面表现,凭什么说"非常正常”?
⑦MousePosTracker到底特殊在哪?
序 · 这一讲的性质,和硬啃它的正确姿势
26 讲不是新知识,是 22 讲的验收考试。
22 讲给的全是"应该":Model 要厚、View 不许知道具体 Controller、Controller 之间不许互相认识、删掉一个交互应该只需注释一行。这些话单看都对,但不落到代码上就永远无法证伪。26 讲交上来一份作业,硬啃的方法只有一个:
把 22 讲的每条原则做成一把尺子,一条条量这份代码。做到了的,问"靠什么机制做到的";没做到的,问"为什么这里就妥协了"。
还有一个麻烦:原文通篇只给了接口规格(那一堆 class X { methods: ... }),一行实现都没有。 于是最关键的三个方法——Controller.onpaint、stop()、invalidateRect()——读起来全是黑盒,你只知道它们存在,不知道它们干什么。
所以我把源码拉下来对着读了:
curl -sL https://codeload.github.com/qiniu/qpaint/tar.gz/refs/heads/master | tar xz
# 关键文件:paintweb/www/{dom.js, view.js, creator/*.js, accel/menu.js, index.htm}
一个必须先说明的坑:仓库 master 是全课讲完之后的最终形态——已经有了选择工具(
accel/select.js)、对象 ID、hitTest、和服务端同步(QSynchronizer),那些是 27~30 讲的内容。本篇引用源码时只取 26 讲这一层,凡属后面才加进来的,我都会标出来。最明显的一处差异:26 讲的规格里样式类叫
QLineStyle(只有 width/color),master 里已经变成QShapeStyle(多了fillColor)。这本身就是个信号——Model 层的字段确实会随业务增长,这一点在第一节会变成一个关键论据。
零 · 先把三层的连接关系钉死
原文分三节讲三层,但连接关系散在各节里,从没画在一起过。先补上,后面所有讨论都挂在这张图上:
┌──────────────────────────────────────────────┐
│ 组装根(index.htm 的 <script> 列表 + 初始化) │ ← 全程序唯一知道"谁认识谁"的地方
└──────────────────────────────────────────────┘
│ 创建并 registerController
▼
Controller ──────────────┐
(多个,彼此零连接) │ 占用事件槽 / 调 invalidateRect / 读 properties
│ ▼
│ QPaintView (ViewModel)
│ │ 持有
│ addShape ▼
└──────────────▶ QPaintDoc (Model / DOM 树)
│
└── onpaint(ctx) ──▶ CanvasRenderingContext2D
三条箭头,三个方向,一条都不能反:
| 谁 → 谁 | 通过什么 | 反过来会怎样 |
|---|---|---|
| Controller → Model | doc.addShape(shape) |
Model 认识 Controller = 业务内核绑死交互方式 |
| Controller → ViewModel | 占事件槽、读 lineStyle、叫 invalidateRect |
— |
| ViewModel → Model | this.doc |
Model 认识 View = 22 讲说的依赖倒过来了 |
| Controller ↔ Controller | 零 | 删一个塌一片 |
| ViewModel → 具体 Controller | 零(只认字符串名字) | View 得跟着每个新交互改 |
再补一张原文完全没给的图——一次"画矩形"的完整时序。这张图是理解后面第三、四、五节的钥匙:
按下鼠标 QPaintView.onmousedown ──▶ QRectCreator.onmousedown
记住 pt1,started = true ← 状态存在 Controller 里
移动鼠标(×N) QPaintView.onmousemove ──▶ QRectCreator.onmousemove
更新 pt2 ──▶ view.invalidateRect()
│
┌───────────────┴───────────────┐
│ ctx.clearRect 全屏擦掉 │
│ doc.onpaint 已定稿的图形 │ ← Model 画
│ current.onpaint 正在拖的橡皮筋 │ ← Controller 画
└───────────────────────────────┘
松开鼠标 QPaintView.onmouseup ──▶ QRectCreator.onmouseup
doc.addShape(buildShape()) ← 这一刻才进 Model
reset():started = false
整个过程里,那个矩形有两次"存在":先作为 Controller 的私有状态存在(可以被 ESC 抹掉),松手那一刻才作为 Model 的数据存在。 这句话是本讲最容易滑过去、也最值钱的一句,第三、四节会把它拆开。
一 · Model 自己画:这是不是在打自己的脸
1.1 原文的说法
从简洁的方式来说,是让 Model 层自己来绘制,这样就避免暴露 DOM 层的实现细节。虽然这样让 Model 层变得有那么一点点不纯粹,因为和 GDI 耦合了。但是我个人认为耦合 GDI 比暴露 DOM 的数据细节要好,因为 GDI 的接口通常来说更稳定。
作者自己都用了"有那么一点点不纯粹"这种含糊措辞。含糊的地方就是要硬啃的地方。
1.2 先把冲突摆明
22 讲说得斩钉截铁:
根就一条:Model 层不 import 任何 UI 头文件。做到这点,后两条(好测、跨平台)自动成立。
26 讲让 QLine.onpaint 里出现了 ctx.strokeStyle、ctx.beginPath()。这不就是 UI 吗?"Model 越厚越好"的三条收益(与界面框架无关、容易测、容易跨平台),是不是当场作废了?
1.3 解开:耦合"接口"和耦合"平台"是两回事
看真实代码(dom.js):
class QLine {
onpaint(ctx) {
let style = this.style
ctx.lineWidth = style.lineWidth
ctx.strokeStyle = style.lineColor
ctx.beginPath()
ctx.moveTo(this.pt1.x, this.pt1.y)
ctx.lineTo(this.pt2.x, this.pt2.y)
ctx.stroke()
}
}
两个关键事实,都藏在细节里:
| 事实 | 意味着什么 |
|---|---|
ctx 是参数传进来的,不是 Model 去找来的 |
dom.js 全文没有一次 document. 、没有一个 DOM 元素、没有一个 import。它不知道 canvas 存在,只知道"有人会给我一个能画画的东西" |
它只用到 ctx 的 6 个成员 |
lineWidth / strokeStyle / beginPath / moveTo / lineTo / stroke。全部 4 种图形加起来也只用到 11 个 |
第一条决定了 22 讲那三条收益一条都没丢:
- 容易测?传一个记录调用的假 ctx 就能单测绘制逻辑,不用起浏览器。(我在配套代码的冒烟测试里就是这么干的:一个
Proxy当 ctx,node 里跑完整套。) - 跨平台?换 Node canvas、换 SVG 后端、换 PDF 后端,Model 一行不改。
所以准确的说法不是"Model 耦合了 GDI",而是:
Model 依赖的是一份 11 个成员的绘图接口契约,而这份契约由调用方在运行时注入。 这在今天叫依赖注入 / 端口适配器,1980 年代叫"传一个设备上下文进来"。
耦合平台(#include <windows.h>)和耦合一个由别人实现、由调用方注入的窄接口,是完全不同的两件事。原文那句"有那么一点点不纯粹",其实不准确——它一点都不脏,只是没说清机制。
1.4 "GDI 更稳定"的可验证形式
这句话听起来像经验之谈,其实可以当场验。把那 6 个动词放到近四十年的绘图 API 里:
| 动作 | PostScript (1984) | Windows GDI (1985) | Cairo (2003) | Canvas 2D (2004→HTML5) |
|---|---|---|---|---|
| 起一条路径 | newpath |
(隐含) | cairo_new_path |
beginPath() |
| 移到某点 | moveto |
MoveToEx |
cairo_move_to |
moveTo() |
| 连到某点 | lineto |
LineTo |
cairo_line_to |
lineTo() |
| 描边 | stroke |
(笔画即描边) | cairo_stroke |
stroke() |
| 设线宽 | setlinewidth |
CreatePen(w,…) |
cairo_set_line_width |
lineWidth |
| 设颜色 | setrgbcolor |
CreatePen(…,color) |
cairo_set_source_rgb |
strokeStyle |
同一组动词,换了四套名字,四十年没变过。
为什么?因为这组接口对应的是"纸和笔"这个物理隐喻,而物理不变。只要输出目标还是一个二维平面、还要用线条和填充去覆盖它,这几个动词就跑不掉。
现在看反面。假如按另一条路走——Model 把数据暴露出来、View 负责画——View 需要知道什么?
pt1.x pt1.y pt2.x pt2.y (QLine)
x y width height (QRect)
x y radiusX radiusY (QEllipse)
points[] close (QPath)
style.lineWidth style.lineColor (每个都有)
这份接口的宽度,等于你业务的复杂度,并且随业务一起长。 而且我们手上就有它变过的实锤:master 版本里 QLineStyle 已经变成了 QShapeStyle,多出一个 fillColor。如果绘制在 View 侧,这一个字段的增加就要同时改 Model 和 View 两处;现在只改了 dom.js。
于是"稳定"这个词有了可操作的定义:
一个接口稳不稳定,看它会不会随着你的业务一起变。
绘图接口对应的是设备能力(不随业务变),DOM 数据接口对应的是业务本身(必然随业务变)。
依赖选择的准则不是"依赖谁更方便",而是"依赖谁能让我自己的接口少改一次"。
1.5 还有一笔账原文没算:这条同时买到了开闭原则
如果 View 来画,QPaintView.onpaint 必然长成这样:
for (let s of doc.shapes) {
switch (s.type) { // ← 每加一种图形,这里就得改一次
case "line": drawLine(ctx, s); break
case "rect": drawRect(ctx, s); break
case "ellipse": drawEllipse(ctx, s); break
// ...
}
}
实际的写法是(dom.js 结尾):
onpaint(ctx) {
let shapes = this._shapes
for (let i in shapes) {
shapes[i].onpaint(ctx) // ← 加一百种图形,这里一个字不改
}
}
“让 Model 自己画”= 把一个 switch 换成一次多态分发。 这条收益比"不暴露细节"更硬:加一种新图形,改动量从"新增一个类 + 改 View 的 switch + 改 ViewModel 可能的类型枚举"变成"新增一个文件"。这正是 62 讲要讲的开闭原则,只不过在这里它是"依赖稳定接口"顺手带来的赠品。
1.6 代价(诚实地说)
这条选择不是白给的,有两处要认账:
其一,Model 里出现了渲染语义。 lineColor、fillColor 这些字段严格说是"怎么显示"而不是"是什么"。对画图程序无所谓(图形的外观就是它的业务),但换成 Word,“这段字是标题”(Model)和"标题显示成 18pt 加粗蓝色"(样式/View)就必须分开,否则换主题要改文档数据。判据:外观本身是不是业务的一部分。
其二,只对"一个渲染目标"成立。 哪天要导出 DXF、要生成语音描述、要算包围盒,onpaint 这一个方法就不够了,Shape 上会长出第二个、第三个 onXxx,最后逼你上访问者模式。
最有力的证据是作者自己的另一半代码。 同一个仓库的 Go 服务端(paintdom/shape.go)里,Shape 接口是这样的:
type Shape interface {
GetID() ShapeID // 就这一个方法。没有 Paint,没有任何绘制
}
关键判断:所以"Model 自绘"不是 Model 层的本性,而是"浏览器端这一份 Model"的选择——因为它只有一个渲染目标,且渲染就是它的全部用途。服务端那份 Model 只负责存和同步,一行绘制都没有。
同一个业务概念(Shape),在两端长出两副不同的骨架。这件事本身比"Model 该不该画图"更值得记:Model 层的形状由它的使用场景决定,不由教条决定。
二 · 一个 Web 程序,凭什么要自己写 invalidateRect
2.1 原文只给了半句答案
原因是我们没有用浏览器的 Virtual View,整个 DOM 的数据组织完全自己管理,这样我们面临的问题就和传统桌面开发完全一致。
"没有用浏览器的 Virtual View"这句需要翻译。它说的是保留模式与立即模式的分野——图形编程里最基础的一条分界线,原文默认你知道。
| 保留模式 Retained Mode | 立即模式 Immediate Mode | |
|---|---|---|
| 你交给系统的是 | 一棵对象树(“页面上有这些东西”) | 一串绘图命令(“往这儿画条线”) |
| 谁记着你的文档 | 系统。树一直活着 | 没有人。命令执行完只剩像素 |
| 谁决定何时重画 | 系统(窗口被遮挡、树变了,它自己重画) | 你。系统不知道你画了什么,也就不知道什么时候该重画 |
| 例子 | HTML DOM、SVG、WPF、Qt Widgets | Canvas 2D、OpenGL、GDI 的 WM_PAINT、Dear ImGui |
qpaint 用的是 <canvas>——立即模式。于是两件事同时发生:
2.2 关键推论:dom.js 的存在,是选 canvas 的代价
这是原文完全没点破、但一点破就通的一条:
如果 qpaint 用 SVG 或 HTML 元素来画图,那么浏览器的 DOM 树本身就是 Model,
dom.js这个文件根本不需要存在。
用 SVG 的话,画一个矩形就是 svg.appendChild(<rect x y w h>):浏览器替你记着这个矩形、替你在窗口 resize 时重画、替你做命中测试(点击哪个元素浏览器直接告诉你)。你的"文档"就住在浏览器里。
选了 canvas,等于把浏览器给你的这棵树退掉。于是:
| 退掉的东西 | 你必须自费重建的 |
|---|---|
| 那棵一直活着的对象树 | QPaintDoc + 四个 Shape 类(整个 dom.js) |
| "什么时候该重画"的判断 | invalidateRect,以及每个 Controller 里那几十次手动调用 |
| 命中测试(点中了谁) | hitTest(26 讲还没有,27 讲被迫补上) |
| 增量重绘 | 22 讲说的排版引擎那一层(qpaint 直接放弃,见 2.4) |
所以 22 讲讲了半天"Model 是 DOM",到 26 讲变成了字面意义上的真事:他们真的手写了一棵 DOM 树,因为把浏览器那棵退掉了。
2.3 那为什么还要选 canvas
值得掂量一下这笔交易,因为它是本讲唯一一个被完全跳过的重大决策:
| 选 canvas 得到 | 说明 |
|---|---|
| 图形数量上的性能 | 几千个 SVG 节点会让浏览器的样式计算和布局垮掉;canvas 里几千个图形只是几千次 stroke() |
| Model 的可移植性 | dom.js 那套类换到 Node、换到 Electron、换到原生 canvas 都能用;一旦 Model 是 SVG 元素就跟浏览器绑死了 |
| 完全的绘制控制权 | 橡皮筋预览、自定义选择框、控制点——不用跟浏览器的渲染规则打架 |
| 跟服务端 Model 对得上 | 后面 28~30 讲要把 Shape 序列化成 JSON 同步给服务端;纯 JS 对象能,SVG 元素不能 |
最后一条其实是决定性的:这门课后面四讲全在做联网协同。从第一行代码起就选了一个"数据不住在浏览器里"的方案,是为了后面能把数据搬到服务端去。 26 讲说"本质上还是单机版",但架子已经是按联网搭的。
2.4 然后看真实实现:那个参数被扔了
invalidateRect(reserved) { // 参数名直接就叫 reserved(保留)
let ctx = this.drawing.getContext("2d")
let bound = this.drawing.getBoundingClientRect()
ctx.clearRect(0, 0, bound.width, bound.height) // 全屏擦
this.onpaint(ctx) // 全部重画
}
而 QRectCreator 那边认认真真地把脏矩形算出来传了进去:
onmousemove(event) {
if (this.started) {
this.rect.pt2 = view.getMousePos(event)
view.invalidateRect(this.rect) // ← 传了,但对面根本不看
}
}
这不是偷懒,是一个正经手法,值得单独记一条:
把接口按最终形态定好,实现先给最笨的那个。
好处是:将来要做增量重绘时,改动被圈死在invalidateRect这一个函数体里——所有调用方早就在传正确的脏区了,一处都不用改。
如果反过来先定成repaint()无参,那天到来时你要去改几十个调用点,还得挨个想"这次到底脏了哪儿"——而那个信息在当时的调用现场最清楚,事后就找不回来了。一句话:接口里先留下你现在不用、但将来必然需要的信息。信息在现场是免费的,事后重建是昂贵的。
2.5 由此解释:QPaintView 为什么薄得不像个 ViewModel
22 讲把 ViewModel 说得很重——排版引擎、双向绑定、"数据变化 → 屏幕区域"的映射表。可 26 讲的 QPaintView 里,属于 ViewModel 自己的数据只有两样:properties 和 drawing。说好的 Model 镜像呢?
答案已经在上面了:
ViewModel 的厚度,正比于局部更新的精细度。
ViewModel 之所以存在,是因为要回答"这次数据变化影响了屏幕的哪一块"。qpaint 的回答是"整块"——这个问题被取消了,那么承载答案的那层结构自然就退化没了,只剩胶水。
反过来说:Word 的 ViewModel 厚,不是因为 Word 高级,是因为它必须回答"改了第 2 页一个字,屏幕上哪几行要重排"。
这条把 22 和 26 缝在了一起,也给了一个实用判据:当你发现自己的 ViewModel 层"好像没什么东西",先别怀疑架构,先问自己做不做局部更新。不做,它就该是薄的。
三 · Controller 接口里的 onpaint:原文列了却一字未提
原文给的规格是:
interface Controller {
stop(): void
onpaint(ctx: CanvasRenderingContext2D): void
}
然后全文再没提过 onpaint。Controller 不是负责响应事件、改 Model 的吗?它画什么?
3.1 它画的是"还不存在的东西"
看 QPaintView.onpaint 的真实实现:
onpaint(ctx) {
this.doc.onpaint(ctx) // ① 已经定稿的图形
if (this._current != null) {
this._current.onpaint(ctx) // ② 正在拖、还没定稿的那一个
}
}
绘制管线是两段的。 你按住鼠标拖矩形时,屏幕上那个跟着鼠标变形的框——它不在 doc._shapes 里,它谁都不属于,它只是 QRectCreator 内部的两个点。要让它显示出来,Controller 必须有机会往画布上画一笔。
这就是 22 讲说的**“Controller 逻辑过程中临时生成的 View,应属于 Controller”**——只不过在 canvas 里,"临时的 View"不是一个 DOM 元素,就是这几行额外的绘图调用。
3.2 真实实现里的漂亮一手:预览和成品是同一段代码
onpaint(ctx) {
if (this.started) {
this.buildShape().onpaint(ctx) // ← 就这一行
}
}
buildShape() 是什么?是 onmouseup 里用来创建真正图形的那个方法:
onmouseup(event) {
this.rect.pt2 = view.getMousePos(event)
view.doc.addShape(this.buildShape()) // ← 同一个 buildShape
this.reset()
}
每一次鼠标移动,它都造一个完整的、货真价实的 QRect 出来,让它画自己,然后扔掉。松手那一次,造出来的那个不扔,塞进 Model。
于是:
| 做了什么 | |
|---|---|
| 预览 | buildShape().onpaint(ctx) —— 造,画,扔 |
| 定稿 | doc.addShape(buildShape()) —— 造,留 |
"预览"和"成品"的唯一区别,是要不要 addShape。 绘制代码零重复,预览和成品也永远不可能长得不一样(这是所有画图软件的经典 bug 来源:预览的虚框和松手后的实物对不上)。
回头看第一节:这一手之所以能成立,正是因为"让 Model 自己画"那个决定。如果绘制逻辑在 View 里,Controller 想画预览就得再调一次 View 的私有绘制函数,或者自己抄一遍。
两个设计决定在这里咬合上了——这是判断架构好坏最可靠的信号:好的决定会互相帮忙,坏的决定会互相打架。
3.3 那为什么不干脆先塞进 Model,ESC 时再删掉
这是最自然的偷懒想法,也是必须挡住的。四条理由,一条比一条硬:
| # | 理由 | 后果 |
|---|---|---|
| 1 | Model 的不变量被破坏 | _shapes 里的每个元素本应是"一个完整的图形"。混进半成品后,所有遍历 shapes 的代码都得先判断"这个是不是画完了" |
| 2 | 撤销栈被污染 | 拖动过程中鼠标移了 200 次,撤销历史里就是 200 条"修改矩形"。用户按一次 Ctrl+Z 期待撤销整个矩形 |
| 3 | 协同同步会炸(27~30 讲) | Model 的每次变更都要发给服务端。拖一个矩形 = 200 个网络请求,别人屏幕上会看见你手抖的全过程 |
| 4 | ESC 变成了"删除" | 放弃一个从没存在过的东西是零成本的;删除一个已存在的东西要处理 ID、要回滚、要通知别人 |
于是得到一条通用规则:半成品数据不进 Model。
Model 里只放"用户已经确认的、值得存盘的、愿意让别人看见的"东西。
推论:每个交互过程中间态的数据,都需要一个 Model 之外的住处——而那个住处就是 Controller 自己。
这就自然引出了下一节。
四 · 数据住在哪:一个能当场判定的标准
原文只轻描淡写地说了一句:
选择当前 lineWidth、lineColor 操作的对象是 ViewModel 的数据,不是 Model。
但没给判据。把上一节的结论接上,数据其实有三个住处,而且每个都有一个一句话就能问出答案的测试:
| 住在哪 | 一句话测试 | qpaint 里是谁 |
|---|---|---|
| Model | 换台机器打开同一个文档,它该跟过去吗? | _shapes 数组、每个图形的坐标和样式 |
| ViewModel | 开两个窗口看同一个文档,它该各有一份吗? | properties(当前线宽/颜色)、currentKey(当前工具)、将来的 Selection、滚动位置、缩放比例 |
| Controller | 松开鼠标之后,它该消失吗? | rect.pt1、started、points[] |
三个问题指向同一件事的三个维度:跨设备(存盘)、跨视图(呈现)、跨事件(过程)。
拿它去检验原文那句"把当前 lineWidth 看作某种 Selection 是完全正确的认知":
- Selection 通过测试二(两个窗口看同一文档,各自选中的东西不同)→ ViewModel。
- 当前线宽同样通过测试二 → ViewModel。
- 它们是同一类东西,因为它们回答的是同一个问题:“这个用户此刻怎么看这份文档”。 原文那个类比不是修辞,是分类学上的同类项。
4.1 快照 vs 引用:ViewModel 数据变成 Model 数据的那一刻
现在有个微妙问题:lineWidth/lineColor 住在 ViewModel,可画出来的每个图形也带着 lineStyle。图形里那份,是 ViewModel 那份的引用,还是拷贝?
真实代码给了答案(creator/rect.js):
buildShape() {
let style = defaultStyle.clone() // ← clone(),不是直接引用
switch (this.shapeType) {
case "line": return new QLine(rect.pt1, rect.pt2, style)
...
}
必须是拷贝。 如果是引用,你画了十个黑色矩形,然后把调色板改成红色——十个矩形会一起变红。这不是画图程序该有的行为。
于是"创建一个图形"这个动作,本质上是一次快照:把 ViewModel 的当前状态固化成 Model 的一部分。
这个瞬间在所有编辑器里都存在:Word 里你设好字体再打字、PS 里你选好画笔再落笔——设置活在 ViewModel(会变),落下去的那一笔活在 Model(不会变)。反过来,如果需求是"改调色板,选中的图形跟着变",那也不是引用,是"遍历 Selection,逐个改 Model"——27 讲的
selection_setProp正是这么写的。永远不要靠共享引用来实现联动,那是把两层的生命周期焊死。
配套代码里第 ② 个实验就是把这个 clone() 拆掉,可以直接看到旧图形集体变色。
五 · Controller 为什么必须是对象,还必须有 stop()
5.1 21 讲留下的坑
21 讲讲的是控制反转:消息循环抢走了 main 的位置,你的代码从"一条顺序执行的流程"碎成了一堆互不相连的回调。当时那讲留下了一个没答完的问题——那我原来那段顺序逻辑去哪了?
26 讲给出了答案。看"画一个矩形"这件事,用户脑子里它是顺序的:
按下 → 记住起点 → 拖 → 松开 → 图形完成
代码里它是这样的:
onmousedown(e) { ... } ← 函数返回,栈销毁
onmousemove(e) { ... } ← 一次新的调用,中间可能隔了 300 毫秒
onmousemove(e) { ... } ← 又一次,还可能穿插着别的事件
onmouseup(e) { ... } ← 又一次
四次不相连的调用之间,"起点在哪"必须存在某个地方。 函数栈上不行(返回就没了),全局变量不行(多个交互会打架),Model 里不行(第三节那四条理由)。
所以 Controller 是对象,不是因为面向对象好看,是因为它必须有一个活过多次事件回调的躯壳。
Controller = 被消息循环打碎的那段顺序逻辑,重新粘起来的地方。它的本体是一台状态机。
5.2 三台状态机,摆在一起看
原文只讲了 QRectCreator 一个。三个并排才看得出"选择自己感兴趣的事件"是什么意思:
| QRectCreator | QPathCreator | QFreePathCreator | |
|---|---|---|---|
| 订阅的事件 | down / move / up / key | down / move / dblclick / key | down / move / up / key |
| 起手 | mousedown 记 pt1 | mousedown 记第一个点 | mousedown 记起点 |
| 推进 | mousemove 更新 pt2 | mousedown 再加一个点;mousemove 只动橡皮筋末端 | mousemove 每一下都存一个点 |
| 收尾 | mouseup | dblclick 或 Enter | mouseup |
| 放弃 | ESC → reset | ESC → reset | ESC → reset |
| 产出 | QLine/QRect/QEllipse | QPath | QPath |
三条观察:
QPathCreator根本没订阅mouseup——它的stop()里只还三个槽 + keydown。这就是"允许 Controller 选择自己感兴趣的事件"的字面含义:没兴趣的事件,连接都不接。- 同样是"记一个点",三台机器的触发时机完全不同:一次(Rect)、每次点击(Path)、每次移动(FreePath)。这是后面第七节"多条实现路径"的根。
- 四台机器的
reset()长得一模一样:清状态、重画、回到空闲。放弃永远是零成本的,因为没有任何东西泄漏到 Controller 之外。
5.3 stop() 到底在 stop 什么
原文只给了签名。实现是这样的(每个 Creator 都有一份):
constructor(view, shapeType) {
let ctrl = this
view.onmousedown = function(event) { ctrl.onmousedown(event) } // ← 抢占
view.onmousemove = function(event) { ctrl.onmousemove(event) }
view.onmouseup = function(event) { ctrl.onmouseup(event) }
view.onkeydown = function(event) { ctrl.onkeydown(event) }
}
stop() {
let view = this.view
view.onmousedown = null // ← 交还
view.onmousemove = null
view.onmouseup = null
view.onkeydown = null
}
"事件委托"这个听起来很架构的词,落到代码上就是四个赋值语句。
这里藏着一个决定整个设计形状的事实:
QPaintView的事件槽是"独占"的,不是"广播"的。onmousedown是一个字段,不是一个监听器列表。同一时刻只有一个 Controller 能拿到鼠标。
这一条解释了三件事:
| 现象 | 因为槽是独占的 |
|---|---|
为什么必须有 stop() |
不还槽,下一个 Controller 就抢不干净——旧的还在收事件,旧的橡皮筋还在画 |
为什么 invokeController 第一件事是 stopController() |
换人之前必须先让上一个人退场,并且是它自己退场(它知道该清理什么) |
| 为什么这套设计天生防冲突 | 两个 Controller 不可能同时响应同一次点击,"互相干扰"这个 bug 类别根本不存在 |
invokeController 的实现印证了这一点:
invokeController(name) {
this.stopController() // 先停
if (name in this.controllers) {
let controller = this.controllers[name]
this._setCurrent(name, controller()) // 再起
}
}
一句话记住:
stop()是"独占资源"这个设计的必然配件。凡是独占的东西,都必须有归还的动作——锁要 unlock,文件要 close,事件槽要还回去。这跟 12 讲讲互斥、跟 RAII、跟defer是同一件事在不同抽象层的复现。
代价也要认:独占意味着"旁听"是不可能的。一个只想看看鼠标在哪、不想抢占鼠标的 Controller,在这套机制里没有位置——这就是第八节 MousePosTracker 那个怪相的成因。
5.4 一个容易漏看的细节:注册的是工厂,不是实例
view.registerController("RectCreator", function() {
return new QRectCreator(view, "rect") // ← 注册的是一个会造 Controller 的函数
})
invokeController 里 controller() 调一次,每次激活都得到一个全新的实例。这不是随手写的:
- Controller 是状态机,状态必须每次从头开始。复用实例就要写一个
reset()并保证它清干净了每一个字段——那是漏 bug 的经典位置。 - 构造函数同时承担了"抢占事件槽"的职责,天然应该每次激活都跑一遍。
有状态的东西,用完就扔比洗干净再用更安全。
六 · 菜单为什么不 new,只念一个名字
6.1 一根字符串切断了编译期依赖
原文说:
菜单并不直接和各类创建图形的 Controller 打交道,而是调用 qview.invokeController 来激活对应的 Controller,这就避免了两类 Controller 相互耦合。
翻成机制:menu.js 里没有出现过 QRectCreator 这个标识符。 它只出现过字符串 "RectCreator"。
function onClickCtrl(key) {
// ...
view.invokeController(key) // key 是从按钮 id 上取的字符串
}
于是依赖图上,menu.js → rect.js 这条边不存在。两者共同依赖的,只是一份命名约定——一根字符串。
6.2 拿 22 讲那把尺子量一下
22 讲给了唯一一条可当场检验的判据:
做得恰当时,干掉某个交互特别容易——不用删 Controller 代码,只需把创建它的那一行注释掉即可。
真的量一下。把 index.htm 里 <script src="creator/rect.js"> 这一行注释掉:
| 会发生什么 | 说明 |
|---|---|
| 程序照常启动,不报错 | 没人 import 它 |
| Path / FreePath 照常工作 | Controller 之间零耦合,实锤 |
| Line/Rect/Ellipse/Circle 四个按钮还在,点了没反应 | invokeController 里那句 if (name in this.controllers)——查不到就静默返回 |
前两条满分。第三条露馅了。
6.3 作者自己没做到自己讲的那条
22 讲说得很清楚:“Controller 的辅助 View 必须归它自己(否则界面上会留一个死按钮)”。而 menu.js 里:
function installControllers() {
document.getElementById("menu").innerHTML = `
<input type="button" id="PathCreator" value="Create Path" ...>
<input type="button" id="RectCreator" value="Create Rect" ...>
...` // ← 六个按钮硬编码在这里
}
按钮列表归 menu.js,按钮行为归各个 creator 文件。一个交互被劈成了两半,住在两个地方。 结果就是上面那个死按钮。
要做对,应该让每个 creator 文件在自注册行为的同时,也把自己的按钮插进菜单:
onViewAdded(function(view) {
view.registerController("RectCreator", () => new QRectCreator(view, "rect"))
menu.addButton("RectCreator", "Create Rect") // ← 界面也自己带
})
这样注释掉一个文件,按钮和行为一起消失,真正做到"删一个交互 = 少加载一个文件"。
这不是挑刺,是硬啃的收获本身。 71 讲专门要讲"如何阅读别人的代码"——读到能拿作者自己的原则去量出他自己的偏差,才算读进去了。
而且这个偏差很典型:中心化的界面容器(菜单栏、工具栏、设置页)是所有插件式架构最常见的破口。 逻辑很容易做到插件化,界面上那个"总得有人知道有哪些项"的列表,总在诱惑你把它集中写死。
6.4 另一笔账:静默失败
if (name in this.controllers) 查不到就什么都不做——名字拼错不会报错。用字符串换来解耦,代价就是把编译期检查换成了运行期的沉默。
值不值?在这个规模上值(六个名字,写错一眼看得见)。规模大了,通常的补法是:注册时校验、启动时做一次"所有菜单项都能找到对应 Controller"的自检、或者用常量代替裸字符串。关键是知道自己付了什么,而不是假装没付。
6.5 组装根在哪
22 讲说"所有模块间的’认识’关系集中在一个地方,所以注释掉一行才能生效"。qpaint 的这个地方就是 index.htm:
<script src="dom.js"></script> <!-- Model -->
<script src="view.js"></script> <!-- ViewModel -->
<script src="creator/path.js"></script> <!-- ↓ 这几行就是"加载哪些 Controller" -->
<script src="creator/freepath.js"></script>
<script src="creator/rect.js"></script>
<script src="accel/menu.js"></script>
这份 <script> 列表就是依赖注入的组装根,朴素到有点好笑,但它确实是全程序唯一一处"决定这个应用由哪些部件构成"的地方。今天你用 Spring 的 @Configuration、用 React 的 <Providers>、用 main.go 里那一串 New——是同一个东西穿了不同的衣服。
七 · 4 种图形 → 6 种界面表现,凭什么"非常正常"
原文只丢下一句"这是非常正常的现象"就过去了。它其实是本讲最有普适价值的一条,值得挖到底。
7.1 Circle 和 Ellipse:同一个类,两种完全不同的手势
两者在 Model 层都是 QEllipse。看 buildShape() 怎么造它们:
case "ellipse":
let rx = r.width / 2 // r 是拖出来的外接矩形
let ry = r.height / 2
return new QEllipse(r.x + rx, r.y + ry, rx, ry, style) // 圆心 = 矩形中心
case "circle":
let rc = Math.sqrt(r.width * r.width + r.height * r.height)
return new QEllipse(rect.pt1.x, rect.pt1.y, rc, rc, style) // 圆心 = 起手那一点
手势语义完全相反:
| 你按下的那一点是 | 你拖出的距离是 | |
|---|---|---|
| Ellipse | 外接矩形的一个角 | 矩形的对角线 |
| Circle | 圆心 | 半径 |
同一个数据结构,两种截然不同的操作心智。你没法从 QEllipse 这个类推出用户该怎么画它。
7.2 Path 和 FreePath:同一个类,两台完全不同的状态机
两者产出的都是 new QPath(points, close, style)。但(见 5.2 那张表)一个是"点、点、点、双击结束",一个是"按住不放、随手涂、松开结束"。连订阅的事件集合都不一样。
7.3 由此得到一般规律
Model 的分类轴是"存什么",Controller 的分类轴是"怎么做"。这是两套正交的分类,不该被强行对齐。
| 按什么切分 | 增长的原因 | |
|---|---|---|
| Model 的类 | 数据结构的形状 | 业务里出现了新的东西 |
| Controller | 用户的意图与手势 | 业务里出现了新的做法 |
于是四种组合全都合法,而且 qpaint 里四种都出现了:
| 关系 | 例子 |
|---|---|
| 一个 Model 类 ← 多个 Controller | QEllipse ← EllipseCreator + CircleCreator |
| 一个 Controller → 多个 Model 类 | QRectCreator → QLine / QRect / QEllipse(靠 shapeType 参数) |
| 一对一 | QRect ← RectCreator |
| Controller → 零个 Model 类 | MousePosTracker(下一节) |
最常见的新手错误,是把这两根轴焊死:每个 Model 类配一个 Controller。
后果是:想给同一个东西加第二种画法(比如"从中心画矩形"),你会发现无处安放,只好去改 Model 类或者塞一堆 if。Controller 的数量应该由"用户有多少种做法"决定,而不是由"你有多少个数据类"决定。
反过来,QRectCreator 用一个 shapeType 参数服务四种图形——参数化是把多对多的映射收敛掉的工具。判据很简单:状态机一样就参数化,状态机不一样就拆类。 Line/Rect/Ellipse/Circle 的状态机完全相同(都是"两点定形"),只有 buildShape 那一个 switch 不同;Path 和 FreePath 的状态机不同,所以是两个类。
八 · MousePosTracker 特殊在哪
原文的说法:
它并不操作任何正统意义的数据(Model 或 ViewModel),而是操作输入的事件。
8.1 表面的特殊:它跳过了整个数据层
它干的事是:收到 mousemove → 把坐标写到状态栏。不读 Model、不写 Model、不读 ViewModel、不写 ViewModel。 事件进来,直接变成它自己那块界面上的字。
按 22 讲的框架,状态栏那个 <span> 是它自己的辅助 View。所以它的完整形状是:
事件 ──▶ Controller ──▶ 自己的辅助 View
(既没经过 Model,也没经过 ViewModel)
这逼着我们把 Controller 的定义放宽:Controller 不是"改 Model 的那个东西",而是"订阅一组事件、自治地完成一件事、并且不被任何人依赖的那个东西"。 改不改 Model 是它可能做的事之一,不是它的定义。
它也是正交分解最干净的样本:干掉它,整个程序除了状态栏不再更新,没有任何其他地方能察觉。
8.2 真实实现更怪:它压根没走委托机制
看代码(accel/menu.js):
function installMousePos() {
let mousepos = document.getElementById("mousepos")
onViewAdded(function(view) {
let old = view.drawing.onmousemove // ← 注意:drawing,不是 view
view.drawing.onmousemove = function(event) {
let pos = view.getMousePos(event)
mousepos.innerText = "MousePos: " + pos.x + ", " + pos.y
old(event) // ← 处理完,再把事件交给原来的人
}
})
}
它没有 registerController,没有 invokeController,也没有 stop()——它根本不是那套机制里的一员。它绕到 view.drawing(那个 <canvas> 的 DOM 元素)那一层,把浏览器级别的 onmousemove 包了一层装饰器。
8.3 这暴露了 View 委托机制的一个真实缺口
回到 5.3 那条:QPaintView 的事件槽是独占的。
于是一个 Controller 只有两个选择:要么抢占鼠标(那 Creator 们就都别想工作了),要么完全收不到鼠标。 而 MousePosTracker 想要的是第三种:旁听——我看一眼就好,事件还归你。
这套委托机制里没有"旁听"这个位置。所以它只能翻墙。
代价是实实在在的:
| 代价 | 说明 |
|---|---|
| 它绕过了 View 的抽象 | 它直接摸 DOM 元素,于是它不再是跨平台的——换成原生桌面版,这段要重写 |
| 装饰器链无法拆卸 | old(event) 这种包法只能加不能减。没有 stop(),也没法 stop() |
| 顺序变得隐式 | 谁先 install 谁就在链的外层,这个顺序没有任何地方声明 |
正确的修法,是承认事件有两种订阅语义:
语义 谁需要 独占 / 委托 我来处理,别人别管 各种 Creator(同一时刻只能有一个) 旁听 / 广播 我只观察,不影响别人 MousePosTracker、坐标尺、调试面板、埋点 补上后者(一个
addListener列表),MousePosTracker就能回到框架内,也能被stop()掉。这是本讲里最值得带走的一条工程直觉:当你发现某个模块"必须绕过框架才能实现"时,通常不是这个模块特殊,而是框架少了一种语义。 原文说它"特殊",其实它一点都不特殊——它只是第一个撞到墙的。
九 · 顺手记下代码里的三处瑕疵
不是挑刺。71 讲要讲"如何阅读别人的代码",读到能挑出这几处,才说明你不是在顺着作者的叙述滑行。
其一:Path 的绘制逻辑被抄了三遍。
QRectCreator.onpaint 用 buildShape().onpaint(ctx) 复用了 Model 的绘制(3.2 那一手)。但 QPathCreator.onpaint 和 QFreePathCreator.onpaint 各自手写了一遍 canvas 绘制:
onpaint(ctx) { // QPathCreator,手抄版
ctx.lineWidth = props.lineWidth
ctx.strokeStyle = props.lineColor
ctx.beginPath()
ctx.moveTo(this.fromPos.x, this.fromPos.y)
for (let i in this.points) { ctx.lineTo(this.points[i].x, this.points[i].y) }
ctx.lineTo(this.toPos.x, this.toPos.y) // ← 多这一段"到当前鼠标"的橡皮筋
ctx.stroke()
}
为什么会抄:预览需要多画一段"最后一个点 → 当前鼠标"的线,而 buildShape() 造出来的 QPath 不含 toPos。
但它没必要抄:new QPath(points.concat([toPos]), close, style).onpaint(ctx) 就够了(配套代码里我就是这么写的)。现在的后果是——画线的逻辑存在三处,改一处(比如加圆角端点、加虚线)就得改三处,而且漏掉一处不会报错,只会让预览和成品长得不一样。
其二:QFreePathCreator 有个真 bug。
buildShape() {
return new QPath(points, this.close, defaultStyle.clone())
// ^^^^^^^^^^ 构造函数里从来没赋过值 → undefined
}
this.close 永远是 undefined。因为 undefined 是假值,行为上刚好等同于 false,所以从没暴露过。这是"靠巧合正确"的典型标本——哪天有人给 QPath 加一句 if (this.close === undefined) throw ...,或者改成三态,它立刻就炸。
其三:菜单按钮列表硬编码(6.3 已经展开)。
三处凑一起,还有一个共同的元观察:它们全都出现在"预览"和"菜单"这类边角料上,核心的三层结构一处问题都没有。 这挺符合真实工程的样子——架构的价值不在于让所有代码都完美,而在于让必然会出现的脏东西被圈在无关紧要的地方。
十 · 概要设计那三问,用画图程序填一遍
原文最后给了三个问题,但没用自己的例子答一遍。补上——这张表才是本讲真正的产出物:
Model (dom.js) |
ViewModel (view.js) |
Controller (creator/*, accel/*) |
|
|---|---|---|---|
| 负责什么 | 文档是什么:图形的数据与自绘 | 用户此刻怎么看:当前样式、当前工具、重绘调度、事件分发 | 用户能干什么:把一串事件翻译成一次业务动作 |
| 依赖谁 | 谁都不依赖(只依赖注入进来的 ctx 契约) | Model | Model + ViewModel |
| 谁依赖它 | ViewModel、Controller | Controller | 没有人 |
| 接口是什么 | addShape / onpaint |
事件槽、invalidateRect、register/invoke/stopController |
stop / onpaint + 一个字符串名字 |
| 接口稳不稳 | 稳(业务动作级) | 中(invalidateRect 的参数已经为将来留好了) |
稳(只有两个方法) |
| 能否少知道一点 | 已经是零 | 只知道"有 Controller 这种东西",不知道有哪些 | 彼此零知道 |
依赖图是一棵树,没有环,叶子节点(Controller)没有任何入边。"它能够少知道一些子系统的存在么"这个问题,在这份代码里的答案是:Controller 谁都不认识,View 只认识一个抽象接口和一堆字符串,Model 谁都不认识。
十一 · "别被框架绑架"的可操作版本
原文这段话很容易读成鸡汤:
框架不应该增加代码的耦合,否则这样的框架就应该丢了。
给它一个能当场用的判据:
把框架抽掉,你的业务逻辑还是不是一个完整的东西?
对着 qpaint 验:把 <canvas>、把浏览器、把所有 DOM 全拿走,dom.js 还是一套完整的、可测试的、能序列化的图形文档模型。它从没变成过任何框架的形状。
反面长什么样,今天更常见:
| 写法 | 出了什么事 |
|---|---|
图形数组存在 React 的 useState 里 |
你的文档模型变成了 React 的一部分。想在 Node 里跑一次导出?跑不了 |
| 图形是 Vue 的响应式对象 | 序列化时带一堆 __ob__,还得先 toRaw |
业务规则写在 useEffect 里 |
"什么时候该重算"这条业务知识,被表达成了框架的依赖数组 |
框架该做的是"替你写掉一些代码",不该做的是"决定你的业务概念长什么样"。
分界线:框架可以住在 View 和 Controller 里,不该住进 Model 里。
这也正好是"Model 层越厚越好"那条的另一个理由——厚 Model 是你对抗框架变迁的资产。前端框架五年一换,你的业务模型不该跟着换。
总结
26 讲的实质是:22 讲那套原则的一次可执行的证明。 而硬啃它的收获,几乎全在原文没说的地方:
三个核心机制,原文都只给了签名:
| 机制 | 原文说了 | 实际是 |
|---|---|---|
Controller.onpaint |
只列在接口里 | 橡皮筋预览。buildShape().onpaint(ctx)——预览和成品是同一段代码,区别只在要不要 addShape |
Controller.stop |
只列在接口里 | 把抢占的四个事件槽还回去。独占资源的必然配件 |
invalidateRect(rect) |
“总是全部重画” | 参数收了不用。接口按最终形态定,实现先给最笨的 |
三条能带走的判断:
| 判断 | 怎么用 |
|---|---|
| 依赖接口不随业务变的那一方 | "GDI 更稳定"的可验证形式:绘图动词四十年没变,DOM 字段这一版就多了个 fillColor |
| 数据的三个住处,三个一句话测试 | 换机器要不要跟过去(Model)/ 两个窗口要不要各一份(ViewModel)/ 松手要不要消失(Controller) |
| Model 按"存什么"分,Controller 按"怎么做"分 | 两根轴正交。焊死它们是新手错误 |
一句话速记
Model 只依赖一份四十年没变过的绘图动词表,所以它自己画;Controller 是被消息循环打碎的顺序逻辑重新粘起来的状态机,所以它必须是对象、必须能 stop、必须能画自己那个还没出生的图形;ViewModel 薄成胶水,只因为这一版放弃了局部更新。
几条值得记住的判断
- 耦合一个由调用方注入的窄接口 ≠ 耦合平台——
ctx是参数,不是import,所以 22 讲的三条收益一条没丢。 - 让 Model 自己画,顺手买到了开闭原则:一个 switch 换成一次多态分发,加图形从"改三处"变成"加一个文件"。
- 选 canvas 的代价就是
dom.js本身——退掉浏览器那棵保留模式的树,就得自费重建一棵。 - 半成品不进 Model:撤销栈、协同同步、Model 不变量、ESC 语义,四条理由。
- 好的设计决定会互相帮忙:Model 自绘 → 预览零重复代码。互相打架就说明有一个错了。
- 当某个模块必须绕过框架才能实现,通常是框架少了一种语义(这里是"旁听")。
- 中心化的界面容器是插件式架构最常见的破口——作者自己也没躲过(六个按钮硬编码在
menu.js)。
配套代码
代码/qpaint-mini.html——单文件,浏览器直接打开。刻意对齐 26 讲这一层(没有选择工具、对象 ID、服务端同步,那些是 27~30 讲的)。
右侧面板实时显示同一时刻三层各自握着什么数据,这是第四节那张表的可视化版:拖动过程中盯着看,能清楚看到起点存在 Controller 里、松手那一刻才跳进 Model。
文件末尾有三个改一行就能看见的实验:
| 实验 | 会看到 |
|---|---|
注释掉 registerController("CircleCreator", ...) |
程序不崩、其他工具照常——但 Circle 按钮变成死按钮(6.3 那个偏差) |
去掉 buildShape() 里的样式拷贝 |
改一次颜色,所有旧图形集体变色(4.1 快照 vs 引用) |
注释掉 QPaintView.onpaint 里的第 ② 行 |
拖动时全无预览,松手才突然出现(第三节 Controller.onpaint 的全部理由) |
思考题
-
第八节说这套委托机制缺了"旁听"语义。动手把它补上:给
QPaintView加一组addEventObserver/removeEventObserver,把MousePosTracker改造成一个正常注册、能被stop()的 Controller。做完对比一下——你补的这个东西,和 21 讲讲的"事件冒泡"是不是同一样东西? -
第七节说"状态机一样就参数化,状态机不一样就拆类"。现在来一个新需求:按住 Alt 时,矩形从中心向外画。 按这条判据,它该是
QRectCreator的第五个shapeType、一个构造参数、还是一个新类?(提示:先问它的状态机和现有的哪里不一样。) -
回到你自己手上的代码:找一个"拖拽 / 多步表单 / 向导"这类跨多个事件的交互,看看它的中间状态现在住在哪。是全局变量?是组件 state?还是已经混进了你要存盘的数据里?拿第四节那三个问题量一遍——“松开鼠标之后它该消失吗”,如果答案是"该",但它现在活得比这更久,那就是一处将来会咬人的地方。
