Appearance
空白的一秒
背景:异步面板加载时 UI 空白闪烁,后端慢时用户以为界面卡死。
过程:async renderCustom 先显示"加载中…"占位,消除空白间隙。
高风险三颗红石子落地之后,外交官把注意力转向了黄石子。
黄石子不像红石子那样惊心动魄——它不会让软件崩溃,不会让数据丢失,不会让安全漏洞敞开大门。但黄石子有它自己的锋利:它让用户觉得"这个软件有点卡"、"怎么没反应"、"是不是死了"。
第一颗黄石子,外交官选了最不起眼的一个——异步渲染的空白闪烁。
"这也算问题?"AI 同行者翻了翻审计记录,"不就是后端慢的时候,弹窗打开有一瞬间是白的吗?几百毫秒的事。"
"几百毫秒够用户想很多事了,"外交官说,"够他想'是不是点错了',够他想'软件是不是卡住了',够他再点一下——然后两个加载同时进行。"
他拿起那颗黄石子,放在桌上最显眼的位置。
"我们先从最轻的开始。轻,但看得见。"
舞蹈套装的空白一秒
第一个案发地点是舞蹈套装弹窗。
用户打开动作库,点进"舞蹈套装"。弹窗打开了——然后有一瞬间,什么都没有。
一秒。或者几百毫秒。取决于后端读配置文件有多快。
然后套装列表"唰"地一下出现。
"为什么会白?"AI 问。
"因为 renderCustom 是 async 的,"外交官打开 motion-popup.ts,"它先 await loadDanceSets(),等后端数据回来,再往容器里填内容。await 的这段时间里,容器是空的。"
"那为什么不在 await 之前先放点什么?"
"问得好,"外交官笑了,"这就是问题所在——代码的作者想都没想过这个。在他的心智模型里,renderCustom 是一个'把内容渲染到容器里'的函数。内容从哪来、要等多久,不是他考虑的事。"
他指着那一行 await loadDanceSets():
"异步函数有一个特点——它的第一行是同步执行的,遇到第一个 await 才让出控制权。这意味着,在 await 之前,你有机会往容器里放东西。放一个'加载中…',放一个骨架屏,放一个旋转的圆圈——放什么都行,但不能什么都不放。"
"空白 = 不确定性,"外交官继续说,"用户看到空白,不知道是正常加载还是程序挂了。看到'加载中…',他就知道——哦,在加载,等一下。同样是等,一个是焦虑的等,一个是安心的等。"
三行代码的修复
修复有多简单?
三行。
typescript
renderCustom: async (container) => {
const loading = document.createElement('div');
loading.style.cssText = 'padding:24px;text-align:center;color:var(--text-muted);font-size:13px;';
loading.textContent = '加载中…';
container.appendChild(loading);
try {
await loadDanceSets();
container.innerHTML = '';
// ... 实际内容三行 DOM 创建 + 一行 appendChild + 一行清空。
"就这么简单?"AI 有点意外。
"就这么简单,"外交官说,"简单到很多人不屑于做——'不就是一行文字吗,犯得着吗?'。但用户体验就是由这些'犯得着吗'的小事堆出来的。"
他顿了顿:
"而且这里有个细节。"
"什么细节?"
"用 textContent 而不是 innerHTML,"外交官指了指那行代码,"虽然'加载中…'是硬编码字符串,不可能有 XSS 风险,但这是一种习惯。能不用 innerHTML 就不用。习惯养好了,哪天传进来的是变量,也不会出事。"
"和 XSS 那章呼应上了。"
"互相呼应,"外交官点头,"好的代码不是每个地方都用不同的巧思,是每个地方都用同样的习惯。"
预设场景的同款问题
第二个案发地点是预设场景弹窗。
一模一样的问题——buildPresetScenesLevel 的 renderCustom 是 async 的,先 await GetPresetScenes(),再往容器里填内容。await 的这段时间,容器是空的。
"同样的修复?"AI 问。
"同样的修复,"外交官确认,"先塞一个'加载中…',await 回来再清空、填内容。"
他打开 scene-menu.ts,同样的三行代码,同样的结构。
"两个地方,一样的模式,"AI 说,"为什么不抽成一个工具函数?"
"可以抽,但没必要,"外交官摇摇头,"三行代码,两个地方。抽成函数要多写五六行(函数定义、参数、返回值),反而更重。DRY 原则不是'所有重复都要消除',是'重复到一定程度再消除'。三行两处——还没到程度。"
"那什么时候该抽?"
"三处以上,或者超过十行,或者逻辑会变——三处以上改起来麻烦,十行以上读起来重复,逻辑会变的话改一个地方忘一个地方。"外交官数着手指头,"满足其中一个,就该考虑抽了。"
为什么 SlideMenu 不帮你做这件事
"等等,"AI 忽然想到了什么,"SlideMenu 的 renderCustom 支持 async,那它为什么不在调用 renderCustom 之前自动放一个加载状态?"
"好问题,"外交官说,"我也想过。答案是——SlideMenu 不知道加载状态应该长什么样。"
"什么意思?"
"有的弹窗加载快,几十毫秒,加个加载状态反而闪一下——更难看。有的弹窗加载慢,几秒,需要一个带进度条的加载。有的弹窗内容是卡片列表,骨架屏更合适。有的弹窗只有几个按钮,转个圈就行。"
他摊开手:
"通用组件做不了这个决定。它不知道你的内容是什么、加载要多久、用户预期是什么。所以它把决定权交给你——你负责在 await 之前放你觉得合适的加载状态。"
"组件做少一点,调用方想多一点。"
"对,"外交官说,"这是组件设计的一个原则——不要替调用方做它最懂的事。渲染内容是调用方最懂的,加载状态也是内容的一部分,所以调用方来做。"
体验的粒度
改完两个弹窗,外交官停下来看了一会儿。
"有没有觉得……有点简陋?"AI 说,"就三个字'加载中…',连个动画都没有。"
"是简陋,"外交官承认,"但它比空白强一万倍。"
他伸出两根手指:
"用户体验有两个粒度。第一个粒度是'有没有'——有没有反馈?有没有提示?有没有状态?这是 0 和 1 的区别。空白是 0,'加载中…'是 1。有了 1,再谈好不好看。"
"第二个粒度是'好不好'——加个旋转图标?加个骨架屏?加个进度条?这些都是 1 到 100 的优化。没有 1,100 就是空中楼阁。"
"所以我们今天做的是 0 到 1。"
"对,0 到 1,"外交官点头,"快赢项之所以叫快赢,就是因为它成本低、见效快。三行代码,解决一个 0 到 1 的问题。性价比极高。"
看不见的设计
傍晚的时候,外交官做了一个小实验。
他找了一个没看过代码的人,让他分别用旧版和新版打开舞蹈套装弹窗。
"看出区别了吗?"他问。
那人想了想:"好像……新版没那么卡了?"
"你看到加载中的文字了吗?"
"呃……没注意。但就是感觉快了一点。"
外交官笑了。
"这就是好的加载状态,"他对 AI 说,"用户不会注意到它的存在——因为它太自然了。但如果没有它,用户会立刻觉得'怎么这么慢'。"
"好的体验是隐形的。"
"对,"外交官说,"就像好的代码——你读的时候不觉得它写得好,你只是觉得'这事就该这么写'。等你看到坏代码的时候,才会意识到好代码有多难得。"
他把这颗黄石子放进"已处理"的那堆里。
黄石子堆里,第一颗已经落下。
接下来还有更多——缩略图优化、布料缓存、碰撞器归一化、时间流转阈值……每一颗都是一个小问题,每一颗解决了都不会让用户惊呼"哇",但会让用户悄悄觉得——
"这个软件,好像越来越顺手了。"
附录:异步 UI 设计原则
| 原则 | 说明 |
|---|---|
| 0 到 1 优先 | 先解决"有没有反馈",再优化"反馈好不好看"。空白是最大的恶 |
| await 前占位 | async render 函数的第一行同步代码是黄金位置——放加载状态 |
| 组件不越界 | 通用组件不替调用方决定加载态样式,因为调用方最懂内容 |
| 重复的阈值 | 三行两处不抽函数,三处以上或逻辑复杂再抽。DRY 有度 |
| 体验是隐形的 | 好的加载态用户不会注意到。没有的时候,用户才会觉得慢 |
教训:用户对速度的感知,不完全等于实际的速度。空白的一秒比有提示的三秒更难熬。让用户知道"在加载",比让加载变快 200 毫秒,成本更低,效果更好。