长任务监控与优化深度解析:从 Long Tasks API 到时间切片的艺术
一句话概括
长任务(Long Task)是浏览器主线程上占用超过 50ms 的 JavaScript 执行块,通过 Long Tasks API 捕获、时间切片(Time Slicing)技术拆分、配合性能监控与帧率分析,将卡顿问题从黑盒变为可定位、可拆分、可治理的工程问题。
背景与意义
从 RAIL 模型说起
Google 提出的 RAIL 性能模型定义了以用户为中心的性能目标:
| 阶段 | 目标 | 含义 |
|---|---|---|
| Response | < 50ms | 用户操作后的视觉反馈 |
| Animation | < 16.67ms (60fps) | 每帧的渲染与 JS 执行总时间 |
| Idle | < 50ms | 主线程空闲的时间块 |
| Load | < 1000ms | 首屏内容可见 |
其中最关键的数据是 50ms 阈值。研究表明,超过 50ms 的用户操作反馈会让人产生”迟滞感”。当主线程连续被 JavaScript 占用超过 50ms 时,浏览器无法及时执行:
- 帧渲染(requestAnimationFrame 回调)
- 用户交互事件(click/scroll/touch 的回调)
- 微任务队列的刷新
- 空闲时段工作(requestIdleCallback)
这就是为什么 2017 年 W3C 发布 PerformanceLongTaskTiming 规范(Long Tasks API),将”超过 50ms 的任务”定义为长任务——它们是造成用户感知卡顿的根本元凶。
真实的卡顿场景
一个电商首页加载时可能同时发生以下”长任务风暴”:
- 数据层:解析 2MB 的 SSR 水合 JSON
- 组件层:React 对数千个 DOM 节点进行 diff 和 reconciliation
- 第三方脚本:数据埋点 SDK 注入、AB 测引擎计算
- 布局层:浏览器重新计算样式(Recalculate Style)触发的 Forced Reflow
这些任务加起来动辄几百毫秒,主线程被完全阻塞,用户在这个窗口内点击按钮没有任何响应。
概念与定义
长任务的定义
根据 Performance Timeline Level 2 规范:
一个长任务(Long Task)是指任何在浏览器主线程上执行的、持续时间超过 50ms 的任务单元。任务包括但不限于:JavaScript 执行、HTML 解析、样式计算、布局(Layout)、绘制(Paint)以及这些阶段的组合。
关键术语
| 术语 | 含义 |
|---|---|
| Interaction to Next Paint (INP) | 从用户交互到下一次画面更新的时间,Core Web Vitals 新指标 |
| Total Blocking Time (TBT) | FCP 到 TTI 之间主线程被长任务阻塞的总时间 |
| Time to Interactive (TTI) | 页面完全可交互的时间点,Lighthouse 指标 |
| Frame Drop (丢帧) | 帧间隔超过 16.67ms 导致的视觉卡顿 |
| Time Slicing (时间切片) | 将长任务拆分为多个 ≤50ms 的短任务的技术 |
长任务的构成(PerformanceLongTaskTiming)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// PerformanceLongTaskTiming 结构
{
name: 'self' | 'same-origin-ancestor' | 'same-origin-descendant' | 'cross-origin-ancestor',
entryType: 'longtask',
startTime: 12345.6, // 开始时间(相对于导航开始)
duration: 187.3, // 持续时间(毫秒)
attribution: [
{
name: 'script',
containerType: 'iframe' | 'embed' | 'object',
containerSrc: '',
containerId: '',
containerName: '',
}
]
}
最小示例
监听并记录长任务
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
<!DOCTYPE html>
<html>
<head>
<title>Long Tasks 演示</title>
</head>
<body>
<button id="causeBlock">触发长任务</button>
<div id="log"></div>
<script>
// 使用 PerformanceObserver 监听长任务
const observer = new PerformanceObserver((list) => {
const entries = list.getEntries();
for (const entry of entries) {
const log = document.getElementById('log');
const p = document.createElement('p');
p.textContent = `[长任务] 耗时 ${entry.duration.toFixed(1)}ms ` +
`开始于 ${entry.startTime.toFixed(1)}ms ` +
`来源: ${entry.name} ` +
`归因: ${entry.attribution[0]?.containerName || '主线程'}`;
log.appendChild(p);
// 也可以通过 PerformanceObserver 上报到监控平台
reportLongTask(entry);
}
});
// 注意:longtask 的 buffered 标志位不可用,必须在页面加载前注册
observer.observe({ type: 'longtask', buffered: false });
// 触发长任务的模拟
function causeLongTask() {
const start = performance.now();
// 一个典型的"阻塞"循环:占用主线程 200ms
while (performance.now() - start < 200) {
// 模拟繁重计算
JSON.parse(JSON.stringify(
Array.from({ length: 10000 }, (_, i) => ({
id: i,
data: new Array(100).fill(Math.random()),
}))
));
}
}
document.getElementById('causeBlock').addEventListener('click', causeLongTask);
// 上报长任务(示例)
function reportLongTask(entry) {
// 在实际生产环境中,这里发送到监控后端
console.log('[监控] 上报长任务:', {
duration: entry.duration,
startTime: entry.startTime,
url: window.location.href,
timestamp: Date.now(),
});
}
</script>
</body>
</html>
核心知识点拆解
1. 长任务的根源分析
长任务的成因通常可以归为以下类别:
(a) 大计算量 JavaScript
包括:大量 DOM 操作、复杂数据处理、大规模正则匹配、循环/递归中未做切分。
典型例子:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
// ❌ 坏:30000 次 DOM 操作不加打断
const container = document.getElementById('list');
for (let i = 0; i < 30000; i++) {
const item = document.createElement('div');
item.textContent = `第 ${i} 项`;
container.appendChild(item); // 每次 append 都会触发布局
}
// ✓ 好:使用 Fragment 批量操作
const fragment = document.createDocumentFragment();
for (let i = 0; i < 30000; i++) {
const item = document.createElement('div');
item.textContent = `第 ${i} 项`;
fragment.appendChild(item);
}
container.appendChild(fragment); // 单次布局触发
(b) 强制同步布局(Forced Reflow / Layout Thrashing)
当 JS 读取样式属性(如 offsetHeight、getComputedStyle)时,如果当前有排队的样式变更未执行,浏览器被迫同步执行布局计算。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// ❌ 坏:读写交替强制布局
const boxes = document.querySelectorAll('.box');
for (const box of boxes) {
const width = box.offsetWidth; // 读 → 强制布局
box.style.width = (width + 10) + 'px'; // 写
const height = box.offsetHeight; // 读 → 再强制布局
box.style.height = (height + 10) + 'px'; // 写
}
// ✓ 好:批量读、批量写
const widths = [];
for (const box of boxes) {
widths.push(box.offsetWidth); // 一批读
}
for (let i = 0; i < boxes.length; i++) {
boxes[i].style.width = (widths[i] + 10) + 'px'; // 一批写
boxes[i].style.height = (widths[i] + 10) + 'px';
}
(c) 大型网络请求的解析
从服务端返回的 JSON 数据过大时,JSON.parse() 本身可能成为长任务。2MB 的 JSON 字符串在移动设备上解析可能需要 100~200ms。
1
2
3
4
5
// 危险信号:单次解析超过 50ms
const data = JSON.parse(largeJsonString); // 可能阻塞主线程
// 优化:如果可拆分,使用流式解析
// 或者:使用 Web Worker 在后台线程中解析
(d) 垃圾回收(GC)停顿
V8 的 Major GC(Full Mark-Compact)会触发”全停顿”(Stop-The-World),大堆内存场景下停顿可能超过 100ms。
1
2
3
4
// GC 停顿很难通过常规手段优化,但可以通过以下方式减少:
// 1. 对象池复用,减少 GC 触发频率
// 2. 避免大型临时对象在关键渲染路径上创建
// 3. 使用 ArrayBuffer / TypedArray 管理大型数据
2. 时间切片(Time Slicing)技术
时间切片的核心思想:将一个超时任务拆分为多个小任务,每个小任务执行完毕后把控制权交还给浏览器,使浏览器有机会处理渲染和事件。
实现方式一:requestAnimationFrame 切片
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
function processLargeArray(array, processFn, chunkSize = 1000) {
return new Promise((resolve) => {
let index = 0;
function processChunk() {
const end = Math.min(index + chunkSize, array.length);
for (; index < end; index++) {
processFn(array[index], index);
}
if (index < array.length) {
// 还有数据,下一帧继续
requestAnimationFrame(processChunk);
} else {
resolve();
}
}
requestAnimationFrame(processChunk);
});
}
// 使用
const bigData = Array.from({ length: 100000 }, (_, i) => ({ id: i }));
processLargeArray(bigData, (item) => {
// 渲染每个条目
renderItem(item);
});
实现方式二:requestIdleCallback + 剩余时间预算
requestIdleCallback 在浏览器空闲时回调,参数 deadline.timeRemaining() 返回当前帧剩余时间(通常为 50ms)。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
function processWithIdleCallback(array, processFn, timeBudget = 5) {
let index = 0;
function idleWork(deadline) {
// 只要还有剩余时间,就继续处理
while (deadline.timeRemaining() > 0 && index < array.length) {
processFn(array[index], index);
index++;
}
// 用 while 而不是 for 是为了能在每次循环检查 deadline
// 如果一次循环超过了 timeBudget,这循环本身会被打断
if (index < array.length) {
// 还有剩余工作,注册下次空闲回调
requestIdleCallback(idleWork, { timeout: 2000 });
}
}
requestIdleCallback(idleWork, { timeout: 2000 });
}
实现方式三:setTimeout(0) 让出主线程
这是最经典也最简单的切片方式,将任务延迟到下一个宏任务执行。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function processWithSetTimeout(array, processFn, chunkSize = 500) {
return new Promise((resolve) => {
let index = 0;
function next() {
const start = performance.now();
// 执行当前块,但不超过 10ms
while (performance.now() - start < 10 && index < array.length) {
processFn(array[index], index);
index++;
}
if (index < array.length) {
setTimeout(next, 0);
} else {
resolve();
}
}
setTimeout(next, 0);
});
}
3. 三种切片方案的对比
| 方案 | 优先级 | 后台行为 | 执行时机 | 适用场景 |
|---|---|---|---|---|
| rAF | 高(渲染前执行) | 页面不可见时暂停 | 下一帧渲染前 | 动画计算、DOM 更新 |
| rIC | 低(空闲时执行) | 后台继续但不保证 | 空闲时段 | 数据分析、日志上报 |
| setTimeout(0) | 中 | 后台也会执行 | 下一次宏任务 | 通用切片、大量数据处理 |
4. 如何自动化检测和归因长任务
生产环境中,手动排查长任务效率太低。自动化方案包括:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
// 自动化长任务监控与堆栈采样
class LongTaskMonitor {
constructor(options = {}) {
this.threshold = options.threshold || 50; // 毫秒
this.sampleInterval = options.sampleInterval || 5; // 毫秒采样间隔
this.onLongTask = options.onLongTask || console.warn;
this.observer = null;
this.backgroundTasks = 0;
}
start() {
// 方法 1:PerformanceObserver(可靠但不包含堆栈信息)
this.observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration >= this.threshold) {
this.onLongTask({
type: 'longtask',
duration: entry.duration,
startTime: entry.startTime,
attribution: entry.attribution,
stack: null, // 无法从 PerformanceObserver 获取堆栈
});
}
}
});
this.observer.observe({ type: 'longtask' });
// 方法 2:周期采样堆栈(可以获得堆栈但开销较大)
// 在长任务期间采样堆栈需要额外的检测逻辑
}
stop() {
this.observer?.disconnect();
}
}
需要注意的是,标准 PerformanceObserver 无法获取导致长任务的代码堆栈。生产环境中通常通过自己的费时检测逻辑+”定时堆栈采样”来定位源码:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
// 定时堆栈采样:模拟 CPU Profiler
function startStackSampling(interval = 5) {
const stacks = [];
const timer = setInterval(() => {
// 创建 Error 获取当前调用栈
const stack = new Error().stack;
stacks.push({
time: performance.now(),
stack,
});
}, interval);
return {
stop: () => clearInterval(timer),
getStacks: () => stacks,
};
}
实战案例:大数据表格渲染优化
这是一个真实电商后台的表格渲染优化案例。运营需要查看 5 万条订单数据,最初实现一次性渲染所有行,导致主线程阻塞超过 3 秒,点击排序或筛选后页面卡死 1~2 秒。
优化前(一次性渲染)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
class DataTable {
constructor(container, data) {
this.container = container;
this.data = data;
}
render() {
// ❌ 一次性生成 50000 行 DOM
const html = this.data.map(row => `
<tr>
<td>${row.id}</td>
<td>${row.orderNo}</td>
<td>${row.amount}</td>
<td>${row.status}</td>
<td>${row.createTime}</td>
</tr>
`).join('');
document.querySelector('tbody').innerHTML = html;
// 上面这一行加上 join 和 innerHTML 设置,总耗时约 800ms~1200ms
}
sort(field) {
// ❌ 每次排序重建全部 DOM
this.data.sort((a, b) => a[field] > b[field] ? 1 : -1);
this.render();
}
}
优化后(虚拟滚动 + 时间切片)
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
interface RowData {
id: number;
orderNo: string;
amount: number;
status: string;
createTime: string;
}
class VirtualScrollingTable {
private container: HTMLElement;
private data: RowData[];
private rowHeight = 40; // 行高(px)
private visibleRows = 20; // 可见行数
private bufferRatio = 2; // 缓冲比例
private totalHeight: number;
private tbody: HTMLElement;
private scrollTop = 0;
private cachedFragments: Map<string, DocumentFragment> = new Map();
constructor(container: HTMLElement, data: RowData[]) {
this.container = container;
this.data = data;
this.totalHeight = data.length * this.rowHeight;
// 初始化 DOM 结构
this.container.style.overflow = 'auto';
this.container.style.position = 'relative';
const table = document.createElement('table');
table.style.width = '100%';
this.tbody = document.createElement('tbody');
// 撑开滚动条
const spacer = document.createElement('div');
spacer.style.height = `${this.totalHeight}px`;
spacer.style.position = 'absolute';
spacer.style.top = '0';
spacer.style.width = '1px';
table.appendChild(this.tbody);
this.container.appendChild(spacer);
this.container.appendChild(table);
// 绑定滚动事件(节流)
this.container.addEventListener('scroll', () => {
this.onScroll();
});
this.renderVisible();
}
private onScroll() {
this.scrollTop = this.container.scrollTop;
// 使用 requestAnimationFrame 合并渲染
if (!this.pendingRender) {
this.pendingRender = true;
requestAnimationFrame(() => {
this.pendingRender = false;
this.renderVisible();
});
}
}
private pendingRender = false;
// 使用时间切片分块创建 DOM
private renderVisible() {
const startIdx = Math.max(0,
Math.floor(this.scrollTop / this.rowHeight) - this.visibleRows
);
const endIdx = Math.min(this.data.length,
startIdx + this.visibleRows * this.bufferRatio
);
const visibleData = this.data.slice(startIdx, endIdx);
// 清除全部现有子节点,用 DocumentFragment 做时间切片
this.tbody.innerHTML = '';
this.tbody.style.transform = `translateY(${startIdx * this.rowHeight}px)`;
// 时间切片:每 500 行一批,通过 requestAnimationFrame 分段渲染
this.timeSlicedRender(visibleData, startIdx);
}
private timeSlicedRender(data: RowData[], offset: number) {
const chunkSize = 500;
let current = 0;
const processNextChunk = () => {
const end = Math.min(current + chunkSize, data.length);
const fragment = document.createDocumentFragment();
for (let i = current; i < end; i++) {
const row = data[i];
const tr = document.createElement('tr');
tr.style.height = `${this.rowHeight}px`;
tr.innerHTML = `
<td>${row.id}</td>
<td>${row.orderNo}</td>
<td>${row.amount}</td>
<td><span class="status-${row.status}">${row.status}</span></td>
<td>${row.createTime}</td>
`;
fragment.appendChild(tr);
}
this.tbody.appendChild(fragment);
current = end;
if (current < data.length) {
// 使用 requestAnimationFrame 将剩余工作推迟到下一帧
requestAnimationFrame(processNextChunk);
}
};
requestAnimationFrame(processNextChunk);
}
// 排序优化:只排序数据源,不重建 DOM,让视图自动更新
sort(field: keyof RowData) {
this.data.sort((a, b) => String(a[field]) > String(b[field]) ? 1 : -1);
this.renderVisible();
}
}
通过以下优化措施:
| 优化手段 | 效果 |
|---|---|
| 虚拟滚动 | DOM 节点从 50000 降到 ~60 |
| 时间切片 | 单帧任务从 ~300ms 降到 ~8ms |
| rAF 合并 | 滚动事件频繁触发时只在帧边界执行一次 |
| DocumentFragment | 减少布局触发次数 |
长任务从优化前的 TBT 3.2 秒 降低到 TBT < 50ms。
底层原理
1. 浏览器帧生命周期与长任务的关系
浏览器的一帧(Frame)由以下阶段构成:
1
2
输入事件处理 → requestAnimationFrame → 样式计算 → 布局 → 绘制 → 合成 → requestIdleCallback
[--- 一帧约 16.67ms ----]
当 JavaScript 执行(例如一个 Event Handler)超过 16.67ms 时,它就会侵占下一帧的渲染时间。当执行超过 50ms 时,至少 3 帧无法渲染——用户明显感觉到”僵住”。
关键发现:长任务并不一定是连续的 JS 代码块。在 DevTools Performance 面板中看到的黄色长条,可能由多个事件回调 + 浏览器任务(如 HTML 解析、样式重计算)拼接而成。
例如:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
// 这些看似不连续的微任务,在浏览器眼里是一个"长任务"
setTimeout(() => {
// 1. click 事件回调开始 0ms
heavyWork(); // 执行到 30ms
// 2. 微任务队列
Promise.resolve().then(() => {
anotherHeavyWork(); // 执行到 55ms
});
// 3. 其他
doSomethingElse(); // 执行到 70ms
}, 0);
// 总耗时 70ms → 浏览器记录为长任务
浏览器在计算”任务时间”时,会把同一宏任务中连续执行的所有代码(包括微任务)合并计算,因此微任务嵌套也可能导致长任务。
2. V8 的 Ignition 解释器与 TurboFan 编译器如何影响任务时间
V8 对 JavaScript 的执行经历了”解释执行 → 热点函数编译 → 优化编译”的过程。
- 未优化阶段:Ignition 解释器逐行执行字节码,速度慢但内存占用低
- 优化阶段:TurboFan JIT 编译器将热点函数编译为优化后的机器码
当 TurboFan 对函数进行优化编译时(发生在后台线程),对主线程没有直接影响。但当 TurboFan 发现假设不成立(如 add(1, 2) 本被优化为整数加法,但后来被调用为 add(1, "hello") ),触发 Deoptimization(去优化)时,将执行栈从优化代码回退到解释器代码,这个过程虽然不是长任务的直接原因,但去优化后的代码执行速度显著下降,使得同一段代码比优化前”变成”长任务。
优化建议:保持函数的类型稳定,避免传入不同类型的参数,让 TurboFan 的优化假设持续成立。
1
2
3
4
5
6
7
8
9
10
11
12
13
// ❌ 类型不稳定 → 去优化
function sum(a, b) {
return a + b;
}
sum(1, 2); // V8 优化为整数加法
sum(1, '2'); // 去优化!之后一直使用慢速路径
// ✓ 保持类型一致
function sumNumbers(a, b) {
return a + b;
}
sumNumbers(1, 2);
sumNumbers(3, 4);
3. 浏览器的事件循环与任务队列
HTML 标准定义的事件循环处理模型(Event Loop Processing Model)包含以下队列:
1
2
3
4
5
6
7
8
9
10
11
宏任务队列 (Task Queue):
- 用户交互事件 (click/keydown/touch)
- setTimeout/setInterval 回调
- 网络请求完成 (fetch/XMLHttpRequest)
- postMessage
- requestAnimationFrame(特殊,在渲染阶段前插入)
微任务队列 (Microtask Queue):
- Promise.then/catch/finally
- MutationObserver
- queueMicrotask
浏览器每次从宏任务队列取出一个任务开始执行。关键规则:
在一个宏任务中,当 JS 执行栈清空时,会立即清空整个微任务队列。
这意味着:如果在一个宏任务中连续 Promise.resolve().then() 创建了无穷微任务链,浏览器永远无法进入下一轮事件循环,导致”死循环式”长任务。
所以时间切片的本质是:将任务拆分为多个宏任务,让浏览器在两个宏任务之间有执行渲染的机会。
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
// 错误:微任务链不会让出主线程
function badSlicing() {
Promise.resolve().then(() => {
processChunk1();
// 继续 .then() → 仍然在同一个宏任务内
Promise.resolve().then(() => {
processChunk2();
});
});
}
// 正确:使用宏任务(setTimeout)
function correctSlicing() {
setTimeout(() => processChunk1(), 0);
setTimeout(() => processChunk2(), 0);
// 在 chunk1 和 chunk2 之间,浏览器有机会渲染
}
高频面试题解析
面试题 1:Lighthouse 报告中出现了”避免长主线程任务”的警告,TBT 达到 1200ms。你会如何排查和修复?
解答思路:
排查阶段:
- 使用 Performance panel 录制交互过程,定位最长的长任务
- 查看长任务的
attribution确认来源(self / 第三方脚本 / iframe) - 记录每个长任务发生时的 CPU Profile,找到耗时最长的函数调用栈
- 分析长任务的类型:是 JS 纯计算、DOM 操作、还是 Forced Reflow
修复手段(按优先级排序):
- 懒加载:将非首屏内容延迟加载,避免在关键渲染路径上执行大量 JS
- 代码拆分:使用 dynamic import() 将大模块拆小,配合路由级别的懒加载
- Web Worker:将数据解析、格式化等非 UI 任务移到 Worker 中
- 时间切片:对必须一次性处理的大数据,按 5ms/10ms 时间片拆分
- 减少不必要的 DOM 操作:批量读写、使用 DocumentFragment、避免 Forced Reflow
- 第三方脚本排期:将埋点、AB 测等 SDK 通过
async或defer特性加载,或使用requestIdleCallback
面试题 2:Long Tasks API 有什么限制?如何突破?
解答思路:
主要限制:
(a) 无法获取堆栈:PerformanceLongTaskTiming 只提供 duration 和 attribution(来源 iframe 或容器),不提供导致长任务的源代码位置。
突破方案:结合周期性堆栈采样(如上文 startStackSampling)或使用 DevTools Protocol 连接到 Chrome 的 CPU Profiler。
(b) 跨域脚本无法归因:如果长任务来自跨域 CDN 脚本(如埋点 SDK),attribution 中 name 为 cross-origin-ancestor,无法获取具体调用栈。
突破方案:在 CDN 脚本的响应头中添加 Timing-Allow-Origin: *。
(c) 在 Worker 中不可用:Long Tasks API 只监控主线程,不适用于 Web Worker。
突破方案:在 Worker 中手动测量执行时间,通过 postMessage 通知主线程。
(d) 无法在页面加载前注册:PerformanceObserver 需要尽早注册,但由于 longtask 的 buffered: true 不可用(规范限制),会丢失页面加载早期的长任务。
突破方案:在 <head> 中以内联 <script> 的方式尽早注册 Observer。
面试题 3:实现一个函数,接收一个耗时的同步任务,在不改变调用方式的前提下,让它自动以时间切片方式执行?
解答思路:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
/**
* 将一个同步函数转换为时间切片执行版本
* @param {Function} fn - 同步函数
* @param {Object} options
* @param {number} options.budget - 每帧时间预算(ms)
* @param {number} options.totalBudget - 整体超时限制(ms)
* @returns {Function} 返回 Promise 版本的函数
*/
function makeTimeSliced(fn, options = {}) {
const { budget = 5, totalBudget = 5000 } = options;
return function (...args) {
return new Promise((resolve, reject) => {
const generator = fn.apply(this, args);
// 约定:fn 必须是 Generator Function,通过 yield 分割步骤
// 如果是普通函数,回退为直接执行
if (!generator || typeof generator.next !== 'function') {
// 不是 generator,直接执行(兜底)
try {
resolve(fn.apply(this, args));
} catch (e) {
reject(e);
}
return;
}
const startTime = performance.now();
function step() {
const frameStart = performance.now();
// 在当前帧预算内尽可能多执行
while (performance.now() - frameStart < budget) {
// 检查整体超时
if (performance.now() - startTime > totalBudget) {
reject(new Error('time-slicing timeout'));
return;
}
const { value, done } = generator.next();
if (done) {
resolve(value);
return;
}
}
// 预算用完,让出主线程
requestAnimationFrame(step);
}
// 使用 requestAnimationFrame 或 setTimeout(0)
requestAnimationFrame(step);
});
};
}
// 使用方式:
function* heavyComputation(data) {
for (let i = 0; i < data.length; i++) {
data[i] = complexTransform(data[i]);
yield; // 每个元素处理完后让出主线程
}
return data;
}
const slicedHeavy = makeTimeSliced(heavyComputation, { budget: 8 });
await slicedHeavy(new Array(100000).fill(42));
// 主线程不会长时间阻塞
注意:真正的通用方案需要结合 async/await 重写,或使用 Babel/Rewire 工具将循环自动转换为时间切片版本。这题考察的是对”时间切片思想”的理解和对 Generator 的掌握。
总结与扩展
长任务监控与优化是前端性能工程中最”硬核”的领域之一。它要求开发者理解浏览器的事件循环模型、渲染管道、V8 的 JIT 编译机制以及异步编程的底层原理。
值得进一步探索的方向:
- Renderer 与 Compositor 线程分离:Chrome 将渲染分为 Renderer 主线程和 Compositor 线程,部分动画和滚动可以在 Compositor 线程中 60fps 运行而不受主线程影响。将动画样式改为
transform和opacity隔离到合成器层面 - OffscreenCanvas:将 Canvas 绘制任务从主线程移到 Worker,使用
OffscreenCanvas.transferControlToOffscreen()实现多线程渲染 - 用户态调度器(User-level Scheduler):WICG 正在推进的 Scheduler API(Chrome 87+ 已实现
scheduler.yield()),提供标准的让出控制权 API,无需再依赖 setTimeout/rAF hack 实现切片 - IsInputPending:同样处于 Proposal 阶段的
navigator.scheduling.isInputPending()可以主动检测是否有待处理的用户输入,在长任务中主动让出主线程
掌握长任务优化,本质上就是在对抗”任务粒度”与”用户感知”之间的摩擦。理解每一毫秒的去向,是前端性能工程师的必修课。