加载中...

这一步回答一个问题:我在页面上点了「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.ondblclicknull——你双击画布,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.htmlCmd+Option+ISources → 找到文件 → 点行号打断点。

打在哪 操作 该看到什么
invokeControllerthis._current = f() 点「Rect」按钮 name"RectCreator";单步一下,this._currentnull 变成一个 QRectCreator
QRectCreator.onmousedown 在画布上按一下 走完两行,this.rect.pt1 变成你点的坐标、this.startedtrue同时看 this.view.doc._shapes——还是空的
QRectCreator.onpaintbuildShape() 按住鼠标拖 它会不停地停下来(每移动一下停一次)——这就是"每一帧现造现扔"
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 讲那条"删掉一个交互只需注释一行",
      并且亲眼看到作者自己没做干净的那个地方(四个死按钮)。
公告栏
这是我的个人知识库。
记录技术,也记录生活 —— 读过的、试过的、想明白的,都堆在这儿。
最新文章
网站资讯
文章数目 :
5
已运行时间 :
本站总字数 :
15.7k
本站访客数 :
本站总访问量 :
最后更新时间 :
全局知识图谱
当前页面 已访问 文章 标签
ESC 关闭 · 滚轮缩放 · 拖拽移动 · Ctrl+G 开关