加载中...

这一步补齐三块,都是前两步跟着"一次画矩形"走时绕过去的:

原文的节 状态
Model 层 ✓ 带读-01
ViewModel 层 机制讲完了,"责任"那部分还没讲 → 第一部分
Controller 层 运行机制讲完了,menu.js 那部分还没讲 → 第二部分
架构思维上学到什么 完全没讲 → 第三部分

第一部分 · ViewModel 收口

1.1 三个「View」,到底谁是谁

这是 26 讲最容易把人绕晕的地方,因为"View"这个词在这一讲里指了三样不同的东西:

说的是 实际是谁 干什么
真正的 View(把东西显示到屏幕上) 浏览器 把 canvas 那片格子合成到屏幕;把按钮、菜单渲染出来
view.js / QPaintView ViewModel 层 调度:转交事件、擦画布、管 Controller 上岗下岗
Controller 自己的辅助 View 归 Controller 拖动时的橡皮筋、菜单按钮

原文那句话就是在说第一条:

“View 层:实际是 ViewModel 层,真正的 View 层被浏览器实现了。”

所以文件名叫 view.js,但它是 ViewModel。 这个命名不一致是历史遗留,作者自己也承认了。

顺带一个诚实的补充:对 qpaint 来说,"真正的 View"其实是分裂的——

界面的哪部分 谁负责显示
按钮、菜单、状态栏 浏览器(它们是 HTML 元素,浏览器全包了)
画布里的图形 Model 自己画onpaint)+ 浏览器只负责把像素合成到屏幕

换句话说:用 HTML 元素做的界面,浏览器替你实现了 View;用 canvas 画的内容,View 这份活被你自己挪进了 Model。
这正是带读-01 说的"选了白板就得自己记本子"的另一面。

1.2 view.js 剩下的两个小方法

带读-02 跟着一次画矩形走,把 view.js 读掉了 80%。还剩两个。

getMousePos:屏蔽平台差异的最小样本

getMousePos(event) {
    return { x: event.offsetX, y: event.offsetY }
}

为什么需要它? 因为一个鼠标事件身上带着好几种坐标

属性 相对谁
event.clientX / clientY 浏览器窗口左上角
event.pageX / pageY 整个页面左上角(含滚动)
event.offsetX / offsetY 被点中的那个元素左上角 ← canvas 的左上角

Model 里图形的坐标是相对 canvas 的,所以必须用 offsetX/Y

关键是:为什么这个换算要放在 View 上,而不是每个 Controller 各自做?

因为它是平台相关的。换成桌面版,换算方式完全不同。放在 View 里,六个 Controller 都不用管。

更实在的一条:将来加缩放/平移功能时,换算会变成——

getMousePos(e) {
    return {
        x: (e.offsetX - this.平移X) / this.缩放,
        y: (e.offsetY - this.平移Y) / this.缩放
    }
}

改这一处,六个 Controller 一个字都不用改。 这就是这个三行方法存在的全部价值。

它也是原文说的 View 层责任之一「屏蔽平台的差异」最小的一个样本。

get lineStyle:那次快照发生在这里

get lineStyle() {
    return new QLineStyle(this.properties.lineWidth, this.properties.lineColor)
}

注意 new——每次调用都造一个新的。

这就是带读-01 · 3.2 说的快照:Controller 拿到的是一份拷贝,塞进图形里;
之后你改调色板,改的是 this.properties,跟已经发出去的那些拷贝无关。

properties = { lineWidth: 2, lineColor: "黑" }     ← ViewModel 的数据,会变
      │
      │ 每次 view.lineStyle 都 new 一份拷贝
      ▼
矩形①.lineStyle = { 2, 黑 }    ← 各自独立,改调色板不影响它们
矩形②.lineStyle = { 2, 黑 }

get 是什么? 语法糖,让你写 view.lineStyle 而不是 view.lineStyle()
Python 里就是 @property

@property
def lineStyle(self):
    return QLineStyle(self.properties["lineWidth"], self.properties["lineColor"])

1.3 为什么 QPaintView 这么薄

22 讲把 ViewModel 说得很重——排版引擎、双向数据绑定、"数据变化 → 屏幕区域"的映射表。
QPaintView 里属于它自己的数据只有两样:propertiesdrawing说好的那些呢?

答案:ViewModel 的厚度,正比于局部更新的精细度。

ViewModel 之所以存在,是为了回答**“这次数据变化影响了屏幕的哪一块”**。
qpaint 的回答是"整块"(invalidateRect 收了参数不用,永远全屏重画)——
这个问题被取消了,那么承载答案的那层结构自然就退化没了,只剩胶水。

反过来说:Word 的 ViewModel 厚,不是因为 Word 高级,是因为它必须回答
“改了第 2 页一个字,屏幕上哪几行要重排”。

实用判据:当你发现自己的 ViewModel"好像没什么东西",
先别怀疑架构,先问自己做不做局部更新。不做,它就该是薄的。

1.4 View 层的两个责任(原文那两条)

原文说,即便把绘制交给 Model 之后 View 只剩"胶水层",它仍然承担两个重要责任:

责任 在 qpaint 里体现为
屏蔽平台的差异 getMousePos 的坐标换算;preventDefault() 这类浏览器专有的杂事;canvas 事件转交那一层
定义界面布局 index.htm 里那几行 html——菜单在上、画布在下。不同尺寸的设备布局不同,在这一层控制最妥当

两条的共同点:它们都是"跟平台/设备有关,跟业务无关"的事。

所以理想架构里,跟平台绑死的代码只剩 View 层加少量辅助 View
Model 层平台无关(带读-01 · 3.1),Controller 大部分是事件响应也跨平台——
这正是"Model 越厚越好"那条的延伸收益。


第二部分 · 菜单与组装

2.1 菜单怎么做到「不认识任何一个 Controller」

原文说:

“菜单并不直接和各类创建图形的 Controller 打交道,而是调用 qview.invokeController 来激活对应的
Controller,这就避免了两类 Controller 相互耦合。”

落到代码上,机制朴素得可疑:

// menu.js
<input type="button" id="RectCreator" value="Create Rect" onclick="onClickCtrl('RectCreator')">

function onClickCtrl(key) {
    view.invokeController(key)          // key 是从按钮 id 上取的【字符串】
}

menu.js 里从头到尾没有出现过 QRectCreator 这个标识符。
它只出现过字符串 "RectCreator"

于是依赖图上 menu.js → rect.js 这条边不存在。两者共同依赖的,只是一份命名约定——一根字符串。

代价也要认:拼错名字不会报错。invokeController 里那句 if (name in this.controllers) 查不到就静默返回。
用编译期检查换来了解耦。 在六个名字的规模上值,规模大了就得靠注册时校验或启动自检来补。

2.2 组装根:index.htm 那份 <script> 列表

<script src="dom.js"></script>            <!-- Model -->
<script src="view.js"></script>           <!-- ViewModel -->
<script src="creator/path.js"></script>       <!-- ↓ 这几行 = 启用哪些交互 -->
<script src="creator/freepath.js"></script>
<script src="creator/rect.js"></script>
<script src="accel/menu.js"></script>

翻成 Python 就是 main.py 顶上那一堆(带读-00 · 1.1 讲过):

import dom
import view
import creator.rect       # ← import 它 = 启用"画矩形/线/椭圆/圆"这四个功能
import creator.path
import accel.menu

这份列表就是 22 讲说的「组装根」:全程序唯一一处声明"这个应用由哪些零件构成"的地方。

配合各个 creator 文件末尾的自注册(带读-02 阶段 0),加一个新工具只要两步:
① 写一个新文件;② 这里加一行。不用改 view.js、不用改 dom.js、不用改任何已有 creator。

2.3 ★ 动手:把一行注释掉,看会发生什么

22 讲给了唯一一条能当场检验的判据:

“做得恰当时,干掉某个交互特别容易——不用删 Controller 代码,只需把创建它的那一行注释掉即可。”

现在亲手验一遍。 打开 代码/qpaint-mini.html,找到组装那一段,
把 Circle 那一行注释掉(前面加 //):

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))

保存,刷新页面。你会看到:

# 现象 说明什么
程序照常启动,不报错 没人 import 它,删了谁都不知道
矩形、折线、自由笔……其余五个照常工作 Controller 之间零耦合,实锤
Circle 按钮还在,而且点了会高亮 ✗ 露馅了
点完 Circle,画布上怎么点都没反应——而且之前用的工具也没了 ✗ 比"死按钮"更糟

第 ④ 条值得展开。点 Circle 按钮时发生的事:

invokeController("CircleCreator") {
    this.stopController()                  // ① 先把当前工具停掉 —— 这一步照常执行了!
    let f = this.controllers["CircleCreator"]   // ② 查不到,是 undefined
    if (f) { ... }                         // ③ 不执行
}
// 回到 menu 的 onclick:把 Circle 按钮标成"选中"

结果:五个盒子全空、_current = null,但界面上 Circle 显示为选中。
你不但没换到 Circle 工具,还把原来的工具弄丢了,得再点一次别的按钮才能恢复。

(我在 Node 里跑过这个改动,输出确实如此:当前工具 = ""_current = null、五个盒子全空、
Circle 按钮 className = "on"、在画布上画完 shapes 数量不变。)

2.4 前两条满分,后两条露馅——作者自己没做干净

22 讲说得很清楚:

Controller 的辅助 View 必须归它自己(否则界面上会留一个死按钮)。”

menu.js 里:

function installControllers() {
    document.getElementById("menu").innerHTML = `
    <input type="button" id="PathCreator" value="Create Path" ...>
    <input type="button" id="RectCreator" value="Create Rect" ...>
    ... 六个按钮硬编码在这里`
}

按钮列表归 menu.js,按钮行为归各个 creator 文件——一个交互被劈成了两半,住在两个地方。

要做对,应该让每个 creator 在自注册行为的同时,把自己的按钮也插进菜单:

onViewAdded(function(view) {
    view.registerController("RectCreator", () => new QRectCreator(view, "rect"))
    menu.addButton("RectCreator", "Create Rect")     // ← 界面也自己带
})

这样注释掉一个文件,按钮和行为一起消失,才真正做到"删一个交互 = 少加载一个文件"。

这不是挑刺,是这一步的收获本身。 71 讲专门要讲"如何阅读别人的代码"——
读到能拿作者自己的原则去量出他自己的偏差,才算读进去了。

而且这个偏差很典型:中心化的界面容器(菜单栏、工具栏、设置页)是所有插件式架构最常见的破口。
逻辑很容易做到插件化,界面上那个"总得有人知道有哪些项"的列表,总在诱惑你把它集中写死。

2.5 MousePosTracker:一个既不碰 Model 也不碰 ViewModel 的 Controller

原文说它"极其简单,但也很特殊":

“它并不操作任何正统意义的数据(Model 或 ViewModel),而是操作输入的事件。”

它干的事:收到 mousemove → 把坐标写到状态栏。不读不写 Model,不读不写 ViewModel。

事件  ──▶  Controller  ──▶  自己的辅助 View(那个状态栏 <span>)
                (既没经过 Model,也没经过 ViewModel)

这逼着我们把 Controller 的定义放宽:
Controller 不是"改 Model 的那个东西",而是"订阅一组事件、自治地完成一件事、并且不被任何人依赖的那个东西"。
改不改 Model 是它可能做的事之一,不是它的定义。

但真实实现比原文说的更怪——它压根没用委托机制:

function installMousePos() {
    let mousepos = document.getElementById("mousepos")
    onViewAdded(function(view) {
        let old = view.drawing.onmousemove          // ← 注意:drawing,不是 view
        view.drawing.onmousemove = function(event) {
            mousepos.innerText = "MousePos: " + ...
            old(event)                              // ← 处理完,再交给原来的人
        }
    })
}

没有 registerController,没有 invokeController,也没有 stop()
它绕到 view.drawing(那个 <canvas> 元素)那一层,把浏览器级别的 onmousemove 包了一层。

为什么要翻墙? 回到带读-00 · 2.2 那条:View 的事件盒子是独占的
于是一个 Controller 只有两个选择:要么抢占鼠标(那 Creator 们就别想工作了),
要么完全收不到。而 MousePosTracker 想要的是第三种:旁听——我看一眼就好,事件还归你。

这套委托机制里没有"旁听"这个位置,所以它只能翻墙。 代价是实实在在的:

代价 说明
绕过了 View 的抽象 它直接摸 DOM 元素 → 不再跨平台,换桌面版要重写
装饰器链无法拆卸 old(event) 这种包法只能加不能减,没有 stop()
顺序变成隐式的 谁先 install 谁在链的外层,这个顺序没有任何地方声明

正确的修法,是承认事件有两种订阅语义:

语义 谁需要
独占 / 委托 我来处理,别人别管 各种 Creator(同一时刻只能一个)
旁听 / 广播 我只观察,不影响别人 MousePosTracker、坐标尺、调试面板、埋点

补上后者,MousePosTracker 就能回到框架内,也能被 stop() 掉。

这是本讲最值得带走的一条工程直觉:当你发现某个模块"必须绕过框架才能实现"时,
通常不是这个模块特殊,而是框架少了一种语义。

原文说它"特殊",其实它一点都不特殊——它只是第一个撞到墙的。


第三部分 · 架构思维

3.1 概要设计三问,用画图程序填一遍

原文说:需求分析之后进入架构第二步——概要设计,核心是分解子系统,关心三个问题:

  • 每个子系统负责什么事情?
  • 它依赖哪些子系统?它能够少知道一些子系统的存在么?
  • 它们是通过什么接口耦合的?这个接口是否自然体现业务关系?是否足够稳定?

原文提了三问,但没用自己的例子答一遍。补上——这张表才是 26 讲真正的产出物:

Modeldom.js ViewModelview.js Controllercreator/*, accel/*
负责什么 文档是什么:图形的数据与自绘 用户此刻怎么看:当前样式、当前工具、重绘调度、事件分发 用户能干什么:把一串事件翻译成一次业务动作
依赖谁 谁都不依赖(只依赖注入进来的那支笔) Model Model + ViewModel
谁依赖它 ViewModel、Controller Controller 没有人
接口是什么 addShape / onpaint 五个事件盒子、invalidateRectregister/invoke/stopController stop / onpaint + 一个字符串名字
接口稳不稳 稳(业务动作级) 中(invalidateRect 的参数已为将来留好) 稳(只有两个方法)
能否少知道一点 已经是零 只知道"有 Controller 这种东西",不知道有哪些 彼此零知道
依赖图(箭头 = "认识")

   Controller ①  Controller ②  Controller ③      ← 叶子,【没有任何入边】
        │             │             │
        └──────┬──────┴─────────────┘
               ▼
          ViewModel
               │
               ▼
             Model                                ← 【谁都不认识】

是一棵树,没有环,叶子节点没有任何入边。

"它能够少知道一些子系统的存在么"这个问题,在这份代码里的答案是:
Controller 谁都不认识,View 只认识一个抽象接口和一堆字符串,Model 谁都不认识。

3.2 「别被框架绑架」的可操作版本

原文这段很容易读成鸡汤:

“框架不应该增加代码的耦合,否则这样的框架就应该丢了。”

给它一个能当场用的判据:

把框架抽掉,你的业务逻辑还是不是一个完整的东西?

对着 qpaint 验:把 <canvas>、把浏览器、把所有 DOM 全拿走,
dom.js 还是一套完整的、可测试的图形文档模型(带读-01 · 3.1 那三支笔就是证据)。
它从没变成过任何框架的形状。

反面长什么样,今天更常见:

写法 出了什么事
图形数组存在 React 的 useState 文档模型变成了 React 的一部分。想在 Node 里跑一次导出?跑不了
图形是 Vue 的响应式对象 序列化时带一堆框架内部字段,还得先转换
业务规则写在 useEffect "什么时候该重算"这条业务知识,被表达成了框架的依赖数组

框架该做的是"替你写掉一些代码",不该做的是"决定你的业务概念长什么样"。
分界线:框架可以住在 View 和 Controller 里,不该住进 Model 里。

这也正好是"Model 层越厚越好"那条的另一个理由——厚 Model 是你对抗框架变迁的资产
前端框架五年一换,你的业务模型不该跟着换。


第四部分 · 26 讲收尾

4.1 一张总表

文件 一句话 带读第几步
Model dom.js 文档是什么 + 图形自己画自己 01
ViewModel view.js 调度:转交事件、擦画布、管上岗下岗。自己一件业务都不办 02 + 本篇
Controller creator/*.js 被翻过来的 workflow:一组步骤 + 记着走到第几步的字段 02
组装 index.htm 全程序唯一一处"谁认识谁" 本篇
浏览器 建 document 树 · 送事件 · 把 canvas 合成到屏幕。它不知道 qpaint 在干什么 02

4.2 值得带走的六条

# 判断
1 让 Model 自己画——把一个会不断变长的 if/elif,换成一句永远不用改的调用
2 笔是参数——把"取笔"从循环里提到循环外,顺带白拿"能换渲染目标"“能测试”
3 半成品不进 Model——撤销栈、协同同步、Model 不变量、ESC 语义,四条理由
4 数据三个住处:换机器该跟过去吗(Model)/ 开两窗口该各一份吗(ViewModel)/ 松手该消失吗(Controller)
5 Model 按"存什么"分类,Controller 按"怎么操作"分类——两套分类轴,不该强行对齐
6 某个模块必须绕过框架才能实现时,通常是框架少了一种语义(这里是"旁听")

4.3 四个自测问题

# 问题 答案在
1 文件名叫 view.js,为什么说它是 ViewModel?那"真正的 View"是谁? 1.1
2 getMousePos 就三行,为什么不让每个 Controller 自己算? 1.2
3 registerController("CircleCreator", ...) 注释掉,程序会怎样?哪几条符合 22 讲的预期,哪几条露馅了? 2.3 · 2.4
4 MousePosTracker 为什么要绕过 View 的委托机制?这说明框架缺了什么? 2.5

答案

1. 因为它不负责"把东西显示到屏幕上",它负责调度:转交事件、擦画布、管 Controller 上岗下岗。
真正的 View 是浏览器——按钮菜单由它渲染,canvas 的像素由它合成到屏幕。
(而画布里图形的"怎么画",被挪进了 Model 的 onpaint。)

2. 因为坐标换算是平台相关的(换桌面版就完全不同),而且将来会变(加缩放/平移时)。
放在 View 里,改这一处,六个 Controller 一个字不用改。这是"View 屏蔽平台差异"最小的样本。

3. 符合预期的:① 程序照常启动不报错;② 其余五个工具照常工作(Controller 之间零耦合,实锤)。
露馅的:③ Circle 按钮还在、点了还会高亮;④ 点完之后当前工具被 stop 掉了、新的又没造出来
五个盒子全空——比"死按钮"更糟。
根源:按钮列表硬编码在 menu.js 里,没跟着 Controller 走,违反了 22 讲那条"辅助 View 必须归它自己"。

4. 因为 View 的事件盒子是独占的,一个盒子只能装一个函数。
MousePosTracker 想要的是旁听(我看一眼,事件还归你),而这套机制里没有"旁听"这个位置——
要么抢占,要么收不到。所以缺的是一种"广播/观察"语义。


下一步

26 讲到此结束。 接着进 27 讲——给这个程序加"选择工具",看架构怎么接住新需求:

新需求:能选中一个图形、能拖动它、能改它的颜色
      ↓ 被逼出来的
hitTest(pt)     —— 点了一下,到底点中了哪个图形?(Model 要新增能力)
Selection       —— "当前选中了谁"住在哪一层?(用带读-01 · 1.4 那三个测试答)
deleteShape     —— 能选中了,才谈得上删

带读-01 · 2.7 那张「QPaintDoc 的方法是被什么逼出来的」表,从这一讲开始一行行兑现。

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