完整源码按讲分放在各章节的
代码/qpaint源码/下,读法与结构地图见 带读-源码导航。
迷路了就看 带读-地图.md——一页纸:哪个文件在哪层、几条链路怎么走、谁认识谁。
读法:第一、二部分顺着读(心智模型,一行 JS 都没有);
第三部分当字典查,不要通读;第五部分是验收。
序 · 26~30 讲怎么走
这五讲是同一个程序的连续演进。跟着"需求怎么长大"走,比跟着"介绍模块"走好懂:
| 讲 | 程序变成了什么 | 被逼出来的新东西 |
|---|---|---|
| 26 | 单机版画图程序,能画六种图形 | Model / ViewModel / Controller 三层怎么分 |
| 27 | 加上"选中、拖动、改属性" | Selection、hitTest(点中了谁) |
| 28 | 数据要能存到服务端 | 对象 ID(单机版不需要,联网就必须有)、变更的粒度 |
| 29 | 客户端与服务端对话 | 网络协议怎么设计、版本怎么升级 |
| 30 | 多人同时改一份图 | 变更如何计算与同步、Model 层该多厚 |
第 0 步(本篇) 前端底子 —— 让代码从"天书"变成"看得懂的字"
第 1 步 26 讲 · Model 层 dom.js ← 见 带读-01
第 2 步 26 讲 · 一次完整的画矩形 view.js + creator/rect.js ← 见 带读-02
第 3 步 26 讲 · 收口 ViewModel责任 + menu.js + 架构思维 ← 见 带读-03
第 4 步 27 讲 · 加选择工具 ← 带读-04
第 5 步 28 讲 · 离线持久化与对象ID ← 带读-05
第 6 步 29 讲 · 网络协议与服务端 ← 带读-06(含异步)
第 7 步 30 讲 · 对接与Model层厚度 ← 带读-07(全系列收口)
收尾 五讲带走了什么 ← 带读-08
第 1 步已经有了:带读-01-Model层(dom.js).md
第一部分 · 先建立心智模型
这一部分一行 JS 都没有。 但读完之后,代码看起来会完全不一样。
1.1 一个网页 = 一个图形界面程序
你熟悉的 Python:
python main.py # 从上往下执行 → 执行完 → 进程退出
一个网页:
① 浏览器拿到 index.htm,从上往下读
② 遇到 <script src="dom.js">,就把它【整个执行一遍】,再继续往下读
③ 所有 <script> 执行完 —— 相当于 main() 返回了
④ 但程序【不退出】,浏览器进入消息循环,坐在那儿等事件
⑤ 从此你的代码只会被【事件】唤醒:鼠标动了、键按了、按钮点了
这就是 21 讲讲的那件事,一模一样。
<script>那几行是main(),执行完不退出,进入消息循环。
你在 21 讲写的那几个 Python 脚本(收件箱 + 主循环),浏览器全给你做好了。
⚠️ <script> 不是"事件",它是"布置现场"
最容易搞混的一点。<script> 只属于阶段一,跑一次就完了:
<script> |
事件 | |
|---|---|---|
| 什么时候发生 | 页面加载时,一次 | 用户操作时,无数次 |
| 谁触发 | 浏览器读到这一行就执行 | 用户 |
| 干什么 | 定义类、造对象、填登记表 | 照着登记表找人,调用他 |
| 之后还会再跑吗 | 不会(除非刷新页面) | 会,一直会 |
<script>是开门营业前的布置:摆桌子、贴值班表、安排谁上哪个岗。
事件是营业中进来的客人:照着值班表找人接待。
值班表是<script>贴的——但<script>本身不是客人。
拿真的 index.htm 走一遍,注意大部分文件执行完只是"定义了几个类",一个事件都没绑:
<script src="dom.js"> 执行 → 定义 QLine/QRect/QEllipse/QPath/QPaintDoc
【一个事件都没绑】
<script src="view.js"> 执行 → 定义 QPaintView 这个类
【也没绑,只是定义了个类】
<script src="creator/rect.js"> 执行 → 定义 QRectCreator 类
+ 安排好"等 View 造出来时登记 RectCreator"
<script src="accel/menu.js"> 执行 → 生成六个按钮的 HTML,给按钮绑 onclick
<script>initPaintView(...) 执行 → 真正 new 出 QPaintView,
这一步才把 canvas 的事件接过来
↓
八个文件全执行完。main() 结束。
↓
程序不退出,坐着等事件。
(qpaint 只有一个 html 页面,八个 script 都往这一个页面里装东西。)
最贴的类比:<script src> ≈ Python 的 import
| Python | HTML | |
|---|---|---|
| 写法 | import dom |
<script src="dom.js"> |
| 干什么 | 执行 dom.py 的顶层代码一次 |
执行 dom.js 的顶层代码一次 |
顶层的 class / def |
定义好,供别人用 | 一样 |
| 顶层的裸语句 | 也会执行(比如注册语句) | 一样 |
所以 creator/rect.js 末尾那段"不在任何函数里的裸代码",等于 Python 模块顶层的注册:
# rect.py
class QRectCreator: ...
registry["RectCreator"] = ... # ← 模块顶层,import 时执行一次
两处关键差别:
Python import |
老式 <script> |
|
|---|---|---|
| 命名空间 | import dom 后要写 dom.QLine |
没有命名空间,QLine 直接全局。所有文件共用一个全局空间(更像 from dom import *) |
| 依赖顺序 | import a 时 a 自己会 import b,自动解析 |
没有依赖声明,必须手工保证 dom.js 写在 view.js 前面 |
正因为顺序是手工排的,index.htm 那份 <script> 列表就成了唯一一处声明"这个程序由哪些零件构成"的地方——翻成 Python 就是 main.py 顶上那一堆:
import dom
import view
import creator.rect # ← import 它 = 启用"画矩形/线/椭圆/圆"这四个功能
import creator.path
import accel.menu
删掉一行 import = 删掉一个功能,程序照常跑。
这就是 22 讲说的「组装根」:所有"谁认识谁"的关系集中在一个地方,
所以"注释掉一行就能删掉一个交互"才做得到。第 3 步会让你亲手验这一条。
(免得你以后看别的代码困惑:现代 JS 有真正的 import / export,跟 Python 几乎一样、有命名空间。qpaint 用的是老式全局 <script>。)
顺带认识两个 HTML 标签,够用了:
<canvas id="drawing" width="1024" height="660"></canvas>
<!-- 页面上开一块 1024×660 的空白像素区,起名叫 drawing。图全画在这里 -->
<input type="button" id="RectCreator" value="Create Rect" onclick="onClickCtrl('RectCreator')">
<!-- 一个按钮,点它就执行 onClickCtrl('RectCreator') 这句 JS -->
(CSS 在 qpaint 里几乎没有,可以完全忽略。)
document 是什么
浏览器给你一个全局对象叫 document,它代表「当前这个页面」。
通过它可以找到页面上的任何一个元素:
document.getElementById("drawing") // 从页面上,按名字找出那个元素
对应 html 里的:
<canvas id="drawing" width="1024" height="660"></canvas>
<!-- ↑ 这个名字 -->
| 你写的 | 意思 |
|---|---|
document |
「当前这个页面」 |
document.getElementById("drawing") |
「从页面上按名字找出那个元素」 |
document.onkeydown = 某函数 |
「整个页面的键盘事件盒子,放进这个函数」 |
最要紧的一条:document 只存在于浏览器里。
Node 里没有它,命令行程序里没有它。
所以一段代码里只要出现 document.,就等于宣告"我只能在浏览器里跑"。
这条是理解
dom.js那个设计的前提:dom.js全文没有一个document.。
它从不主动去找界面上的任何东西;它需要的那支笔(ctx),是别人递给它的。「我去找界面」vs「界面递给我」——一字之差,决定了这段代码能不能脱离浏览器。
1.2 为什么代码长成那样:顺序式 vs 登记式
先看你想写、但写不出来的那种代码:
def 画矩形():
起点 = 等用户按下() # ← 我主动"等"
while 用户在拖:
画预览()
终点 = 等用户松开()
文档.加(矩形(起点, 终点))
为什么写不出来:没有"等用户按下"这种函数——一旦你在那儿阻塞着等,
整个界面就卡死了(21 讲那条"循环只有一个,谁占住它界面就死")。
而且更根本的是:用户想先按鼠标还是先敲键盘、想画三个矩形再画一条线,全是他临场定的。
你根本没法预先写下一条流程。
于是只能反过来——放弃规定顺序,改成预先登记:
鼠标按下 → 找 矩形工具.按下
鼠标移动 → 找 矩形工具.移动
鼠标松开 → 找 矩形工具.松开
双击 → 没人登记,这事发生了也没人管
键盘按下 → 找 矩形工具.按键
| 顺序式(Python 脚本) | 登记式(图形界面) | |
|---|---|---|
| 我预先决定的是 | 动作的顺序 | 每种动作找谁 |
| 用户能做什么 | 只能按我定的顺序来 | 想干嘛干嘛,任意顺序 |
| 代码长什么样 | 一条主流程 | 一张登记表 + 一堆互不相连的处理函数 |
这张登记表挂在 View 身上。
26 讲说"View 是承上启下的桥梁"、“View 不该知道任何具体的 Controller”,落到实处就是:
View 只负责端着这张表,按表转交。表上填的是谁,它不关心,也不问。
这跟 21 讲那张"这个坐标归哪个窗口"的表是同一个套路:查表分派。
只不过 21 讲查的是坐标,这里查的是事件名。
1.3 三个主角:用「输入法」一次讲完
qpaint 分三层。这三层你每天都在用,只是没意识到——就是输入法。
| 画图程序 | 输入法 |
|---|---|
Model(_shapes:所有画好的图形) |
你正在写的那篇文档 |
| View(那张登记表) | 系统的输入通道:键盘敲了什么,该交给谁 |
| Controller(矩形工具 / 折线工具 / 自由笔) | 一个输入法(拼音 / 五笔 / 手写) |
| 同时只有一个 Controller 在岗 | 同时只有一个输入法生效 |
| 点菜单换工具 | 切输入法 |
| Controller 里那个"起点"、“已经点了几个点” | 输入法的拼音缓冲区:打了 zhong,还没上屏 |
| 松手才把图形放进 Model | 选了字才上屏,进入文档 |
| 按 ESC 放弃 | 按 ESC 清掉拼音,文档里一个字都没留下 |
换工具前要 stop() |
切走时,上一个的候选框得先消失 |
几个最绕的点,在输入法这边全是日常经验:
| 疑问 | 输入法版本的答案 |
|---|---|
| 为什么一个 Controller 要打包成一个类,不能是几个散函数? | 拼音输入法得记着你已经敲了 zhon,才知道再敲 g 是 zhong。这份记忆必须有地方待着,而且五笔输入法看不见拼音的缓冲区 |
| 为什么画到一半的图形不能先放进 Model? | 打了一半的 zhong 不在文档里。要是直接进文档,按 ESC 就得从文档里"删掉"它,协同编辑时别人还会看到你打字的全过程 |
为什么换工具前必须 stop()? |
拼音打到一半切五笔,候选框要是不消失,就会挂在屏幕上继续响应键盘 |
结构图:View 是岗位,Controller 是来上岗的人
View (整个程序只有一个)
┌────────────────────────────┐
│ 五个岗位(下面叫它"盒子") │
│ onmousedown = ______ │
│ onmousemove = ______ │
│ onmouseup = ______ │
│ ondblclick = ______ │
│ onkeydown = ______ │
└────────────────────────────┘
▲
│ 上岗:把自己的方法填进去
│ 下岗:擦掉(stop)
┌───────────────────┼───────────────────┐
矩形工具 折线工具 自由笔工具 ← Controller
(六个,同时只有一个在岗)
| 问题 | 答案 |
|---|---|
| 盒子是谁的? | View 的。Controller 只是借用,所以用完要还 |
| 一共几个盒子? | 五个,固定不变。不管有多少个 Controller,永远就这五个 |
| 一个 Controller 占几个盒子? | 好几个。 矩形工具占 4 个(down/move/up/key);折线工具也占 4 个,但换了一个(dblclick 代替 up) |
| 同时能有几个在岗? | 一个。 因为一个盒子只能装一个函数 |
盒子不属于 Controller,Controller 也不"是"盒子——Controller 是那个往盒子里填自己名字的人。
1.4 Controller = 一个被翻过来的 workflow
"画一个矩形"本来是个有步骤的流程,但它被消息循环拆散、翻了过来:
class 矩形工具:
def __init__(self):
self.started = False # ← 「我现在走到第几步」,得自己记着
self.起点 = None # ← 沿途攒下的数据
def 按下(self, p): # 步骤 1
self.起点 = p
self.started = True
def 移动(self, p): # 步骤 2
if self.started: # ← 防守:万一用户没按下就在动呢
画预览(self.起点, p)
def 松开(self, p): # 步骤 3
if self.started:
文档.加(矩形(self.起点, p))
self.started = False # ← 回到起点,等下一轮
同一个流程,一个是"我走",一个是"我等":
| 正常 workflow | Controller | |
|---|---|---|
| 谁推动下一步 | 我自己 | 用户 |
| "走到第几步"在哪 | 在代码执行到哪一行 | 必须自己存成字段(started) |
| 步骤保证发生吗 | 保证 | 不保证——用户可能 ESC、可能切走、可能永远不松手 |
| 所以每一步要 | 直接写 | 先检查自己该不该响应(那些 if self.started) |
那些 if self.started 就是把被拆散的流程图重新拼回去的胶水。
所以 26 讲说"Controller 是一台状态机"——workflow 有几步,状态机就有几个状态。
started是矩形工具的状态记号;折线工具的记号是points的长度(已经点了几个点)。
第二部分 · 三个机制
心智模型有了,现在看它落到代码上是什么。三个机制都很短。
2.1 「盒子」是什么:三行 Python 就讲完
def 按下(位置):
print("我被调用了,位置是", 位置)
盒子 = None # ① 一开始盒子是空的
盒子 = 按下 # ② 把函数放进盒子 —— 注意没有括号!
盒子((10, 10)) # ③ 通过盒子调用它 → 等价于 按下((10,10))
就这些。 view.onmousedown 就是上面那个 盒子,只不过它挂在一个对象身上。
| 写法 | 是什么 |
|---|---|
按下 |
函数本身(一个值,可以存、可以传、可以赋给别人) |
按下(123) |
调用它的结果 |
差一对括号,是两件完全不同的事。
浏览器那边的逻辑,朴素得出乎意料:
当硬件报告「鼠标按下了」:
f = view.onmousedown ← 去这个字段里取出函数
如果 f 不是空的:
f(事件信息) ← 调用它
onmousedown 不是一个方法,是一个普通字段——跟 this.pt1 没有本质区别,
只不过它里面装的是函数,而且约定好了会有人去读它。
叫它"盒子",是因为它只能装一样东西:
盒子 = 函数A # 现在是 A
盒子 = 函数B # 现在是 B。A 被【挤掉了】,再也找不回来
跟 x = 1 然后 x = 2 是同一回事——赋值就是覆盖。
这就是"同一时刻只有一个 Controller 在岗"的全部原因。
2.2 上岗与下岗
| 是什么 | 代码 | |
|---|---|---|
| 抢占(上岗) | 把自己的方法写进盒子。上一个人被挤掉 | view.onmousedown = 我的方法 |
| 归还(下岗) | 把盒子清空,腾给下一个 | view.onmousedown = null |
"抢"是说:它不用跟谁打招呼,也没人能拒绝。一句赋值,盒子就归它了。
不归还会真的出事
关键事实:两个 Controller 订阅的事件不一样——
| 占了哪几个盒子 | |
|---|---|
| 矩形工具 | down · move · up · key |
| 折线工具 | down · move · dblclick · key ← 它不订阅 mouseup! |
场景:你按住鼠标拖矩形,拖到一半,点了菜单上的「画折线」。
如果换人时不 stop(),新的只会覆盖它自己关心的那几个盒子:
view.onmousedown = 折线工具 ← 被覆盖了,没问题
view.onmousemove = 折线工具 ← 被覆盖了,没问题
view.onmouseup = 矩形工具 ← ⚠️ 没人动它,【残留】!
view.ondblclick = 折线工具
view.onkeydown = 折线工具
然后你松开鼠标——
那个已经"下岗"的矩形工具被叫醒了。它一看自己身上还记着起点,
就老老实实往文档里塞了一个矩形。你明明已经切去画折线了,画布上却凭空冒出一个矩形。
这就是 stop() 存在的全部理由:不归还的盒子 = 一个已经下岗的 Controller 还能被叫醒干活。
所以换人的第一件事是停掉上一个:
invokeController(name) {
this.stopController() // ← 先让上一个把盒子全还回来
... // 再造新的(新的构造函数会去抢)
}
一进一出,正好配对:构造函数抢盒子,stop() 还盒子。
动手看现象:代码/事件盒子.py(纯 Python,没有一点前端)。
python3 事件盒子.py直接跑,三个场景:正常流程 / 忘了 stop 的 bug 现场 / 正确 stop。
输出会把每一次事件去哪个盒子取到了谁都打出来。
谁来触发换人:两条路,互不相干
"画图"和"换工具"走的是完全不同的两条路:
路线 A · 画图
鼠标在【画布】上动
↓ canvas.onmousedown ← View 占着,永远不换人
↓ view.onmousedown ← 【随时在换】
矩形工具.按下
路线 B · 换工具
鼠标点【菜单按钮】
↓ 按钮.onclick ← 加载时绑好,永远不换人
onClickCtrl("PathCreator")
↓
qview.invokeController("PathCreator")
├── ① 矩形工具.stop() 把盒子还回来
├── ② 查登记表,拿到 "PathCreator" 的工厂
├── ③ 工厂() → new QPathCreator(view)
└── ④ 它的构造函数把盒子重新填上
换工具的点击根本不经过 canvas。 按钮是另一个页面元素,有自己的 onclick 盒子,
而且那个盒子在加载阶段就填好、永远不换人。真正"随时换人"的,只有 View 上那五个盒子。
// menu.js —— 按钮那边
<input type="button" id="RectCreator" value="Create Rect" onclick="onClickCtrl('RectCreator')">
function onClickCtrl(key) { view.invokeController(key) } // key 只是一个字符串
// view.js —— 换人那边
invokeController(name) {
this.stopController() // ① 先让上一个把盒子还回来
if (name in this.controllers) {
let factory = this.controllers[name] // ② 查登记表,拿到工厂
this._setCurrent(name, factory()) // ③ 现造一个,构造函数会去抢盒子
}
}
stopController() {
if (this._current != null) {
this._current.stop() // 让它【自己】去还
this._setCurrent("", null)
}
}
注意 ①:是让 Controller 自己去还,不是 View 把五个盒子全清空。
因为 View 不知道它占了哪几个(矩形工具占up,折线工具占dblclick)——只有它自己知道。谁占的谁还。注意
key只是一个字符串:menu.js里从头到尾没出现过QRectCreator这个名字。
它不认识那个类,只会念一个名字让 View 去查表——这就是 26 讲说的"避免两类 Controller 相互耦合"。
别搞混:有两张表
controllers 登记表 |
五个事件盒子 | |
|---|---|---|
| 比喻 | 花名册:公司有哪些员工 | 值班表:现在谁在岗 |
| 内容 | 名字 → 工厂(六个工具全在里面) | 事件 → 当前在岗那位的方法 |
| 什么时候填 | 加载阶段填好,之后一辈子不变 | 运行阶段反复换 |
| 谁填的 | 各个 creator/*.js 自己登记自己 |
当前 Controller 的构造函数 |
登记表是自注册的——没有一个中央的地方列出"所有 Controller":
// creator/rect.js 末尾,这个文件被加载时就执行
view.registerController("RectCreator", function(){ return new QRectCreator(view, "rect") })
view.registerController("EllipseCreator", function(){ return new QRectCreator(view, "ellipse") })
// creator/path.js 末尾
view.registerController("PathCreator", function(){ return new QPathCreator(view) })
| 谁负责 | |
|---|---|
| 登记表这个空容器 | View(this.controllers = {}) |
| 表里的内容 | 各个 creator 文件自己填 |
| 什么时候填 | 文件被加载的那一刻(就是 1.1 说的"≈ import"那一刻) |
于是加一个新工具只要两步:① 写一个新文件(定义类 + 末尾自己登记);② index.htm 加一行 <script>。
不用改 view.js、不用改 dom.js、不用改任何已有的 creator。
⚠️ 但作者自己在这里没做干净:行为是自注册的,按钮列表却硬编码在
menu.js里。
所以删掉<script src="creator/rect.js">之后:程序不崩、其他工具照常 ✓,
但那四个按钮还在、点了没反应 ✗(invokeController查不到名字就静默返回)。
22 讲自己说过"Controller 的辅助 View 必须归它自己,否则界面上会留一个死按钮"——这里留了四个。
第 3 步会让你亲手注释掉那一行看现象。
为什么盒子在 View 上,不直接在 canvas 上
浏览器只会把事件送到页面元素(canvas)上——它不认识 矩形工具 这种东西。
所以 View 先把它接过来,再转交给自己的盒子。链条是三层:
鼠标硬件
↓
浏览器
↓ canvas.onmousedown ← 页面元素上的盒子,【View 占着,永远不换人】
View
↓ view.onmousedown ← View 上的盒子,【Controller 抢的是这一个,随时换人】
矩形工具.按下 ← 真正干活的方法
多这一层的理由:Controller 不该认识 canvas(认识了就跟浏览器绑死,换成桌面版要重写),
而且 View 可以顺手把坐标换算之类的杂事统一做掉。
2.3 什么时候造出来:注册的是「工厂」,不是「成品」
先分清三个词:
| 词 | 是什么 | 类比 |
|---|---|---|
| 类 class | 一张图纸 | 饼干模具 |
| 实例 instance | 照图纸造出来的那一个具体东西 | 压出来的一块饼干 |
| 工厂 factory | 一个专门造实例的函数 | 「按一下就给你压一块」的按钮 |
class QLine: ... # 类:一张图纸,本身不是任何一条具体的线
a = QLine(p1, p2) # 实例①:一条具体的线
b = QLine(p3, p4) # 实例②:另一条,各有各的数据,互不影响
def 造一条线(): # 工厂:它不是线,它是"造线的东西"
return QLine(p1, p2)
f = 造一条线 # 拿到工厂本身(没括号 → 什么都还没造)
line = f() # 加括号 → 现在才真的造出一个实例
qpaint 的注册表里存的是工厂:
view.registerController("RectCreator", function(){ return new QRectCreator(view, "rect") })
view.registerController("EllipseCreator", function(){ return new QRectCreator(view, "ellipse") })
为什么不直接存类:同一个类配上不同参数 → 不同的工具。存类的话,激活时不知道该传 "rect" 还是 "ellipse"。
工厂 = 类 + 已经填好的参数,打包成一个"一按就出货"的东西。
为什么不存实例(启动时就造好):因为构造函数会抢盒子。启动时把六个全造出来的话——
矩形工具诞生 → 抢走四个盒子
折线工具诞生 → 又抢一遍,把上一个覆盖了
自由笔工具诞生 → 再覆盖……最后一个赢,永久霸占鼠标,其余五个按钮全是死的
| 存什么 | 注册那一刻 | 点菜单时 | 结果 |
|---|---|---|---|
| 实例 | 立刻造六个,六个构造函数全跑一遍 | 用那一个 | 当场坏掉(互抢盒子),状态也会残留 |
| 工厂 | 什么都不造,只存六份"造法" | 现造一个新的 | 每次从零开始,全程只有一个在岗 |
有状态的东西,用完就扔比洗干净再用更安全。
而且"点菜单那一刻才造" ⇒ 诞生即上岗:绑定就写在构造函数里。
动手看现象:代码/工厂和实例.py(纯 Python)。
python3 工厂和实例.py直接跑,三段:
①lambda是什么 ② 加不加 lambda 差在哪(加了之后什么都没造)
③ 花名册存实例 vs 存工厂——存实例那一版当场就能看到三个工具互抢盒子、最后一个赢。
2.4 Controller 身上有三种字段
几个步骤之间要有一份共享的上下文,流程才走得下来——那就是实例字段。
但一个 Controller 身上不止这一种东西:
| 字段 | 是什么 | 从哪来 | 什么时候没 |
|---|---|---|---|
started 起点 points |
流程走到哪 + 沿途攒的数据(真正的"上下文") | 自己产生 | 松手 / ESC 就清掉 |
shapeType |
配置(我是画矩形的还是画圆的) | 造它时定死 | 一辈子不变 |
view |
外部依赖(要用别人的服务) | 构造时递进来 | 一辈子不变 |
但 Controller 之间一点都不共享
这是 26 讲反复强调的那条,而且实际上根本没机会共享:同时只有一个在岗,
切走时上一个被 stop()、整个实例连同上下文一起被扔掉,下一个是现造的全新的。
那如果两个 Controller 都需要同一个东西(比如"当前线宽")?各自向上问 View。
✗ 错的:横向连线
矩形工具 ←──共享──→ 折线工具
✓ 对的:共享的东西住上层,各自去取
View ( 当前线宽 / 颜色 )
↑ ↑
矩形工具 折线工具
整个分层最实用的一条判据:
两个模块需要共享什么,就把那个东西挪到它们共同的上层去,让它们各自去取——
而不是在它们之间连一根线。
连了线,删掉一个另一个就塌了;挪到上层,删掉一个另一个毫无感觉。
这也顺带回答了 26 讲那个问题:"当前线宽"为什么住在 ViewModel 而不是 Model?
因为它是多个 Controller 的共同上层,而不是文档的一部分。
第三部分 · JS 语法速查(当字典查,别通读)
下面每一条 qpaint 里都出现过,没出现过的一条都没列。
3.1 Python ↔ JS 对照表
| 想干的事 | Python | JavaScript |
|---|---|---|
| 定义变量 | x = 5 |
let x = 5 |
| 定义类 | class A: |
class A { … } |
| 构造函数 | def __init__(self, a): |
constructor(a) { |
| 方法 | def f(self, x): |
f(x) { ← 不写 def,不写 self |
| 方法里的"自己" | self(显式参数) |
this(隐式) |
| 只读属性 | @property |
get lineStyle() { |
| 字典 / 对象 | {"x": 0, "y": 0} |
{x: 0, y: 0} ← 键不用引号 |
| 取字段 | d["x"] / o.x |
o.x 和 o["x"] 完全等价 |
| 列表 | [1,2,3] · .append(v) |
[1,2,3] · .push(v) |
| 遍历值 | for v in arr: |
for (let v of arr) |
| 遍历下标 | for i in range(len(arr)): |
for (let i in arr) |
| 键在不在 | "k" in d |
"k" in obj |
| 空 | None |
null |
| 字符串模板 | f"x={x}" |
`x=${x}` |
| 相等判断 | == |
=== |
| 注释 | # |
// |
| 代码块 | 缩进 | { } 花括号 |
| 造一个实例 | QLine(a, b) |
new QLine(a, b) ← 要写 new |
| 无名小函数 | lambda: 表达式 |
()=> 表达式 ← 见 3.5 |
| 用字符串当字段名 | getattr(o,"x") · setattr(o,"x",v) |
o["x"],跟 o.x 完全等价。好处是名字能拼: o["on"+ev] |
3.2 构造函数和方法,差在哪
| 构造函数 | 方法 | |
|---|---|---|
| 什么时候跑 | 对象诞生那一刻,自动跑 | 你主动叫它才跑 |
| 跑几次 | 一个对象一生只跑一次 | 想跑多少次跑多少次 |
| 谁调用它 | 你写 new QLine(...),语言替你调 |
你自己写 line.onpaint(ctx) |
| 名字 | 固定的:Python __init__,JS constructor |
你随便起 |
| 职责 | 把字段填好,让对象进入能用的状态 | 干活 |
line = QLine(a, b) # ← __init__ 在这一刻跑了一次,之后再也不跑
line.onpaint(ctx) # ← 方法,调几次都行
line.onpaint(ctx)
构造函数负责"把它造出来",方法负责"让它干活"。
只是在 qpaint 里,Controller 的构造函数顺手还抢了盒子(2.2 节),所以它比一般的构造函数多干了一件事。
一个容易看晕的地方:名字撞车了
view.onmousedown = function(event) { ctrl.onmousedown(event) }
onmousedown 出现了两次,是两个完全不同的东西:
| 写法 | 是什么 |
|---|---|
view.onmousedown |
View 上的一个字段——那个"盒子" |
ctrl.onmousedown |
Controller 上的一个方法——真正干活的代码 |
这一行读作:往 View 的盒子里塞一张小纸条,纸条上写着"收到事件就去叫 ctrl 的那个方法"。
3.5 箭头函数:()=> 就是 lambda:
qpaint 里到处都是,必须认识。
先复习一下 Python 的 lambda
lambda 就是"写在一行里的、没有名字的函数"。
def 加一(x):
return x + 1
加一的双胞胎 = lambda x: x + 1 # ← 完全一样的东西
格式: lambda 参数: 返回值
等价于: def 无名(参数): return 返回值
我们要用的是不带参数的那种:
lambda: QRectCreator(qview, "rect")
def 无名():
return QRectCreator(qview, "rect")
为什么用 lambda 而不是 def:因为你只是想把一个函数当值传给别人(坎一那条"函数是值"),
懒得给它起名字。
回到 JS
()=> new QRectCreator(qview, "rect")
lambda: QRectCreator(qview, "rect")
一个不带参数的函数,函数体就是"返回箭头右边那个东西"。 完全等价的老式写法:
function() { return new QRectCreator(qview, "rect") }
(真实 qpaint 用老式写法,qpaint-mini.html 用短的。一模一样的东西。)
带参数的形式:
| JS | Python |
|---|---|
e => c.onmousedown(e) |
lambda e: c.onmousedown(e) |
(a, b) => a + b |
lambda a, b: a + b |
e => { 好几行; 语句 } |
(Python 的 lambda 写不了多行,只能 def) |
最要紧的一处:new 在箭头的哪一边
| 写法 | 意思 |
|---|---|
registerController("RectCreator", ()=> new QRectCreator(...)) |
存一个函数。现在什么都没造,将来调用它才造 |
registerController("RectCreator", new QRectCreator(...)) |
现在就造好一个,把成品存进去 |
差的就是那个 ()=>——前者是"工厂",后者是"实例"(见 2.3,后者会当场坏掉)。
3.3 四个必须知道的 JS 坑
| 坑 | 说明 |
|---|---|
for (let i in arr) 给的是下标,不是值 |
跟 Python 的 for x in list 直觉正好相反。qpaint 满地都是 for (let i in shapes) shapes[i].onpaint(ctx) |
undefined ≠ null |
undefined = 从没赋过值;null = 主动置空。Python 访问不存在的属性会报错,JS 不报错,安静地给你 undefined |
| 字段可以凭空长出来 | o.newField = 1 不报错。后果:字段名打错不会报错,只会读到 undefined |
var 和 let |
var 是老写法,let 是新写法。qpaint 混用,读代码时当成一回事就行 |
3.4 一个可以直接跳过的坎:let ctrl = this
你会在每个 Controller 的构造函数里看到这句怪代码:
let ctrl = this // ← 这是什么?
view.onmousedown = function(event) { ctrl.onmousedown(event) }
原因:JS 的 this 看"谁调用的",不看"写在哪",跟 Python 的 self(显式参数、永不跑丢)不是一回事。
浏览器通过 view.onmousedown(...) 调用时,里面的 this 会变成 view,就调错人了。
所以趁 this 还对,先把它存进一个普通变量。
这是 JS 老代码里最常见的一行,你还会看到
let that = this、var self = this——全是同一件事。
看到就知道"哦,在躲 this 的坑",直接略过即可。它不影响你理解架构。
(Python 没这毛病,所以事件盒子.py里我直接写view.onmousedown = self.按下。)
第四部分 · canvas 是什么
4.1 画布就是一片格子
<canvas width="640" height="420">
这是屏幕上一块 640×420 的小方格,总共 268800 个格子,每格存一个颜色,一开始全是白的。
"画一个矩形"在计算机里没有别的意思,就是:把矩形边框经过的那些格子涂成黑色。
ctx 就是操作这块画布的那套命令。它不让你一个格子一个格子点(那太累),
而是给你一组更接近"画画"的动作——就是下一节那张表。
想看"涂格子"长什么样,跑一下 代码/递一支笔.py 里的 ASCII 笔,
它把格子放大到肉眼能见:
ctx.rect(2, 2, 20, 8) 然后 ctx.stroke()
↓
#####################
# #
# #
#####################
真 canvas 干的是一模一样的事,只不过格子是像素(很小很密),涂的是颜色不是 #。
所以图形类里那个
onpaint(ctx)函数,作用就是:
把这个矩形身上存的四个数字(x / y / 宽 / 高),变成画布上一圈被涂黑的格子。
一句话——把"我是什么"翻译成"涂哪些格子"。
4.2 一支笔的隐喻
let ctx = canvas.getContext("2d") // 拿到一支笔
| 代码 | 在干嘛 |
|---|---|
ctx.lineWidth = 2 |
换一支 2 像素粗的笔 |
ctx.strokeStyle = "red" |
换红墨水 |
ctx.beginPath() |
抬笔,准备走一条新路线 |
ctx.moveTo(10, 10) |
笔尖挪到 (10,10),不落笔 |
ctx.lineTo(60, 50) |
规划一段到 (60,50) 的路线,还是没画 |
ctx.stroke() |
落笔,把刚才规划的路线真的描一遍 |
ctx.clearRect(0,0,w,h) |
把整块画布擦干净 |
beginPath/moveTo/lineTo只是"规划路线",stroke()才真的画。
Python 的turtle(penup/goto/pendown/forward)是同一个隐喻。
坐标系:左上角是 (0,0),x 向右,y 向下(和屏幕一致,和数学课相反)。
4.3 最要紧的一条:画完就忘
ctx.rect(10, 10, 50, 40)
ctx.stroke() // 画了一个矩形
这两行之后,canvas 里只剩一片像素,没有"矩形"这个对象。
- 想问"刚才画的矩形在哪"?——没人知道。
- 想把它移动 10 像素?——只能整块擦掉重画。
- 用户点了一下,想知道点中了哪个图形?——canvas 帮不了你。
所以
dom.js那棵树必须存在:浏览器不替你记着你画了什么,那你只能自己记。对比:如果用 HTML 元素或 SVG 画图,浏览器会替你记着每个图形,你甚至能直接问它"用户点中了哪个"。
选了 canvas,就等于把这些服务全退掉,然后自己重新造一遍。QPaintDoc就是自己造的那一份。
第五部分 · 验收
5.1 逐行读 QLine
dom.js 里最简单的一个类。每一行都能说出它在干什么,第 0 步就通过了。
class QLine {
constructor(point1, point2, style) {
this.pt1 = point1
this.pt2 = point2
this.style = style
}
onpaint(ctx) {
let style = this.style
ctx.lineWidth = style.lineWidth
ctx.strokeStyle = style.lineColor
ctx.beginPath()
ctx.moveTo(this.pt1.x, this.pt1.y)
ctx.lineTo(this.pt2.x, this.pt2.y)
ctx.stroke()
}
}
逻辑一字不差的 Python 版:
class QLine:
def __init__(self, point1, point2, style):
self.pt1 = point1
self.pt2 = point2
self.style = style
def onpaint(self, ctx):
style = self.style
ctx.lineWidth = style.lineWidth
ctx.strokeStyle = style.lineColor
ctx.beginPath()
ctx.moveTo(self.pt1.x, self.pt1.y)
ctx.lineTo(self.pt2.x, self.pt2.y)
ctx.stroke()
JS 和 Python 的差别,在这段代码里只有三处:
thisvsself、花括号 vs 缩进、letvs 什么都不写。
26~30 讲要读的代码,95% 就是这个难度。真正难的从来不是语法,是架构。
顺便:现在你能自己回答"为什么说 Model 层耦合了 GDI 但其实不脏"了——
因为 ctx 是参数传进来的。QLine 从头到尾不知道 canvas 存在,它只知道"有人会给我一支笔"。
5.2 五个自测问题
答不上来的直接说编号,我们把那条掰开再讲。
| # | 问题 |
|---|---|
| 1 | 为什么图形界面的代码写不成"一条主流程",只能写成"一张登记表 + 一堆处理函数"? |
| 2 | view.onmousedown = 矩形工具.按下 这句在干什么?为什么说"一个盒子只能装一个"? |
| 3 | stop() 里写 view.onmousedown = null,是在停什么?不写会出什么事? |
| 4 | 矩形工具身上的 started 和 起点 是干嘛的?为什么这两个字段不能放到 Model 里? |
| 5 | 为什么 canvas 画完一个矩形之后,程序就"不知道"那个矩形在哪了?这件事逼出了 qpaint 的哪个文件? |
5.3 最有用的一招:让程序自己演给你看
读前端代码最高效的方式不是读,是打断点,让它停在那儿,去看每个变量当时是什么。
在 代码/qpaint-mini.html 上练一遍(浏览器双击打开):
| 步骤 | 操作 |
|---|---|
| 1 | 打开开发者工具:Mac Cmd + Option + I(Windows F12) |
| 2 | 点 Sources 面板,左边文件树里找到 qpaint-mini.html,点开 |
| 3 | 找到 QRectCreator 的 onmousedown(e) 那一行,点一下左边的行号——出现蓝色标记,这就是断点 |
| 4 | 回到页面,在画布上按一下鼠标 |
| 5 | 程序停住了,页面变灰 |
| 6 | 看右边 Scope 面板:展开 this,能看到 started、rect、shapeType 当前的真实值 |
| 7 | 按 F10 一行行往下走,看 this.rect.pt1 从 {0,0} 变成你刚才点的坐标 |
建议在三个地方各打一次,正好对应三层:
| 在哪打断点 | 你会亲眼看到 |
|---|---|
QRectCreator.onmousedown |
起点被记进了 Controller 自己,Model 里什么都没发生 |
QPaintView.onpaint |
先 doc.onpaint(已画好的),再 current.onpaint(正在拖的橡皮筋)——两段管线 |
QRectCreator.onmouseup |
doc.addShape(...) 执行的那一瞬间,图形才第一次进入 Model |
第三个尤其值得看:“松手那一刻,数据从 Controller 跳进 Model”,你会亲眼看到它发生。
下一步
第 1 步 · Model 层:带读-01-Model层(dom.js).md
之后:
第 2 步 view.js —— 五个盒子 + 一张登记表 + 一个"擦干净重画"的方法
第 3 步 creator/*.js —— 三台状态机;menu.js 怎么做到不认识任何一个 Controller
第 4~7 27 / 28 / 29 / 30 讲
