用 scheduler.postTask 和 scheduler.yield 给长任务分优先级

上周三我在给运营后台的批量导入页面做一次例行性能巡检,倒不是有人报了 bug,是这个页面最近数据量涨得快,我自己心里没底,就想在真机上跑一遍看看。打开 Chrome DevTools 的 Performance 面板,录制了一次"导入五千行 Excel 后点确认"的完整流程,回放的时候主线程那条泳道直接被一整块黄色和紫色的矩形填满,中间夹着几个红色小三角——长任务警告。

我把鼠标悬在最长的那块上,浮层显示 612ms。这个数字本身不算离谱,但问题是这 612ms 是连续的,中间用户如果点了别的按钮,浏览器根本腾不出手去响应,点击事件只能排在任务队列末尾干等。我又切到 Long Animation Frames 的记录方式确认了一遍,用下面这段代码接上 PerformanceObserver

1const observer = new PerformanceObserver((list) => {
2  for (const entry of list.getEntries()) {
3    console.log('LoAF duration:', entry.duration);
4    console.log('blocking duration:', entry.blockingDuration);
5    entry.scripts.forEach((script) => {
6      console.log('  script:', script.name, script.sourceURL, script.duration);
7    });
8  }
9});
10
11observer.observe({ type: 'long-animation-frame', buffered: true });

这个 API 比单纯看 Performance 面板细一层,它能把一帧里到底是哪段脚本、哪个函数占用了多长时间列出来,而且 blockingDuration 直接告诉你这一帧里有多少时间是真正挡住了渲染的。我这次跑出来的结果很清楚:罪魁祸首是导入校验函数,它一次性把五千行数据全部跑完正则和类型检查,中间不给浏览器任何喘息的机会。

这正好撞上了 INP 这个指标的软肋。INP 量的是用户交互到下一次可绘制帧之间的完整耗时,如果这时候主线程正卡在一个几百毫秒的长任务里,不管这次交互是点击、输入还是滚动,浏览器都没法在合理时间内响应,用户能感觉到"页面没反应",然后往往会再点一次,反而让队列更堵。

老办法为什么不够用

这类问题我以前的处理方式是把大任务拆成小块,用 setTimeout(fn, 0) 在每处理一批之后让出主线程:

1async function validateRowsOld(rows) {
2  const errors = [];
3
4  for (let i = 0; i < rows.length; i += 1) {
5    const error = validateRow(rows[i]);
6    if (error) errors.push(error);
7
8    if (i % 100 === 0) {
9      await new Promise((resolve) => setTimeout(resolve, 0));
10    }
11  }
12
13  return errors;
14}

这段代码能缓解问题,但有两个实际的毛病。第一,setTimeout 哪怕参数是 0,浏览器也会按宏任务队列的规则排期,如果页面上同时还有别的定时器、网络回调、用户输入事件在抢这个队列,你的校验任务未必能马上拿到执行权,实际让出的时间常常比预期长得多。第二,也是更麻烦的一点,setTimeout 完全没有优先级的概念——校验任务和一个无关紧要的日志上报定时器,在浏览器眼里是平等的,谁先入队谁先跑,你没法告诉浏览器"用户正在点这个按钮,请优先处理"。

requestIdleCallback 想解决的正是"找个不忙的时机去跑"这件事,浏览器在一帧渲染完、还有空闲时间的窗口里调用你的回调,并且给你一个 deadline.timeRemaining(),理论上很适合做这种非紧急的批处理。但它同样没有优先级参数,而且空闲窗口本身极不稳定——页面动画多、滚动频繁的时候,空闲窗口可能长时间不出现,任务会被无限期推迟。我这次在 Chrome 里试了一下,用 requestIdleCallback 拆分校验任务,五千行数据处理完花了将近两秒,比预想的慢很多,就是因为浏览器一直没能挤出足够大的空闲片段。

我把这个现象记下来,去翻了下 Chrome 团队关于任务调度的博客和规范草案,发现浏览器这几年一直在推一套更完整的方案——scheduler.postTask()scheduler.yield(),思路是把"任务优先级"这件事从应用层的土办法,挪到浏览器原生调度器里去做。

scheduler.postTask() 长什么样

scheduler.postTask() 挂在全局对象 scheduler 上,签名很直接:传一个回调,第二个参数是配置对象,核心是 priority 字段,取值只有三种。

1scheduler.postTask(() => {
2  console.log('这是一个用户阻塞级任务');
3}, { priority: 'user-blocking' });
4
5scheduler.postTask(() => {
6  console.log('这是一个用户可见级任务');
7}, { priority: 'user-visible' });
8
9scheduler.postTask(() => {
10  console.log('这是一个后台级任务');
11}, { priority: 'background' });

user-blocking 用于那些用户正在等待反馈的操作,比如输入框回显、点击后的即时状态切换,浏览器会优先调度它,甚至可能插队到已经排队的低优先级任务前面。user-visible 是默认优先级,适合大多数常规更新,比如列表刷新、图表重绘。background 给那些用户感知不到、可以随时被推迟的工作,比如埋点上报、预取下一页数据、日志聚合。

比单纯的优先级更实用的是它自带的取消支持,用的是标准 AbortController

1const controller = new TaskController({ priority: 'user-visible' });
2
3scheduler.postTask(() => {
4  runExpensiveAggregation();
5}, { signal: controller.signal });
6
7// 用户切换了筛选条件,之前排队的聚合任务已经没意义了
8controller.abort();

TaskControllerAbortController 的子类,多了一个 setPriority() 方法,可以在任务还没执行前动态调整优先级——比如一个后台预取任务,用户忽然把鼠标移到了对应区域,你可以把它从 background 提到 user-visible,不需要取消重发。

1const controller = new TaskController({ priority: 'background' });
2
3scheduler.postTask(() => prefetchDetail(id), { signal: controller.signal });
4
5detailLink.addEventListener('mouseenter', () => {
6  controller.setPriority('user-visible');
7});

scheduler.yield() 在循环里怎么用

postTask 解决的是"把一个任务排到合适的优先级队列里",而 scheduler.yield() 解决的是另一件事:一个任务内部如果本身很长,怎么在执行过程中主动让出主线程,同时不丢失自己的调度上下文。

它的用法是在一个 async 函数内部 await scheduler.yield(),跟 await new Promise(setTimeout) 长得很像,但语义完全不同——yield() 恢复执行时会带着原来任务的优先级,而不是像 setTimeout 那样把后续代码扔进普通的宏任务队列排队。也就是说,如果这段代码本来是以 user-blocking 优先级通过 postTask 启动的,yield 之后的继续执行依然享有这个优先级,不会被淹没在一堆 background 任务后面。

我把前面那段校验函数改成了这样:

1async function validateRows(rows, priority = 'user-visible') {
2  const errors = [];
3
4  const run = async () => {
5    for (let i = 0; i < rows.length; i += 1) {
6      const error = validateRow(rows[i]);
7      if (error) errors.push(error);
8
9      if (i % 200 === 0) {
10        await scheduler.yield();
11      }
12    }
13    return errors;
14  };
15
16  return scheduler.postTask(run, { priority });
17}

postTask 负责把整个校验任务挂到合适的优先级队列,yield 负责在循环内部定期把主线程让出去,让浏览器有机会处理用户这段时间内产生的点击、输入、滚动。两者配合起来,用户在导入过程中点了确认按钮之后的取消操作,能在几十毫秒内得到响应,而不是等整个校验跑完。

实测下来,同样五千行数据,用 scheduler.yield() 拆分之后,校验总耗时和不拆分几乎没差别(多了一点调度开销,量级在几毫秒),但期间穿插的点击事件响应时间从原来接近六百毫秒降到了几十毫秒以内——这正是 INP 想量的东西:不是任务总耗时,是交互被挡住的时长。

一个具体场景:批量渲染列表项

除了校验这种纯计算场景,另一个我常撞见长任务的地方是一次性把大量列表项塞进 DOM。运营后台有个页面,切换标签后要把两三千条日志渲染成表格行,原来的写法很朴素:

1function renderLogRows(logs) {
2  const fragment = document.createDocumentFragment();
3  logs.forEach((log) => {
4    const row = buildRowElement(log);
5    fragment.appendChild(row);
6  });
7  tableBody.appendChild(fragment);
8}

buildRowElement 里除了拼 DOM,还要格式化时间、计算耗时高亮、拼装 tooltip 内容,单条不贵,几千条累积起来就是一个几百毫秒的长任务,用户切标签会明显感觉到卡顿一下才出现内容。改造后按批次分片,并且把用户主动切标签这个动作标成较高优先级:

1async function renderLogRowsScheduled(logs, priority = 'user-visible') {
2  const fragment = document.createDocumentFragment();
3  let batchCount = 0;
4
5  const run = async () => {
6    for (const log of logs) {
7      const row = buildRowElement(log);
8      fragment.appendChild(row);
9      batchCount += 1;
10
11      if (batchCount % 150 === 0) {
12        tableBody.appendChild(fragment.cloneNode(true));
13        fragment.textContent = '';
14        await scheduler.yield();
15      }
16    }
17    if (fragment.hasChildNodes()) {
18      tableBody.appendChild(fragment);
19    }
20  };
21
22  return scheduler.postTask(run, { priority });
23}

这里有个小细节:每次分片提交后要清空 fragment(用 cloneNodetextContent = '' 或者直接新建一个 DocumentFragment),不然下一轮循环会把已经提交过的节点重复计入。分片之间穿插 yield 之后,浏览器能在渲染过程中插入必要的布局和绘制,用户看到的是表格分批填充,而不是长时间空白后一次性出现。

如果用户在渲染过程中又切换了标签,之前那次渲染任务其实已经没有意义了,这时候用 TaskController 配合 AbortSignal 就很顺手:

1let currentRenderController = null;
2
3function switchTab(tabId) {
4  currentRenderController?.abort();
5  currentRenderController = new TaskController({ priority: 'user-visible' });
6
7  const logs = getLogsForTab(tabId);
8  scheduler.postTask(() => renderLogRowsScheduled(logs), {
9    signal: currentRenderController.signal,
10  });
11}

abort() 之后,还没执行的分片会直接被跳过,不需要在业务代码里手写一堆标志位去判断"这次渲染是否还有效"。

和 React 的 useTransition 是什么关系

我们这个后台前端是 React 18,团队里已经在用 useTransition 处理筛选这类场景,很自然会问:这跟 scheduler.postTask() 是不是重复造轮子。

答案是两者分工不一样。React 自己内部维护了一套调度器(react-reconciler 里的 Scheduler 包),用来决定组件树的更新该以什么优先级提交,startTransition 标记的更新会被打上较低优先级,遇到更紧急的用户输入时可以被打断、稍后再继续。这套机制完全在 React 的渲染流程内部生效,管的是"虚拟 DOM 到真实 DOM 这条链路"上的调度,脱离 React 组件树的代码它管不到——比如我前面写的 Excel 校验函数、日志行的手动 DOM 拼装,都不经过 React 的协调过程,startTransition 对它们没有直接作用。

scheduler.postTask()scheduler.yield() 是浏览器平台层面的调度能力,谁都能用,React 组件里的事件处理函数能用,独立的工具库、Web Worker 之外的主线程计算、第三方 SDK 也能用。事实上 React 团队一直在关注这套原生 API,未来的版本有可能把内部调度器的一部分实现切换到基于它,但目前(我看的是 React 18 的源码和文档)React 自带的 Scheduler 包依然是自己实现的一套用户态调度逻辑,跟原生 scheduler 对象是两条独立的路径,不共享优先级队列。

实际项目里我现在的处理方式是:组件渲染相关的低优先级更新交给 useTransition,组件之外的重计算——校验、解析、聚合、大批量 DOM 操作——用 scheduler.postTask() 包一层,两边各管各的。如果一个操作既涉及非 React 计算又涉及组件更新,可以把计算部分放进 postTask 的回调里,算完之后再用 startTransition 把结果灌回状态:

1function handleImport(rows) {
2  scheduler.postTask(async () => {
3    const errors = await validateRows(rows, 'user-visible');
4    startTransition(() => {
5      setValidationErrors(errors);
6    });
7  }, { priority: 'user-visible' });
8}

兼容性现实和退路

这套 API 目前只有 Chromium 系浏览器(Chrome、Edge 从 129 版本起)把 postTaskyield 都做到了稳定可用,不需要 flag。我在 Safari 里试过,Technology Preview 里能看到相关讨论,正式版还没有;Firefox 这边也还没有原生实现。也就是说如果产品面向全量用户,直接裸用这套 API 会在非 Chromium 浏览器里直接报错——scheduler 对象不存在。

我现在的做法是做一层特性检测,退化到之前那套 yield 分片的老办法:

1const hasNativeScheduler =
2  typeof scheduler !== 'undefined' &&
3  typeof scheduler.postTask === 'function' &&
4  typeof scheduler.yield === 'function';
5
6function schedulerYield() {
7  if (hasNativeScheduler) {
8    return scheduler.yield();
9  }
10  return new Promise((resolve) => {
11    if ('requestIdleCallback' in window) {
12      requestIdleCallback(() => resolve(), { timeout: 50 });
13    } else {
14      setTimeout(resolve, 0);
15    }
16  });
17}
18
19function schedulerPostTask(task, { priority = 'user-visible' } = {}) {
20  if (hasNativeScheduler) {
21    return scheduler.postTask(task, { priority });
22  }
23  const delay = priority === 'background' ? 16 : 0;
24  return new Promise((resolve) => {
25    setTimeout(() => resolve(task()), delay);
26  });
27}

这个退化版本没法真正复现浏览器内部的优先级队列,只是尽量模拟一下行为——background 级别的任务多等一帧,其它的走原本的宏任务排期。社区里已经有几个 ponyfill 库把这层封装做得更完整,会在不支持的浏览器里用 MessageChannel 搭配一个轻量的内部优先级队列去模拟,比我这几行手写的更严谨,如果项目要大范围铺开,我倾向于直接引入现成的库,而不是自己维护这层退化逻辑。

改完拿真实数据对一遍

代码层面的改动看着合理,我还是想拿数据验证一下,不然很容易变成"感觉快了"这种主观判断。web-vitals 库里的 onINP 可以直接接进页面,拿到的是浏览器按标准算法计算出来的值,不是我自己掐秒表:

1import { onINP } from 'web-vitals';
2
3onINP((metric) => {
4  console.log('INP:', metric.value, metric.rating);
5  metric.entries.forEach((entry) => {
6    console.log('  interaction target:', entry.target, entry.duration);
7  });
8}, { reportAllChanges: true });

reportAllChanges 开着的话,每一次交互都会触发一次回调,而不是只在页面卸载前报一次最终值,方便我在开发环境里实时盯着某个具体操作的耗时变化。改造前那次导入操作对应的交互耗时经常落在 400ms 到 600ms 之间,评级是"需要改进";分片加上 postTask 之后,同一批数据同一台设备上再测,交互耗时大多落在 100ms 以内,评级变成"好"。中间穿插的点击、切换标签这些动作也顺带受益,因为它们不再需要排在一个几百毫秒的长任务后面等。

线上环境我们暂时还没有把这套指标接进正式的 RUM 上报,先在内部测试环境跑了几天确认稳定,等这个季度性能专项排上日程了再往生产铺。

这次巡检最后落地的改动不算复杂:校验函数按分片加了 yield,日志渲染按批次提交并配上可取消的 postTask,第三方浏览器走特性检测的退路。批量导入那个页面点击响应时间从原来接近六百毫秒的长任务阻塞,降到了穿插在几十毫秒级的分片之间,Performance 面板里那条被涂满的主线程泳道,现在中间能看出明显的间隙——间隙就是浏览器腾出来处理用户点击的空当。