本文最后更新于天前,内容可能已不再适用!

在前端页面里,长列表渲染是一个很常见的性能问题。数据量不大时,直接map生成 HTML 再一次性插入 DOM 通常没有明显问题;但当列表变长、模板渲染逻辑变复杂、图片和元信息较多时,这种同步渲染方式就容易占满主线程,导致页面切换、滚动和点击出现明显卡顿。

本文记录一次对hexo-bilibili-bangumi追番页面分页渲染的优化:把原本一次性同步完成的模板渲染,改成在浏览器空闲帧中分批执行,从而降低单帧压力,让页面先保持可交互。

背景

追番页面会先渲染每个分类前 10 条数据,剩余数据通过bangumis.json异步加载。旧实现大致是这样的:

const html = {
  wantWatch: data.wantWatch.slice(10).map((item) => renderItem(item)).join('\n'),
  watching: data.watching.slice(10).map((item) => renderItem(item)).join('\n'),
  watched: data.watched.slice(10).map((item) => renderItem(item)).join('\n')
};

document.querySelectorAll('#bangumi-item1>.bangumi-pagination')[0].insertAdjacentHTML('beforeBegin', html.wantWatch);
document.querySelectorAll('#bangumi-item2>.bangumi-pagination')[0].insertAdjacentHTML('beforeBegin', html.watching);
document.querySelectorAll('#bangumi-item3>.bangumi-pagination')[0].insertAdjacentHTML('beforeBegin', html.watched);

这段代码的问题不在于写法复杂,而在于它把三类数据的模板渲染集中在一个任务里完成。浏览器主线程在执行这段 JavaScript 时,不能同时处理用户输入、样式计算、布局和绘制。如果数据量增加,单次任务耗时变长,就会出现掉帧和交互延迟。

对于博客页面来说,用户最直接的感受不是“渲染总耗时是多少”,而是“页面是不是能立刻响应”。因此优化目标不是把所有 HTML 更快地一次性生成出来,而是把大任务拆小,让浏览器有机会在任务之间处理渲染和输入。

原理

浏览器页面的 JavaScript、样式计算、布局、绘制和用户输入处理大多运行在主线程上。如果一个 JavaScript 任务长时间不结束,浏览器就没有机会进入下一帧,也就无法及时响应滚动、点击和视觉更新。

分时函数的核心思想是:

  1. 把一个大任务拆成多个小任务。
  2. 每次只执行一部分工作。
  3. 当前帧还有空闲时间时多做一点,没有空闲时间时让出主线程。
  4. 下一次空闲时继续处理剩余任务。

浏览器提供了requestIdleCallback,它会在主线程空闲时执行回调。回调参数里的deadline.timeRemaining()可以告诉我们当前空闲周期大概还剩多少时间。利用这个信息,可以把长列表渲染拆成多批。

不过requestIdleCallback并不是所有环境都支持,因此实现时还需要准备一个降级方案:如果浏览器不支持,就使用setTimeout延后执行。这样虽然不能精确感知空闲时间,但仍然可以避免在一个同步任务中渲染全部内容。

方法实现

这次优化拆成三步。

1. 封装空闲调度函数

先封装一个runWhenIdle,优先使用requestIdleCallback,否则退化到setTimeout

function runWhenIdle(callback) {
  if (typeof requestIdleCallback === 'function') {
    requestIdleCallback(callback);
    return;
  }
  setTimeout(() => {
    callback({
      timeRemaining: () => 0
    });
  }, 16);
}

这里的降级实现给了一个timeRemaining()为 0 的 deadline。后面的批处理逻辑会保证即使没有剩余时间,每一轮也至少处理一条数据,避免任务永远无法推进。

2. 复用编译后的模板函数

原来每条数据都直接调用pug.render。如果运行时支持pug.compile,可以先把模板编译成渲染函数,然后每条数据复用这个函数:

function createBangumiPageRenderer() {
  if (hexoBilibiliBangumiOptions.pug && typeof hexoBilibiliBangumiOptions.pug.compile === 'function') {
    const render = hexoBilibiliBangumiOptions.pug.compile(hexoBilibiliBangumiOptions.pugTemplate);
    return function hexoBilibiliBangumiRenderPage(item) {
      return render({
        item,
        ...hexoBilibiliBangumiOptions.pugOptions
      });
    };
  }
  return function hexoBilibiliBangumiRenderPage(item) {
    return hexoBilibiliBangumiOptions.pug.render(hexoBilibiliBangumiOptions.pugTemplate, {
      item,
      ...hexoBilibiliBangumiOptions.pugOptions
    });
  };
}

这一步不是分时渲染的必要条件,但它能减少每条数据的重复开销。长列表优化通常要同时关注两个方向:减少总计算量,以及避免单次计算阻塞太久。

3. 在空闲帧中分批渲染

核心批处理逻辑如下:

function renderItemsInIdle(items, renderPage, onComplete) {
  const html = [];
  let index = 0;

  function renderBatch(deadline) {
    let renderedInFrame = false;
    while (index < items.length && (!renderedInFrame || deadline.timeRemaining() > 0)) {
      html.push(renderPage(items[index]));
      index++;
      renderedInFrame = true;
    }

    if (index < items.length) {
      runWhenIdle(renderBatch);
      return;
    }

    onComplete(html.join('\n'));
  }

  runWhenIdle(renderBatch);
}

这里有一个细节:循环条件不是简单的deadline.timeRemaining() > 0,而是:

!renderedInFrame || deadline.timeRemaining() > 0

这样可以保证每个空闲回调至少渲染一条数据。否则在降级方案里timeRemaining()一直是 0,任务就会被不断重新调度,却不会真正处理任何条目。

最后,把三个分类组织成任务队列,按顺序分批渲染并插入到对应分页按钮之前:

function renderTasksInIdle(tasks) {
  const renderPage = createBangumiPageRenderer();
  let taskIndex = 0;

  function runNextTask() {
    if (taskIndex >= tasks.length) return;

    const task = tasks[taskIndex];
    taskIndex++;

    renderItemsInIdle(task.items, renderPage, (html) => {
      document.querySelectorAll(task.selector)[0].insertAdjacentHTML('beforeBegin', html);
      runNextTask();
    });
  }

  runNextTask();
}

调用时只需要传入数据和目标选择器:

renderTasksInIdle([
  {
    items: data.wantWatch.slice(10),
    selector: '#bangumi-item1>.bangumi-pagination'
  },
  {
    items: data.watching.slice(10),
    selector: '#bangumi-item2>.bangumi-pagination'
  },
  {
    items: data.watched.slice(10),
    selector: '#bangumi-item3>.bangumi-pagination'
  }
]);

效果

优化前,bangumis.json加载完成后,页面会立刻同步执行三类列表的模板渲染。数据越多,单次任务越长,用户越容易感受到卡顿。

优化后,渲染被拆分到多个空闲帧中执行:

  • 首屏内容和标签切换逻辑不需要等待全部剩余数据渲染完成。
  • 浏览器可以在批次之间处理绘制和输入事件。
  • 单帧 JavaScript 执行时间降低,滚动和点击更不容易被长任务阻塞。
  • 支持requestIdleCallback的浏览器可以根据真实空闲时间动态多渲染几条;不支持时也能通过setTimeout分批推进。
  • Pug 模板优先编译后复用,减少重复解析模板的成本。

这种优化不会让所有内容“瞬间完成”,但会改善用户感知性能。对用户来说,页面能先响应、逐步补齐内容,通常比等待一个长任务全部执行完更自然。

适用场景

分时渲染适合以下情况:

  • 列表数据较多,但不要求所有内容立即同步展示。
  • 每条数据的渲染逻辑较重,例如模板渲染、格式化、复杂 DOM 字符串拼接。
  • 页面初始交互比完整内容一次性出现更重要。
  • 不方便引入虚拟列表,但希望降低长任务阻塞。

如果是后台管理系统里的超大表格,虚拟列表可能是更彻底的方案;如果是博客、文章页、追番页这类静态内容为主的场景,分时渲染的改动更小,收益也比较直接。

注意事项

分时渲染不是银弹,实现时需要注意几个边界:

  1. 每个批次至少处理一项,避免低空闲时间或降级方案下任务无法推进。
  2. 插入 DOM 的时机要稳定。本文的实现是在某个分类全部渲染完成后再插入,避免一个分类的内容被频繁分段插入造成布局抖动。
  3. 如果用户可能在渲染未完成时切换页面或销毁容器,需要增加取消机制或容器存在性判断。
  4. 如果列表极长,单纯把 HTML 存在数组中最后一次性插入仍可能占用较多内存,可以进一步改成每 N 条插入一次。
  5. requestIdleCallback适合低优先级任务,不适合用户点击后必须立即完成的关键反馈。

总结

这次优化的关键不是换一个更复杂的框架,而是把“同步做完所有事”的思路改成“浏览器空闲时分批做”。对长列表、模板渲染和博客插件这类场景来说,分时函数是一个成本低、侵入小、效果明确的优化手段。

最终实现保留了原有数据结构和 DOM 插入位置,只替换了渲染调度方式:数据仍然来自bangumis.json,模板仍然使用 Pug,展示结果保持一致,但渲染过程对主线程更友好。

最后修改:2026-06-15 08:11:24
如果觉得我的文章对你有用,请随意赞赏