加载中...

读法:第一部分顺着读(一行代码都没有);第二部分是代码,你会发现只是在确认第一部分;第四部分动手。

一个文件,六个类,其中四个长得几乎一样。比你想的简单。


第一部分 · 先建立心智模型

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,换成了一句永远不用改的调用。

想看现象:代码/自己画自己.py4.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 里是谁
换台机器打开同一份图,它该跟过去吗 Modeldom.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 moveTolineTo
QRect x y width height rect(x, y, w, h)
QEllipse x y radiusX radiusY ellipse(x, y, rx, ry, ...)
QPath points[] close moveTo → 一串 lineToclose 为真再 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 的完整接口一共两个方法addShapeonpaint
没有删除、没有修改、没有撤销、没有存盘。这不是偷懒。

接口的贫瘠,如实反映了需求的贫瘠。
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 笔 真的在终端里把图形画出来 换一个渲染目标

QLineQRectQPaintDoc 的代码一个字没改,它们从头到尾不知道自己在给谁画。

但要诚实:在 qpaint 里,屏幕之外的笔基本只用在测试上。
这个能力是白送的,不是为它专门设计的——写法 B 本来就更短更省事,路顺带没堵死而已。

判断一个设计好不好,不是看"这样更灵活",而是看"这样是不是更省事、而且顺手没把路堵死"。

3.2 为什么每个图形自带一份样式

QLine / QRect 都有自己的 this.lineStyle——是一份拷贝,不是共用一个全局的

如果共用会怎样:你画了十个黑矩形,然后把调色板改成红色——十个矩形会一起变红

所以创建图形的那一刻要 clone() 一份:

"创建一个图形"本质上是一次快照:把 ViewModel 里"用户此刻的选择"(会变),
固化成 Model 里"这一笔的属性"(不该再变)。

这个瞬间在所有编辑器里都存在:Word 里你设好字体再打字、PS 里你选好画笔再落笔——
设置活在 ViewModel,落下去的那一笔活在 Model。

想看现象:qpaint-mini.html 文件末尾的实验 ②,把 clone 拆掉,改一次颜色所有旧图形集体变色。


第四部分 · 验收

4.1 动手:给它加一种新图形

目标:亲手证明"加一种图形,QPaintDoc 一行都不用改"(1.3 那条)。

  1. 浏览器打开 代码/qpaint-mini.html
  2. Cmd + Option + I(Windows:F12),点 Console 面板
  3. 粘这段,回车:
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()
    }
}
  1. 再粘这两句,回车:
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)串起来读。

公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关