前端页面性能优化(四)

前端页面性能优化之运行时性能与交互流畅

在前几篇中,我们主要解决了两个问题:

  • 页面能不能尽快“看见东西”(首屏、图片、资源缓存等)
  • 资源能不能“高效传输”(格式、压缩、CDN、缓存策略等)

但用户在真正使用页面时,还会碰到另外一类体验问题:

  • 点击按钮后半天没反应。
  • 滚动列表时卡顿、掉帧。
  • 动画不连贯、拖动不跟手。

这就涉及到本文要引入的主题:

运行时性能与交互流畅:在页面已经加载完成之后,如何让一切操作“顺滑自然”。


一、浏览器主线程与帧率

理解运行时性能的核心,是搞清楚这两个概念:

  • 主线程(Main Thread):大多数 JS 执行、样式计算、布局(Layout)、绘制(Paint)都在这里进行。
  • 帧率(FPS):页面每秒钟更新画面的次数,目标通常是 60 FPS(即每帧约 16.6 ms)。

当主线程被长时间占用时,会出现:

  • JS 长任务执行时间过长。
  • 布局 / 绘制延后或积压。
  • 用户输入事件响应延迟。

这些都会表现为:

  • 卡顿、掉帧、界面“卡住不动”。
  • 点击无响应或“延迟响应”。

因此,运行时优化的大方向可以描述为:

减少主线程压力,把复杂 / 耗时的计算拆散、延后或移出主线程。


二、重排与重绘:避免“无谓的布局工作”

浏览器的渲染过程大致包含:

  • 重排(Reflow / Layout):计算元素的布局、大小和位置。
  • 重绘(Repaint):在不改变布局的情况下重新绘制元素(如颜色、背景)。

频繁、无序的重排 / 重绘会显著影响运行时性能。

1. 避免 Layout Thrashing(布局抖动)

典型的坏例子:

1
2
3
4
for (let i = 0; i < 1000; i++) {
const height = element.clientHeight; // 读
element.style.height = height + 1 + "px"; // 写
}

读写交替会导致浏览器不断触发布局计算。

更好的做法:

  • 把读和写分离。
1
2
3
4
5
6
// 先读
const height = element.clientHeight;
const newHeight = height + 1000;

// 再写(一次性)
element.style.height = newHeight + "px";

或者批量进行 DOM 读写时,尽量使用:

  • 虚拟 DOM(React / Vue 等框架已经在做)。
  • 统一在动画帧里操作(requestAnimationFrame)。

2. 批量写入 DOM

如果你需要往 DOM 中插入大量元素,以下两种写法性能差异很大:

坏例子:

1
2
3
4
5
6
7
const list = document.getElementById("list");

data.forEach((item) => {
const li = document.createElement("li");
li.textContent = item.text;
list.appendChild(li);
});

更好的方式:

1
2
3
4
5
6
7
8
9
10
const list = document.getElementById("list");
const fragment = document.createDocumentFragment();

data.forEach((item) => {
const li = document.createElement("li");
li.textContent = item.text;
fragment.appendChild(li);
});

list.appendChild(fragment);

使用 DocumentFragment 可以将多次 DOM 操作合并为一次插入,减少重排开销。


三、使用 transform / opacity 做动画

在 CSS 动画中,常见的属性有:

  • 位置相关:top / left / right / bottom / margin
  • 大小相关:width / height
  • 变换相关:transform(如 translate / scale / rotate)。
  • 透明度:opacity

从性能角度来看:

  • 修改 top / left / width / height 等属性,通常会触发布局与重排。
  • 修改 transform / opacity,则通常只需要在合成阶段处理,不必重新布局。

因此,一个非常重要的经验是:

尽量用 transform + opacity 做动画,避免频繁改动布局相关属性。

示例对比:

坏例子(可能频繁触发布局):

1
2
3
4
5
6
7
8
9
.box {
position: absolute;
top: 0;
transition: top 0.3s ease;
}

.box.move {
top: 200px;
}

更好的做法:

1
2
3
4
5
6
7
8
.box {
transform: translateY(0);
transition: transform 0.3s ease;
}

.box.move {
transform: translateY(200px);
}

配合 will-change(慎用),可以提前让浏览器为动画做优化:

1
2
3
.box {
will-change: transform;
}

注意:will-change 会增加内存消耗,不要在大量元素上无脑使用。


四、长任务拆分:别一次性“卡死”主线程

当一个 JS 函数执行时间过长(例如超过 50 ms),就会形成一个“长任务”(Long Task),在此期间浏览器无法处理其他事件。

典型场景:

  • 一次性处理超大数组。
  • 在主线程做复杂计算(如图像处理、大量数据格式化)。
  • 繁重的同步逻辑放在初始化阶段。

1. 使用 requestIdleCallback / setTimeout 切片执行

思路:

把大的计算任务拆成多个小任务,分批执行,每次让出主线程,让浏览器有机会处理输入与渲染。

简单示例(使用 setTimeout 切片):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
function processLargeList(list) {
const chunkSize = 1000;
let index = 0;

function runChunk() {
const end = Math.min(index + chunkSize, list.length);
for (let i = index; i < end; i++) {
// 处理 list[i]
}
index = end;

if (index < list.length) {
// 让出主线程,稍后继续
setTimeout(runChunk, 0);
}
}

runChunk();
}

使用 requestIdleCallback(兼容性需自行评估):

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
function processLargeListIdle(list) {
let index = 0;

function work(deadline) {
// 在浏览器空闲时间片内执行
while (deadline.timeRemaining() > 0 && index < list.length) {
// 处理 list[index]
index++;
}

if (index < list.length) {
requestIdleCallback(work);
}
}

requestIdleCallback(work);
}

2. 把重计算移到 Web Worker

浏览器提供了 Web Worker,可以在独立线程中执行 JS,使其不会阻塞主线程。

典型适用场景:

  • 大量数据的排序、过滤、聚合。
  • 复杂的算法计算(例如路径规划、加解密)。
  • 图像处理、音频处理等。

基本用法示例:

1
2
3
4
5
6
7
8
9
// main.js
const worker = new Worker("./worker.js");

worker.postMessage({ type: "PROCESS_DATA", payload: bigData });

worker.onmessage = (event) => {
const result = event.data;
// 使用计算结果更新 UI
};
1
2
3
4
5
6
7
8
// worker.js
self.onmessage = (event) => {
const { type, payload } = event.data;
if (type === "PROCESS_DATA") {
const result = heavyCompute(payload);
self.postMessage(result);
}
};

注意:Worker 无法直接操作 DOM,需要通过 postMessage 与主线程通信。


五、列表虚拟化:不渲染“看不见的元素”

长列表是运行时性能的另一个常见痛点:

  • DOM 节点过多 → 布局与绘制成本急剧上升。
  • 滚动时频繁触发重排 / 重绘 → 掉帧卡顿。

列表虚拟化(Virtualized List) 的核心思想是:

只渲染视口内“看得见”的那一小部分元素,其余部分用占位高度模拟。

常见方案:

  • 自己实现一个简易虚拟列表。
  • 使用成熟库:
    • React:react-windowreact-virtualized
    • Vue:vue-virtual-scroller 等。

一个极简思路(伪代码):

1
2
3
4
5
6
7
8
9
10
const itemHeight = 40;

function onScroll(e) {
const scrollTop = e.target.scrollTop;
const startIndex = Math.floor(scrollTop / itemHeight);
const visibleCount = Math.ceil(containerHeight / itemHeight) + buffer;
const endIndex = startIndex + visibleCount;

// 实际渲染 [startIndex, endIndex) 之间的列表项
}

通过:

  • 容器总高度 = itemHeight * totalCount
  • 只渲染一截列表 DOM。

可以显著降低 DOM 数量和渲染压力。


六、滚动与输入事件优化

1. 使用 passive 事件监听器

滚动事件(如 touchstart / touchmove / wheel)如果没有特别声明,浏览器在调用监听器前会假设监听器可能会调用 preventDefault(),从而阻塞滚动。

通过设置 passive: true,可以告诉浏览器:

这个监听器不会阻止默认滚动行为,可以放心提前滚动。

示例:

1
2
3
4
5
6
7
window.addEventListener(
"scroll",
(e) => {
// 读 scrollTop 或做一些轻量操作
},
{ passive: true }
);

注意:使用 passive: true 的监听器中,不能再调用 e.preventDefault()

2. 对高频事件做节流 / 防抖

滚动、窗口 resize、输入框输入等事件都可能高频触发。

直接在回调中执行大量逻辑,必然会影响流畅度。

可以使用:

  • 节流(throttle):控制一定时间内只执行一次。
  • 防抖(debounce):在事件停止触发一段时间后才执行。

简单节流示例:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
function throttle(fn, delay) {
let lastTime = 0;
return function (...args) {
const now = Date.now();
if (now - lastTime > delay) {
lastTime = now;
fn.apply(this, args);
}
};
}

const onScroll = throttle(() => {
// 处理滚动逻辑
}, 100);

window.addEventListener("scroll", onScroll, { passive: true });

七、性能监控与问题排查

要想系统地优化运行时性能,仅靠“感觉”是不够的,需要配合工具和数据。

1. Lighthouse 与 Web Vitals

  • Lighthouse:Chrome DevTools 中内置的性能分析工具。
    • 可以生成性能报告,包含 LCP、CLS、TBT 等指标。
  • Web Vitals:Google 提出的核心 Web 体验指标。
    • 可以在真实用户环境中采集 FID / INP / LCP / CLS 等。

在实际项目中,可以:

  • 定期通过 Lighthouse 检查关键页面。
  • 使用 web-vitals 等库上报 RUM(Real User Monitoring)数据到监控平台。

2. Chrome DevTools Performance 面板

当你怀疑页面在某个交互时存在卡顿,可以:

  1. 打开 Chrome DevTools → Performance。
  2. 点击“Record”,执行页面中的关键操作。
  3. 停止记录后,查看:
    • Main 线程的 Timeline。
    • 有没有长任务(长条块)。
    • 哪些函数 / 脚本占用了大量时间。

通过这个面板,你可以直观地看到:

  • 某次点击操作中,JS 执行、Layout、Paint 分别消耗了多少时间。
  • 是否存在频繁的样式计算与布局。
  • 哪些第三方脚本在“拖后腿”。

3. Performance API 与自定义埋点

浏览器提供了 Performance 相关 API,可以在代码中打点:

1
2
3
4
5
6
7
8
9
performance.mark("start-render");

// 做一些渲染工作...

performance.mark("end-render");
performance.measure("render-time", "start-render", "end-render");

const measures = performance.getEntriesByName("render-time");
console.log(measures[0].duration, "ms");

你可以:

  • 为关键交互(如打开某个弹窗、加载某个模块)打点测量。
  • 将这些指标上报到监控系统,持续观察真实用户环境下的性能表现。

八、运行时性能优化实践清单

最后同样给出一个“Checklist”,方便在项目中快速巡检:

  • 布局与渲染

    • 是否有频繁的 DOM 读写交替(Layout Thrashing)?
    • 对批量 DOM 操作是否使用了 DocumentFragment / 虚拟 DOM?
    • 动画是否优先使用 transform / opacity
  • 长任务与计算

    • 是否存在超过 50 ms 的长任务?
    • 是否使用了任务切片(setTimeout / requestIdleCallback)?
    • 是否将重计算迁移到 Web Worker?
  • 列表与滚动

    • 长列表是否使用虚拟滚动?
    • 滚动事件监听是否使用了 passive: true
    • 高频事件是否做了节流 / 防抖?
  • 动画与交互

    • CSS 动画是否尽量避免 layout 属性?
    • 关键交互是否保证在 100 ms 内响应?
  • 监控与诊断

    • 是否有定期使用 Lighthouse / DevTools 分析性能?
    • 是否在真实环境采集 Web Vitals 指标?
    • 是否为关键交互埋点测量时长?

九、总结

本文聚焦在**“页面加载完成之后”**这一阶段,从:

  • 浏览器主线程与帧率
  • 重排 / 重绘与动画实践
  • 长任务拆分与 Web Worker
  • 列表虚拟化与滚动优化
  • 性能监控与诊断工具

系统地梳理了前端运行时性能优化的思路。

整个《前端页面性能优化》系列,到目前为止已经覆盖了:

  1. 图片资源优化(格式与体积)
  2. 静态资源与缓存策略(传输与复用)
  3. 加载顺序与首屏体验(用户“看见内容”的时机)
  4. 运行时性能与交互流畅(用户“用起来是否顺滑”)

有了这套体系,再结合你项目的业务特点和技术栈,相信你可以制定出一套适合自己团队的性能优化落地方案,而不仅仅是零散的“性能小技巧”。


前端页面性能优化(四)
https://sunjc.vip/2025/10/03/前端页面性能优化(四)/
作者
Sunjc
发布于
2025年10月3日
许可协议