一个让人安心的数字:26 讲那一版(v26)全部代码只有 593 行,其中核心的
dom.js只有 109 行。
不是那种读不完的大工程。
一 · 源码在哪:每一讲的代码放在自己章节下
每一讲的代码,就在那一讲的目录里。
26-实战(一)…/代码/
├── qpaint-mini.html 我改写的单文件版(带右侧面板,做实验用)
├── 事件盒子.py · 自己画自己.py · 工厂和实例.py · 递一支笔.py
└── qpaint源码/v26/ ← 26 讲那一版(593 行)
27-实战(二)…/代码/qpaint源码/
├── v27/ ← 加了选择工具(952 行)
├── diff-dom-v26-v27.txt ← 27 讲给 Model 加了什么(314 行)
└── diff-view-v26-v27.txt ← 给 ViewModel 加了什么(75 行,适合练手)
28-实战(三)…/代码/
├── 两个ID.py localID / displayID 的可跑演示
└── qpaint源码/
├── v28/ ← 加了离线持久化
├── diff-dom-v27-v28.txt
└── diff-view-v27-v28.txt (17 行)
29-实战(四)…/代码/qpaint源码/
├── v29/ ← 浏览器端【与 v28 完全相同】
├── v29-服务端/ ← 【新增】paintdom 的 Go 代码
│ shape.go · drawing.go · service.go · main.go + 两个测试
└── diff-dom-v28-v29.txt ← 【空文件】——浏览器端一个字没改
31-辅助界面元素…/代码/qpaint源码/
├── v31/ ← 控件化(+ controls/ 四个文件)
├── diff-dom-v30-v31.txt ← 【0 字节】——Model 层一行没改
├── diff-view-v30-v31.txt ← 多例支持
└── diff-menu-v30-v31.txt ← 菜单【反而少了 36 行】
30-实战(五)…/代码/qpaint源码/
├── v30/ ← dom.js 856 行
├── v30-服务端/ ← 多了一条 /sync
├── diff-dom-v29-v30.txt ← 746 行,最大的一次变更
├── diff-view-v29-v30.txt ← 【只有 22 行】
└── diff-service-v29-v30.txt ← 服务端就多了一条路由
五个版本的行数
| 文件 | 层 | v26 | v27 | v28 | v29 | v30 | 倍数 |
|---|---|---|---|---|---|---|---|
dom.js |
Model | 109 | 306 | 497 | 497 | 856 | ×7.9 |
view.js |
ViewModel | 112 | 125 | 131 | 131 | 133 | ×1.2 |
| 三个原有 Creator | Controller | 269 | 256 | 256 | 256 | 256 | ×0.95 |
服务端 *.go |
Model+Ctrl | — | — | — | 657 | 725 | 新增 |
需求的重量几乎全压在 Model 上,上面两层几乎没被打扰。
展开见 带读-07 · 五。
二 · ⚠️ 先说两处跟带读里不一样的地方
这两处会让你对不上,先讲清楚:
| 真实的 v26~v28 | 带读里 / qpaint-mini.html |
|
|---|---|---|
| View 怎么拿到 | 一个全局变量 qview(view.js 最后一行 var qview = new QPaintView())。Controller 直接用它: qview.doc.addShape(...) |
我把 view 当参数传进 Controller:new QRectCreator(qview, "rect") |
| Controller 的构造函数 | constructor(shapeType)——不收 view |
constructor(view, shapeType) |
为什么会有这个差异:是 v31(31 讲)为了把画图程序做成控件才改的——
一个页面要能放多个画布,全局的 qview 就不够用了。详见
带读-09 · 五。
我写 qpaint-mini.html 时照的是 v31 之后那版。
不影响理解:
qview是全局变量还是参数,只是"Controller 怎么拿到 View"的写法差别,
机制完全一样。读真实源码时,看到光秃秃的qview.xxx就知道那是全局的那个 View。
另外,v26 里 QPaintDoc 的字段叫 this.shapes(没有下划线),_shapes 是 v28 才改的。
三 · 推荐阅读顺序
先把最小的 v26 完整读一遍,再看 diff。 这样你就有了一个"完整的东西"打底。
| 顺序 | 文件 | 行数 | 读它是为了 | 对应带读 |
|---|---|---|---|---|
| 1 | 26 讲/…/v26/dom.js |
109 | Model 长什么样。一口气能读完 | 带读-01 |
| 2 | 26 讲/…/v26/index.htm |
17 | 组装根。最短的文件,先看它有全局感 | 带读-03 · 2.2 |
| 3 | 26 讲/…/v26/view.js |
112 | ViewModel:五个盒子 + 花名册 + 重画 | 带读-02 |
| 4 | 26 讲/…/v26/creator/rect.js |
108 | 一台 Controller 状态机 | 带读-02 |
| 5 | 26 讲/…/v26/accel/menu.js |
86 | 菜单也是 Controller | 带读-03 · 2.1 |
| ↑ 到这里 26 讲的代码你就全读完了(432 行) | ||||
| 6 | 27 讲/…/diff-dom-v26-v27.txt |
— | 27 讲给 Model 加了什么 | 带读-04 |
| 7 | 27 讲/…/v27/accel/select.js |
91 | 新增的选择工具(第四台状态机) | 同上 |
| 8 | 28 讲/…/diff-dom-v27-v28.txt |
— | 28 讲给 Model 加了什么 | 带读-05 |
| 9 | 29 讲/…/v29-服务端/service.go |
247 | 服务端的路由表(只看开头那七行) | 带读-06 |
| 10 | 30 讲/…/diff-dom-v29-v30.txt |
— | 30 讲:同步器 + QSerializer | 带读-07 |
四 · 结构地图(行号是真的,可以直接跳)
v26/dom.js —— 109 行,Model
1 class QLineStyle 线宽 + 颜色。就两个字段
8 class QLine ┐
26 class QRect │ 四个图形类,结构一模一样:
45 class QEllipse │ constructor 记住几何数据
64 class QPath ┘ onpaint 拿别人递来的笔,自己画自己
92 class QPaintDoc 根:一个数组 + addShape + onpaint
93 constructor this.shapes = []
97 addShape push 进数组
103 onpaint for 每个图形: 它自己画 ← 【整个 Model 的核心就这两行】
读法:先看 92~109(QPaintDoc,17 行),再回头看 8~25(QLine),
其余三个图形类扫一眼就行——换汤不换药。
v26/index.htm —— 17 行,组装根
<canvas id="drawing"> 那块画布
<div id="menu"> 菜单要塞进来的地方
<script src="dom.js"> ┐
<script src="view.js"> │ 这份列表 = "这个程序由哪些零件构成"
<script src="creator/…"> │ 删掉一行 = 删掉一个功能
<script src="accel/menu.js"> ┘
v26/view.js —— 112 行,ViewModel
1 class QPaintView
2 constructor ← 【最长的一段,2~51】做四件事:
造空 Model、五个空盒子、空花名册、
把 canvas 和 document 的事件接过来转交
52 get currentKey 当前工具的名字
55 get lineStyle 当前样式的【快照】(每次 new 一份)
60 onpaint ① doc.onpaint ② 当前 Controller 的 onpaint(橡皮筋)
67 getMousePos 屏幕坐标 → 画布坐标
74 invalidateRect 取笔 → 全屏擦 → 全部重画
81 registerController 往花名册里记一条(存的是【工厂】)
88 invokeController 先 stop 旧的 → 查花名册 → 工厂() 造新的
95 stopController 让当前 Controller 自己 stop()
102 _setCurrent
108 var qview = new QPaintView() ← 全局的那个 View 在这儿诞生
v26/creator/rect.js —— 108 行,一台状态机
2 function normalizeRect 往左上角拖时宽高会是负的,规整一下
18 class QRectCreator
19 constructor 填字段 + 【抢走四个事件盒子】
32 stop 【把四个盒子还回去】(=null)
39 reset 清空自己 + 要求重画
43 buildShape 按 shapeType 造出 QLine/QRect/QEllipse/QPath
64 onmousedown 记起点,started = true
68 onmousemove 更新终点 → invalidateRect
74 onmouseup addShape ★ 图形诞生 → reset
81 onkeydown ESC → reset
87 onpaint buildShape().onpaint(ctx) ← 【橡皮筋,造→画→扔】
这个文件是"Controller = 被翻过来的 workflow"最好的样本,109 行里状态机的五个动作齐全。
v27/dom.js —— 306 行(27 讲:加选择)
4 hitLine / hitRect / normalizeRect / fill / deleteItem 几个工具函数(数学,可跳)
61 class QShapeStyle ← QLineStyle 改名而来,多了 fillColor + setProp + clone
75 class QLine ┐
112 class QRect │ 每个图形【多了四个方法】:
149 class QEllipse │ bound() 我占哪块地方 ← 画选中框要用
195 class QPath ┘ hitTest() 这一点在我身上吗 ← 点选要用
move() 把我挪一挪 ← 拖动要用
setProp() 改我的某个样式 ← 改颜色要用
275 class QPaintDoc
280 addShape
285 deleteShape ← 新增
288 hitTest ← 新增,【倒着遍历】,命中就返回
300 onpaint
读法:直接跳到 112~148 看 QRect(37 行),四个新方法一眼看完,其余三个图形同理。
v27/accel/select.js —— 91 行(新增的选择工具)
1 class QShapeSelector
2 constructor 抢四个盒子(跟 QRectCreator 一样)
12 stop 还盒子 + 把鼠标光标恢复成默认
20 reset
25 onmousedown doc.hitTest(pt) → 设 view.selection
34 onmousemove 按住时:更新偏移;没按住时:悬停在图形上就把光标变"移动"手势
48 onmouseup 坐标变了 → selection.move(dx,dy) ★ 这一刻 Model 才真的变
58 onkeydown Delete → doc.deleteShape;ESC → reset
70 onpaint 画那个【灰色虚线选中框】(用 selection.bound())
v28/dom.js —— 497 行(28 讲:加离线持久化)
新增的全是 70~161 这一段(存储层)和 QPaintDoc 里的几个 _ 方法,图形类只各加了三行。
5 hitLine … deleteItem 和 v27 一样,没动
70 _getNextID "号发到几号了",从 10000 开始
81 _makeLocalDrawingID 给新文档发一个 localID
87 removeSomeCache 【localStorage 满了,淘汰最早的一篇文档】
105 localStorage_setItem ★【全程序写盘唯一的口子】——满了就淘汰再重试
116 loadDrawing 读一篇文档的 JSON
121 documentChanged ★ 文档级变更 → 重写 "dg:localID"
128 loadShape ★【反序列化】:看 type 字段决定 new 哪个类(那个 switch)
151 shapeChanged ★ 图形级变更 → 重写 "localID:shapeID"
163 class QShapeStyle 没动
177 class QLine … 四个图形类【各只加三行】:
constructor 加 this.id = ""
move / setProp 末尾加 shapeChanged(this)
391 class QPaintDoc
396 _reset 清空 + localID/displayID 置空
402 _load 从 localStorage 把整篇文档读回来
422 _stringify 文档序列化——【只存 shapeID 列表,不存图形内容】
434 _initShape 给新图形发一个 id
445 init ★【读 URL 里的 #t10001;没有就新发一个 ID 并改 URL】
461 reload
466 addShape 发 ID → shapeChanged → documentChanged
473 deleteShape 只发 documentChanged
读法:先看 466~476(addShape/deleteShape,11 行),
再看 121~127 和 151~161(那两个 xxxChanged),28 讲的核心就这 20 行。
其余(init、_load、_stringify)是围绕它们的配套。
五 · 怎么看那几个 diff 文件
- 这一行是【旧版】的 (v26/v27)
+ 这一行是【新版】的 (v27/v28)
前面没符号的是没变、用来定位的上下文
@@ -92,7 +275,12 @@ 表示:旧文件第 92 行起 7 行,对应新文件第 275 行起 12 行
建议先看 diff-view-v26-v27.txt(很短,十几行) 练手,再看 diff-dom-*(长)。
六 · 能跑起来吗
v26 / v27 直接用浏览器打开 index.htm 就能跑(它们不依赖服务端)。
v28 打开也能跑,而且会自动跳到 #t10001,画的东西刷新后还在——
这正是 28 讲要的效果,可以直接验。
想看 localStorage 里存了什么:
Cmd+Option+I→ Application 面板 → 左边 Local Storage。
你会看到dg:10001、10001:1、10001:2这些 key,跟带读-05 讲的一模一样。
(qpaint-mini.html 是我按 26 讲那一版改写的单文件版,加了右侧面板,用来做实验更方便;
想读"作者原本怎么写的",看这里的 v26/。)
