本篇要回答的问题:辅助界面元素(自定义控件)的架构和应用程序有何不同?怎么搭一个统一的控件框架让控件可替换、可包装第三方库,以及把"画图"程序本身控件化要克服哪些"唯一性假设"?
第二章"桌面软件开发"进入尾声。前面五讲用"画图"程序实战验证了应用程序的业务架构(MVC)。本讲换个对象:辅助界面元素——也就是通用控件或自定义控件(如画图程序里的线型选择器、颜色选择器)。它和应用程序的架构考虑颇有不同,最大差异在于多实例。起因是产品经理嫌 <select> 实现的颜色选择器不够友好,要换成可视化的版本。
辅助界面元素的框架
先总结基础版控件(BaseLineWidthPicker、BaseColorPicker)的使用接口:
| 接口项 | 含义 |
|---|---|
| id | 控件 id,用于获取顶层结点 |
| value | 控件的值,即 Model 层数据;控件 DOM 常简单到只是一个值(线型是 number,颜色是 Color) |
| palette | 颜色选择器的调色板,指示可选颜色 |
| blur() | 主动让控件失去焦点 |
| onchange | 值经用户交互改变时触发;直接 element.value = xxx 赋值不会触发 |
框架做法:每个控件用 <div type="xxx"> 占位。引入全局 qcontrols: QControls,所有控件向它 register(type, control),把类型与构建函数关联。init() 遍历文档所有 div,凡带 type 且注册过的,就调用构建函数把占位 div 替换为真实控件。
构建函数(以 BaseColorPicker 为例)三步走:
| 步骤 | 做什么 |
|---|---|
| 1. 读参数 | 从占位 div 读 id、onchange、palette |
| 2. 替换界面 | div.outerHTML = ... 替换为真实界面(如 <select>) |
| 3. 装事件 | 若用户关心 onchange,把响应函数装到真实界面的 onchange 上 |
jQuery 颜色选择器:包装第三方库
新版 ColorPicker 基于 jQuery 社区的 spectrum 控件,使用姿势与 BaseColorPicker 完全一致,替换时只需把 type 从 BaseColorPicker 改成 ColorPicker。
对待引入的 jQuery 有两种态度:
| 态度 | 做法 | 风险 |
|---|---|---|
| 作为主体框架 | 满屏 $,jQuery 风格代码到处蔓延 |
哪天弃用 jQuery,大量活跃模块都要改,代价高 |
| 仅局部使用(本讲采纳) | 只在颜色选择器等少数场景用,包装隔离 | 控制蔓延范围,主体仍裸写 JS |
包装中有个古怪点:用 Object.defineProperty 改写界面元素 value 的 get/set。原因——element.value = xxx 不触发 onchange,必须从 set 函数感知改写。而 set 里调 elem.spectrum("set", value) 内部又会写 element.value,造成死循环,靠引入 busy 标志(已在 set 中则直接返回)破解。
辅助界面元素的架构设计:为什么不用 MVC
这些控件都没有用 MVC。
关键判断:不是控件不适合 MVC,而是规模太小——DOM 只是一个 value 而非一棵树,Model 层几乎没代码,View/Control 也过于短小,没必要再做清晰的模块划分(View 管呈现、Control 管事件,心里有谱即可)。
但并非所有控件都这么简单。把整个"画图"程序改造成一个标准控件 PaintView 是可行的,关键是修正与"唯一性"有关的假设:
| 原假设(单例) | 控件化后的问题 | 解决方案 |
|---|---|---|
全局唯一 qview: QPaintView |
同界面可能多个 View 实例 | qview 含义从"单例"变为"当前实例" |
| 辅助界面元素 id 固定(单例) | 多个 QPaint 实例共享一套控件 | 保持单例,引入"当前实例"概念 + 焦点切换事件 |
| Controller 直接 registerController | 多 View 下注册时机不对 | 引入 onViewAdded/onCurrentViewChanged,新 View 创建时再注册 Controller |
样式存在 qview.style |
多 View 样式不一致、和单例控件冲突 | 把它挪到全局,改名 defaultStyle |
改造后 newPaintView 创建实例并 fireViewAdded,PaintView(div) 把占位 div 换成 <canvas> 并初始化,注册为标准控件,即可到处引用做出 DEMO。
遗留问题:URL 地址。应用程序 load 文档后改 URL 没问题;但作为控件、一个界面多个 PaintView 时,URL 该显示哪个文档 ID?谁都不合适——若非显示不可,得在实例旁放个辅助元素来显示。
总结
控件的架构与应用程序"没有本质不同",唯一要额外操心的是支持多实例。多实例看似简单,实则是检验架构合理性的试金石——它会逼你消灭"全局变量满天飞"的坏习惯。即便不真的控件化,用"控件化"视角审视应用,也有助于做出更好的架构决策、形成更好的设计规范。
先把"是什么"回答清楚
| 概念 | 一句话说明 |
|---|---|
| 辅助界面元素 | 通用控件/自定义控件,DOM 常简化为单个 value |
| QControls 注册框架 | type→构建函数的注册表,把 <div type="xxx"> 占位替换为真实控件 |
| onchange 触发规则 | 仅用户交互触发;element.value= 赋值不触发,需改写 value 的 get/set |
| 包装第三方库 | 把 jQuery/spectrum 限制在局部,避免风格蔓延 |
| 唯一性假设 | 单例代码里隐含的"全局只有一个"前提,控件化时必须逐一修正 |
| 单例→当前实例 | 控件保持单例,qview 改指"当前 View",配合焦点切换事件 |
一句话速记
控件架构和应用没本质区别,差的只是必须支持多实例;而多实例的真正障碍,是那些藏在全局变量里的"唯一性假设"——把它们一个个揪出来改成"当前实例",应用就具备了控件化的底子。
几条值得记住的判断
- 小规模可不上 MVC:DOM 只是一个 value 时,分层是过度设计。
- 第三方库要隔离包装:限制蔓延范围,换库成本才可控。
- 多实例是架构试金石:能否控件化,暴露了你对全局状态的依赖程度。
思考题
PaintView 控件化后,URL 该显示哪个文档 ID 的问题留给了读者。如果同一界面有多个 PaintView 实例,你会如何设计 URL/路由与"当前实例"的关系?把你应用里某个"理所当然只有一个"的全局对象拿出来,设想它变成多实例,会有多少处代码需要改?




