Skip to content

图书馆的摆书人

背景:模型库几百个卡片一次性渲染,主线程阻塞导致滚动卡顿。

过程:rAF 分片填充 DOM——requestAnimationFrame 分批渲染,避免阻塞。


模型库就像一座图书馆。

用户打开模型库的那一刻,图书管理员要把所有的书——几百个模型卡片——一本一本地摆到书架上。

每本书有封面(缩略图)、有书名(模型名)、有标签(分类标签)。 摆一本书,就要创建一个 DOM 元素,设置样式,绑定事件。 摆一百本书,就要做一百次。

如果只有几十本书——没问题,管理员手快,一眨眼就摆完了。 但如果有几百本呢? 上千本呢?

管理员埋头摆书,摆啊摆啊。 用户在旁边等,点什么都没反应——因为管理员太忙了,没空理他。


阻塞的主线程

"为什么会卡住?"AI 同行者问。

"因为 JavaScript 是单线程的,"外交官说,"摆书(DOM 操作)和响应用户输入(点击、滚动)都在同一个线程里。摆书的时候,用户输入就只能排队等着。"

"摆完了才能响应?"

"摆完了才能响应,"外交官点头,"如果摆 500 本书要 300ms,用户就会觉得'打开模型库会卡一下'。300ms 说长不长,说短不短——足够用户觉得'这个软件有点慢'。"

"那怎么办?"

"分批发,"外交官说,"不要一次把所有书都摆上去。一帧摆一批,摆完一批就让出主线程,响应用户输入。下一帧再摆下一批。"

"就像……摆几本,歇口气,再摆几本?"

"就是这个意思,"外交官笑了,"图书管理员也不能一口气摆几百本书啊。摆二十本,直个腰,喝口水,再摆二十本。虽然总时间变长了一点,但用户随时能拿到已经摆好的书。"


rAF 分片

"具体怎么做?"AI 问。

"用 requestAnimationFrame,"外交官翻开 library.ts,找到 loadThumbnailsForLevel 函数。

旧代码大概是这样的:

typescript
function loadThumbnailsForLevel(level, container) {
    for (const model of level.models) {
        const row = createModelRow(model);
        container.appendChild(row);
        loadThumbnail(model, row);
    }
}

"循环一次跑完,中间不休息。"

新代码改成这样:

typescript
function loadThumbnailsForLevel(level, container) {
    const models = level.models;
    let index = 0;
    const BATCH_SIZE = 20;

    function batch() {
        const end = Math.min(index + BATCH_SIZE, models.length);
        for (; index < end; index++) {
            const row = createModelRow(models[index]);
            container.appendChild(row);
            loadThumbnail(models[index], row);
        }
        if (index < models.length) {
            requestAnimationFrame(batch);
        }
    }

    batch();
}

"每帧摆 20 本,摆完 20 本就约下一帧的 rAF,"外交官解释,"下一帧浏览器先处理用户输入、重绘,然后再摆下一批。"

"为什么是 20 本?不是 10 本,不是 50 本?"

"又是经验值,"外交官笑了,"20 是一个比较安全的数字。每帧 16ms 预算,创建 20 个 DOM 元素大概用 2-3ms,剩下的时间浏览器可以干别的。多了的话,万一某一批慢了,就会掉帧。"

"少了的话呢?"

"少了总时间变长,"外交官说,"500 个模型,每批 10 个,要 50 帧——接近 1 秒才能全部摆完。用户滚动的时候,下面还在不断冒出来,也挺烦的。20 个是平衡点——既不卡,也不至于太慢。"


先有骨架,再有内容

"还有一个细节,"外交官说,"缩略图的加载也要分批。"

"缩略图不是异步的吗?"

"是异步的,"外交官点头,"但一次触发 500 个图片请求也不行。浏览器会排队,服务器也会压力大。"

"所以也要分批?"

"DOM 先创建好,占位图先放上,"外交官说,"用户先看到一排灰色的占位框。然后缩略图一张一张慢慢加载出来。就像——"

"就像书店刚开门,书架上先放好书的位置牌,书一本一本摆上去?"

"就是这个意思,"外交官笑了,"用户先看到整体结构——有多少本书,在哪一排——然后封面慢慢出来。比白屏等半天然后一下子全出来,体验好得多。"

"渐进式渲染。"

"对,渐进式,"外交官确认,"先有骨架,再有内容。用户知道'东西正在加载',心里就不慌。"


虚拟滚动呢?

"等等,"AI 想到了什么,"我听说有一种叫'虚拟滚动'的东西——只渲染可视区域内的元素,滚到哪渲染到哪。那样不是更高效吗?"

"是更高效,"外交官点头,"但也更复杂。"

他掰着手指头数虚拟滚动的问题:

  1. 要计算每一行的高度——模型卡片高度一致还好,如果有的卡片有两行标题、有的有标签,高度就不一样了。
  2. 滚动条高度要模拟——实际只有几十个元素,但滚动条要表现出几百个元素的高度。
  3. 快速滚动会白屏——滚太快,渲染跟不上,就会看到空白。
  4. 测量和重排开销——每次滚动都要重新计算哪些元素在可视区。

"虚拟滚动是重型方案,"外交官说,"适合几千上万条数据的场景。我们这里,一般用户也就几百个模型,多的上千个。rAF 分片足够了——实现简单,效果也不差。"

"什么时候才需要虚拟滚动?"

"什么时候用户说'我有 5000 个模型,模型库卡爆了',什么时候再上虚拟滚动,"外交官说,"现在还没到那个程度。不要提前优化。"


感知速度 vs 实际速度

"有意思的是,"外交官说,"rAF 分片之后,实际总时间变长了。"

"变长了?"

"变长了,"外交官确认,"一次性渲染 500 个元素可能要 300ms。分 25 批渲染,因为中间有 rAF 的间隔,总时间可能要 500ms。实际更慢了。"

"但用户会觉得更快?"

"用户会觉得更快,"外交官肯定地说,"因为第一批 20 个在 16ms 内就出来了——用户立刻能看到内容。后面的慢慢加载,不影响用户操作。用户可以先点第一个模型,不用等全部加载完。"

"感知速度比实际速度更重要。"

"对,"外交官说,"这是前端性能优化的老生常谈了——但就是有用。用户不关心你花了多少毫秒渲染完所有内容,用户关心的是'我多快能看到东西'、'我多快能开始操作'。"

"首屏时间 > 总时间。"

"首屏时间 > 总时间,"外交官重复,"这是铁律。"


第九颗石子

第九颗黄石子落进"已处理"的堆里。

这一颗的故事,是关于"慢慢来"的。 不是所有优化都是让事情变得更快。有时候,让事情慢一点、但用户感觉更快——反而是更好的优化。

"今天讲了好几个'感知比实际重要'的故事了,"AI 说,"加载占位、缩略图优化、阴影渐隐、还有这个分片渲染……"

"因为前端优化到最后,优化的都是人的感知,"外交官说,"技术指标是基础,但最终的裁判是用户的眼睛和手指。"

他看着那堆黄石子,数了数:

  1. 空白的一秒——感知速度
  2. 证件照——感知质量
  3. 织布机的垃圾——长时间稳定
  4. 快递员与护盾——性能 + 质量
  5. 调光器——看不见的节省
  6. 夕阳的影子——视觉连续性
  7. 沉默的函数——代码契约
  8. 不存在的 bug——验证确认
  9. 摆书人——感知速度

九颗了。 中优项十五颗,已经过了一半。


附录:长列表渲染优化策略

策略适用场景复杂度效果
一次性全渲染< 50 条极低简单直接
rAF 分片渲染50 ~ 1000 条实现简单,首屏快
分页/加载更多任意数量需要用户手动翻页
虚拟滚动> 1000 条极致性能,实现复杂

选最简单的、能解决问题的方案。不要为了"技术先进"而上复杂方案。


补记:书架的秩序

摆书人解决了速度问题——书摆得够快了。但 Jieling 走进图书馆,扫了一眼书架,皱了皱眉。

"这些书是按什么排的?"

Riku 看了一眼。模型库和动作库的排列顺序,是扫描时 allModels 数组的自然顺序——Go 端 filepath.Walk 遍历目录的先后。在文件系统层面,这个顺序取决于 inode 分配,基本上是随机的。

"按扫描顺序," Riku 说。

"那就是没排序。"

文件夹一直是排过的——Array.from(subdirs).sort()buildLevel 里写死了。但模型行和动作行没有。它们跟着扫描顺序走,扫到哪个是哪个。

Riku 在 config.ts 里加了一个全局状态:librarySortMode,默认 'default',可选 'name'。然后在 buildLevel 的 items 构建完成后,加了一段:

typescript
if (librarySortMode === 'name') {
    items.sort((a, b) => a.label.localeCompare(b.label, 'zh'));
}

localeCompare'zh' 参数,按中文拼音排序。文件夹和模型行混合排——不区分类型,只看名字。这比「先排文件夹再排模型」更直觉,因为用户找东西时不会先想「这是文件夹还是文件」,只会想「它叫什么」。

动作弹窗里加了一个切换按钮:「排序:默认」↔「排序:名称」。点一下,重新渲染,书架瞬间从散乱变得整齐。


但 Jieling 还是不满意。

"我刚才用了那个动作," 她指着屏幕上某个 VMD 文件,"然后我切回去想再用一次——我得重新在几百个文件里找它。"

"你刚用过,它还在原位。"

"对,但我得记得它在哪。"

Riku 想了想。图书馆有「最近归还」的书架——读者不用去主库翻,在门口就能拿到最常用的几本。

他在 config.ts 里加了 recentMotions 数组——一个内存中的最近使用列表,最多 10 条。每次 loadVMDFromPath 成功后,调用 addRecentMotion(path, name),把这条动作推到列表头部。如果已存在,先移除再推到头部——和「最近打开的模型」用的是同一个模式。

动作弹窗根菜单里多了一个入口:「最近使用」。点进去是一个扁平列表,最多 10 行,按时间倒序。每行是文件名,点击直接加载。

不需要持久化——关掉应用就清空。因为「最近」是一个会话级概念:用户今天反复用的动作,明天可能不需要了。如果需要,他们会用排序或搜索找到它。

"这就像图书馆门口的还书架," Riku 对 Jieling 说,"不存过夜。"

Jieling 试了一下:加载一个动作,回到动作弹窗,「最近使用」里多了一行。再加载另一个,新的推到最前面,旧的往下挪。

"够用了," 她说。


补记:楼层的记忆

摆书人解决了速度问题。但还有一个问题——"我刚在三楼科幻区,刷新之后怎么回了一楼大厅?"

图书管理员把整个图书馆重新摆了一遍。摆完之后,每个人都回到了一楼。

这是模型库刷新后的体验。用户在文件夹里翻了三层,找到了想要的模型。然后刷新——或者扫描完新模型自动刷新——刷完之后回到根目录。用户又得重新一层层点进去。

"为什么刷新之后会回到根目录?"

"因为简单,"外交官说,"刷新 = 重新构建整个库。构建 = 从根目录开始。最简单的实现,但最不考虑用户感受。"

修复方法:刷新之前把导航栈存下来(currentNavPath.slice() 拷贝一份),刷新之后从根目录按栈里的路径逐层往下走。能走多深走多深——如果最深那层被删了,停在最近的存在的那一层。

typescript
let currentLevel = rootLevel;
for (const folderName of savedPath) {
    const nextLevel = currentLevel.children.find(c => c.name === folderName);
    if (!nextLevel) break;
    currentLevel = nextLevel;
}
// 最后渲染 currentLevel

十几行代码。用户不会再觉得"这软件没心没肺"。

教训:最便宜的用户体验优化,就是记住用户的选择。他翻到了哪一层、滚到了哪里——刷新的时候不要丢。十几行代码的事,但用户会悄悄觉得——这个软件挺懂我的。


教训:前端优化到最后,优化的都是人的感知。实际花了多少时间不重要,用户觉得花了多少时间才重要。首屏时间 > 总时间。让用户先看到东西、先能操作,比什么都强。摆得快是第一步,摆得整齐是第二步,记住读者刚拿过哪本是第三步。