文章

长任务监控与优化深度解析:从 Long Tasks API 到时间切片的艺术

长任务监控与优化深度解析:从 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 的任务”定义为长任务——它们是造成用户感知卡顿的根本元凶。

真实的卡顿场景

一个电商首页加载时可能同时发生以下”长任务风暴”:

  1. 数据层:解析 2MB 的 SSR 水合 JSON
  2. 组件层:React 对数千个 DOM 节点进行 diff 和 reconciliation
  3. 第三方脚本:数据埋点 SDK 注入、AB 测引擎计算
  4. 布局层:浏览器重新计算样式(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 读取样式属性(如 offsetHeightgetComputedStyle)时,如果当前有排队的样式变更未执行,浏览器被迫同步执行布局计算。

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。你会如何排查和修复?

解答思路

排查阶段

  1. 使用 Performance panel 录制交互过程,定位最长的长任务
  2. 查看长任务的 attribution 确认来源(self / 第三方脚本 / iframe)
  3. 记录每个长任务发生时的 CPU Profile,找到耗时最长的函数调用栈
  4. 分析长任务的类型:是 JS 纯计算、DOM 操作、还是 Forced Reflow

修复手段(按优先级排序)

  1. 懒加载:将非首屏内容延迟加载,避免在关键渲染路径上执行大量 JS
  2. 代码拆分:使用 dynamic import() 将大模块拆小,配合路由级别的懒加载
  3. Web Worker:将数据解析、格式化等非 UI 任务移到 Worker 中
  4. 时间切片:对必须一次性处理的大数据,按 5ms/10ms 时间片拆分
  5. 减少不必要的 DOM 操作:批量读写、使用 DocumentFragment、避免 Forced Reflow
  6. 第三方脚本排期:将埋点、AB 测等 SDK 通过 asyncdefer 特性加载,或使用 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 需要尽早注册,但由于 longtaskbuffered: 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 运行而不受主线程影响。将动画样式改为 transformopacity 隔离到合成器层面
  • OffscreenCanvas:将 Canvas 绘制任务从主线程移到 Worker,使用 OffscreenCanvas.transferControlToOffscreen() 实现多线程渲染
  • 用户态调度器(User-level Scheduler):WICG 正在推进的 Scheduler API(Chrome 87+ 已实现 scheduler.yield()),提供标准的让出控制权 API,无需再依赖 setTimeout/rAF hack 实现切片
  • IsInputPending:同样处于 Proposal 阶段的 navigator.scheduling.isInputPending() 可以主动检测是否有待处理的用户输入,在长任务中主动让出主线程

掌握长任务优化,本质上就是在对抗”任务粒度”与”用户感知”之间的摩擦。理解每一毫秒的去向,是前端性能工程师的必修课。

本文由作者按照 CC BY 4.0 进行授权