服役超过十年的 MES 前端,技术栈是 EasyUI 1.4.3 + jQuery 1.11.3 这对”考古级”组合。它有一个存在了同样十年的体验缺陷:combobox 下拉面板的宽度永远等于输入框宽度——这是框架默认行为。于是所有长选项都逃不过被截断的命运:”SMT贴片车间-第一生产线-回流焊工段-NS-04高端精密贴片机设备”在下拉里只剩前半截,用户靠脑补补全。
这类问题单看是小事,逐页修是不归路:全系统几十个页面、数不清的下拉框,每个页面写一遍 panelWidth 计算?下一个新页面照样忘。正确的解法是在全局扩展层做一个通用机制:改一处,全系统生效,且给页面留逃逸口。本文记录这个机制的完整设计、实现、三重陷阱踩坑实录,以及一套无头浏览器验证矩阵——作为年度复盘,这也是”老系统增强”这类工作方法论的一个样本。
一、需求拆解:不只是”变宽”
把”下拉面板自适应内容宽度”这句话拆成可验收的约束:
| 约束 | 含义 |
|---|---|
| 全局生效 | 改通用扩展文件一处,所有引用它的页面(全系统)立即生效 |
| 可覆盖 | 页面显式设置了 panelWidth 的,不接管,保持原行为 |
| 有上限 | 自适应有最大宽度封顶(默认 500px),页面可用 panelMaxWidth 选项覆盖 |
| 不伤邻居 | datebox / combotree / combogrid 等同样基于 combo 的组件不受干扰 |
| 幂等 | 重复打开、异步 reload 数据后重新打开,都按最新内容重算,无残留状态 |
| 无痕 | 机制注入的临时状态用完即恢复,不污染组件 options |
“可覆盖”和”无痕”这两条最容易被忽略,但恰恰是通用机制能不能长期活下去的关键:一个不给逃逸口的全局魔法,迟早会被某个特殊页面逼到 fork 掉。
二、机制设计
2.1 拦截点选型
EasyUI 的组件体系里,combobox 构建在 combo 之上,方法分发链是:
1 | $('#x').combobox('showPanel') |
datebox、datetimebox、combotree、combogrid 全部经过这个收口点。所以包装 $.fn.combo.methods.showPanel 一处即可拦截所有 combo 系组件——这是”全局生效”的物理基础。备选方案比较:
- 重写
onShowPanel回调:面板宽度此时已定,只能打开后再改尺寸,闪烁;且页面自己传的回调会覆盖 defaults 上的钩子,不可靠; - fork 组件源码:升级即丢,违背”老系统增强”原则;
- 方法包装(Method Wrapping):保存原函数引用,前后加逻辑,调用链不变——最终选择。
2.2 宽度怎么测:克隆进隐藏 table
打开面板前,把面板内全部 .combobox-item 克隆到一个隐藏容器里测”自然宽度”。核心问题是用什么容器测——这直接踩出了后文的陷阱二,最终方案是 table:
1 | ┌─────────────────────────────────────────────┐ |
shrink-to-fit 的 table,表宽天然等于最长单元格宽度,每项一行又保住了”逐项取最大”的语义,表高顺便给出列表总高——一次 DOM 探测,两个判定依据(宽度 + 是否出纵向滚动条需要预留滚动条宽度)。相比逐项测量(N 次强制回流),全量克隆只触发一次布局,千级数据量也不卡。
2.3 宽度合成公式
1 | width = contentWidth + 2; // 边框与取整补偿 |
返回 null 表示”内容本来就放得下”,走框架默认逻辑——机制只在必要时出手。
2.4 无痕注入
测得宽度后临时写入 options.panelWidth,调用原 showPanel(内部会用它定面板宽度),finally 里立刻恢复 null:
1 | state.options.panelWidth = width; // 仅本次打开生效 |
副作用生命周期 = 一次同步调用,任何异步路径(reload 数据、重复打开)看到的都是干净状态。
三、三重陷阱实录
机制本身的代码量不大,真正的成本全在”让它真的生效”上。三个陷阱按隐蔽程度递增,每一个都是靠打点取证才定位的。
陷阱一:defaults 的”定型”机制,Math.min 出 NaN 静默失效
第一版在扩展文件里写了:
1 | $.fn.combo.defaults.panelMaxWidth = 500; // 看起来很合理 |
验证时面板纹丝不动。排查发现 EasyUI 在 bundle 加载时就已经执行了:
1 | $.fn.combobox.defaults = $.extend({}, $.fn.combo.defaults, {...}); |
这一步是值拷贝(快照),不是引用。之后我再去改 combo.defaults.panelMaxWidth,combobox.defaults 里的快照还是 null;每个 combobox 实例初始化时从 combobox.defaults 取值,拿到的自然是 null。于是宽度合成公式里:
1 | Math.min(374, undefined) // = NaN |
不报错、不接管、无任何日志——静默失效是最恶心的失败模式。修复是三层兜底:combo.defaults 与 combobox.defaults 都补默认值(覆盖未来初始化的实例),合成公式里 opts.panelMaxWidth || 500(覆盖已存在的实例)。
教训:给”继承链式 defaults”体系打补丁,先确认 defaults 是引用共享还是加载时快照。
陷阱二:inline-block 克隆测出”所有选项之和”
最初的测量容器用 div + 克隆项设 display:inline-block。两个选项的用例完美通过,20 个选项的用例测出 6014px——直接撞上封顶值 500,而真实最长项只有 304px。
根因是 CSS 布局语义:inline-level 的盒子在 shrink-to-fit 容器里排成一行,容器的 max-content 宽度是它们之和。两个选项的用例里”短”选项只贡献 14px,误差被 374 vs 371 的量级掩盖了——用例覆盖不足时,错误方案会给出正确-looking 的结果。
修复即换成上文 table 方案(每项独占一行)。为确认 table 测的是真实渲染宽度,做了一个隔离实验:同一段富文本分别用”600px 可见容器真实渲染 / inline-block 克隆 / table 克隆 / width:max-content”四种方式测量,后三者完全一致(207.3px),并反推出旧方案 241 = 207.3 + 34(第二项宽度)的求和构成。
教训:测量的容器语义决定测量结果;验证测量方案时用”多方式对照 + 反推误差构成”,比信任单一方案可靠。
陷阱三:验证链路自己也有 bug
最有意思的是调试过程本身。机制装好了、用例跑了、面板没变宽——一度怀疑是包装没生效。逐步打点后发现验证链路自己埋了两个坑:
- 用脚本注入测试代码时,
$(...)被转义处理错误写成了$.(...)——一个语法错误让整个<script>块静默死亡,之前”看起来跑了”的输出全是缓存假象; - 诊断代码里检查”showPanel 是否被包装”,却写在测试自身的 SPY 替换之后——检查到的是 SPY 自己,得出”机制没安装”的错误结论。
修复验证链路后真凶才现形(正是陷阱一)。也就是说:当结论是”机制没生效”时,先审计验证手段本身——调试器也会说谎,说谎的方式是让你测错对象。
四、验证矩阵
交付前的最终回归,无头 Chrome + 项目真实 EasyUI/jQuery 依赖,9 个场景一次跑齐:
| 场景 | 期望 | 实测面板宽 | 结论 |
|---|---|---|---|
| 短选项(不超输入框) | 不接管 | 220(=输入框宽) | ✓ |
| 长选项 | 自适应加宽 | 359(内容357+2) | ✓ |
| 超长选项 | 封顶 | 500 | ✓ |
| 显式 panelWidth=300 | 不接管 | 300 | ✓ |
| formatter 富文本选项 | 按真实内容判定 | 220(实测内容207<220) | ✓ |
| 20 条长选项(出滚动条) | 内容+滚动条预留 | 321(304+2+15) | ✓ |
| 页面覆盖 panelMaxWidth=350 | 按页面值封顶 | 350 | ✓ |
| datebox | 不受干扰 | 180(框架自带值) | ✓ |
| 二次打开 / 状态恢复 | 幂等无残留 | 359 一致,临时值已恢复 null | ✓ |
这个矩阵的价值不在”全绿”,而在每一行都是一个可能被回归破坏的契约——下次有人改这段代码,矩阵能告诉他破坏了什么。
上面的矩阵不必只停留在纸面:下面的演示页是自包含的真实环境(jQuery 1.11.3 + EasyUI 1.4.3 + 本机制代码全部内联,无外部依赖),可以直接点开各下拉框体验自适应与封顶行为,或点”运行验证矩阵”当场复跑全部九个场景:
五、小结
回头看这件事的账:代码改动一个文件几十行,覆盖全系统所有 combobox;耗时的不是写代码,是三个陷阱的取证与验证矩阵的搭建。几条可迁移的经验:
- 小痛点值得机制化:判断标准不是”这个 bug 多严重”,而是”它是不是结构性重复”——几十个页面共享同一个框架默认行为的问题,就该在框架层解决;
- 通用机制三原则:可逃逸(显式配置优先于机制)、无痕(临时状态必须恢复)、幂等(重复执行结果一致);
- 静默失效优先怀疑类型:
NaN/undefined在比较运算里永不报错,Math.min(x, undefined)这类组合要在代码审查里当成地雷; - 测量类逻辑要有对照实验:单一方案的”正确结果”可能是误差掩盖,多方案对照 + 反推误差构成才能定性;
- 验证手段本身是代码:也会错,而且它错的时候你倾向于信它。
老系统的价值不在技术新,在于每一处通用机制都能被全量页面复用。给老框架做增强,是投入产出比很高的一类工作。