运行时卡顿排查
场景
用户反馈页面在操作过程中卡顿掉帧(如滚动卡顿、输入延迟、动画不流畅),你会怎么排查和优化?
分析思路
理解卡顿的本质
浏览器渲染目标是 60fps(每帧 ≈ 16.67ms)。如果一帧的工作耗时超过这个时间,就会掉帧,用户感觉到卡顿。
一帧 16.67ms 内要完成以上所有步骤。JS 执行时间过长是最常见的卡顿原因。
第一步:Performance 面板定位
DevTools → Performance → 录制操作 → 停止录制
关键观察点:
- Main 线程:黄色三角标记的是长任务(Long Task, > 50ms)
- 帧率行:红色帧 = 掉帧,绿色 = 流畅
- 火焰图:展开看具体是哪个函数耗时长
- Summary 面板:看 Scripting / Rendering / Painting 时间占比
// 编程方式监控长任务
const observer = new PerformanceObserver((list) => {
for (const entry of list.getEntries()) {
if (entry.duration > 50) {
console.warn(`⚠️ Long Task detected: ${entry.duration.toFixed(0)}ms`, entry);
}
}
});
observer.observe({ type: 'longtask', buffered: true });
第二步:常见卡顿原因与解决方案
原因 1:JS 长任务阻塞主线程
❌ 同步处理大量数据
function processLargeList(items: DataItem[]) {
// 一次性处理 10000 条,主线程阻塞数百 ms
const result = items.map((item) => heavyCompute(item));
renderList(result);
}
✅ 时间切片(Time Slicing)
function processWithTimeSlicing(items: DataItem[], chunkSize = 100) {
let index = 0;
function processChunk() {
const chunk = items.slice(index, index + chunkSize);
chunk.forEach((item) => heavyCompute(item));
index += chunkSize;
if (index < items.length) {
requestIdleCallback(processChunk); // 空闲时处理下一批
} else {
renderList(items);
}
}
processChunk();
}
✅ 使用 scheduler.yield()(实验性 API)
async function processWithYield(items: DataItem[]) {
for (let i = 0; i < items.length; i++) {
heavyCompute(items[i]);
// 每处理 100 个,让出主线程
if (i % 100 === 0 && 'scheduler' in window) {
await (window as any).scheduler.yield();
}
}
}
✅ Web Worker 处理密集计算
// worker.ts
self.onmessage = (e: MessageEvent<DataItem[]>) => {
const result = e.data.map((item) => heavyCompute(item));
self.postMessage(result);
};
// main.ts
const worker = new Worker(new URL('./worker.ts', import.meta.url));
worker.postMessage(largeDataSet);
worker.onmessage = (e) => {
renderList(e.data);
};
原因 2:频繁触发重排(Reflow)
❌ 强制同步布局(Layout Thrashing)
function resizeElements(elements: HTMLElement[]) {
elements.forEach((el) => {
// 读 → 写 → 读 → 写... 每次读都强制浏览器重排
const width = el.offsetWidth; // 读(触发重排)
el.style.width = width * 2 + 'px'; // 写
});
}
✅ 批量读,批量写
function resizeElements(elements: HTMLElement[]) {
// 先批量读取
const widths = elements.map((el) => el.offsetWidth);
// 再批量写入(只触发一次重排)
elements.forEach((el, i) => {
el.style.width = widths[i] * 2 + 'px';
});
}
✅ 使用 requestAnimationFrame
function batchDOMUpdates(updates: Array<() => void>) {
requestAnimationFrame(() => {
updates.forEach((update) => update());
});
}
原因 3:滚动性能差
❌ scroll 事件未节流
window.addEventListener('scroll', () => {
// 每次滚动都执行:一秒可能触发 60+ 次
heavyScrollHandler();
});
✅ 节流 + passive
function throttle<T extends (...args: unknown[]) => void>(fn: T, delay: number): T {
let timer: ReturnType<typeof setTimeout> | null = null;
return ((...args: unknown[]) => {
if (!timer) {
timer = setTimeout(() => {
fn(...args);
timer = null;
}, delay);
}
}) as T;
}
window.addEventListener('scroll', throttle(scrollHandler, 16), { passive: true });
✅ CSS 优化滚动
.scroll-container {
/* 告诉浏览器内容可能随时滚动变化 */
will-change: transform;
/* 使用合成层 */
transform: translateZ(0);
/* 内容裁剪优化 */
contain: layout style paint;
}
原因 4:动画不流畅
❌ JS 定时器动画
setInterval(() => {
element.style.left = parseFloat(element.style.left) + 1 + 'px'; // 每帧触发重排
}, 16);
✅ 只用 transform 和 opacity
// JS
element.style.transform = `translateX(${x}px)`;
// CSS(更好)
// .animated { transition: transform 0.3s ease; }
✅ CSS 动画使用 GPU 加速属性
.smooth-animation {
/* 只使用 compositor-only 属性 */
transform: translateX(100px);
opacity: 0.8;
/* 避免 top/left/width/height 等触发重排的属性 */
}
原因 5:React / Vue 过度重渲染
React 重渲染排查
// 开发环境使用 React DevTools Profiler
// 或使用 why-did-you-render 库
// ✅ 用 React.memo 避免不必要的重渲染
const ExpensiveList = React.memo(function ExpensiveList({ items }: { items: Item[] }) {
return (
<ul>
{items.map((item) => (
<li key={item.id}>{item.name}</li>
))}
</ul>
);
});
// ✅ 用 useMemo 缓存计算结果
function Dashboard({ data }: { data: DataItem[] }) {
const processedData = useMemo(() => {
return data.map((item) => expensiveTransform(item));
}, [data]);
return <Chart data={processedData} />;
}
第三步:排查流程总结
常见面试问题
Q1: 什么是长任务?如何检测?
答案:
长任务是指执行时间超过 50ms 的任务。50ms 阈值来源于 RAIL 模型(Response 100ms + 一帧 16ms 的余量)。
检测方式:
- Performance 面板:黄色三角标记
- PerformanceObserver API:
observe({ type: 'longtask' }) - Long Animation Frames API(新标准):
observe({ type: 'long-animation-frame' })
Q2: 滚动卡顿的常见原因有哪些?
答案:
- scroll 事件处理太重:未节流、做了大量计算
- 未使用
{ passive: true }:浏览器等待preventDefault()判断,延迟滚动 - DOM 节点过多:数千个列表项 → 用虚拟列表
- 触发重排重绘:滚动时读取
offsetTop等 → Layout Thrashing - 大面积重绘:
position: fixed元素 +box-shadow等 - 图片解码阻塞:大量图片同时加载 → 用
loading="lazy"+decoding="async"
Q3: 如何区分 CPU 瓶颈和 GPU 瓶颈?
答案:
| 维度 | CPU 瓶颈 | GPU 瓶颈 |
|---|---|---|
| Performance 表现 | Scripting/Rendering 时间长 | Painting/Composite 时间长 |
| 典型场景 | 大量 JS 计算、DOM 操作 | 合成层过多、大面积重绘 |
| DevTools 工具 | Performance 面板 Main 线程 | Layers 面板查看合成层数量 |
| 优化方向 | 代码分割、Worker、算法优化 | 减少合成层、避免层爆炸 |
在 Performance 面板的 Summary 中看 Scripting vs Painting 时间比就能大致判断。