加载中...

先说结论: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~148QRect)就够了——
另外三个图形类是同样的四个方法,扫一眼即可。
然后完整读 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 讲的全部产出。

真实数字(我把 v26v27 两个分支拉下来数过):

文件 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 里,onControllerResetonSelectionChanged 就是两个普通的回调字段
单播,一个槽一个人。原文那段是设计讨论,不是代码说明。 作者最后选了最简单的实现。

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
}

改名 + 改字段名(widthlineWidth)是不兼容改动,所有 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 讲开始会重新变难——"多个人同时改一份数据"是一整套新问题,单机世界里根本不存在。

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关