Issac Lau

How browser works part 3: split long running tasks

May 5, 2025
12 min read
Loading

这是我们理解浏览器工作原理系列文章的第二篇,此前我们分别介绍了浏览器的架构事件循环。 尽管,事件循环机制已经能够让我们尽量写出非阻塞的代码,但是当我们的代码中存在同步执行的耗时任务时,仍然会阻塞主线程,导致应用程序卡顿。

本文尝试利用浏览器的现有机制切分耗时任务,让应用程序能够平滑运行。

一个耗时任务

App.tsx
function factorial(n: number) { if (n === 0 || n === 1) { return 1n; } let result = 1n; for (let i = 2n; i <= n; i++) { result *= i; } return result; } function App() { const [result, setResult] = useState<number>(); const [len, setLen] = useState<number>(); return ( <div> <Button onClick={()=> { setResult(undefined); setLen(undefined); const startTime= performance.now(); const result= factorial(120000); console.log(`计算阶乘结果: ${result.toString().length}`); setLen(result.toString().length); const endTime= performance.now(); const duration= endTime - startTime; console.log(`计算阶乘耗时: ${duration} 毫秒`); setResult(duration); }} > 计算 120000 阶乘 </Button> <div className="break-words"> <div>耗时: {result}</div> <div>长度: {len}</div> </div> </div> ); }

以上代码,当我们点击计算阶乘按钮时,浏览器会卡顿一段时间,无法响应其他事件(可以尝试选中页面文字,会发现无法选中)。

切分耗时任务

如何将耗时任务切分,我们首先想到的是,将任务切分成多个小块,每个小块执行完成后,再执行下一个小块。

App.tsx
function factorialChunked( n: number, chunkSize = 1000, onProgress?: (current: number, total: number) => void, onComplete?: (result: bigint) => void ) { if (n === 0 || n === 1) { onComplete?.(1n); return; } let result = 1n; let current = 2; function computeChunk() { // 计算当前块的结束位置 const end = Math.min(current + chunkSize - 1, n); // 计算当前块的阶乘 while (current <= end) { result *= BigInt(current); current++; } // 更新进度 onProgress?.(current - 1, n); // 如果还有剩余,继续计算下一块 if (current <= n) { // 让出主线程,稍后继续执行下一块 setTimeout(computeChunk, 0); } else { // 计算完成,返回结果 onComplete?.(result); } } computeChunk(); }

以上代码,我们通过 setTimeout 将任务切分成了多个小块,每个小块执行完成后,再执行下一个小块。

点击计算阶乘按钮,此时去选中页面文字,会发现可以选中了。

使用 setTimeout 切分任务的问题是,当 setTimeout 嵌套超过 4 层时,会有一个大概 4.7 ms 的延迟。 一个更好地产生宏任务的方式是使用 MessageChannel 来切分任务。

切分耗时任务 MessageChannel

App.tsx
function factorialChunkedMessageChannel( n: number, chunkSize = 1000, onProgress?: (current: number, total: number) => void, onComplete?: (result: bigint) => void ) { const channel = new MessageChannel(); const taskQueue: any[] = []; channel.port1.onmessage = () => { const task = taskQueue.shift(); if (task) task(); if (taskQueue.length > 0) { channel.port2.postMessage(null); } }; function scheduleTask(task: () => void) { taskQueue.push(task); if (taskQueue.length === 1) { channel.port2.postMessage(null); } } if (n === 0 || n === 1) { onComplete?.(1n); return; } let result = 1n; let current = 2; function computeChunk() { // 计算当前块的结束位置 const end = Math.min(current + chunkSize - 1, n); // 计算当前块的阶乘 while (current <= end) { result *= BigInt(current); current++; } // 更新进度 onProgress?.(current - 1, n); // 如果还有剩余,继续计算下一块 if (current <= n) { // 让出主线程,稍后继续执行下一块 scheduleTask(computeChunk); } else { // 计算完成,返回结果 onComplete?.(result); } } computeChunk(); }

以上代码,我们通过 MessageChannel 将任务切分成了多个小块,每个小块执行完成后,再执行下一个小块。

切分耗时任务 scheduler

任务调度在 react 中是一个非常核心的问题,react 专门实现了 scheduler 来解决这个问题。

App.tsx
import { unstable_scheduleCallback as scheduleCallback, unstable_shouldYield as shouldYield, unstable_NormalPriority as NormalPriority, } from "scheduler"; function factorialChunkedScheduler( n: number, chunkSize = 1000, onProgress?: (current: number, total: number) => void, onComplete?: (result: bigint) => void ) { if (n === 0 || n === 1) { onComplete?.(1n); return; } let result = 1n; let current = 2; function computeChunk() { // 计算当前块的结束位置 const end = Math.min(current + chunkSize - 1, n); // 计算当前块的阶乘 while (current <= end) { result *= BigInt(current); current++; } // 更新进度 onProgress?.(current - 1, n); // 如果还有剩余,继续计算下一块 if (current <= n) { // 让出主线程,稍后继续执行下一块 scheduleCallback(NormalPriority, computeChunk); } else { // 计算完成,返回结果 onComplete?.(result); } } computeChunk(); }

以上代码,我们通过 scheduler 将任务切分成了多个小块,每个小块执行完成后,主动调用 scheduleCallback 再执行下一个小块。

切分耗时任务 react scheduler 更平滑的方式

react 的 scheduler 提供了 shouldYield 方法,可以让我们更平滑地切分任务。

App.tsx
import { unstable_scheduleCallback as scheduleCallback, unstable_shouldYield as shouldYield, unstable_NormalPriority as NormalPriority, } from "scheduler"; function factorialChunkedSchedulerSmooth( n: number, onProgress?: (current: number, total: number) => void, onComplete?: (result: bigint) => void ) { if (n === 0 || n === 1) { onComplete?.(1n); return; } let result = 1n; let current = 2; function computeChunk() { while (current <= n) { result *= BigInt(current); current++; if (shouldYield()) { // 更新进度 onProgress?.(current - 1, n); // 让出主线程,稍后继续执行下一块 scheduleCallback(NormalPriority, computeChunk); return; } } onProgress?.(current - 1, n); // 计算完成,返回结果 onComplete?.(result); } computeChunk(); }

以上代码中,在外部我们无需自己去主动按照每一 1000 个循环来拆分任务,而是通过 shouldYield 方法, 在任务执行过程中,主动让出主线程,而无需再调用 scheduleCallback,好处是:

  • shouldYield 会按照时间去拆分任务(默认 5ms),省去我们手动拆分任务的麻烦
  • 我们主动拆分任务,还是有可能阻塞主线程,因为我们拆分的任务的颗粒度可能还是太大, 比如我们按照 1000 个循环来拆分任务,但是阶乘计算越往后,单次计算的时间越长,所以当数据量比较大的时候, 还是有可能阻塞主线程。

切分耗时任务 react scheduler 的 continuation 实现

App.tsx
function factorialChunkedSchedulerContinuation( n: number, onProgress?: (current: number, total: number) => void, onComplete?: (result: bigint) => void ) { if (n === 0 || n === 1) { onComplete?.(1n); return; } let result = 1n; let current = 2; function computeChunk() { while (current <= n) { result *= BigInt(current); current++; if (shouldYield()) { // 更新进度 onProgress?.(current - 1, n); // 返回一个函数,scheduler 内部 会自动让出主线程,稍后继续执行下一块 return computeChunk; } } onProgress?.(current - 1, n); // 计算完成,返回结果 onComplete?.(result); } scheduleCallback(NormalPriority, computeChunk); }

可以看到上面的代码里,我们只需要主动调用一次 scheduleCallback,然后每次在任务执行过程中,如果需要让出主线程,就返回一个函数, scheduler 内部会自动让出主线程,稍后继续执行下一块。

切分耗时任务 scheduler.postTask 简单实现

浏览器原生提供了 scheduler.postTask 方法(safari 不支持),可以使用其切分任务。

App.tsx
import { unstable_scheduleCallback as scheduleCallback } from "scheduler"; const postTask = (function createPostTask() { if ("scheduler" in globalThis) { console.log("Feature scheduler.postTask: Supported"); return (task: () => void) => { (globalThis as any).scheduler.postTask(task); }; } else { console.error( "Feature scheduler.postTask: NOT Supported. use react scheduler instead" ); return (task: () => void) => { scheduleCallback(NormalPriority, task); }; } })(); function factorialChunkedSchedulerPostTask( n: number, chunkSize = 1000, onProgress?: (current: number, total: number) => void, onComplete?: (result: bigint) => void ) { if (n === 0 || n === 1) { onComplete?.(1n); return; } let result = 1n; let current = 2; function computeChunk() { // 计算当前块的结束位置 const end = Math.min(current + chunkSize - 1, n); // 计算当前块的阶乘 while (current <= end) { result *= BigInt(current); current++; } // 更新进度 onProgress?.(current - 1, n); // 如果还有剩余,继续计算下一块 if (current <= n) { // 让出主线程,稍后继续执行下一块 postTask(computeChunk); } else { // 计算完成,返回结果 onComplete?.(result); } } computeChunk(); }

切分耗时任务 scheduler.yield 简单实现

浏览器原生提供了 scheduler.yield 方法(safari 不支持),可以让我们更方便地切分任务。

App.tsx
function yieldToMainThread() { // Use scheduler.yield if it exists: if ("scheduler" in globalThis && "yield" in (globalThis as any).scheduler) { return (globalThis as any).scheduler.yield(); } // Fall back to setTimeout: return new Promise((resolve) => { setTimeout(resolve, 0); }); } async function factorialChunkedSchedulerYield( n: number, onProgress?: (current: number, total: number) => void, onComplete?: (result: bigint) => void ) { if (n === 0 || n === 1) { onComplete?.(1n); return; } let result = 1n; let current = 2; while (current <= n) { result *= BigInt(current); current++; if (current % 500 === 1) { // 更新进度 onProgress?.(current - 1, n); await yieldToMainThread(); } } onProgress?.(current - 1, n); // 计算完成,返回结果 onComplete?.(result); }

总结

本文讨论了浏览器中切分耗时任务的几种方式,并给出了对应的代码实现。

  • 使用 setTimeout 切分任务
  • 使用 MessageChannel 切分任务
  • 使用 react.scheduler 切分任务
  • 使用 scheduler.postTask 切分任务
  • 使用 scheduler.yield 切分任务

有没有想过,react.scheduler 内部是怎么实现的呢?事实上,react.scheduler 内部的实现用到了 setTimeout , MessageChannel 具体取决于 react 运行的环境。 同时 react.scheduler 内部也有针对 scheduler.postTaskscheduler.yield 实现, 也许未来浏览器支持更广泛之后,react.scheduler 就会采用原生的 scheduler.postTaskscheduler.yield 实现。

评论

Loading