前端主线程不卡顿:避免 Long Task 的最佳实践清单(含 Worker/切片/诊断 Demo)
很多“卡顿”并不是因为你 JS 写得不够快,而是因为 主线程被长任务(Long Task)占满:浏览器没机会处理输入、计算样式、布局、绘制,最终表现为滚动掉帧、点击无响应、输入延迟、动画卡住。
本文按“能落地、对性能最有效”的优先级,给出一套避免重任务阻塞主线程的清单,并补全每条的实现 demo / 关键点 / 坑位。
术语对齐:DevTools 里一般把 > 50ms 的任务标为 Long Task(经验阈值)。但体感卡不卡,往往跟你的帧预算有关:60fps 下每帧约 16.7ms(扣掉布局/绘制后,留给 JS 的时间更少)。
0. 先有一个最小的“决策顺序”
遇到卡顿时,不要一上来就“节流/防抖”或“微优化”。更高收益的顺序通常是:
- **能丢 Worker 吗?**能 → Worker + Transferable(最优)
- 不能丢 Worker → **能切片吗?**能 → time slicing(scheduler / MessageChannel)
- 切片也不理想 → **DOM/渲染是否过重?**虚拟列表、读写分离、避免强制同步布局
- 仍然卡 → 算法/数据结构 + 内存分配/GC(减少复杂度、减少分配、避免大对象结构化克隆)
下面按这个优先级展开。
1) 首选:把重活挪出主线程(Web Worker)
适用:大计算、解析/压缩、加密、图像处理、数据聚合、AST/JSON 大解析等。
核心思想很简单:Worker 里做计算,主线程只负责渲染与交互。
1.1 最小可运行 Demo:把“十万/百万级计算”放进 Worker
下面的 demo 用一个大数组做示例(你也可以替换成:解压、哈希、排序、聚合等)。
main.ts(主线程)
1// 1) 创建 Worker(不同构建工具写法略有差异;见下文“工程集成”)2const worker = new Worker(new URL("./sum.worker.ts", import.meta.url), {3 type: "module",4});5
6// 2) 准备数据:尽量使用 TypedArray / ArrayBuffer,方便 Transferable 零拷贝传输7const view = new Float64Array(10_000_000);8for (let i = 0; i < view.length; i++) view[i] = Math.random();9
10const buffer = view.buffer;11console.log("before transfer:", buffer.byteLength); // 8000000012
13// 3) Transferable:把 buffer “转移所有权”给 worker(零拷贝)14worker.postMessage({ buffer }, [buffer]);15
16// 注意:转移后,主线程这份 buffer 会变成 detached17console.log("after transfer:", buffer.byteLength); // 018
19worker.onmessage = (e) => {20 console.log("sum:", e.data.sum);21 worker.terminate();22};
sum.worker.ts(Worker 线程)
1// sum.worker.ts2self.onmessage = (e) => {3 const buffer = e.data.buffer as ArrayBuffer;4
5 const view = new Float64Array(buffer);6 let sum = 0;7 for (let i = 0; i < view.length; i++) sum += view[i];8
9 // 结果很小,直接 postMessage 回去即可10 (self as DedicatedWorkerGlobalScope).postMessage({ sum });11};
1.2 Transferable:真正的“性能开关”
Worker 当然强,但很多人用起来仍然慢,最常见原因是:频繁 postMessage + 大对象结构化克隆(structured clone)。
要点
- 能 transfer 的尽量 transfer(零拷贝):
ArrayBuffer/MessagePort/ImageBitmap/OffscreenCanvas等
- 不能 transfer 的大对象(巨大 JSON、深层对象树):
- 要么在 Worker 里“算完只回传小结果”
- 要么把数据变成更紧凑的二进制结构(TypedArray)
一个非常常见的“坑位”
你把大 JSON 放 Worker parse 了,但又把整棵对象树 postMessage 回主线程 —— 这一步还是会结构化克隆,依然可能很慢,甚至更慢(Worker 只是把慢换了个地方)。
更推荐的模式是:
- 在 Worker parse/计算;
- Worker 只回传 渲染所需的最小结果(聚合、分页后的 slice、统计摘要、索引等)。
示例:Worker 解析大 JSON,但只返回统计结果
1// main.ts2const worker = new Worker(new URL("./json.worker.ts", import.meta.url), {3 type: "module",4});5
6const res = await fetch("/big.json");7const buf = await res.arrayBuffer();8
9worker.postMessage({ buf }, [buf]);10worker.onmessage = (e) => {11 const { top10ByCount } = e.data as { top10ByCount: Array<[string, number]> };12 console.log(top10ByCount);13};
1// json.worker.ts2self.onmessage = (e) => {3 const buf = e.data.buf as ArrayBuffer;4 const text = new TextDecoder().decode(buf);5 const data = JSON.parse(text) as Array<{ type: string }>;6
7 const counter = new Map<string, number>();8 for (const item of data) {9 counter.set(item.type, (counter.get(item.type) || 0) + 1);10 }11
12 const top10ByCount = Array.from(counter.entries())13 .sort((a, b) => b[1] - a[1])14 .slice(0, 10);15
16 (self as DedicatedWorkerGlobalScope).postMessage({ top10ByCount });17};
1.3 需要多任务并发?用 Worker Pool(别滥开 Worker)
Worker 不是越多越好:
- 启动有成本(创建线程、加载脚本、初始化运行时)
- 过多 Worker 会导致线程竞争、上下文切换变多,反而慢
更稳的做法是:建一个小型 Worker 池(通常 2~4 个)复用,按任务队列分发。
下面给一个“最小可用”的 Worker 池思路(伪代码级,便于你按项目适配):
1type Job<T, R> = { payload: T; resolve: (r: R) => void; reject: (e: unknown) => void };2
3export function createWorkerPool<T, R>(4 createWorker: () => Worker,5 size: number6) {7 const workers = Array.from({ length: size }, createWorker);8 const idle = [...workers];9 const queue: Job<T, R>[] = [];10
11 function runNext() {12 if (idle.length === 0 || queue.length === 0) return;13 const worker = idle.pop()!;14 const job = queue.shift()!;15
16 const onMessage = (e: MessageEvent<R>) => {17 cleanup();18 job.resolve(e.data);19 idle.push(worker);20 runNext();21 };22 const onError = (e: ErrorEvent) => {23 cleanup();24 job.reject(e.error || new Error(e.message));25 idle.push(worker);26 runNext();27 };28 const cleanup = () => {29 worker.removeEventListener("message", onMessage);30 worker.removeEventListener("error", onError);31 };32
33 worker.addEventListener("message", onMessage);34 worker.addEventListener("error", onError);35 worker.postMessage(job.payload);36 }37
38 return {39 exec(payload: T) {40 return new Promise<R>((resolve, reject) => {41 queue.push({ payload, resolve, reject });42 runNext();43 });44 },45 destroy() {46 for (const w of workers) w.terminate();47 queue.length = 0;48 idle.length = 0;49 },50 };51}
1.4 OffscreenCanvas:把“绘制”也丢到 Worker(可选)
如果你的瓶颈不仅在计算,还在画图(例如大量 2D 绘制 / WebGL 组织),并且目标环境支持 OffscreenCanvas,那么可以把 canvas 的绘制也挪到 Worker。
main.ts
1const canvas = document.querySelector("canvas")!;2const offscreen = canvas.transferControlToOffscreen();3
4const worker = new Worker(new URL("./render.worker.ts", import.meta.url), {5 type: "module",6});7
8worker.postMessage({ canvas: offscreen, dpr: devicePixelRatio }, [offscreen]);
render.worker.ts
1type InitPayload = { canvas: OffscreenCanvas; dpr: number };2
3self.onmessage = (e: MessageEvent<InitPayload>) => {4 const { canvas, dpr } = e.data;5
6 // 2D 示例(WebGL 同理)7 const ctx = canvas.getContext("2d");8 if (!ctx) return;9
10 // 在 worker 里你可以用 setInterval / 自己的时间循环驱动渲染11 let t = 0;12 setInterval(() => {13 t += 0.02;14 const w = canvas.width;15 const h = canvas.height;16
17 ctx.clearRect(0, 0, w, h);18 ctx.fillStyle = "#7CA7FF";19 ctx.beginPath();20 ctx.arc(w / 2, h / 2, 40 + 20 * Math.sin(t), 0, Math.PI * 2);21 ctx.fill();22 }, 16);23};
坑位提醒
- OffscreenCanvas 的支持度与行为差异仍需评估(尤其是 Safari 生态)。真实工程里建议做能力检测与降级策略。
- Worker 不能访问 DOM;你需要把“计算/绘制”和“DOM 更新/交互”拆成两部分。
1.5 工程集成小抄:Worker 脚本怎么放?
不同构建工具对 Worker 的打包方式不同,最常见两种:
- 打包器支持
new Worker(new URL(..., import.meta.url)):上文 demo 即是这种写法。 - 不想折腾打包:把 worker 脚本放到
public/,用绝对路径创建。
例如:
1// public/workers/heavy-task.js2// self.onmessage = ...3
4const worker = new Worker("/workers/heavy-task.js");
2) 次选:把任务切片(Time Slicing)让出事件循环
适用:任务无法放 Worker(依赖主线程环境/库),或迁移成本高;但你仍希望 UI 能持续响应。
核心原则:每次只做一小片(建议 ~5-8ms),然后 yield,把控制权还给浏览器去渲染/处理输入。
2.1 更现代:scheduler.yield() / scheduler.postTask()
Web Scheduler API 可以更细粒度地控制任务优先级,让交互更顺滑(但兼容性需要评估,务必准备 fallback)。
一个带降级的 yield 工具
1function yieldToMain() {2 // eslint-disable-next-line @typescript-eslint/no-explicit-any3 const s = (globalThis as any).scheduler;4 if (s && typeof s.yield === "function") {5 return s.yield() as Promise<void>;6 }7 return new Promise<void>((r) => setTimeout(r, 0));8}
切片处理大数组
1async function processInChunks<T>(2 items: T[],3 fn: (item: T, index: number) => void,4 opts: { budgetMs?: number; signal?: AbortSignal } = {}5) {6 const budgetMs = opts.budgetMs ?? 8;7 let i = 0;8
9 while (i < items.length) {10 const start = performance.now();11 for (; i < items.length && performance.now() - start < budgetMs; i++) {12 if (opts.signal?.aborted) throw new DOMException("Aborted", "AbortError");13 fn(items[i], i);14 }15 await yieldToMain();16 }17}
小经验:如果你发现仍有掉帧,把
budgetMs降到 56ms;如果你更重视吞吐(例如后台预计算),可以升到 1012ms,但交互风险会变大。
2.2 更通用:MessageChannel(比 setTimeout(0) 更稳定)
MessageChannel 常用于实现“可控切片调度”:它能把回调排到宏任务队列,让浏览器有机会渲染与处理输入。
1function createMessageChannelYield() {2 const channel = new MessageChannel();3 const queue: Array<() => void> = [];4
5 channel.port1.onmessage = () => {6 const fn = queue.shift();7 fn?.();8 };9
10 return function yieldByMessageChannel() {11 return new Promise<void>((r) => {12 queue.push(r);13 channel.port2.postMessage(null);14 });15 };16}17
18const yieldByMessageChannel = createMessageChannelYield();
把上面的 yieldToMain() 替换为 yieldByMessageChannel() 即可。
2.3 requestIdleCallback:能用,但别迷信
requestIdleCallback 的语义是“闲时做事”,非常适合做 预热、缓存、非关键数据处理。
但它的问题也很明显:
- 不可靠:可能长时间不触发(尤其在持续滚动/动画/繁忙主线程时)
- 部分移动端(尤其 iOS 场景)表现并不理想
建议至少加一个 timeout 兜底:
1function idle(timeout = 200) {2 if ("requestIdleCallback" in window) {3 return new Promise<IdleDeadline>((r) =>4 requestIdleCallback(r, { timeout })5 );6 }7 return new Promise<void>((r) => setTimeout(r, 0));8}
2.4 “输入优先”的切片:可选加强(isInputPending)
如果目标是让输入更“跟手”,可以在循环里增加一个输入检测(支持度同样需评估):
1// eslint-disable-next-line @typescript-eslint/no-explicit-any2const isInputPending = () => (navigator as any).scheduling?.isInputPending?.();3
4// 在循环里:5if (isInputPending?.()) await yieldToMain();
3) 把“必须同步”的工作变少(算法/数据结构优化)
如果你的瓶颈本质是 O(n²) / 重复遍历 / 重复计算 / 内存抖动,那么:
- Worker/切片只能“缓解体感”,吞吐仍然糟糕
- 真正的收益往往来自:复杂度降低 + 减少分配 + 增量更新
3.1 用 Map/Set 替代“反复扫描”
反例:每次都在数组里 find(潜在 O(n
1const result: Array<{ key: string; count: number }> = [];2for (const item of items) {3 const hit = result.find((x) => x.key === item.key);4 if (hit) hit.count++;5 else result.push({ key: item.key, count: 1 });6}
更稳:Map 一次遍历
1const counter = new Map<string, number>();2for (const item of items) {3 counter.set(item.key, (counter.get(item.key) || 0) + 1);4}5const result = Array.from(counter.entries()).map(([key, count]) => ({6 key,7 count,8}));
3.2 增量更新:别每次都全量重算
典型场景:
- 大列表筛选/排序
- 图表数据聚合
- 编辑器/AST 派生状态
如果每次输入都全量重算,哪怕你切片了,整体吞吐也会被拖垮。
思路通常是:
- 维护一个“可更新的数据结构”(索引、分桶、前缀和、缓存)
- 输入变化时只更新受影响的部分(增量 diff)
3.3 减少内存分配:让 GC 不成为“隐形长任务”
一些非常常见的内存陷阱:
- 在循环里不断创建临时对象/临时数组
- 大数组频繁
splice/unshift(移动大量元素) - 大对象反复
JSON.stringify/parse
经验建议:
- 对大数组优先用“批处理 + 新数组”或“标记删除”,避免在头部/中部频繁插删
- 对热路径尽量避免“每次都创建新对象”(可以复用缓冲区、复用 TypedArray)
- 大 JSON:能 Worker 就 Worker;否则至少减少 parse 次数、做缓存
4) DOM/渲染相关:避免布局抖动与长渲染
很多“阻塞”其实是主线程在做 Style / Layout / Paint / Composite,尤其是你在 JS 里触发了强制同步布局(forced reflow)。
4.1 读写分离:避免 forced reflow
反例:写后立刻读(容易触发同步布局)
1for (const el of elements) {2 el.style.width = "200px";3 // 这里读布局信息,浏览器可能被迫马上计算布局4 const h = el.offsetHeight;5 console.log(h);6}
推荐:先读后写(批量)
1const heights = elements.map((el) => el.offsetHeight);2for (let i = 0; i < elements.length; i++) {3 elements[i].style.width = heights[i] > 100 ? "240px" : "200px";4}
4.2 大列表:虚拟列表(只渲染可视区域)
大列表“全量渲染”会直接把主线程压爆:创建大量 DOM、计算样式、布局、绘制都很重。
React 生态里常见方案是 @tanstack/react-virtual / react-window 等。下面给一个最小示例(思路相同,库可替换):
1import * as React from "react";2import { useVirtualizer } from "@tanstack/react-virtual";3
4export function VirtualList({ items }: { items: string[] }) {5 const parentRef = React.useRef<HTMLDivElement>(null);6
7 const v = useVirtualizer({8 count: items.length,9 getScrollElement: () => parentRef.current,10 estimateSize: () => 36,11 overscan: 8,12 });13
14 return (15 <div ref={parentRef} style={{ height: 520, overflow: "auto" }}>16 <div17 style={{18 height: v.getTotalSize(),19 position: "relative",20 width: "100%",21 }}22 >23 {v.getVirtualItems().map((row) => (24 <div25 key={row.key}26 style={{27 position: "absolute",28 top: 0,29 left: 0,30 width: "100%",31 transform: `translateY(${row.start}px)`,32 height: row.size,33 display: "flex",34 alignItems: "center",35 padding: "0 12px",36 boxSizing: "border-box",37 }}38 >39 {items[row.index]}40 </div>41 ))}42 </div>43 </div>44 );45}
4.3 动画:只动 transform/opacity(避免触发布局)
原则:
- 尽量避免动画期间改变
width/height/top/left等会触发布局的属性 - 使用
transform/opacity通常能让浏览器走更轻的合成路径(不保证,但概率更高)
5) 事件处理:节流/防抖 + 输入优先
适用:scroll、mousemove、resize、input 等高频事件。
5.1 passive: true:避免滚动/触摸被阻塞
1window.addEventListener("touchmove", onMove, { passive: true });2window.addEventListener("wheel", onWheel, { passive: true });
直觉解释:非 passive 的监听器,浏览器需要等你回调执行完,才能确定是否调用
preventDefault(),这会直接影响滚动流畅度。
5.2 用 rAF 节流(比 setTimeout 更贴近渲染节奏)
1function throttleByRaf<T extends (...args: any[]) => void>(fn: T): T {2 let scheduled = false;3 let lastArgs: any[] | null = null;4
5 return function (this: unknown, ...args: any[]) {6 lastArgs = args;7 if (scheduled) return;8 scheduled = true;9 requestAnimationFrame(() => {10 scheduled = false;11 fn.apply(this, lastArgs as any);12 });13 } as T;14}
5.3 防抖:输入停止后再做重计算(并支持取消)
1function debounce<T extends (...args: any[]) => void>(fn: T, delay = 200): T {2 let t: number | undefined;3 return function (this: unknown, ...args: any[]) {4 window.clearTimeout(t);5 t = window.setTimeout(() => fn.apply(this, args), delay);6 } as T;7}
更工程化的做法是“输入触发 → 取消上一轮后台任务 → 开启新一轮”,例如:
1let controller = new AbortController();2
3function onInput(q: string) {4 controller.abort();5 controller = new AbortController();6 startHeavySearch(q, { signal: controller.signal }); // Worker / 切片任务都可以检查 signal7}
6) 分阶段加载:把“现在必须做的”缩到最小
适用:初始化很重、首屏卡顿明显。
6.1 代码分割:让重组件晚一点加载
以 Next.js 为例:
1import dynamic from "next/dynamic";2
3const HeavyChart = dynamic(() => import("./HeavyChart"), {4 ssr: false,5 loading: () => <div style={{ height: 520 }}>Loading...</div>,6});
6.2 让浏览器先画一帧:requestAnimationFrame 后再做非关键工作
一个朴素但有效的技巧:
1requestAnimationFrame(() => {2 // 第一帧之后3 requestAnimationFrame(() => {4 // 再让一帧(更稳),此时通常首屏已可交互5 warmupNonCriticalWork();6 });7});
注意:这不是银弹。它的意义是“先给用户一个能动的界面”,让重活稍后发生;真正的重活仍建议 Worker / 切片。
7) 诊断与度量:用工具定位“真正的重任务”
7.1 DevTools Performance:先看 Long Task 从哪来
建议路径:
- Record 一段卡顿操作
- 看 Main thread flame chart
- 重点关注:
- Long Task(>50ms)
- JS 执行 / Style / Layout / Paint / GC 分布
- 是否存在 forced reflow(通常表现为你写了样式后立刻读布局)
7.2 线上监控:用 PerformanceObserver 监听 longtask(可选)
1export function observeLongTask(2 onLongTask: (duration: number, entry: PerformanceEntry) => void3) {4 if (!("PerformanceObserver" in window)) return;5
6 const observer = new PerformanceObserver((list) => {7 for (const entry of list.getEntries()) {8 if (entry.duration > 50) onLongTask(entry.duration, entry);9 }10 });11
12 // 部分浏览器用 entryTypes,部分支持 type/buffered13 try {14 // @ts-expect-error - longtask typing may be missing depending on TS lib15 observer.observe({ type: "longtask", buffered: true });16 } catch {17 observer.observe({ entryTypes: ["longtask"] });18 }19
20 return () => observer.disconnect();21}
线上要注意:采样率、性能开销、隐私与数据脱敏,不要把用户输入/URL 参数等敏感信息带出去。
7.3 自己量:performance.mark/measure 给关键路径打点
1performance.mark("heavy:start");2doHeavyWork();3performance.mark("heavy:end");4performance.measure("heavy", "heavy:start", "heavy:end");5
6for (const m of performance.getEntriesByName("heavy")) {7 console.log("heavy cost(ms):", m.duration);8}
一套实战决策树(怎么选)
- **能放 Worker 吗?**能 → Worker + Transferable(最优)
- 不能放 Worker → **能切片吗?**能 → time slicing(MessageChannel / scheduler)
- 切片也不行 → **能减少 DOM/布局吗?**虚拟列表、批量更新、读写分离
- 仍然卡 → 算法/数据结构、减少分配、避免大对象结构化克隆、减少 GC
如果你把“重任务”的具体例子贴一下(比如:大列表渲染/JSON 解析/图片处理/排序聚合/加密压缩/编辑器 AST 等),我可以按你的场景给一套更具体的落地方案:建议用 Worker 还是切片、切片粒度、数据传输方式,以及更贴近你业务的数据结构与代码组织。