Skip to content

空白的一秒

背景:异步面板加载时 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 那章呼应上了。"

"互相呼应,"外交官点头,"好的代码不是每个地方都用不同的巧思,是每个地方都用同样的习惯。"


预设场景的同款问题

第二个案发地点是预设场景弹窗。

一模一样的问题——buildPresetScenesLevelrenderCustom 是 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 毫秒,成本更低,效果更好。