"辅助界面元素"就是控件——通用控件(下拉框、按钮)或自己写的控件(颜色选择器)。
这一讲干两件事:① 搭一个控件框架,把丑的颜色选择器换成好看的;
② 反过来问——能不能把整个画图程序本身,做成一个控件?
这一讲的代码:
| 你要的东西 | 路径 |
|---|---|
| v31 完整代码 | 代码/qpaint源码/v31/ |
| ↳ 控件框架(26 行) | controls/base.js |
| ↳ 三个控件 | BaseLineWidthPicker.js · BaseColorPicker.js · ColorPicker.js |
| ↳ 把画图程序做成控件 | PaintView.js(20 行)· PaintDemo.htm |
| Model 的 diff | diff-dom-v30-v31.txt ——0 字节 |
| ViewModel 的 diff(多例支持) | diff-view-v30-v31.txt |
| 菜单的 diff(反而变少了) | diff-menu-v30-v31.txt |
(v31/base/ 那两个第三方库 jQuery + spectrum 几千行,没放进仓库。)
一 · 这次的数字
| 文件 | v30 → v31 | |
|---|---|---|
dom.js(Model) |
856 → 856 | 一行都没改 |
view.js(ViewModel) |
133 → 170(+37) | 多例支持 |
accel/menu.js |
156 → 120(−36) | 反而少了! |
creator/*.js · select.js |
各 +4~12 | 构造函数多收一个 view 参数 |
新增 controls/ |
— → 104 | 控件框架 + 三个控件 |
新增 PaintView.js · PaintDemo.htm |
— → 51 | 画图程序作为控件 |
两个数字值得盯着看:
| 说明什么 | |
|---|---|
| Model 零改动 | "把程序变成控件"这件事跟"文档是什么"毫无关系——又一次印证 Model 该放什么 |
| 菜单反而少了 36 行 | 因为原来手写的那一大坨 <select><option>…</option></select>,被 <div type="ColorPicker"> 一行替掉了 |
二 · 控件框架:又是「注册表 + 工厂」
2.1 起因:产品经理嫌丑
原来的颜色选择器是拿通用 <select> 硬凑的:
<select id="lineColor" onchange="onPropChanged('lineColor')">
<option value="black">black</option>
<option value="red">red</option>
… 六个
</select>
能用,但难看。 要换成一个真正的调色板控件。
2.2 框架就三步
① 用占位 div 声明"这里要一个什么控件":
<div type="BaseLineWidthPicker" id="lineWidth" onchange="onIntPropChanged('lineWidth')"></div>
<div type="ColorPicker" id="lineColor" onchange="onPropChanged('lineColor')"
palette="black,red,blue,green,yellow,gray"></div>
② 每个控件向一个全局注册表登记自己:
class QControls {
constructor() { this.data = {} }
register(type, control) { this.data[type] = control } // 类型 → 构建函数
}
var qcontrols = new QControls()
③ 启动时遍历所有 div,把带 type 的换成真控件:
init() {
let divs = document.getElementsByTagName("div")
for (let i = divs.length-1; i >= 0; i--) { // ★ 倒着遍历
let div = divs[i]
let type = div.getAttribute("type")
if (type != null) {
let control = this.data[type]
if (control) control(div) // 调用构建函数,就地替换
}
}
}
为什么倒着遍历? 因为
getElementsByTagName返回的是活的集合——
替换过程中 div 会消失,正着遍历下标会错位。这是个真实的坑,不是风格。
2.3 ★ 这是第三次出现同一个套路
view.registerController("RectCreator", ()=> new QRectCreator(view, "rect")) // 26 讲
qshapes.register("rect", json => new QRect(json)) // 30 讲
qcontrols.register("ColorPicker", ColorPicker) // 31 讲
| 注册表 | 注册的是"怎么造什么" | 触发时机 |
|---|---|---|
view.controllers |
怎么造一个 Controller | 点菜单时 |
qshapes |
怎么从 JSON 造回图形 | 加载文档时 |
qcontrols |
怎么把一个 div 变成控件 |
页面启动时 |
收益也完全一样:加一个新的 = 写一个文件 + 一行
register,别处一个字不改。
这个套路在这门课里已经出现三次了,可以当成一件成手的工具收起来。
2.4 构建函数的三步
以 BaseColorPicker 为例(controls/BaseColorPicker.js):
function BaseColorPicker(div) {
// ① 从占位 div 上读入所有参数
let id = div.id, onchange = div.onchange
let colors = div.getAttribute("palette").split(",")
// ② 把占位 div 换成真正的界面
let options = colors.map(c => `<option value="${c}">${c}</option>`)
div.outerHTML = `<select id="${id}">${options.join("")}</select>`
// ③ 把用户关心的事件装到真界面上
let elem = document.getElementById(id)
if (onchange) elem.onchange = onchange
}
qcontrols.register("BaseColorPicker", BaseColorPicker)
读参数 → 换界面 → 装事件。 三个控件都是这个形状。
三 · 包装 jQuery:两种对待第三方框架的态度
新版颜色选择器基于 jQuery 社区的 spectrum。这是整个画图程序第一次引入第三方框架。
3.1 两种态度
| 态度 | 代价 | |
|---|---|---|
| A | jQuery 设计优良,当作团队的基础框架,允许 $ 满屏都是 |
哪天不想用了,大量模块要调整,尤其是活跃的项目 |
| B | jQuery 不是主体框架,只是因为要用 spectrum 才引入它——尽可能限制使用范围 | 要多写一层包装 |
作者选 B。 于是 jQuery 被关在 controls/ColorPicker.js 这一个 34 行的文件里,
外面的代码完全不知道它存在:
<!-- 使用方只看到这个,跟用 BaseColorPicker 一模一样 -->
<div type="ColorPicker" id="lineColor" onchange="…" palette="…"></div>
这就是 26 讲那句"别被框架绑架"的一次实践(带读-03 · 3.2):
判据是"把框架抽掉,业务逻辑还在不在"。
这里换掉 spectrum,只需重写这 34 行——因为它被包在一个接口后面。
3.2 Object.defineProperty 那段古怪代码
ColorPicker.js 里有一段看着很怪:
Object.defineProperty(document.getElementById(id), "value", {
get() { return value },
set(x) {
if (this.busy) return // ← 防死循环
value = x
this.busy = true
elem.spectrum("set", value)
this.busy = false
}
})
它在改写这个界面元素 value 属性的读写函数。为什么?
问题一:element.value = xxx 不会触发 onchange。
onchange 只在用户交互时才发。代码里直接赋值改不出事件来——
但 27 讲那个"选中图形后调色板要跟着变"的功能,走的正是代码赋值这条路。
所以只能自己接管 value 的 setter。
问题二:接管之后会死循环。
你写 elem.value = "red"
→ 我们的 set 被调用
→ 调 spectrum("set", "red") 让控件显示红色
→ spectrum 内部又执行 element.value = "red"
→ 我们的 set 又被调用…… ♾️
解法:一个 busy 标志。 已经在 set 里面了就直接返回。
这段代码丑,但它诚实地暴露了"包装第三方组件"的真实成本——
你要把它的行为掰成你自己那套接口的样子,中间难免有这种胶水。
这也是"限制使用范围"的另一个理由:胶水只写一次,不要写在二十个地方。
四 · ★ 为什么这些控件没用 MVC
这是全篇最值钱的一段。
观察三个控件的代码,会发现它们都没有分层——没有 Model / View / Controller。
是因为辅助界面元素不适合用 MVC 架构来编写么?当然不是。
更本质的原因是因为它们规模太小了。“这些界面元素的特点是 DOM 都是一个 value,并不是一棵树,这样 Model 层就没什么代码了。
同样的逻辑,View 层、Control 层代码量都过于短小,就没必要有那么清楚的模块划分。
View 负责界面呈现,Control 负责事件响应,只是在心里有谱就好了。”
对照带读-01 讲的"Model 三要素":
| 要素 | 画图程序的 Model | 一个颜色选择器的 Model |
|---|---|---|
| 有结构 | 一个有序的图形列表 | 就一个字符串 "red" |
| 有行为 | onpaint hitTest move toJSON… |
没有 |
| 有身份 | 每个图形有 id | 不需要 |
Model 层退化成一个值,MVC 就没有意义了。
判据(可以直接拿去用):
分层的成本是固定的(三个文件、几层调用),收益却正比于规模。
规模小到 Model 只是一个值时,分层的成本就超过收益了。但注意后半句:「只是在心里有谱就好了」——
不写成三个模块,不等于不分。你仍然知道哪几行是"呈现"、哪几行是"响应事件"。
省掉的是文件和目录,不是思路。
五 · ★ 把画图程序改造成控件:拆掉单例
反过来问:能不能让整个画图程序变成一个控件,一个页面上放好几个?
<div type="PaintView" id="drawing1" width="500" height="400"></div>
<div type="PaintView" id="drawing2" width="500" height="400"></div>
能,但要先拆掉一堆"只有一个"的假设。
5.1 藏着的单例假设
| 假设 | 在哪 | 多实例了会怎样 |
|---|---|---|
全局只有一个 qview |
view.js 最后一行 var qview = new QPaintView() |
两个画布共用一个 View——彻底乱套 |
画布的 id 写死是 "drawing" |
document.getElementById("drawing") |
第二个画布找不到自己 |
| 菜单等辅助元素 id 固定 | accel/menu.js |
两套菜单会撞 id |
registerController 在文件加载时直接调 |
各 creator 文件末尾 | 那时候 View 还没造出来 |
5.2 qview 的含义变了
方案有两种,作者选了后者:
| 做法 | 结果 | |
|---|---|---|
| A | 辅助界面元素也做成多例,每个画布配一套自己的菜单 | 界面上会有好几套菜单 |
| B | 辅助界面元素保持单例,引入"当前实例"的概念 | 一套菜单,操作当前那个画布 |
于是
qview这个全局变量留下来了,但含义完全变了:
从"那个唯一的 View" 变成 “当前这个 View”。
有了"当前"就得有"切换",于是加了焦点相关的事件:
var qview = null
function setCurrentView(view) { // 切换当前实例
let old = qview
qview = view
for (let h of _onCurrentViewChangeds) h(old) // 通知所有关心的人
}
// canvas 的 onmouseenter 里调 setCurrentView(view) —— 鼠标移进哪块画布,哪块就是当前
5.3 onViewAdded:注册时机的问题
原来:文件一加载就 qview.registerController(...)——因为那时全局 qview 已经存在。
现在:View 是后来才被造出来的,而且可能造好几个。
解法是再来一个事件:
// creator/rect.js —— v31
onViewAdded(function(view) { // 「等有 View 被造出来时,叫我」
view.registerController("RectCreator", function() {
return new QRectCreator(view, "rect") // ★ view 从参数进来
})
})
// PaintView.js
function newPaintView(drawingID) {
let view = new QPaintView(drawingID)
fireViewAdded(view) // ← 造好了,喊一嗓子
return view
}
又是"事件 = 我通知你但不认识你"(带读-08 · 三 · ⑥)。
view.js不知道有哪些 Controller 想登记,creator 也不知道 View 什么时候会被造出来。
5.4 qview.style 挪到全局 defaultStyle
原来"当前线宽/颜色"住在 View 上。多例之后:
每个画布各有一套样式,但调色板只有一个 —— 用户会懵。
所以把它从 View 挪到全局,改名 defaultStyle。
这条正好反过来印证带读-01 · 1.4 那三个测试:
“当前颜色"该住哪一层,取决于**“开两个视图时该不该各有一份”**——
26~30 讲的答案是"该”(所以住 ViewModel);
31 讲把产品决策改了(一套调色板管所有画布),数据的住处就跟着变了。数据住哪层不是纯技术问题,它跟着交互设计走。
5.5 ★ 这解答了全系列的一个悬念
带读-源码导航 · 二提过一处对不上的地方:
v26~v30: constructor(shapeType) 用全局的 qview
后来的版本:constructor(view, shapeType) view 当参数传进来
当时我只能说"作者到更后面的版本才改的"。现在知道了:就是 v31,就是这一讲,为了控件化。
- constructor(shapeType) {
- qview.onmousedown = function(event) { ctrl.onmousedown(event) }
+ constructor(view, shapeType) {
+ this.view = view
+ view.onmousedown = function(event) { ctrl.onmousedown(event) }
qpaint-mini.html里我用的是传参数那一版,所以它天然支持多例——
这不是我改的,是照 v31 之后的写法抄的。
六 · 结语那段最实在的话
“支持多实例听起来是一项简单的工作,但是从我的观察看,对很多工程师来说实际上并不简单。
不少初级工程师写代码往往容易全局变量满天飞,模块之间相互传递信息不假思索地基于全局变量来完成。
这些不良习惯会导致代码极难控件化。”
为什么"能不能控件化"是一个好的检验标准?
因为它逼你回答一个问题:这段代码里,有哪些东西是"假设只有一个"的?
| 常见的隐性单例 | 一多实例就出事 |
|---|---|
| 全局变量存状态 | 两个实例互相踩 |
| 写死的元素 id | 第二个实例找不到自己 |
| 加载时直接初始化 | 那时候实例还不存在 |
| 单例的辅助界面 | 不知道该服务谁 |
“当然我们不见得什么桌面应用程序都要考虑把它控件化。但是我们花一些精力去思考控件化的话,
会有助于你对架构设计中的一些决策提供帮助。”这是一种"思想实验"式的架构审视——不一定真做,但问一遍"如果要放两个会怎样",
就能揪出一堆隐性耦合。跟 22 讲那条"删掉一个交互只需注释一行"是同一类判据:
拿一个不一定发生的场景,去照出代码里的假设。
七 · 验收
| # | 问题 | 答案在 |
|---|---|---|
| 1 | 这一讲 Model 层(dom.js)改了多少行?为什么?而 menu.js 为什么反而变少了? |
一 |
| 2 | 控件框架的三步是什么?它跟前面哪两个东西是同一个套路? | 二 |
| 3 | 为什么这三个控件都没用 MVC?什么时候该分层、什么时候不该? | 四 |
| 4 | 把画图程序做成控件,要拆掉哪些"只有一个"的假设?qview 的含义怎么变了? |
五 |
| 5 | 为什么"能不能控件化"是一个有用的架构检验标准? | 六 |
答案
1. 零行(diff-dom-v30-v31.txt 是 0 字节)。因为**“把程序变成控件"跟"文档是什么"毫无关系**——
控件化改的是"实例怎么管”,不是"数据是什么"。
menu.js 少了 36 行,是因为原来手写的一大坨 <select><option>… 被 <div type="ColorPicker"> 一行替掉了。
2. ① 用 <div type="xxx"> 占位声明;② 每个控件向 qcontrols 注册自己的构建函数;
③ 启动时遍历所有 div,把带 type 的换成真控件。
跟 26 讲的 registerController、30 讲的 qshapes.register 是同一个套路:注册表 + 工厂。
3. 不是不适合,是规模太小——它们的 Model 就是一个值("red"、3),不是一棵树,
Model 层没代码,View/Controller 也短到不值得拆。
判据:分层的成本固定,收益正比于规模;规模小到 Model 只是一个值时,成本就超过收益了。
但**“只是在心里有谱就好了”**——省掉的是文件,不是思路。
4. 要拆掉:全局唯一的 qview、写死的画布 id、固定 id 的辅助界面、
加载时直接 registerController。
qview 从"那个唯一的 View"变成"当前那个 View",并配上 setCurrentView / onCurrentViewChanged;
注册改由 onViewAdded 事件驱动;qview.style 挪到全局 defaultStyle。
5. 因为它逼你回答**“这段代码里有哪些东西是假设只有一个的”**——
全局变量存状态、写死的 id、加载时直接初始化,这些隐性单例平时看不出来,一问"放两个会怎样"就全暴露了。
跟 22 讲"删掉一个交互只需注释一行"是同一类判据:拿一个不一定发生的场景,照出代码里的假设。
下一步
32 讲:架构——系统的概要设计。 它会把 26 讲那三个概要设计问题系统化
(带读-03 · 3.1 用画图程序填过一遍那张表)。
再往后 41~44 讲会把 29 讲那个 Mock 服务端做成正式版(数据库、多租户、高可靠)。
