先说结论:27 讲没有多少新概念,这是正常的。 作者自己开篇就说了:
“上一讲开始进入实战模式。从目前看到的反馈看,我的预期目标并没有达到……增加一篇’实战(中)'篇。
一方面把前面没交代清楚的补一下,另一方面对画图程序做一次需求的迭代。”它是一篇补课 + 迭代演示。在整个系列里它是「验证章」,不是「知识章」。
26 讲 建立框架 ← 最难,概念全在这 27 讲 【验证框架】 ← 你在这,薄是正常的 28~30 联网带来的全新问题 ← 这才是下一批新东西所以本篇只讲三件事:关注点该在哪、一个可迁移的方法、唯一真正的新知识(精讲)。
〇 · 这一讲的代码在哪
完整源码在 26 讲目录下(整个系列的源码集中放一处):
| 你要的东西 | 路径 |
|---|---|
| 27 讲这一版的完整代码 | 代码/qpaint源码/v27/ |
| 新增的选择工具(91 行,独立文件) | v27/accel/select.js |
| 26→27 到底改了什么(Model) | diff-dom-v26-v27.txt |
| 26→27 改了什么(ViewModel,十几行,适合拿来练手看 diff) | diff-view-v26-v27.txt |
| 上一版(26 讲)对照着看 | 26 讲的 v26/ |
| 每个文件的结构地图(标了行号) | 带读-源码导航 |
本篇讲的东西,在 v27/dom.js(306 行)里的位置:
112 class QRect ★ 直接跳这儿,37 行看完四个新方法(第二节)
121 bound() 我占哪块地方 ← 画选中框要用
124 hitTest() 这点在我身上吗 ← 点选要用
130 move() 把我挪一挪 ← 拖动要用
134 setProp() 改我的样式 ← 改颜色要用
288 QPaintDoc.hitTest ★ 【倒着遍历】,命中就返回(第二节末尾)
61 class QShapeStyle ★ QLineStyle 改名而来(第四节 · 小重构)
建议读法:
v27/dom.js只看112~148(QRect)就够了——
另外三个图形类是同样的四个方法,扫一眼即可。
然后完整读accel/select.js(91 行),它是第四台状态机。
能直接跑:浏览器打开
v27/index.htm。画几个图形,然后点一下图形试试选中、拖动、按 Delete。
一 · 关注点:diff 里「没有出现」的东西
26 讲讲了一堆"应该"——Model 越厚越好、Controller 之间不许互相知道、删一个交互只需注释一行。
这些话在 26 讲那一版里是无法证伪的,因为那一版是一次性写出来的,从没经历过变更。
27 讲是第一次变更。而变更是检验架构的唯一手段。
所以别盯着"加了什么代码",盯着**“什么没被迫改”**:
| 如果架构不好,本该发生 | 实际发生 |
|---|---|
view.js 跟着大改 |
只 +13 行 |
| 每个 Creator 都要适配新的选择逻辑 | 各改一行(加一句 fireControllerReset()) |
index.htm 要改好几处 |
+1 行 |
加 hitTest 要在某处写一串 if 是矩形…elif 是圆… |
零个 if,每个图形自己实现一份 |
这四行"没发生",就是 27 讲的全部产出。
真实数字(我把 v26、v27 两个分支拉下来数过):
| 文件 | v26 → v27 | |
|---|---|---|
dom.js(Model) |
109 → 306 | 需求的重量几乎全落在这里——这正是"Model 越厚越好"想要的 |
view.js(ViewModel) |
112 → 125 | 只涨 13 行,非常稳定 |
accel/select.js |
无 → 91 | 新增的选择工具,独立一个文件 |
creator/rect.js |
108 → 93 | 反而变少了(normalizeRect 搬去了 Model,它自己也要用) |
index.htm(组装根) |
17 → 18 | 只多一行 <script src="accel/select.js"> |
最后一行是带读-03 那句"加一个交互 = 加一行
<script>"在作者自己代码里的实锤。
二 · 可迁移的方法:从需求推接口
这是 27 讲教的、而 26 讲没教的东西:26 讲是"读一个已经写好的架构",27 讲是"面对一个新需求该怎么想"。
先别看代码,自己推一遍
要做到"点一下选中某个图形,然后拖动它、改它的颜色、删掉它",
程序必须能回答哪些它现在答不上来的问题?
想两分钟。答案是五个,而且全是 Model 答不上来的:
| 用户的动作 | 程序得回答 | → 新方法 | 加在哪 |
|---|---|---|---|
| 在画布上点一下 | 点中了哪个图形? | hitTest(pt) |
每个图形 + QPaintDoc |
| 选中后要有标记框 | 这图形占哪块地方? | bound() |
每个图形 |
| 拖动它 | 怎么把它挪 (dx,dy)? | move(dx, dy) |
每个图形 |
| 改颜色 | 怎么改它某个样式? | setProp(key, val) |
每个图形 + QShapeStyle |
| 按 Delete | 怎么从文档里删掉? | deleteShape(shape) |
QPaintDoc |
架构设计就是这件事:不是先想类图,是先问
“这个需求要求程序回答什么它现在答不上来的问题”,再把这些问题变成接口,装到该装的那一层上。
顺带看一个复利现象
那四个新方法,全是"每个图形自己实现一份"——跟 onpaint 一模一样的模式。
带读-01 · 1.3 讲的"办法 A:把笔递过去,你自己画",在 27 讲被原样复用了四次:
| 问题 | 办法 A(实际) | 办法 B(如果 26 讲当初选错) |
|---|---|---|
| 这一点点中谁 | s.hitTest(pt) |
一串 if 是矩形…elif 是圆… |
| 你占哪块 | s.bound() |
再来一串 |
| 把你挪一挪 | s.move(dx,dy) |
再来一串 |
| 改你的样式 | s.setProp(k,v) |
再来一串 |
一个设计决定,在需求增长时反复付利息。这就是"好架构"的实际含义。
要是当初选了办法 B,27 讲要加的不是四个方法,而是四串if/elif,
而且以后每加一种图形都得回去改四个地方。
还有一个小细节值得看:QPaintDoc.hitTest 是从后往前遍历的——
for (let i = n-1; i >= 0; i--) { // ★ 倒着找
let ret = shapes[i].hitTest(pt)
if (ret.hitCode > 0) return ret // 命中就立刻返回
}
因为 onpaint 从前往后画(后画的盖在上面),最上面的图形是数组里最后那个。
点击应该命中你看得见的那个,所以两个方法的遍历方向必须相反。
——带读-01「顺序即图层」那条的直接后果。
三 · 精讲:Controller 之间怎么通信
这是 27 讲唯一一处真正的新知识,26 讲完全没有。
3.1 问题长什么样
新需求第 3 条:创建完图形后自动回到选择状态。
拆开看,这件事需要两个 Controller 配合:
| 谁 | 知道什么 / 能干什么 |
|---|---|
QRectCreator |
只有它知道"一个图形画完了"(松手那一刻) |
| 菜单(Menu) | 只有它会切换工具(调 invokeController) |
但 26 讲立的规矩是:Controller 之间不许互相知道。(否则删一个塌一片。)
于是:一个知道事情发生了,另一个知道该怎么办,两个还不许说话。怎么办?
3.2 为什么不让 Creator 直接切?
先问一个更尖锐的问题:Creator 明明已经认识 qview 了,它直接写这一句不就完了?
// 为什么不这么写?
reset() {
this.started = false
qview.invokeController("ShapeSelector") // ← 我自己切
}
技术上完全可行。 但有三个理由不这么干,第三个最实在:
| # | 理由 |
|---|---|
| 1 | Creator 因此知道了世上有个叫 ShapeSelector 的东西——它跟一个具体的 Controller 耦合了 |
| 2 | 这不是 Creator 的职责。它的活是"把一串动作翻译成一个图形",交出图形就该下班 |
| 3 | 策略一旦要变,得改三处(三个 Creator 各写了一遍) |
第 3 条展开一下。假设产品经理说:
“画完之后别切回选择工具,保持当前工具,让用户能连着画好几个。”
| 写法 | 改哪里 |
|---|---|
| Creator 自己切 | 三个 Creator 全要改 |
| 走事件 | 改菜单里那一个 handler,Creator 一个字不用动 |
这就是关键:事件机制把两件事拆开了——
内容 谁知道 性质 事实 “一个图形画完了” 只有 Creator 知道 不会变 策略 “画完之后该切回选择工具” 是一条产品决策 随时会变 发事件的人只报告事实,听事件的人决定策略。
把易变的(策略)和不变的(事实)分开,是这套机制的全部价值。
3.3 代码怎么走:三步
① View 上准备一个槽,和一个"喊话"的方法
class QPaintView {
constructor() {
this.onControllerReset = null // ← 一个空槽(就是个盒子)
}
fireControllerReset() { // ← 喊话
if (this.onControllerReset != null) {
this.onControllerReset()
}
}
}
② 菜单启动时把自己挂上去(听)
// accel/menu.js
qview.onControllerReset = function() {
qview.invokeController("ShapeSelector") // 策略住在这里
}
③ Creator 画完时喊一嗓子(说)
// creator/rect.js —— v27 就多了这一行
reset() {
this.started = false
qview.invalidateRect(this.rect)
qview.fireControllerReset() // ← 新增
}
3.3b 完整链路(标出每一步在哪个文件)
v27 里 Controller 之间的通信一共两条,都经过 View 中转。
链路 A ·「我画完了」→ 自动切回选择工具
【启动时 · 只发生一次】
【menu.js】 qview.onControllerReset = function(){ qview.invokeController("ShapeSelector") }
↑ 菜单把【自己的函数】放进 View 的槽
【运行时 · 每次画完一个图形】
你松开鼠标
↓ 浏览器:查 document 树 → 命中 <canvas>
↓ 【view.js】 canvas.onmouseup 转交函数 → view.onmouseup 盒子
↓ 【rect.js】 QRectCreator.onmouseup
↓ 【dom.js】 doc.addShape(矩形) ★ 图形诞生
↓ 【rect.js】 reset() → qview.fireControllerReset() 「我画完了」← 只报告事实
↓ 【view.js】 看 onControllerReset 槽 → 不是 null → 取出来调用
↓ 【menu.js】 那个函数执行 → qview.invokeController("ShapeSelector") ← 策略在这里
↓ 【view.js】 stopController() → rect.js 把四个盒子还回来
↓ 【view.js】 查花名册 → 工厂()
↓ 【select.js】 QShapeSelector 构造函数 → 抢走四个盒子
链路 B ·「选中的东西变了」→ 菜单更新调色板
【启动时 · 只发生一次】
【menu.js】 qview.onSelectionChanged = onSelectionChanged
↑ 菜单把自己的函数放进 View 的【另一个】槽
【运行时 · 每次选中】
你在画布上点一下(选择工具在岗)
↓ 【view.js】 转交 → view.onmousedown 盒子
↓ 【select.js】 QShapeSelector.onmousedown
↓ 【dom.js】 doc.hitTest(pt) → 倒着遍历,返回命中的那个图形
↓ 【select.js】 view.selection = 那个图形 ← 一次【赋值】
↓ 【view.js】 selection 的 setter 里:发现变了 → 调 onSelectionChanged 槽
↓ 【menu.js】 那个函数执行 → 读 selection.style,把调色板的值改成它
两条链路的差别
| 谁触发 | 怎么触发 | 性质 | |
|---|---|---|---|
A onControllerReset |
Creator | 显式调 fireControllerReset() |
「我干完一件事了」 |
B onSelectionChanged |
View 自己 | selection = x 这次赋值顺带触发(在 setter 里) |
「数据变了」 |
B 就是 22 讲说的 DataChanged 模式——数据一变就通知,我不知道谁在听。
A 则是"动作完成通知"。两种都用同一套槽机制。
一张图收尾
rect.js ──┐ ┌──▶ menu.js
path.js ──┤ fire / setter │
freepath ───┤ ↓ │
select.js ─┴──▶ View 的槽 ────────┘
(中转站)
✗ 这两边的 Controller,从头到尾没有互相认识过
✗ rect.js 里没出现过 "menu",menu.js 里没出现过 "QRectCreator"
Creator 不知道听众是谁,菜单不知道是谁喊的。双方都只认识 View。
3.4 它跟你已经懂的「事件盒子」是同一个东西
onControllerReset 也是一个盒子——一个装函数的普通字段。你早就懂了,只是方向不同:
| 盒子 | 谁放进去 | 谁取出来调 | 什么时候调 |
|---|---|---|---|
view.onmousedown |
Controller(构造时抢) | View | 浏览器把鼠标事件送来时 |
view.onControllerReset |
菜单(启动时挂) | View | Creator 调 fire... 时 |
同一个机制,两个方向:
方向一:事件从下往上(外部世界 → Controller)
浏览器 ──▶ canvas 盒子 ──▶ View 盒子 ──▶ 当前 Controller
方向二:事件在 Controller 之间横向传递,View 当中转站
Creator ──喊──▶ View 的 onControllerReset 槽 ──▶ 菜单
View 在两个方向上干的是同一件事:端着盒子,按盒子转交,不关心内容。
3.5 设计取舍:要不要开一个「通用事件总线」
原文接着追问了一个好问题:
“类似情况还会有多少?以后是不是还会有更多的事件需要在 Controller 之间传递、需要 View 来中转的?”
于是引出两个设计选择:要不要支持任意事件?监听是单播还是多播?
最通用的做法是搞一个事件总线:
class QEventManager {
fire(eventName, ...params)
addListener(eventName, handler)
removeListener(eventName, handler)
}
但作者明确反对把它直接暴露在 View 的接口上:
“一旦 View 聚合了这个 QEventManager,通用是通用了,但是
Controller 之间会有什么样的事件飞来飞去,就比较难去从机制上把控了。”“代码即文档。如果能够用代码约束的事情,最好不要在文档中来约束。”
所以他主张:底层可以用 QEventManager 实现,但接口上开成具体的方法——
fireControllerReset / onControllerReset / offControllerReset。
这样看一眼 View 的接口,就知道这个程序里到底有哪几种跨 Controller 的事件。
| 通用事件总线 | 具名方法 | |
|---|---|---|
| 加一种新事件 | 不用改 View | 要在 View 上加三个方法 |
| 能不能一眼看出有哪些事件 | 不能——散落在各处的字符串里 | 能——View 的接口就是完整清单 |
| 传错事件名 | 运行时静默失败 | 编译期/调用处就发现 |
这条可以直接拿去用。 你以后设计任何接口都会碰到同一个取舍:
要不要开一个通用的execute(sql)?要不要给配置系统开一个set(anyKey, anyValue)?通用性和可控性是一对矛盾。开一个"什么都能传"的口子,等于放弃了对"到底传了什么"的把控。
一个诚实的补充:我查了实际代码——
QEventManager从头到尾没被实现过。
v27 和后来的 master 里,onControllerReset和onSelectionChanged就是两个普通的回调字段,
单播,一个槽一个人。原文那段是设计讨论,不是代码说明。 作者最后选了最简单的实现。
3.6 这个模式你以后会反复见到
它的通用名字是观察者模式 / 发布订阅 / 回调。同一个东西在这门课里已经出现过好几次:
| 出处 | 长什么样 |
|---|---|
| 22 讲 | Model 发 DataChanged 事件,View 自己刷新 |
| 27 讲(本篇) | Creator 发 ControllerReset,菜单切工具 |
| 27 讲 | selection 一变就发 onSelectionChanged,菜单更新调色板 |
| 硬件层 | 中断 |
| 服务端 | webhook |
一句话记住它为什么存在:
事件是唯一一种「我通知你,但我不认识你」的通信方式。直接调用 = 我认识你(依赖出现了);发事件 = 谁想听谁听(依赖消失了)。
四 · 另一处新知识:那次小重构
// v26
class QLineStyle {
constructor(width, color) { ... } // 两个字段
}
// v27
class QShapeStyle {
constructor(lineWidth, lineColor, fillColor) { ... } // 三个字段 + setProp + clone
}
改名 + 改字段名(width→lineWidth)是不兼容改动,所有 new QLine(..., style) 都要跟着改。作者的原话:
“最初 new QLine、QRect 时传入的最后一个参数是 QLineStyle,从设计上这是一次失误,
这意味着后面这些构造还是都需要增加更多参数如 QFillStyle 之类。
把最后一个参数改为 QShapeStyle,这从设计上就完备了。”
判据:参数名划定了这个口子将来能长多大。
叫lineStyle,就注定只能装线条相关的东西;叫shapeStyle,就能装这个图形的一切样式。顺带记两句作者的话:“重构关键是要及时处理”;以及他借机说的——
更喜欢静态类型语言,是因为重构有遗漏时编译器会告诉你哪里漏改了,
JS 这种弱类型只能靠单元测试兜底。
五 · 这些可以直接跳过
| 内容 | 为什么可跳 |
|---|---|
hitLine 的数学(点到直线距离公式) |
纯几何,跟架构零关系 |
QPath.bound() 遍历所有点算包围盒 |
同上 |
menu.js 新增那 70 行 |
大部分是琐碎的 DOM 操作 |
hitCode 现在只有 0 和 1 |
知道"它是给将来留的口子"就够了——真实软件里"点中"要分点在内部/边框/控制点/旋转手柄,一个布尔值不够用。 跟 invalidateRect(rect) 收了参数不用是同一个手法:接口按最终形态定,实现先给最笨的 |
隐藏的 ShapeSelector 按钮(visibility:hidden) |
一个小 hack:为了复用菜单那套按 id 高亮的逻辑。 它反倒印证了带读-03 · 2.4 那个毛病——按钮列表和 Controller 是分开管的 |
六 · 验收
| # | 问题 | 答案在 |
|---|---|---|
| 1 | 27 讲的"产出"应该看什么?为什么说盯着"加了什么代码"是看错了地方? | 一 |
| 2 | 面对"能选中、拖动、改样式、删除"这个需求,怎么推出 Model 该加哪些方法? | 二 |
| 3 | QRectCreator 明明认识 qview,为什么不直接写 qview.invokeController("ShapeSelector")? |
3.2 |
| 4 | 作者为什么反对给 View 开一个通用的 QEventManager 接口? |
3.5 |
答案
1. 应该看 diff 里"没有出现"的东西。26 讲那些"应该"在没经历变更前是无法证伪的,
27 讲是第一次变更——view.js 只 +13 行、三个 Creator 各改一行、index.htm +1 行、
加 hitTest 时零个 if。这四个"没发生"才是产出。
2. 问自己:这个需求要求程序回答哪些它现在答不上来的问题?
→ 点中了哪个(hitTest)/ 占哪块地方(bound)/ 怎么挪(move)/ 怎么改样式(setProp)/ 怎么删(deleteShape)。
先有问题,再有接口。
3. 技术上可行,但:① Creator 会因此认识一个具体的 Controller 名字;
② "画完之后干什么"不是它的职责;③ 最实在的——策略一变要改三处。
事件机制把事实(“画完了”,Creator 知道,不会变)和策略(“该切回选择”,产品决策,随时会变)拆开了。
4. 因为通用性和可控性是一对矛盾。开一个"什么都能传"的口子,
就没法一眼看出"Controller 之间到底有哪些事件在飞"。
开成具名方法(fireControllerReset 等),View 的接口本身就是一份完整清单——
这就是他说的"代码即文档:能用代码约束的事,别在文档里约束"。
下一步
第 5 步 · 28 讲:为什么一联网,就必须给每个图形发身份证
新需求:把这张图存到服务端去
↓ 被逼出来的
对象 ID —— 单机版指着图形说"就是它"就行;
联网了,你得在网络那头【说出它的名字】
数据变更 —— 改一个图形,是把整张图重传,还是只传"哪里变了"?
带读-01 · 2.7 那张「QPaintDoc 的方法是被什么逼出来的」表,下一行从这里开始兑现。
28 讲开始会重新变难——"多个人同时改一份数据"是一整套新问题,单机世界里根本不存在。
