五讲干了一件事:把一个 109 行的画图程序,在五轮真实需求下推到 1513 行前端 + 725 行后端,
然后回头看——这些行数落在了哪里。答案就是这套课想教的全部东西。
一 · 五轮需求,一张表
| 讲 | 新需求 | 被逼出来的东西 | 代价落在哪 |
|---|---|---|---|
| 26 | 能画图 | 三层分工 · 图形自绘(多态)· 事件委托 · 状态机式 Controller | — |
| 27 | 能选中 / 拖动 / 改样式 / 删除 | hitTest bound move setProp deleteShape · Selection · 事件中转 |
Model +197 View +13 Creator 各 +1 行 |
| 28 | 关掉再打开还在 · 断网能编辑 | 对象 ID(localID/displayID)· 分层存储 · 变更分级 |
Model +191 View +6 Controller 零改动 |
| 29 | 有个服务端 | 网络协议 · Mock 版服务端 · Multi-User Model | 浏览器端零改动 新增 657 行 Go |
| 30 | 两边真的接起来 | 版本号算变更 · /sync 协议 · QSerializer · onload |
Model +359 View +2 |
每一讲的推导都是同一个动作:
不是先想类图,是先问"这个需求要求程序回答什么它现在答不上来的问题",
再把这些问题变成接口,装到该装的那一层上。
27 讲最典型:想做"选中并拖动",就得回答五个新问题——
点了一下,点中了哪个? → hitTest(pt)
选中框画在哪? → bound()
怎么把它挪 (dx,dy)? → move(dx, dy)
怎么改它的样式? → setProp(key, val)
怎么从文档里删掉? → deleteShape(shape)
二 · 全系列最硬的三个数字
| 层 | 文件 | v26 | v30 | 倍数 |
|---|---|---|---|---|
| Model | dom.js |
109 | 856 | ×7.9 |
| ViewModel | view.js |
112 | 133 | ×1.2 |
| Controller | 三个原有 Creator 合计 | 269 | 256 | ×0.95 |
第三行最值得盯着看。
27 讲加选择工具、28 讲加离线存储、29 讲加服务端、30 讲加同步——
rect.js、path.js、freepath.js 从头到尾几乎没被打扰过,还反而少了 13 行
(normalizeRect 被搬去了 Model,因为它本来就是 Model 的活)。
"Model 越厚越好"不是审美偏好,是这组数字。
逻辑放进 Model,它的变更就波及不到上面两层;散出去,每一次需求都要惊动所有人。而且必须内聚——原文那句:
“Model 层总体来说是最容易测试的,因为它的环境依赖最小。如果这些代码被分散到
View、Controller 层中,代码的阅读难度、维护难度、测试的难度都会大幅增加。”856 行放一个文件里看着"胖",散到八个文件里看着"均衡"——但后者每一处都变得难测、难改。
「胖」不是缺点,「散」才是。
Model 层最终的五项职责(原文总结,全系列最该抄下来的一张表):
| # | 职责 | 哪一讲长出来的 |
|---|---|---|
| 1 | 业务逻辑,对外暴露业务接口(最本职) | 26 |
| 2 | 实现 View 委托的 onpaint,完成绘制 |
26 |
| 3 | 实现 Controller 的 hitTest,支持 selection |
27 |
| 4 | 与服务端通讯——View、Controller 都不需要感知服务端 | 29 / 30 |
| 5 | 离线编辑 localStorage 的存取 | 28 |
三 · 反复出现的六个手法
这套课真正的价值不在"画图程序怎么写",在于同一招被用了好几次。
① 让 X 自己干(多态)
for s in shapes: s.onpaint(ctx) # 而不是 if 是矩形…elif 是圆…
26 讲一个决定,后面被原样复用了五次:
| 方法 | 哪一讲加的 | 不这么做的话 |
|---|---|---|
onpaint |
26 | 一串 if/elif |
hitTest bound move setProp |
27 | 再来四串 |
toJSON |
30 | 再来一串 |
好的设计决定会反复付利息。 要是 26 讲当初选了"画法写在外面",
27 讲要加的就不是四个方法,而是四串if/elif,而且每加一种图形都要回去改四个地方。
② 口子先开好,实现先给最笨的
| 口子 | 哪一讲留的 | 当时的实现 | 兑现了吗 |
|---|---|---|---|
invalidateRect(rect) |
26 | 参数收了不用,全屏重画 | 一直没做局部更新 |
hitCode |
27 | 只有 0 和 1 | 留给"点在边框/控制点/旋转手柄" |
localID / displayID 分家 |
28 | 只差一个 t 前缀 |
30 讲兑现 |
paintweb 的转译层 |
30 | 原封不动转发 | 留给多租户 |
信息在现场是免费的,事后重建是昂贵的。
接口里先留下你现在不用、但将来必然需要的信息。
③ 注册表 + 工厂
view.registerController("RectCreator", ()=> new QRectCreator(view, "rect")) // 26 讲
qshapes.register("rect", json => new QRect(json)) // 30 讲
同一个套路,一个注册"怎么造 Controller",一个注册"怎么从 JSON 造回图形"。
两者的收益也一样:加一个新的 = 写一个类 + 一行 register,别处一个字不改。
④ 中间加一层,两头互不认识
Creator ──喊──▶ View 的槽 ──▶ 菜单 27 讲:跨 Controller 通信
浏览器 ─────▶ paintweb ───▶ paintdom 30 讲:跨进程通信
同一个形状,只是一个隔的是两个模块,一个隔的是两个进程。
⑤ 只动变了的那部分
| 为了什么 | 怎么做 | |
|---|---|---|
| 本地分层存储(28) | 少写盘 | 改一个图形,只重写 10001:2 那一条 |
| 网络只发 changes(30) | 少传数据 | 改一个图形,只把它放进 changes |
先用在硬盘上,再用在网络上。28 讲那个变更分级,就是 30 讲同步的地基。
⑥ 事件:唯一一种「我通知你,但我不认识你」的通信方式
| 事件 | 谁发 | 谁听 | 哪一讲 |
|---|---|---|---|
onControllerReset |
Creator | 菜单 | 27 |
onSelectionChanged |
View 的 setter | 菜单 | 27 |
onload |
Model(加载完) | View | 30 |
DataChanged |
Model(数据变了) | View | 28 讲预告 |
直接调用 = 我认识你(依赖出现了);发事件 = 谁想听谁听(依赖消失了)。
四 · 值得背下来的判断
分层
| # | |
|---|---|
| 1 | 数据的三个住处,三个一句话测试:换台机器该跟过去吗(Model)/ 开两个窗口该各一份吗(ViewModel)/ 松手该消失吗(Controller) |
| 2 | 半成品不进 Model——撤销栈、协同同步、Model 不变量、ESC 语义,四条理由 |
| 3 | Model 按"存什么"分类,Controller 按"怎么操作"分类——两套分类轴,不该强行对齐(4 种图形 ↔ 6 种工具) |
| 4 | ViewModel 的厚度,正比于局部更新的精细度——不做局部更新,它就该是薄的 |
| 5 | Model 的形状由使用场景决定:浏览器端三层、服务端四层;浏览器的 Model 自绘、服务端的 Model 一行绘制都没有 |
接口
| # | |
|---|---|
| 6 | 依赖接口不随业务变的那一方——绘图动词四十年没变,DOM 字段这一版就多了个 fillColor |
| 7 | 接口的严谨程度,取决于"一旦定错了,收回来有多贵"——localStorage 随意,网络协议严谨 |
| 8 | 门要开成业务动作的样子,不要开成数据结构的样子(addShape vs _shapes.push) |
| 9 | 参数名划定了这个口子将来能长多大(lineStyle → shapeStyle) |
| 10 | 只要一个标识符可能被外部世界改掉,就别拿它当内部引用的 key(localID / displayID) |
| 11 | 一个设计良好的 URL,读一眼就能画出对方的 Model 树 |
工程决策
| # | |
|---|---|
| 12 | 不要烧脑——一个方案会逼出大量中间态时,先看能不能换个方案让这些情况根本不存在(一条条编辑操作 → 一次 /sync) |
| 13 | 判断最坏情况,不是平均情况——"记操作历史"平均很省,但最坏情况没有上界 |
| 14 | 及早推出 Mock——把协议这个最贵的错误,提前到最便宜的时候暴露。30 讲真的改了协议,代价很小 |
| 15 | 判断一个设计好不好,不是看"更灵活",是看"是否更省事、且顺手没把路堵死" |
| 16 | 当某个模块必须绕过框架才能实现时,通常是框架少了一种语义(MousePosTracker 缺"旁听") |
五 · 作者自己没做到的地方(诚实清单)
读到能拿作者自己的原则量出他自己的偏差,才算读进去了。
| # | 问题 | 违反了哪条 | 后来修了吗 |
|---|---|---|---|
| 1 | 菜单按钮列表硬编码在 menu.js |
22 讲"Controller 的辅助 View 必须归它自己" | ✗ 一直没修。注释掉一个 creator,会留下会高亮的死按钮,而且点了会把当前工具弄丢 |
| 2 | MousePosTracker 绕过委托机制,直接包 canvas 的 onmousemove |
它不该知道 canvas | ✗ 框架缺"旁听"语义 |
| 3 | deleteShape 不清 localStorage 里那条 |
— | ✗ 小泄漏,等文档淘汰才清 |
| 4 | v28 里 shape.type 被当两个东西用(父指针 + 类名) |
可读性 | ✓ v30 修了(toJSON 出来后 hack 自然消失) |
| 5 | QEventManager 讨论了但从没实现 |
— | — 实际就是两个普通回调字段 |
| 6 | _loadRemote 里"本地离线编辑的内容会被放弃" |
— | ✗ 很粗暴的冲突策略:远程赢。真正的协同要复杂得多 |
第 4 条特别值得看:重构的常见形态不是"把烂代码改漂亮",而是
“引入一个新概念(toJSON/QSerializer),原来的将就自然就没地方待了”。
六 · 这五讲到底是什么——一个定位说明
6.1 它不是「前端底层原理」
这是最容易误解的一点。 这五讲在这个位置:
应用架构 ← 【这五讲在这里】MVC 分层、数据住哪、接口设计、同步策略
─────────────────
前端工程 打包、模块、工程化 ← 完全没碰
框架原理 React 的 Fiber、Vue 的响应式 ← 完全没碰
浏览器 API DOM、事件、canvas、localStorage ← 【用到了,但只讲"怎么用"】
─────────────────
浏览器内核 渲染管线、JS 引擎、事件循环 ← 只擦了个边
操作系统 进程、内存、网络栈 ← 那是 06~16 讲
作者自己在 26 讲就撇清过:
“这个程序没有依赖任何第三方库,是裸写的 JavaScript 代码。……
这并不是去鼓励裸写 JavaScript 代码,这只是为了消除不同人的喜好差异。”
浏览器只是这个案例的运行环境,不是主题。
6.2 顺带学到的前端(都是用法层面)
| 学到的深度 | |
|---|---|
网页的运行模型(<script> 是 main,之后进消息循环) |
✓ 挺完整 |
document 是元素树、按坐标/焦点分派事件 |
✓ 够用 |
| 事件 = 往字段里放函数 | ✓ 但没讲冒泡/捕获/事件委托的完整机制 |
| canvas 是一片格子、立即模式 vs 保留模式 | ✓ 这条其实挺硬 |
| localStorage、XMLHttpRequest、异步回调 | ✓ 用法 |
JS 语法(class / 闭包 / 箭头函数 / this) |
✓ 读代码够用 |
没学到的:渲染管线(DOM→CSSOM→Layout→Paint→Composite)、JS 引擎与事件循环、
框架原理(虚拟 DOM diff / Fiber / 响应式)、工程化(模块、打包)、CSS、HTTP 缓存与 CDN。
那是另一条线,跟这门课基本不重叠。
6.3 但这里有个反转:这五讲教的东西更长命
| 保质期 | |
|---|---|
| React 的 Fiber 怎么实现的 | React 换代就过时 |
| 浏览器渲染管线 | 换个平台(Android / Qt / Unity)不适用 |
| MVC 分层 · 数据住哪层 · 事件解耦 · 增量同步 · 从需求推接口 | 换任何平台、任何年代都成立 |
把"画图程序"三个字划掉,剩下的全都成立:
| 这里叫 | 换个场景就是 |
|---|---|
| Model / ViewModel / Controller | 任何有界面的程序:Qt、Android、Unity、终端 TUI |
| 事件盒子 / 上岗下岗 | 任何"控制反转"的框架:回调、监听器、中间件 |
localID / displayID |
数据库自增主键 vs 业务单号;inode vs 文件名;commit SHA vs 分支名 |
| 分层存储 + 变更分级 | 任何"增量同步":Git、rsync、数据库 binlog |
| Mock 先行 | 任何跨团队协作:接口先定、并行开发 |
paintweb 转发层 |
网关 / BFF |
你读的是 JavaScript,学的不是 JavaScript。
6.4 一句准确的定位
这五讲是「一个真实应用的架构演进案例」,不是前端教程。
它教的是"逻辑该放哪儿",不是"浏览器怎么跑起来的"。
顺带一提:这门课里更接近"图形界面底层原理"的是 21 讲
(消息循环、事件分派、焦点、GDI、控制反转)——
而且那一讲讲的也不是前端,是所有图形界面程序的共同底层。
七 · 如果只记三条
一 · 架构设计 = 先问"这个需求要求程序回答什么它现在答不上来的问题",
再把问题变成接口,装到该装的那一层上。二 · 数据住哪层,问三句话就够了:换台机器该跟过去吗 / 开两个窗口该各一份吗 / 松手该消失吗。
三 · Model 越厚越好,而且必须内聚——
×7.9vs×1.2vs×0.95就是全部理由。
八 · 接下来去哪
这门课还会拿这个程序继续讲:
| 讲 | 内容 | 跟这五讲的关系 |
|---|---|---|
| 31 | 辅助界面元素的架构设计 | ✓ 已带读:带读-09——控件框架、包装第三方库、控件化拆单例 |
| 32 | 架构:系统的概要设计 | ✓ 已带读:[带读-10](…/32-架构:系统的概要设计/带读-10-32讲:系统的概要设计(附 AI coding 对照).md)——整套方法论的总纲、v01 非 MVC 版对照、以及 AI coding 之后这些边界怎么变 |
| 41~44 | "画图"程序后端实战 | 把 29 讲那个 Mock 变成正式版(数据库、多租户、高可靠) |
| 加餐 | 实战:"画图程序"的整体架构 | 全景复盘 |
带读系列自己的索引:
带读-地图 哪个文件在哪层、几条链路、谁认识谁 ← 迷路了看这个
带读-源码导航 每一讲的源码在哪、每个文件的结构地图
带读-00 前端底子(26~30 共用)
带读-01 ~ 03 26 讲:Model / 一次画矩形 / 收口
带读-04 27 讲:加选择工具
带读-05 28 讲:离线持久化与对象 ID
带读-06 29 讲:网络协议与服务端(含异步)
带读-07 30 讲:对接、同步、Model 层的厚度
带读-08 收尾(26~30)
带读-09 31 讲:辅助界面元素与控件化
带读-10 32 讲:系统的概要设计(附 AI coding 对照)
六个可跑的东西:
| 文件 | 演什么 | 在哪 |
|---|---|---|
qpaint-mini.html |
真能画图,右侧面板实时显示三层各握着什么 | 26 讲 |
事件盒子.py |
盒子 / 抢占 / 归还,不 stop() 的 bug 现场 |
26 讲 |
自己画自己.py |
画法写在外面 vs 写在图形身上 | 26 讲 |
工厂和实例.py |
lambda、工厂 vs 实例、存实例为什么当场坏掉 | 26 讲 |
递一支笔.py |
同一批图形递三支不同的笔(含 ASCII 笔真的画出来) | 26 讲 |
两个ID.py |
localID / displayID,只有一个 ID 会怎样 |
28 讲 |
