把长任务切成片

1103 字 · 3 分钟#性能#JavaScript

用 canvas 表格处理几万行数据时,有一段绕不开的同步计算:排版。行高、列宽都要在渲染之前算出来,而 canvas 里没有 CSS,文本折行、省略全靠 measureText 一次次去量。数据量一大,这段计算就变成火焰图上一根几百毫秒的红色长条,期间页面对滚动、点击没有任何响应。

这篇整理一下我处理这类问题的思路:把长任务切成片,每片跑完就把主线程还给浏览器。

为什么卡

主线程一帧的预算大约 16.7ms,里面还要塞下样式计算、布局、绘制。一个同步任务跑 500ms,就意味着 30 帧的输入事件全部积压——用户的滚动在这 500ms 里一动不动。

排版计算的大头是 measureText。一段文本要在给定宽度内折行,就得反复测量;一行有十几列,一列可能是多行文本,一个几万行的表格量下来,调用次数是百万级的。

Worker 还是分片

第一反应通常是扔给 Web Worker。但排版这种计算和主线程的状态耦合得很紧:字体加载状态、缩放比例、列配置,都得序列化传过去,数据本身也要结构化克隆一份,内存直接翻倍。虽然现在有 OffscreenCanvas 可以在 Worker 里测文本,改造成本仍然不小。

时间分片是更温和的路线:总耗时不会变少(甚至因为调度开销略增),但每一片跑完都交还控制权,用户的操作总能插进来。卡顿的本质不是慢,是挡路。

一个迷你调度器

把「一个计算单元」定义成 generator 的一次 yield,调度器在每帧预算内尽量多跑几步,预算用完就让路:

function schedule(work, { signal, budget = 12 } = {}) {
  const { port1, port2 } = new MessageChannel()

  return new Promise((resolve, reject) => {
    port1.onmessage = () => {
      if (signal?.aborted) {
        port1.close()
        return reject(new DOMException('Aborted', 'AbortError'))
      }
      const deadline = performance.now() + budget
      let step = work.next()
      while (!step.done && performance.now() < deadline) {
        step = work.next()
      }
      if (step.done) {
        port1.close()
        return resolve(step.value)
      }
      port2.postMessage(null) // 下一帧继续
    }
    port2.postMessage(null) // 启动第一片
  })
}

几个选择说明一下。

为什么用 MessageChannel 让出控制权? 候选有四个。setTimeout 嵌套超过五层后会被浏览器钳制到最少 4ms,一帧 16ms 里白白扔掉四分之一;requestAnimationFrame 绑定渲染时机,页面切到后台就完全不跑了;requestIdleCallback 的调用时机没有保证,浏览器忙起来可能一直排不上。MessageChannelpostMessage 每次都产生一个不被钳制的宏任务,React 的 Scheduler 用的也是它。

为什么预算是 12ms 不是 16ms? 一帧里还有样式、布局、绘制要跑。把预算吃满,计算是不卡了,渲染照样掉帧,留几毫秒余量。

取消用 AbortSignal。 这是和 fetch 一致的惯例,调用方不需要学新接口。

空口无凭,跑一下

下面是同一批计算——300 个 4ms 的计算单元,总量约 1.2 秒——分别用两种方式跑。框里的小球由 rAF 驱动,每帧更新一次位置,相当于主线程的心电图:主线程一堵,它立刻僵住。

先点「同步跑完」,看小球僵直、帧率归零、连状态文字都刷不出来;再点「分片跑完」,进度条在走,小球照常跑,页面随便滚动、随便选中文字。

rAF 驱动的小球——主线程的心电图0 fps
同一批计算:300 × 4ms ≈ 1.2s

 

两次的总耗时可以自己对比一下:分片那次通常略长——这就是前面说的,分片不减少计算总量,它买的是响应性,不是速度。

用起来

排版任务本身写成 generator,每算完一行让出一次:

function* measureRows(rows, ctx) {
  const heights = []
  for (const row of rows) {
    heights.push(measureRowHeight(row, ctx)) // 内部是若干次 measureText
    yield
  }
  return heights
}

let controller = new AbortController()

function relayout(rows) {
  controller.abort() // 数据变了,作废进行中的计算
  controller = new AbortController()
  schedule(measureRows(rows, ctx), { signal: controller.signal })
    .then((heights) => paint(heights))
    .catch((err) => {
      if (err.name !== 'AbortError') throw err
    })
}

取消这一步很重要:用户连续编辑时,旧一轮还没算完的结果已经没有意义了,不作废掉就可能出现旧计算晚到、把新结果覆盖的问题。

还有一个顺序问题:可视区域内的行不应该排队。实际做的时候是先同步算完视口内加一点缓冲、立刻渲染一次让用户看到内容,屏幕外的部分再交给调度器慢慢补。算好的行高缓存起来,滚动到哪儿直接用。

局限

分片不减少计算总量,它只保证不挡路。如果计算量继续涨,或者计算本身和主线程状态解耦得开,Worker 加 OffscreenCanvas 是更彻底的方案。另外这个调度器只有一条队列,多个任务抢占、优先级、饥饿这些问题都没碰——React Scheduler 在这层之上还做了小顶堆管理的优先级和过期时间,值得翻一遍源码。