加载中...

"辅助界面元素"就是控件——通用控件(下拉框、按钮)或自己写的控件(颜色选择器)。

这一讲干两件事:① 搭一个控件框架,把丑的颜色选择器换成好看的;
② 反过来问——能不能把整个画图程序本身,做成一个控件?

这一讲的代码:

你要的东西 路径
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 服务端做成正式版(数据库、多租户、高可靠)。

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