这一步补齐三块,都是前两步跟着"一次画矩形"走时绕过去的:
原文的节 状态 Model 层 ✓ 带读-01 ViewModel 层 机制讲完了,"责任"那部分还没讲 → 第一部分 Controller 层 运行机制讲完了, menu.js那部分还没讲 → 第二部分架构思维上学到什么 完全没讲 → 第三部分
第一部分 · ViewModel 收口
1.1 三个「View」,到底谁是谁
这是 26 讲最容易把人绕晕的地方,因为"View"这个词在这一讲里指了三样不同的东西:
| 说的是 | 实际是谁 | 干什么 |
|---|---|---|
| 真正的 View(把东西显示到屏幕上) | 浏览器 | 把 canvas 那片格子合成到屏幕;把按钮、菜单渲染出来 |
view.js / QPaintView |
ViewModel 层 | 调度:转交事件、擦画布、管 Controller 上岗下岗 |
| Controller 自己的辅助 View | 归 Controller | 拖动时的橡皮筋、菜单按钮 |
原文那句话就是在说第一条:
“View 层:实际是 ViewModel 层,真正的 View 层被浏览器实现了。”
所以文件名叫 view.js,但它是 ViewModel。 这个命名不一致是历史遗留,作者自己也承认了。
顺带一个诚实的补充:对 qpaint 来说,"真正的 View"其实是分裂的——
界面的哪部分 谁负责显示 按钮、菜单、状态栏 浏览器(它们是 HTML 元素,浏览器全包了) 画布里的图形 Model 自己画( onpaint)+ 浏览器只负责把像素合成到屏幕换句话说:用 HTML 元素做的界面,浏览器替你实现了 View;用 canvas 画的内容,View 这份活被你自己挪进了 Model。
这正是带读-01 说的"选了白板就得自己记本子"的另一面。
1.2 view.js 剩下的两个小方法
带读-02 跟着一次画矩形走,把 view.js 读掉了 80%。还剩两个。
getMousePos:屏蔽平台差异的最小样本
getMousePos(event) {
return { x: event.offsetX, y: event.offsetY }
}
为什么需要它? 因为一个鼠标事件身上带着好几种坐标:
| 属性 | 相对谁 |
|---|---|
event.clientX / clientY |
浏览器窗口左上角 |
event.pageX / pageY |
整个页面左上角(含滚动) |
event.offsetX / offsetY |
被点中的那个元素左上角 ← canvas 的左上角 |
Model 里图形的坐标是相对 canvas 的,所以必须用 offsetX/Y。
关键是:为什么这个换算要放在 View 上,而不是每个 Controller 各自做?
因为它是平台相关的。换成桌面版,换算方式完全不同。放在 View 里,六个 Controller 都不用管。
更实在的一条:将来加缩放/平移功能时,换算会变成——
getMousePos(e) {
return {
x: (e.offsetX - this.平移X) / this.缩放,
y: (e.offsetY - this.平移Y) / this.缩放
}
}
改这一处,六个 Controller 一个字都不用改。 这就是这个三行方法存在的全部价值。
它也是原文说的 View 层责任之一「屏蔽平台的差异」最小的一个样本。
get lineStyle:那次快照发生在这里
get lineStyle() {
return new QLineStyle(this.properties.lineWidth, this.properties.lineColor)
}
注意 new——每次调用都造一个新的。
这就是带读-01 · 3.2 说的快照:Controller 拿到的是一份拷贝,塞进图形里;
之后你改调色板,改的是 this.properties,跟已经发出去的那些拷贝无关。
properties = { lineWidth: 2, lineColor: "黑" } ← ViewModel 的数据,会变
│
│ 每次 view.lineStyle 都 new 一份拷贝
▼
矩形①.lineStyle = { 2, 黑 } ← 各自独立,改调色板不影响它们
矩形②.lineStyle = { 2, 黑 }
get 是什么? 语法糖,让你写 view.lineStyle 而不是 view.lineStyle()。
Python 里就是 @property:
@property
def lineStyle(self):
return QLineStyle(self.properties["lineWidth"], self.properties["lineColor"])
1.3 为什么 QPaintView 这么薄
22 讲把 ViewModel 说得很重——排版引擎、双向数据绑定、"数据变化 → 屏幕区域"的映射表。
可 QPaintView 里属于它自己的数据只有两样:properties 和 drawing。说好的那些呢?
答案:ViewModel 的厚度,正比于局部更新的精细度。
ViewModel 之所以存在,是为了回答**“这次数据变化影响了屏幕的哪一块”**。
qpaint 的回答是"整块"(invalidateRect 收了参数不用,永远全屏重画)——
这个问题被取消了,那么承载答案的那层结构自然就退化没了,只剩胶水。
反过来说:Word 的 ViewModel 厚,不是因为 Word 高级,是因为它必须回答
“改了第 2 页一个字,屏幕上哪几行要重排”。
实用判据:当你发现自己的 ViewModel"好像没什么东西",
先别怀疑架构,先问自己做不做局部更新。不做,它就该是薄的。
1.4 View 层的两个责任(原文那两条)
原文说,即便把绘制交给 Model 之后 View 只剩"胶水层",它仍然承担两个重要责任:
| 责任 | 在 qpaint 里体现为 |
|---|---|
| 屏蔽平台的差异 | getMousePos 的坐标换算;preventDefault() 这类浏览器专有的杂事;canvas 事件转交那一层 |
| 定义界面布局 | index.htm 里那几行 html——菜单在上、画布在下。不同尺寸的设备布局不同,在这一层控制最妥当 |
两条的共同点:它们都是"跟平台/设备有关,跟业务无关"的事。
所以理想架构里,跟平台绑死的代码只剩 View 层加少量辅助 View。
Model 层平台无关(带读-01 · 3.1),Controller 大部分是事件响应也跨平台——
这正是"Model 越厚越好"那条的延伸收益。
第二部分 · 菜单与组装
2.1 菜单怎么做到「不认识任何一个 Controller」
原文说:
“菜单并不直接和各类创建图形的 Controller 打交道,而是调用
qview.invokeController来激活对应的
Controller,这就避免了两类 Controller 相互耦合。”
落到代码上,机制朴素得可疑:
// menu.js
<input type="button" id="RectCreator" value="Create Rect" onclick="onClickCtrl('RectCreator')">
function onClickCtrl(key) {
view.invokeController(key) // key 是从按钮 id 上取的【字符串】
}
menu.js里从头到尾没有出现过QRectCreator这个标识符。
它只出现过字符串"RectCreator"。
于是依赖图上 menu.js → rect.js 这条边不存在。两者共同依赖的,只是一份命名约定——一根字符串。
代价也要认:拼错名字不会报错。invokeController 里那句 if (name in this.controllers) 查不到就静默返回。
用编译期检查换来了解耦。 在六个名字的规模上值,规模大了就得靠注册时校验或启动自检来补。
2.2 组装根:index.htm 那份 <script> 列表
<script src="dom.js"></script> <!-- Model -->
<script src="view.js"></script> <!-- ViewModel -->
<script src="creator/path.js"></script> <!-- ↓ 这几行 = 启用哪些交互 -->
<script src="creator/freepath.js"></script>
<script src="creator/rect.js"></script>
<script src="accel/menu.js"></script>
翻成 Python 就是 main.py 顶上那一堆(带读-00 · 1.1 讲过):
import dom
import view
import creator.rect # ← import 它 = 启用"画矩形/线/椭圆/圆"这四个功能
import creator.path
import accel.menu
这份列表就是 22 讲说的「组装根」:全程序唯一一处声明"这个应用由哪些零件构成"的地方。
配合各个 creator 文件末尾的自注册(带读-02 阶段 0),加一个新工具只要两步:
① 写一个新文件;② 这里加一行。不用改 view.js、不用改 dom.js、不用改任何已有 creator。
2.3 ★ 动手:把一行注释掉,看会发生什么
22 讲给了唯一一条能当场检验的判据:
“做得恰当时,干掉某个交互特别容易——不用删 Controller 代码,只需把创建它的那一行注释掉即可。”
现在亲手验一遍。 打开 代码/qpaint-mini.html,找到组装那一段,
把 Circle 那一行注释掉(前面加 //):
qview.registerController("LineCreator", ()=> new QRectCreator(qview, "line"))
qview.registerController("RectCreator", ()=> new QRectCreator(qview, "rect"))
qview.registerController("EllipseCreator", ()=> new QRectCreator(qview, "ellipse"))
// qview.registerController("CircleCreator", ()=> new QRectCreator(qview, "circle"))
qview.registerController("PathCreator", ()=> new QPathCreator(qview))
qview.registerController("FreePathCreator", ()=> new QFreePathCreator(qview))
保存,刷新页面。你会看到:
| # | 现象 | 说明什么 |
|---|---|---|
| ① | 程序照常启动,不报错 | 没人 import 它,删了谁都不知道 |
| ② | 矩形、折线、自由笔……其余五个照常工作 | Controller 之间零耦合,实锤 |
| ③ | Circle 按钮还在,而且点了会高亮 | ✗ 露馅了 |
| ④ | 点完 Circle,画布上怎么点都没反应——而且之前用的工具也没了 | ✗ 比"死按钮"更糟 |
第 ④ 条值得展开。点 Circle 按钮时发生的事:
invokeController("CircleCreator") {
this.stopController() // ① 先把当前工具停掉 —— 这一步照常执行了!
let f = this.controllers["CircleCreator"] // ② 查不到,是 undefined
if (f) { ... } // ③ 不执行
}
// 回到 menu 的 onclick:把 Circle 按钮标成"选中"
结果:五个盒子全空、_current = null,但界面上 Circle 显示为选中。
你不但没换到 Circle 工具,还把原来的工具弄丢了,得再点一次别的按钮才能恢复。
(我在 Node 里跑过这个改动,输出确实如此:当前工具 = ""、_current = null、五个盒子全空、
Circle 按钮 className = "on"、在画布上画完 shapes 数量不变。)
2.4 前两条满分,后两条露馅——作者自己没做干净
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 讲专门要讲"如何阅读别人的代码"——
读到能拿作者自己的原则去量出他自己的偏差,才算读进去了。而且这个偏差很典型:中心化的界面容器(菜单栏、工具栏、设置页)是所有插件式架构最常见的破口。
逻辑很容易做到插件化,界面上那个"总得有人知道有哪些项"的列表,总在诱惑你把它集中写死。
2.5 MousePosTracker:一个既不碰 Model 也不碰 ViewModel 的 Controller
原文说它"极其简单,但也很特殊":
“它并不操作任何正统意义的数据(Model 或 ViewModel),而是操作输入的事件。”
它干的事:收到 mousemove → 把坐标写到状态栏。不读不写 Model,不读不写 ViewModel。
事件 ──▶ Controller ──▶ 自己的辅助 View(那个状态栏 <span>)
(既没经过 Model,也没经过 ViewModel)
这逼着我们把 Controller 的定义放宽:
Controller 不是"改 Model 的那个东西",而是"订阅一组事件、自治地完成一件事、并且不被任何人依赖的那个东西"。
改不改 Model 是它可能做的事之一,不是它的定义。
但真实实现比原文说的更怪——它压根没用委托机制:
function installMousePos() {
let mousepos = document.getElementById("mousepos")
onViewAdded(function(view) {
let old = view.drawing.onmousemove // ← 注意:drawing,不是 view
view.drawing.onmousemove = function(event) {
mousepos.innerText = "MousePos: " + ...
old(event) // ← 处理完,再交给原来的人
}
})
}
没有 registerController,没有 invokeController,也没有 stop()。
它绕到 view.drawing(那个 <canvas> 元素)那一层,把浏览器级别的 onmousemove 包了一层。
为什么要翻墙? 回到带读-00 · 2.2 那条:View 的事件盒子是独占的。
于是一个 Controller 只有两个选择:要么抢占鼠标(那 Creator 们就别想工作了),
要么完全收不到。而 MousePosTracker 想要的是第三种:旁听——我看一眼就好,事件还归你。
这套委托机制里没有"旁听"这个位置,所以它只能翻墙。 代价是实实在在的:
| 代价 | 说明 |
|---|---|
| 绕过了 View 的抽象 | 它直接摸 DOM 元素 → 不再跨平台,换桌面版要重写 |
| 装饰器链无法拆卸 | old(event) 这种包法只能加不能减,没有 stop() |
| 顺序变成隐式的 | 谁先 install 谁在链的外层,这个顺序没有任何地方声明 |
正确的修法,是承认事件有两种订阅语义:
语义 谁需要 独占 / 委托 我来处理,别人别管 各种 Creator(同一时刻只能一个) 旁听 / 广播 我只观察,不影响别人 MousePosTracker、坐标尺、调试面板、埋点 补上后者,
MousePosTracker就能回到框架内,也能被stop()掉。这是本讲最值得带走的一条工程直觉:当你发现某个模块"必须绕过框架才能实现"时,
通常不是这个模块特殊,而是框架少了一种语义。
原文说它"特殊",其实它一点都不特殊——它只是第一个撞到墙的。
第三部分 · 架构思维
3.1 概要设计三问,用画图程序填一遍
原文说:需求分析之后进入架构第二步——概要设计,核心是分解子系统,关心三个问题:
- 每个子系统负责什么事情?
- 它依赖哪些子系统?它能够少知道一些子系统的存在么?
- 它们是通过什么接口耦合的?这个接口是否自然体现业务关系?是否足够稳定?
原文提了三问,但没用自己的例子答一遍。补上——这张表才是 26 讲真正的产出物:
Model(dom.js) |
ViewModel(view.js) |
Controller(creator/*, accel/*) |
|
|---|---|---|---|
| 负责什么 | 文档是什么:图形的数据与自绘 | 用户此刻怎么看:当前样式、当前工具、重绘调度、事件分发 | 用户能干什么:把一串事件翻译成一次业务动作 |
| 依赖谁 | 谁都不依赖(只依赖注入进来的那支笔) | Model | Model + ViewModel |
| 谁依赖它 | ViewModel、Controller | Controller | 没有人 |
| 接口是什么 | addShape / onpaint |
五个事件盒子、invalidateRect、register/invoke/stopController |
stop / onpaint + 一个字符串名字 |
| 接口稳不稳 | 稳(业务动作级) | 中(invalidateRect 的参数已为将来留好) |
稳(只有两个方法) |
| 能否少知道一点 | 已经是零 | 只知道"有 Controller 这种东西",不知道有哪些 | 彼此零知道 |
依赖图(箭头 = "认识")
Controller ① Controller ② Controller ③ ← 叶子,【没有任何入边】
│ │ │
└──────┬──────┴─────────────┘
▼
ViewModel
│
▼
Model ← 【谁都不认识】
是一棵树,没有环,叶子节点没有任何入边。
"它能够少知道一些子系统的存在么"这个问题,在这份代码里的答案是:
Controller 谁都不认识,View 只认识一个抽象接口和一堆字符串,Model 谁都不认识。
3.2 「别被框架绑架」的可操作版本
原文这段很容易读成鸡汤:
“框架不应该增加代码的耦合,否则这样的框架就应该丢了。”
给它一个能当场用的判据:
把框架抽掉,你的业务逻辑还是不是一个完整的东西?
对着 qpaint 验:把 <canvas>、把浏览器、把所有 DOM 全拿走,
dom.js 还是一套完整的、可测试的图形文档模型(带读-01 · 3.1 那三支笔就是证据)。
它从没变成过任何框架的形状。
反面长什么样,今天更常见:
| 写法 | 出了什么事 |
|---|---|
图形数组存在 React 的 useState 里 |
文档模型变成了 React 的一部分。想在 Node 里跑一次导出?跑不了 |
| 图形是 Vue 的响应式对象 | 序列化时带一堆框架内部字段,还得先转换 |
业务规则写在 useEffect 里 |
"什么时候该重算"这条业务知识,被表达成了框架的依赖数组 |
框架该做的是"替你写掉一些代码",不该做的是"决定你的业务概念长什么样"。
分界线:框架可以住在 View 和 Controller 里,不该住进 Model 里。这也正好是"Model 层越厚越好"那条的另一个理由——厚 Model 是你对抗框架变迁的资产。
前端框架五年一换,你的业务模型不该跟着换。
第四部分 · 26 讲收尾
4.1 一张总表
| 层 | 文件 | 一句话 | 带读第几步 |
|---|---|---|---|
| Model | dom.js |
文档是什么 + 图形自己画自己 | 01 |
| ViewModel | view.js |
调度:转交事件、擦画布、管上岗下岗。自己一件业务都不办 | 02 + 本篇 |
| Controller | creator/*.js |
被翻过来的 workflow:一组步骤 + 记着走到第几步的字段 | 02 |
| 组装 | index.htm |
全程序唯一一处"谁认识谁" | 本篇 |
| 浏览器 | — | 建 document 树 · 送事件 · 把 canvas 合成到屏幕。它不知道 qpaint 在干什么 | 02 |
4.2 值得带走的六条
| # | 判断 |
|---|---|
| 1 | 让 Model 自己画——把一个会不断变长的 if/elif,换成一句永远不用改的调用 |
| 2 | 笔是参数——把"取笔"从循环里提到循环外,顺带白拿"能换渲染目标"“能测试” |
| 3 | 半成品不进 Model——撤销栈、协同同步、Model 不变量、ESC 语义,四条理由 |
| 4 | 数据三个住处:换机器该跟过去吗(Model)/ 开两窗口该各一份吗(ViewModel)/ 松手该消失吗(Controller) |
| 5 | Model 按"存什么"分类,Controller 按"怎么操作"分类——两套分类轴,不该强行对齐 |
| 6 | 某个模块必须绕过框架才能实现时,通常是框架少了一种语义(这里是"旁听") |
4.3 四个自测问题
| # | 问题 | 答案在 |
|---|---|---|
| 1 | 文件名叫 view.js,为什么说它是 ViewModel?那"真正的 View"是谁? |
1.1 |
| 2 | getMousePos 就三行,为什么不让每个 Controller 自己算? |
1.2 |
| 3 | 把 registerController("CircleCreator", ...) 注释掉,程序会怎样?哪几条符合 22 讲的预期,哪几条露馅了? |
2.3 · 2.4 |
| 4 | MousePosTracker 为什么要绕过 View 的委托机制?这说明框架缺了什么? |
2.5 |
答案
1. 因为它不负责"把东西显示到屏幕上",它负责调度:转交事件、擦画布、管 Controller 上岗下岗。
真正的 View 是浏览器——按钮菜单由它渲染,canvas 的像素由它合成到屏幕。
(而画布里图形的"怎么画",被挪进了 Model 的 onpaint。)
2. 因为坐标换算是平台相关的(换桌面版就完全不同),而且将来会变(加缩放/平移时)。
放在 View 里,改这一处,六个 Controller 一个字不用改。这是"View 屏蔽平台差异"最小的样本。
3. 符合预期的:① 程序照常启动不报错;② 其余五个工具照常工作(Controller 之间零耦合,实锤)。
露馅的:③ Circle 按钮还在、点了还会高亮;④ 点完之后当前工具被 stop 掉了、新的又没造出来,
五个盒子全空——比"死按钮"更糟。
根源:按钮列表硬编码在 menu.js 里,没跟着 Controller 走,违反了 22 讲那条"辅助 View 必须归它自己"。
4. 因为 View 的事件盒子是独占的,一个盒子只能装一个函数。
MousePosTracker 想要的是旁听(我看一眼,事件还归你),而这套机制里没有"旁听"这个位置——
要么抢占,要么收不到。所以缺的是一种"广播/观察"语义。
下一步
26 讲到此结束。 接着进 27 讲——给这个程序加"选择工具",看架构怎么接住新需求:
新需求:能选中一个图形、能拖动它、能改它的颜色
↓ 被逼出来的
hitTest(pt) —— 点了一下,到底点中了哪个图形?(Model 要新增能力)
Selection —— "当前选中了谁"住在哪一层?(用带读-01 · 1.4 那三个测试答)
deleteShape —— 能选中了,才谈得上删
带读-01 · 2.7 那张「QPaintDoc 的方法是被什么逼出来的」表,从这一讲开始一行行兑现。
