读法:第一部分顺着读(一行代码都没有);第二部分是代码,你会发现只是在确认第一部分;第四部分动手。
一个文件,六个类,其中四个长得几乎一样。比你想的简单。
第一部分 · 先建立心智模型
1.1 Model 是「文档」,不是「界面」
接着带读-00 那个输入法的比喻:Model = 你正在写的那篇文档。
想一想你写字的时候,那篇文档里有什么、没有什么:
| 文档里有 | 文档里没有 |
|---|---|
| 已经上屏的字 | 正在打、还没上屏的拼音 zhong |
| 字的内容和顺序 | 光标现在在第几行 |
| 每个字的字体字号 | 你现在用拼音还是五笔 |
右边那列全是"此刻的状态",关掉重开就没了;左边那列是"文档本身",存盘、发给别人、换台机器打开都还在。
dom.js 就是左边那列:
| 画图程序的 Model 里有 | 没有 |
|---|---|
| 已经画完的图形(坐标、大小、颜色) | 正在拖、还没松手的那个矩形 |
| 图形的先后顺序 | 当前选中了哪个 · 当前用什么工具 · 调色板选的什么颜色 |
一句话:
dom.js里没有一行跟界面有关的东西。它不知道有按钮、有鼠标、有菜单,
甚至不知道 canvas 存在。它只回答一个问题:一张图,是由什么组成的。
一个比喻:会议室、白板、和你手里那本笔记
会议室(= 页面)
├── 一张桌子
├── 六个按钮(墙上的开关)
└── 一块白板 ← 这就是 canvas,一块【空白的板】
| 东西 | 代码里是什么 |
|---|---|
| 会议室的家具清单:桌子✓ 按钮✓ 白板✓ (只写"有一块白板",没写白板上画了什么) |
document |
| 你手里那本笔记:白板上现在有 ①矩形(10,10,50×40,黑) ②线(20,20)→(80,60) ③圆… | qview.doc |
为什么白板上明明画着,还要另外记个本子? 因为白板上只有墨迹,没有"东西":
| 你想干的事 | 光靠白板 | 有笔记本 |
|---|---|---|
| 「第 2 条线在哪?」 | 答不出来,那只是一片墨迹 | 翻本子,第 2 条 |
| 「把矩形往右挪 10 厘米」 | 做不到,只能整块擦掉重画 | 改本子上的坐标,擦掉,照本子重画 |
| 有人把白板擦了 | 内容永久没了 | 照本子重画一遍,全回来 |
invalidateRect干的就是最后这件事:擦干净整块白板,照着笔记本从头画一遍。
你拖鼠标画矩形时,这个"擦掉 + 照本子重画"每秒发生几十次。
换成"磁贴板"就不用记本子了:
| 白板(canvas) | 磁贴板(SVG / HTML 元素) | |
|---|---|---|
| 你放上去的是 | 墨迹,抹上去就化进板子了 | 一块块磁贴,能拿起来、挪位置 |
| 家具清单上会写 | 只有「一块白板」 | 「白板 + 上面 3 块磁贴」 |
| 问「矩形在哪」 | 板子答不出来 | 浏览器直接告诉你 |
| 要不要自己记本子 | 必须记 | 不用 |
qpaint 选的是白板,所以必须自己记一本——那本笔记就是 dom.js 造出来的东西。
⚠️ dom.js 和浏览器的 document 没关系
名字容易撞车。是两棵完全不同的树:
document(浏览器的 DOM) |
dom.js 造出来的那棵树 |
|
|---|---|---|
| 谁造的 | 浏览器,从 HTML 解析出来 | 作者自己写的代码 |
| 里面装的是 | 界面元素:按钮、canvas、div | 文档内容:矩形、线、椭圆 |
| 回答什么 | 「页面上有哪些控件」 | 「这张图里有哪些图形」 |
| 换个平台还在吗 | 不在,只有浏览器有 | 在,纯数据 + 纯逻辑 |
你画了 500 个图形,在浏览器眼里页面上依然只有一个 <canvas> 元素,它完全不知道里面有什么。
那作者为什么也叫它 “dom”?因为 DOM = Document Object Model =「文档对象模型」,是个通用名词,
指"把文档表示成一棵对象树"这种做法。浏览器只是最有名的用法,不是唯一的。
dom.js = 数据模型,但它带行为
dom.js(一个文件) ← 图纸:QPaintDoc / QRect / QLine … 这些【类】的定义
↓ 运行时
qview.doc(一个实例) ← 数据本体:真正装着这张图的那个【对象】
“数据模型"这个词容易让人只想到"字段”,而这里的 Model 是带行为的:
矩形 = {"x":10, "y":10, "宽":50, "高":40} # ← 只有数据,【不是】这里说的 Model
class QRect:
def __init__(...): self.x = ... # 数据
def onpaint(self, ctx): ... # 行为:自己画自己 ← 关键在这
接着白板的比喻:笔记本上每条记录,不只写着"我是什么",还写着"怎么把我画出来"。
22 讲给了 Model 的三个要素,对一下 26 讲这版:
| 要素 | 26 讲这一版 |
|---|---|
| 有结构(是列表/树,不是一坨散数据) | ✓ _shapes 有顺序 |
| 有行为(有方法,不只有字段) | ✓ onpaint / addShape |
| 有身份(能指着说"就是它") | ✗ 还没有 ID |
第三条留个记号:单机版不需要 ID——你想改哪个矩形,指针指着它就行。
但一联网就说不清了:“我把那个矩形改了”——哪个?"身份"是 28 讲被逼出来的。
1.2 一张图在程序眼里 = 一叠透明片
┌─────────────┐ ← 后画的在上面
│ ○ │ 第 3 张:一个圆
├─────────────┤
│ ╱ │ 第 2 张:一条线
├─────────────┤
│ ┌───┐ │ 第 1 张:一个矩形
└──┴───┴──────┘ ← 先画的在下面
所以 Model 的主体就是一个有顺序的列表:_shapes = [矩形, 线, 圆]。
顺序有意义,它就是「图层」:
| 现象 | 为什么 |
|---|---|
| 新画的图形总在最上面 | addShape 加到末尾 → 最后画 |
| 「置于顶层 / 底层」 | 把它在数组里挪到末尾 / 开头 |
| 两个图形重叠时谁挡谁 | 完全由数组里的先后决定 |
画图软件里的「图层顺序」,在 Model 里就是数组元素换位置,没有别的机关。
QPaintDoc 干的事只有两件:加一张片(addShape)、从下往上把每张片都画一遍(onpaint)。
1.3 最重要的一条:图形自己画自己
一叠透明片要画到纸上,有两种办法:
办法 A(qpaint 用的):把笔递过去
走到每张片前面:"给你笔,你自己画"
→ 我【不需要知道】它们分别是矩形、线、还是圆
办法 B:我自己拿着笔画
"看看这是什么?哦是矩形 → 那我按画矩形的方法画"
→ 我【必须认识每一种】,还得会画每一种
差别在来一种新图形的时候:
# 办法 B —— 那串判断会随着图形种类一起变长
for 片 in 一叠透明片:
if 是矩形(片): 画矩形(笔, 片)
elif 是线(片): 画线(笔, 片)
elif 是圆(片): 画圆(笔, 片)
# ← 每加一种图形,这里就得回来加一行
# 办法 A —— 永远是这两行
for 片 in 一叠透明片:
片.自己画(笔)
# ← 加一百种图形,一个字都不用改
这就叫"多态":同一句调用,不同的对象干不同的事。
26 讲说"让 Model 层自己来绘制",最实在的好处就是——
把一个会不断变长的if/elif,换成了一句永远不用改的调用。想看现象:代码/自己画自己.py。4.1 会让你亲手验一遍。
⚠️ 别混淆:「诞生」和「显示」是两回事
"自己画自己"说的是显示,不是创建。
轴一 · 诞生(Controller 的活,一次性)
点菜单 → 按下记起点 → 拖 → 松开记终点 → new QRect(...) → addShape 进 Model
↑ 到这儿 Controller 的活就干完了
轴二 · 显示(Model 的活,反复无数次)
每次需要重画:擦掉整块画布 → for 每个图形: 图形.onpaint(笔) ← 说的是【这一步】
次数差得很远。 因为 canvas 是"画完就忘"的,只要屏幕上要显示,就得把所有图形从头重画一遍。
你画一个矩形,从按下到松开拖了 100 次鼠标:
| 动作 | 几次 |
|---|---|
addShape(诞生) |
1 次 |
| 整块画布被重画 | 100 多次 |
每个已有图形被叫 onpaint |
各 100 多次 |
诞生 1 次,画 N 次。 而且你画第二个矩形时,第一个还在被反复画。
所以图形不认识 Controller:你用矩形工具画完切去用折线工具,那个
QRectCreator
已经被扔了,但那个矩形还在被一遍遍地画。s.onpaint(ctx)跟"谁创建的 s"完全无关。
1.4 什么该住在 Model 里:三个一句话测试
| 问题 | 答"该"的东西住在 | qpaint 里是谁 |
|---|---|---|
| 换台机器打开同一份图,它该跟过去吗? | Model(dom.js) |
图形的坐标、大小、线宽、颜色 |
| 开两个窗口看同一份图,它该各有一份吗? | ViewModel(第 2 步) | 当前线宽/颜色、当前工具、当前选中 |
| 松开鼠标之后,它该消失吗? | Controller(第 3 步) | 拖到一半的起点、已经点了几个点 |
三个问题是同一件事的三个维度:跨设备(存盘)、跨视图(呈现)、跨事件(过程)。
dom.js里一个字都没有"当前"什么什么——这不是漏了,是划清了界线。
第二部分 · 落到代码:六个类
心智模型有了,看代码基本上只是确认。
2.1 骨架
QPaintDoc ← 一张图(根)
└── _shapes: [ ... ] ← 那一叠透明片,有顺序
├── QLine 线段
├── QRect 矩形
├── QEllipse 椭圆
└── QPath 折线
QLineStyle ← 线宽 + 颜色。每个图形身上都挂着一份
| 组 | 谁 | 干什么 |
|---|---|---|
| 图形(4 个) | QLine · QRect · QEllipse · QPath | 记住自己的几何数据 + 自己把自己画出来 |
| 容器与配件(2 个) | QPaintDoc · QLineStyle | 一叠透明片;一份样式 |
2.2 QLineStyle:热身
class QLineStyle {
constructor(lineWidth, lineColor) {
this.lineWidth = lineWidth
this.lineColor = lineColor
}
}
就是个数据包,装两个值,没有任何方法。单独做个类是因为:四种图形都要用;
它会长大(后面版本多了 fillColor,加字段只改这一个类);"样式"本来就是独立概念。
2.3 QLine:一个图形长什么样
(带读-00 · 5.1 已经逐行读过,这里只回顾结论。)
class QLine {
constructor(pt1, pt2, lineStyle) {
this.pt1 = pt1 // 起点 {x, y}
this.pt2 = pt2 // 终点 {x, y}
this.lineStyle = lineStyle // 自己的那份样式
}
onpaint(ctx) { // ctx 是一支笔,【别人递进来的】
ctx.lineWidth = this.lineStyle.lineWidth
ctx.strokeStyle = this.lineStyle.lineColor
ctx.beginPath()
ctx.moveTo(this.pt1.x, this.pt1.y)
ctx.lineTo(this.pt2.x, this.pt2.y)
ctx.stroke()
}
}
每个图形类都是这个形状,两件事:
constructor → 记住自己的几何数据
onpaint → 拿别人递来的笔,把自己画出来 ← 就是 1.3 的"办法 A"
2.4 另外三个:换汤不换药
结构完全一样,只有中间那一句画法不同:
| 类 | 记住什么 | 画的时候用哪几句 |
|---|---|---|
QLine |
pt1 pt2 |
moveTo → lineTo |
QRect |
x y width height |
rect(x, y, w, h) |
QEllipse |
x y radiusX radiusY |
ellipse(x, y, rx, ry, ...) |
QPath |
points[] close |
moveTo → 一串 lineTo(close 为真再 closePath) |
class QRect {
onpaint(ctx) {
ctx.lineWidth = this.lineStyle.lineWidth
ctx.strokeStyle = this.lineStyle.lineColor
ctx.beginPath()
ctx.rect(this.x, this.y, this.width, this.height) // ← 只有这一句不一样
ctx.stroke()
}
}
四个类、四份几乎一样的代码——这不是"重复",这正是下一节那句调用能成立的前提。
因为它们都有一个叫onpaint的方法,别人才能不管三七二十一地叫它。
2.5 QPaintDoc:整个 Model 层的核心,就三行
class QPaintDoc:
def __init__(self): self._shapes = [] # ① 一叠空的透明片
def addShape(self, shape): self._shapes.append(shape) # ② 加一张
def onpaint(self, ctx):
for s in self._shapes:
s.onpaint(ctx) # ③ 把笔递给每一张
第 ③ 行就是 1.3 那个"办法 A"。 它不问 s 是矩形还是圆,只说一句:“给你笔,你自己画。”
26 讲把它叫"一棵 DOM 树",这版其实还是一层的列表,没有嵌套——够用就行。
2.6 为什么是 _shapes 不是 shapes
那个下划线是一句约定俗成的话:「这是内部的,别从外面直接动」(JS 和 Python 都用这个约定,靠自觉)。
doc.addShape(矩形) # ✓ 走门
doc._shapes.append(矩形) # ✗ 能跑,但你在翻窗
一处要修正:我查了作者仓库的
v26分支(26 讲那一版的真实代码),
那时候写的其实是this.shapes,没有下划线;_shapes是他到 v28(28 讲)才改的。
——这个改动本身正好印证了这一节要讲的道理,所以下面照_shapes讲。
两句效果一样,差别在意思:
| 表达的是 | |
|---|---|
addShape(矩形) |
一个业务动作:往这张图里加一个图形 |
_shapes.append(矩形) |
一个数据结构操作:往数组末尾塞个元素 |
为什么这个区别重要:_shapes 将来可能不是数组了。加了图层分组它会变成一棵真正的树;
加了服务端同步(28~30 讲)之后,addShape 里还要顺便生成 ID、记一笔变更、通知服务端。
只要大家都走
addShape这扇门,这些改动都能塞进门里,外面一处不用改。
满程序都是_shapes.push(...)的话,这些事没有一个地方可以做。这正是 22 讲那条"Model 层的接口要自然体现业务需求"最小的例子:
门要开成业务动作的样子,不要开成数据结构的样子。
2.7 这个 Model 只能加,不能删、不能改
QPaintDoc 的完整接口一共两个方法:addShape 和 onpaint。
没有删除、没有修改、没有撤销、没有存盘。这不是偷懒。
接口的贫瘠,如实反映了需求的贫瘠。
26 讲这版根本没有"选中"这个概念——用户没办法指着某个图形说"就是它"。
既然指不出来,“删掉它”"改它的颜色"这些接口就算写了也没人能调。
把真实仓库里 QPaintDoc 最终的方法列表拉出来,整个 26~30 的剧情就摆在眼前:
| 方法 | 哪一讲加的 | 被什么需求逼出来 |
|---|---|---|
addShape · onpaint |
26 | 能画图 |
hitTest(pt) |
27 | 要能选中 → 得先知道"这一点点中了谁" |
deleteShape |
27 | 能选中了,才谈得上删 |
每个图形有了 id |
28 | 要告诉服务端"我改的是那个图形" |
toJSON |
28/29 | 要把图变成能在网上传的文本 |
reload / _load… |
29 | 要能从服务端把图取回来 |
prepareSync |
30 | 多人同改,要算出"我这边变了什么"再发出去 |
读后面几讲时可以一直拿这张表对照:这一讲新加的方法,是被什么逼出来的?
第三部分 · 两个设计决定
3.1 ctx 为什么是参数传进来的
先搞清楚 ctx 是什么
画布就是一片格子。 <canvas width="640" height="420"> = 屏幕上 640×420 个小方格,
每格存一个颜色,一开始全是白的。
"画一个矩形"在计算机里没有别的意思,就是:把矩形边框经过的那些格子涂成黑色。
ctx 就是"操作这块画布的那套命令":
| 这一句 | 干了什么 |
|---|---|
ctx.strokeStyle = "black" · ctx.lineWidth = 1 |
接下来涂黑色、涂 1 格宽 |
ctx.beginPath() · ctx.rect(10,10,50,40) |
规划一条路线(一个格子都还没涂) |
ctx.stroke() |
动手涂:把这条路线经过的格子涂黑 |
想看"涂格子"长什么样,跑 代码/递一支笔.py 里的 ASCII 笔,它把格子放大到肉眼能见:
ctx.rect(2, 2, 20, 8) 然后 ctx.stroke()
↓
#####################
# #
#####################
所以 onpaint 的作用是:把矩形身上那四个数字(x/y/宽/高),变成画布上一圈被涂黑的格子。
一句话——把"我是什么"翻译成"涂哪些格子"。
「取笔」是两行真代码,问题是它写在哪儿
let 画布 = document.getElementById("drawing") // ① 找到页面上那块 canvas
let ctx = 画布.getContext("2d") // ② 拿到操作【这块画布】的命令集
ctx 是从某一块具体的画布上取出来的:取自哪块,就涂哪块的格子。
# 写法 A(自己取)
for 每个图形:
图形.onpaint()
取笔 ← 500 个图形,取 500 次
涂格子
# 写法 B(qpaint:递下去)
取笔 ← 只取 1 次
for 每个图形:
图形.onpaint(笔)
涂格子
两边的 for 循环一模一样,涂格子的顺序一模一样,画出来的结果一模一样。
唯一的差别是「取笔」那一行在循环里面还是外面。就这么普通的一件事:循环里每次都一样的东西,提到循环外面去。
"递下去"是字面意思,那支笔真的一层层传下来:
QPaintView.invalidateRect()
ctx = 画布.getContext("2d") ← 【取笔】整个程序只在这儿取
doc.onpaint(ctx) ────┐
QPaintDoc.onpaint(ctx) ↓
for s: s.onpaint(ctx) ┐
QRect.onpaint(ctx) ↓
ctx.rect(...) ← 收到就用,从不问这笔哪来的
挪出去的四个好处(按有多实在排)
| # | 好处 | 说明 |
|---|---|---|
| ① | 那个名字只写一次 | 写法 A 里 "drawing" 要在四个图形类各写一遍。改了 html 的 id,四处全崩,而且不报错,就是不显示 |
| ② | 图形类不再需要浏览器 | 写法 A 用了 document → 只能在浏览器跑。写法 B 让它们变成纯"数据 + 算法"。具体兑现就是能写测试——qpaint-mini 的 9 项测试全在 Node 里跑 |
| ③ | 省掉重复的查找 | 500 图形 × 60 帧 = 每秒找 30000 次同一块画布。不是性能灾难,但是纯粹的浪费 |
| ④ | 决定权归调用者 | "画到哪块画布上"是调用者的决定,不是矩形的决定。 矩形只知道自己长什么样 |
打个日常的比方——你有 10 页文件要打印:
| 做法 | |
|---|---|
| 写法 A | 每一页自己跑去找打印机、连上、打、断开——连 10 次,而且每页上写死了"打到 3 楼那台" |
| 写法 B | 你连一次打印机,把 10 页依次送进去。临时改主意打到 PDF,那 10 页一个字不用改 |
顺带白拿的:能递一支别的笔
代码/递一支笔.py 里同一批图形递了三支完全不同的笔:
| 笔 | 干什么 | 用途 |
|---|---|---|
| 记账笔 | 一个格子都不涂,只把"被叫了什么"记下来 | 自动化测试 |
| 说话笔 | 念成人话:“一个黑色的矩形,左上角(2,2),20宽8高” | 无障碍朗读 |
| ASCII 笔 | 真的在终端里把图形画出来 | 换一个渲染目标 |
QLine、QRect、QPaintDoc 的代码一个字没改,它们从头到尾不知道自己在给谁画。
但要诚实:在 qpaint 里,屏幕之外的笔基本只用在测试上。
这个能力是白送的,不是为它专门设计的——写法 B 本来就更短更省事,路顺带没堵死而已。判断一个设计好不好,不是看"这样更灵活",而是看"这样是不是更省事、而且顺手没把路堵死"。
3.2 为什么每个图形自带一份样式
QLine / QRect 都有自己的 this.lineStyle——是一份拷贝,不是共用一个全局的。
如果共用会怎样:你画了十个黑矩形,然后把调色板改成红色——十个矩形会一起变红。
所以创建图形的那一刻要 clone() 一份:
"创建一个图形"本质上是一次快照:把 ViewModel 里"用户此刻的选择"(会变),
固化成 Model 里"这一笔的属性"(不该再变)。
这个瞬间在所有编辑器里都存在:Word 里你设好字体再打字、PS 里你选好画笔再落笔——
设置活在 ViewModel,落下去的那一笔活在 Model。
想看现象:
qpaint-mini.html文件末尾的实验 ②,把clone拆掉,改一次颜色所有旧图形集体变色。
第四部分 · 验收
4.1 动手:给它加一种新图形
目标:亲手证明"加一种图形,QPaintDoc 一行都不用改"(1.3 那条)。
- 浏览器打开 代码/qpaint-mini.html
Cmd + Option + I(Windows:F12),点 Console 面板- 粘这段,回车:
class QTriangle {
constructor(p1, p2, p3, lineStyle) {
this.p1 = p1; this.p2 = p2; this.p3 = p3
this.lineStyle = lineStyle
}
onpaint(ctx) {
ctx.lineWidth = this.lineStyle.lineWidth
ctx.strokeStyle = this.lineStyle.lineColor
ctx.beginPath()
ctx.moveTo(this.p1.x, this.p1.y)
ctx.lineTo(this.p2.x, this.p2.y)
ctx.lineTo(this.p3.x, this.p3.y)
ctx.closePath()
ctx.stroke()
}
}
- 再粘这两句,回车:
qview.doc.addShape(new QTriangle({x:300,y:80}, {x:380,y:220}, {x:220,y:220}, new QLineStyle(3, "#cc0000")))
qview.invalidateRect(null); dump()
画布上出现一个红色三角形。 停下来想一下你刚才做了什么:
- 你没有改
QPaintDoc一个字; - 你没有加任何按钮、菜单、Controller;
- 你只是新做了一张"会自己画自己"的透明片,摞了上去。
s.onpaint(ctx)那句代码根本不知道世上有三角形,但它照样把它画出来了。
(class QTriangle 只能粘一次,想重来就刷新页面。右边 Model 面板的数字会 +1。)
4.2 四个自测问题
不是考试,是定位用的——答不上来说明对应那节没读透,直接说编号。
| # | 问题 | 答案在 |
|---|---|---|
| 1 | onpaint(ctx) 里那支笔,是 QLine 自己去页面上找的,还是别人递进来的?这样做的好处是什么? |
3.1 |
| 2 | QPaintDoc.onpaint 那句 s.onpaint(ctx),为什么不用先判断 s 是矩形还是圆?换成"办法 B",加一种新图形要多改什么? |
1.3 |
| 3 | 你画了 5 个矩形,_shapes 里躺着什么?这时把调色板改成红色,那 5 个会变红吗?为什么? |
3.2 |
| 4 | 下面四样各该住哪一层:① 已画好矩形的坐标 ② 当前选中了哪个图形 ③ 拖到一半还没松手的起点 ④ 调色板当前的颜色 | 1.4 |
答案
1. 别人递进来的。好处按实在程度排:① "drawing" 这个名字只写一次(写法 A 要在四个图形类各写一遍,改 html 的 id 就四处全崩);② 图形类不再需要 document,于是能脱离浏览器测试;③ 省掉每帧几万次重复查找;④ "画到哪块画布"是调用者的决定,不是矩形的决定。
说白了就是把"取笔"那一行从循环里提到循环外——结果完全一样,只是位置不同。
2. 因为画法写在每个图形自己身上。调用 s.onpaint(ctx) 时语言会自动找到 s 那一份,这就是多态。
换成办法 B,那个函数里就得有一串 if/elif,每加一种图形都要回去加一行;办法 A 加一百种也一个字不改。
3. _shapes 里躺着 5 个 QRect 实例,每个挂着自己那一份 QLineStyle。
不会变红——创建时 clone() 了一份快照。调色板改的是 ViewModel 里"用户此刻的选择",跟已经落在纸上的那一笔无关。
4.
| 东西 | 换机器该跟过去吗 | 开两窗口该各一份吗 | 松手该消失吗 | 住在 |
|---|---|---|---|---|
| ① 已画好矩形的坐标 | 该 | — | — | Model |
| ② 当前选中了哪个图形 | 不该 | 该 | — | ViewModel |
| ③ 拖到一半的起点 | 不该 | — | 该 | Controller |
| ④ 调色板当前的颜色 | 不该 | 该 | 不该 | ViewModel |
②④ 是同一类:都在回答"这个用户此刻怎么看这份文档"。
③ 特殊在它连"此刻怎么看"都不是,它是"这一次操作做到了哪一步"。
下一步
第 2 步 · 一次完整的画矩形:带读-02-一次完整的画矩形.md
跟着一次真实操作走完整条链(点按钮 → 按下 → 拖 → 松手),
顺带把 view.js(ViewModel)和 creator/rect.js(Controller)串起来读。
