这一步回答一个问题:我在页面上点了「Rect」,按住鼠标拖了一下,松手,画布上出现一个矩形——
这中间浏览器干了什么、qpaint 干了什么?代码以 代码/qpaint-mini.html 为准(你能打开的那个)。真实 qpaint 结构完全一样。
第一部分 · 先建立心智模型
1.1 浏览器只做三件事
先把浏览器这一侧钉死。从头到尾,浏览器只做三件事:
| # | 干什么 | 多久一次 |
|---|---|---|
| 一 | 把 html 解析成 document 那棵树(页面上有哪些元素),并执行每个 <script> |
加载时一次 |
| 二 | 把硬件事件送到树上正确的那个元素(鼠标按坐标查,键盘按焦点) | 无数次 |
| 三 | 把 canvas 里那片格子合成到屏幕上给你看 | 每帧 |
最要紧的一句:浏览器从头到尾不知道 qpaint 在干什么。
它不知道有"矩形工具",不知道_shapes,不知道你在画图。
它只知道"页面上有个 canvas,有人往里涂格子"。
所以下面每一段,我都会分两栏写:浏览器这边做了什么,qpaint 这边做了什么。
两边的接头处永远是同一个东西——一个盒子。
1.2 四个阶段
阶段 0 · 开门营业 页面加载时跑一次。造 View、填花名册、生成按钮、让第一个工具上岗
阶段 1 · 换班 你点「Rect」按钮 → 矩形工具上岗,占住四个盒子
阶段 2 · 起手 你按下鼠标 → 起点被【矩形工具】记住。Model 里什么都没发生
阶段 3 · 拖动(重复N次) 每动一下 → 全屏擦掉重画一遍,顺便画一个【现造现扔】的预览矩形
阶段 4 · 落定 你松手 → 矩形【诞生】,进入 Model;矩形工具清空自己,等下一轮
1.3 最要紧的一张图:数据什么时候在哪儿
时间 → 按下 ──── 拖动(N 次)──── 松开
─────┬──────────────────┬──────────────────────┬─────────▶
起点 记进【矩形工具】…………………………………………………………………… 用完清掉
(Controller 自己身上)
预览的框 每一帧【现造一个、画出来、扔掉】
(从来没有真正"存在"过)
真矩形 ────────── 不 存 在 ──────────────────────► ★ 进入 Model
从此归 Model,永久
| 起点 | 从按下到松开,一直住在 Controller 自己身上。松手就清掉 |
| 拖动时那个跟着鼠标变形的框 | 不是一个真实存在的矩形,是每一帧现造、画完就扔的临时货 |
| 真正的矩形 | 只在松手那一瞬间诞生一次,进 Model 后就跟 Controller 再无关系 |
这三条就是带读-01 说的"诞生 1 次,画 N 次"和"半成品不进 Model"。下面用代码逐条兑现。
第二部分 · 逐阶段看
阶段 0 · 开门营业(页面加载,跑一次)
浏览器这边
① 拿到 index.htm,从上往下解析,建起 document 那棵树:
<body>
├─ <div id="menu"> ← 空的,按钮还没生成
└─ <canvas id="drawing"> ← 一块 640×420 的空白格子
② 每遇到一个 <script>,把它【整个执行一遍】,再往下读
③ 全部执行完 —— 相当于 main() 返回了
④ 程序不退出,进入消息循环,坐着等事件
qpaint 这边
<script> 里执行的就是这四行:
let qview = new QPaintView("drawing") // ①
qview.registerController("RectCreator", ()=> new QRectCreator(qview, "rect")) // ②(共六条)
installMenu(qview) // ③
qview.invokeController("RectCreator") // ④
qview = QPaintView("drawing") # ①
qview.registerController("RectCreator", lambda: QRectCreator(qview, "rect")) # ②
installMenu(qview) # ③
qview.invokeController("RectCreator") # ④
| 行 | 干什么 | 跑完之后 |
|---|---|---|
| ① | 造一个 View | 有了空 Model、空花名册、五个空盒子;canvas 的事件被接管 |
| ② | 填花名册(六条) | 六条"造法",一个工具都还没造出来 |
| ③ | 生成六个按钮 | 页面上出现按钮,<div id="menu"> 被填满 |
| ④ | 让矩形工具上岗 | 第一个工具被造出来,抢走四个盒子 |
⚠️ qview 不是 document
let qview = new QPaintView("drawing")
↑ ↑
作者自己写的类 一个【字符串】,是那块 canvas 的名字
document ← 浏览器给的,代表整个页面
├── <div id="menu">
│ └── <button id="RectCreator"> …
└── <canvas id="drawing"> ← 页面上的一个元素
▲
│ qview.drawing 指向它
│
qview ←【作者自己造的对象,不属于 document】
├── drawing ──┘
├── doc ─────────► QPaintDoc(那本笔记)
├── controllers ─► 花名册
└── 五个盒子
| 是什么 | 谁造的 | 在哪 | |
|---|---|---|---|
document |
整个页面 | 浏览器 | 浏览器内部 |
<canvas id="drawing"> |
页面上一个元素 | 浏览器(从 html 解析) | 页面上,你能看见 |
qview |
一个 QPaintView 实例 |
作者的代码 | 只在内存里,页面上看不见 |
接着带读-01 会议室的比喻:家具清单 = document,白板 = <canvas>,
而 qview 是会议室里的主持人——不是家具,不在清单上,但他手里拿着那本笔记(doc)、
知道白板在哪(drawing)、有花名册(controllers)、管着现在谁在发言(五个盒子)。
① new QPaintView("drawing") 干了四件事
constructor(drawingID) {
this.doc = new QPaintDoc() // a. 造一个空 Model(带读-01 那个)
this.properties = { lineWidth: 2, lineColor: "#000000" } // b. ViewModel 自己的数据
this.controllers = {} // c. 空花名册
this.onmousedown = this.onmousemove = ... = null // d. 五个空盒子
let drawing = this.drawing = document.getElementById(drawingID), view = this
for (let ev of ["mousedown","mousemove","mouseup","dblclick"])
drawing["on"+ev] = function(e){ // e. 把 canvas 的事件接过来转交
e.preventDefault()
let h = view["on"+ev]
if (h) h(e)
}
document.onkeydown = function(e){ // f. 键盘挂在【整个页面】上
let h = view.onkeydown
if (h) h(e)
}
}
e 和 f 是浏览器与 qpaint 的接头处,下一节专门讲。
e / f:浏览器与 qpaint 的接头处
先把 e 的循环展开——它就是把同一件事写了四遍,循环只是省事:
drawing.onmousedown = function(e){ e.preventDefault(); let h = view.onmousedown; if (h) h(e) }
drawing.onmousemove = function(e){ e.preventDefault(); let h = view.onmousemove; if (h) h(e) }
drawing.onmouseup = function(e){ e.preventDefault(); let h = view.onmouseup; if (h) h(e) }
drawing.ondblclick = function(e){ e.preventDefault(); let h = view.ondblclick; if (h) h(e) }
一行读作:
往 canvas 的「鼠标按下」盒子里放一个函数。这个函数干的事就是:
去 View 的「鼠标按下」盒子里取出函数,调用它。
一句话:转交。
三样没讲过的语法:
| 语法 | 是什么 | Python 对照 |
|---|---|---|
for (let ev of [...]) |
遍历数组的值 | for ev in [...] |
obj["名字"] |
跟 obj.名字 完全等价,只是名字用字符串给。drawing["on"+ev] 是把名字拼出来:ev="mousedown" → "onmousedown" |
setattr(obj, "on"+ev, 函数) |
e.preventDefault() |
叫停浏览器的内置行为 | — |
- 为什么要拼名字:四个事件处理方式一模一样,只有名字不同——拼名字就能一个循环搞定四个。
preventDefault在挡什么:浏览器默认"按住鼠标拖动 = 选中文字"。
不写这句,你拖着画矩形时页面上的文字会被一路选中变蓝。if (h)为什么要判断:因为 View 的盒子可能是空的。矩形工具不占ondblclick,
所以view.ondblclick是null——你双击画布,canvas 的盒子被调用了,去 View 那儿一取是空的,
什么都不做。这就是为什么用矩形工具时双击画布毫无反应。
为什么键盘(f)挂在 document 上,而不是 canvas 上?
21 讲讲过:键盘事件没有坐标。浏览器没法用"落在哪个元素上"决定送给谁,只能靠焦点。
而 <canvas> 默认拿不到焦点——它不是输入框。所以键盘事件根本不会送到 canvas 上。
挂在 document 上 = “整个页面的键盘事件我都要”。
所以整个程序有两个入口,在 View 这一层汇合,往下就统一了:
canvas.onmousedown ┐ canvas.onmousemove │ canvas.onmouseup ├──▶ View 的五个盒子 ──▶ 当前 Controller canvas.ondblclick │ document.onkeydown ┘ ↑ 只有这一个从 document 来,其余四个从 canvas 来这也顺带解释了:你在别处(比如刚点完菜单)按 ESC,画到一半的图形照样会被放弃——
因为键盘是整页收的,不管焦点在哪儿。
② 六条登记里,参数为什么不一样
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))
| 按钮 | 造出来的是哪个类 | 参数 |
|---|---|---|
| Line · Rect · Ellipse · Circle | QRectCreator |
"line" / "rect" / "ellipse" / "circle" |
| Path | QPathCreator |
无 |
| FreePath | QFreePathCreator |
无 |
六个按钮、六个工厂,但只有三个类。(
QRectCreator这名字起得不好,它四种都画。)
qview每个都有——每个 Controller 都要用 View 的服务:抢盒子、读当前样式、叫重画、往 doc 加图形。"rect"只有QRectCreator有——它一个类干四份活,得告诉它这次画哪种。
前四个为什么能共用一个类? 因为操作方式完全一样:按下定一点,拖,松开定第二点,
状态机一模一样。唯一区别是松手那一刻造出什么对象:
buildShape() {
switch (this.shapeType) { // ← shapeType 只在这一处用到
case "line": return new QLine(pt1, pt2, style)
case "rect": return new QRect(r, style)
case "ellipse": return new QEllipse(...) // 外接矩形
case "circle": return new QEllipse(...) // 圆心 + 半径
}
}
而 Path(逐点点击、双击结束)和 FreePath(按住随手涂、松开结束)操作方式完全不同,只能拆成两个类。
判据:状态机一样 → 参数化;状态机不一样 → 拆成不同的类。
这正是 26 讲原文那句"Model 层支持的图形只有四种,但界面表现有六种,这是非常正常的现象":
Model 按"存什么"分类,Controller 按"怎么操作"分类——两套分类轴,本来就不该一一对应。
③④ 生成按钮 + 让第一个工具上岗
function installMenu(view) {
document.getElementById("menu").innerHTML = // 一、生成六个按钮
`<button id="RectCreator">Rect</button> … 六个`
document.getElementById("RectCreator").onclick = ()=> { // 二、填按钮的 onclick 盒子
view.invokeController("RectCreator") // ↑ 只是一个字符串
}
}
传 qview 进去,是因为第二步那个函数要叫到 View。
注意按钮上写的是字符串,不是 Controller 本身——菜单不认识 QRectCreator 这个类。
④ invokeController 不是"调用",是"让它上岗":
| 意思 | |
|---|---|
| 调用 | 叫它执行某个动作,执行完返回 |
| 上岗 | 把它造出来,让它占住事件盒子,然后它就坐在那儿等着 |
这句执行完什么都还没发生:画布上没画任何东西,Model 里还是空的。
只是"从现在起,鼠标事件归矩形工具管了"。
真正"调用"矩形工具的是你的鼠标,通过那五个盒子。这一行的作用是让矩形工具当默认工具——不写它,五个盒子全空,你打开页面怎么点都没反应。
而且它干的事跟你手点「Rect」按钮完全一样,程序只是替你先点了一下。
跑完阶段 0,内存里是这个样子
qview
├── doc ──────────► QPaintDoc { _shapes: [ ] } ← 空的
├── properties = { lineWidth: 2, lineColor: "#000000" }
├── drawing ──────► 页面上那块 <canvas>
├── controllers ──► 花名册 6 条(存的是造法,不是成品)
├── onmousedown = 矩形工具.onmousedown ┐
├── onmousemove = 矩形工具.onmousemove │ 五个盒子
├── onmouseup = 矩形工具.onmouseup │
├── ondblclick = 空 │ ← 矩形工具不管双击
├── onkeydown = 矩形工具.onkeydown ┘
└── _current ─────► 矩形工具实例 { started: false, rect: {…}, view: →qview }
程序坐着不动,等你按鼠标。
阶段 1 · 你点了「Rect」按钮
浏览器这边
鼠标在 (x,y) 点了一下
↓ 拿坐标去 document 树里查:"这个点落在哪个元素上?"
↓ 命中 <button id="RectCreator">
调用它的 onclick 盒子里那个函数
注意:这个点击根本不经过 canvas。 按钮和 canvas 是 document 树里两个并列的元素。
"画图"和"换工具"走的是完全不同的两条路。
qpaint 这边
()=> { qview.invokeController("RectCreator") } // 按钮 onclick 盒子里那个函数
invokeController(name) {
this.stopController() // ① 让上一个下岗,把盒子还回来
let f = this.controllers[name] // ② 查花名册,拿到【工厂】
if (f) { this._current = f(); this._currentKey = name } // ③ 那对括号 → 现造一个
}
③ 一执行,构造函数就跑了——填字段 + 抢四个盒子:
constructor(view, shapeType) {
this.view = view // 外部依赖
this.shapeType = shapeType // 配置:"rect"
this.started = false // 状态:还没起手
this.rect = { pt1:{x:0,y:0}, pt2:{x:0,y:0} } // 上下文:起点终点
let c = this
view.onmousedown = e => c.onmousedown(e) // ★ 抢盒子
view.onmousemove = e => c.onmousemove(e)
view.onmouseup = e => c.onmouseup(e)
view.onkeydown = e => c.onkeydown(e)
}
stop() 的理由,在这张表里
假设之前在岗的是折线工具(它占 down/move/dblclick/key,不占 up):
| 盒子 | 点按钮前 | ① stop 之后 | ③ 造完新的 |
|---|---|---|---|
onmousedown |
折线工具 | 空 | 矩形工具 |
onmousemove |
折线工具 | 空 | 矩形工具 |
onmouseup |
空 | 空 | 矩形工具 |
ondblclick |
折线工具 | 空 | 空 |
onkeydown |
折线工具 | 空 | 矩形工具 |
_current |
折线工具实例 | null | 全新的矩形工具(started=false) |
盯住 ondblclick 那一行:折线工具占着它,矩形工具不占。
所以不 stop 的话,矩形工具只会覆盖 down/move/up/key,ondblclick 会残留折线工具。
然后你随手双击一下——那个已经下岗的折线工具被叫醒,往文档里塞了一条折线。
你明明在画矩形,画布上凭空冒出一条折线。
(代码/事件盒子.py场景二演的就是这个。)
阶段 2 · 按下鼠标
浏览器这边
鼠标在 (120, 80) 按下
↓ 拿坐标去 document 树里查
↓ 命中 <canvas id="drawing">
调用 canvas.onmousedown 盒子里那个函数 ←【阶段 0 的 e 放进去的转交函数】
qpaint 这边
转交函数 → 取 view.onmousedown → 矩形工具.onmousedown(e)
onmousedown(e) {
this.rect.pt1 = this.view.getMousePos(e) // 记下起点 {x:120, y:80}
this.started = true // 状态:起手了
}
注意这里【没有】发生什么:没有调
addShape,没有碰doc,画布上一个格子都没变。
起点只存在于矩形工具自己身上。 这就是"半成品不进 Model"。
阶段 3 · 拖动(重复几十上百次)
浏览器这边
每次鼠标移动 → 产生一个 mousemove 事件 → 查树 → canvas → 调用转交函数
↓(下面 qpaint 涂完格子)
每一帧结束时 → 浏览器把 canvas 那片格子【合成到屏幕上】,你才看得见
qpaint 这边
onmousemove(e) {
if (this.started) { // ← 没起手就不管
this.rect.pt2 = this.view.getMousePos(e) // 更新终点
this.view.invalidateRect(this.rect) // 要求重画
}
}
invalidateRect 是 ViewModel 的活,简单粗暴:
invalidateRect(rect) { // rect 收了不用
let ctx = this.drawing.getContext("2d") // ★ 取笔(整个程序只在这儿取)
ctx.clearRect(0, 0, this.drawing.width, this.drawing.height) // 整块擦干净
this.onpaint(ctx) // 全部重画
}
onpaint(ctx) {
this.doc.onpaint(ctx) // ① Model 里已诞生的:每个自己画自己
if (this._current) this._current.onpaint(ctx) // ② 还没诞生的那个:Controller 自己画
}
① 现在 _shapes 是空的,什么都没画。② 才是关键:
onpaint(ctx) {
if (this.started) {
this.buildShape().onpaint(ctx) // ← 就这一行
}
}
拆开读:
this.buildShape() → 现造一个货真价实的 QRect(带读-01 那个类)
.onpaint(ctx) → 让它【自己画自己】——把格子涂上
(这一行结束) → 这个 QRect 没有任何人持有,被扔掉
所以你看到的那个跟着鼠标变形的框,是每秒钟被造几十次、画一次、然后扔掉的临时对象。
它从来没"存在"过,也从来没进过 Model。ESC 一按started = false,它连造都不会再造。
阶段 4 · 松开鼠标
浏览器这边
鼠标松开 → 查树 → canvas → 调用转交函数
(qpaint 涂完最后一次格子后)→ 浏览器合成到屏幕
qpaint 这边
onmouseup(e) {
if (this.started) {
this.rect.pt2 = this.view.getMousePos(e) // 最后一次更新终点
this.view.doc.addShape(this.buildShape()) // ★★ 诞生!
this.reset()
}
}
reset() {
this.started = false // 清空自己
this.view.invalidateRect(null) // 再重画一次
}
doc.addShape(this.buildShape()) 是整条链的高潮:
this.buildShape() → 又造一个 QRect(跟阶段 3 一模一样的造法)
doc.addShape(它) → _shapes.push(它) ←【这一次不扔了】
跟阶段 3 唯一的区别,就是这一次没扔,塞进了 Model。
最后一次重画:
onpaint(ctx) {
this.doc.onpaint(ctx) // ① _shapes 里现在有 1 个矩形 → 它自己画自己
if (this._current) this._current.onpaint(ctx) // ② started 已是 false → 什么都不画,橡皮筋消失
}
结束状态:
doc._shapes = [ 那个矩形 ] ← 归 Model 所有,跟 Controller 再无关系
矩形工具.started = false ← 清空,等你画下一个
view 的四个盒子 = 还是矩形工具占着 ← 工具没换,可以接着画第二个
第三部分 · 回头看
三个关键时刻
| 时刻 | 发生了什么 | 为什么重要 |
|---|---|---|
| 点按钮那一刻 | 查花名册 → 现造一个 Controller → 它抢走四个盒子 | Controller 用完就扔,每次点按钮都是新的,状态从零开始 |
| 拖动的每一帧 | 全屏擦掉 → Model 里每个图形自己画 → Controller 把预览画上去 | 画得非常频繁,跟"创建"完全是两回事(诞生 1 次,画 N 次) |
| 松手那一刻 | addShape——图形第一次进入 Model |
在此之前那个矩形对 Model 来说根本不存在,ESC 掉是零成本的 |
预览和成品用的是同一个方法
this.buildShape().onpaint(ctx) // 阶段 3(预览):造,画,扔
this.view.doc.addShape(this.buildShape()) // 阶段 4(成品):造,留
唯一区别是要不要 addShape。 好处:绘制代码零重复,而且预览和成品永远不可能长得不一样
——这是所有画图软件的经典 bug 来源(松手后的实物跟刚才的虚框对不上)。
而这一手能成立,正是因为带读-01 那条"让图形自己画自己"。
如果画法写在外面,Controller 想画预览就得再抄一遍。
两个设计决定在这里咬合上了——好的设计决定会互相帮忙。
四方各干了什么
| 在这条链上干的事 | 一句话 | |
|---|---|---|
| 浏览器 | 建 document 树、按坐标/焦点把事件送到元素、把 canvas 合成到屏幕 | 它不知道 qpaint 在干什么 |
| Controller(矩形工具) | 收三个事件、记起点终点、造图形、交给 Model | 把一串动作翻译成一个图形 |
| ViewModel(QPaintView) | 转交事件、提供当前样式、擦画布、组织重画顺序 | 调度,自己一件业务都不办 |
| Model(QPaintDoc + QRect) | 收下图形、被叫的时候自己画出来 | 文档是什么 |
ViewModel 从头到尾没做任何"业务"——它不知道什么是矩形、不知道怎么画、不知道谁在画。
它只做三件事:转交事件、擦画布、按顺序叫别人画。
这就是 26 讲说的"View 层是承上启下的桥梁"“它不应该知道任何具体的 Controller”。
第四部分 · 验收
4.1 打断点,把四个阶段看一遍
打开 代码/qpaint-mini.html,Cmd+Option+I → Sources → 找到文件 → 点行号打断点。
| 打在哪 | 操作 | 该看到什么 |
|---|---|---|
invokeController 里 this._current = f() |
点「Rect」按钮 | name 是 "RectCreator";单步一下,this._current 从 null 变成一个 QRectCreator |
QRectCreator.onmousedown |
在画布上按一下 | 走完两行,this.rect.pt1 变成你点的坐标、this.started 变 true。同时看 this.view.doc._shapes——还是空的 |
QRectCreator.onpaint 里 buildShape() |
按住鼠标拖 | 它会不停地停下来(每移动一下停一次)——这就是"每一帧现造现扔" |
QPaintDoc.addShape |
松开鼠标 | 整个拖动过程只停这一次。走完一步,_shapes.length 从 0 变 1 |
第三、四个断点对着看,"诞生 1 次、画 N 次"就从一句话变成你亲眼见过的事实了。
(拖动时断点太烦的话,看完点一下取消掉。)
4.2 五个自测问题
| # | 问题 | 答案在 |
|---|---|---|
| 1 | 你点「Rect」按钮,这个点击经过 canvas 吗?浏览器怎么知道该送给按钮? | 阶段 1 |
| 2 | 键盘事件为什么挂在 document 上,而鼠标挂在 canvas 上? |
阶段 0 · e/f |
| 3 | 按下鼠标之后、松手之前,doc._shapes 里有几个矩形?那个跟着鼠标动的框住在哪儿? |
阶段 2、3 |
| 4 | buildShape() 在一次画矩形里被调用了多少次?其中几次的结果被留下来了? |
阶段 3、4 |
| 5 | 松手之后,QPaintView 里还留着那个矩形工具吗?为什么不用换掉它? |
阶段 4 |
答案
1. 不经过。 按钮和 canvas 是 document 树里两个并列的元素。浏览器拿点击坐标去树里查
“这个点落在哪个元素上”,命中的是 <button>,就调用按钮的 onclick 盒子。
"画图"和"换工具"走的是完全不同的两条路。
2. 因为键盘事件没有坐标(21 讲那条)。浏览器没法按"落在哪个元素上"决定送给谁,只能靠焦点;
而 <canvas> 默认拿不到焦点,键盘事件根本不会送到它身上。挂在 document 上 = 整个页面的键盘我都要。
3. 0 个。 那个框住在矩形工具自己身上(this.rect.pt1/pt2 + started),
而且它甚至不是一个持久对象——是每一帧现造现扔的。
4. 拖动 N 次就被调用 N+1 次(每帧一次预览 + 松手时一次)。
只有最后一次的结果被 addShape 留下来,其余全扔了。
5. 还留着。 工具没换,_current 还是它,四个盒子还归它——所以你可以接着画第二个矩形。
它只是把 started 清成 false,回到"等你起手"的状态。换工具才需要 stop(),画完一个不需要。
下一步
第 3 步 · 收口 26 讲:带读-03-收口26讲.md——补齐三块:
① 菜单怎么做到"不认识任何一个 Controller"?(它只念一个字符串)
② 整个程序"由哪些零件构成"写在哪儿?把一行 <script> 注释掉会怎样?
—— 亲手验 22 讲那条"删掉一个交互只需注释一行",
并且亲眼看到作者自己没做干净的那个地方(四个死按钮)。
