前端性能优化实践指南

Original in Chinese

前端性能优化是一个老生常谈却又永远不过时的话题。这篇文章整理了我在实际项目中用到过的优化手段,从加载到渲染到运行时,尽量覆盖完整。

加载优化

资源压缩

所有文本资源都应该在部署前压缩。HTML、CSS、JavaScript 开启 gzip 或 brotli 压缩后通常能减小 60%-80% 的体积。

图片是最占带宽的资源。WebP 格式比 JPEG/PNG 小 25%-35%,AVIF 又比 WebP 小 20%。用 <picture> 标签做格式降级:

<picture>
  <source srcset="photo.avif" type="image/avif" />
  <source srcset="photo.webp" type="image/webp" />
  <img src="photo.jpg" alt="" />
</picture>

代码分割

不要把所有 JS 打成一个包。用动态 import() 按路由或功能拆分:

// 路由级别懒加载
const Dashboard = () => import("./Dashboard.svelte");

// 功能级别懒加载
button.addEventListener("click", async () => {
  const { openModal } = await import("./modal");
  openModal();
});

预加载关键资源

对首屏必需的资源用 <link rel="preload"> 提前加载,对下一页可能用到的资源用 <link rel="prefetch"> 空闲时拉取:

<link
  rel="preload"
  href="/fonts/main.woff2"
  as="font"
  type="font/woff2"
  crossorigin
/>
<link rel="prefetch" href="/next-page.js" />

渲染优化

减少 DOM 操作

频繁操作 DOM 是性能杀手。批量更新、使用 DocumentFragment、避免在循环中读布局属性:

// 差
items.forEach((item) => {
  list.appendChild(createEl(item)); // 每次 append 都触发 reflow
});

// 好
const fragment = document.createDocumentFragment();
items.forEach((item) => {
  fragment.appendChild(createEl(item));
});
list.appendChild(fragment); // 只触发一次 reflow

使用 CSS contain

contain 属性告诉浏览器某个元素及其子树与页面其他部分独立,可以单独优化布局和绘制:

.widget {
  contain: layout style paint;
}

这对列表项、卡片组件特别有效。

避免强制同步布局

读写布局属性交替进行会强制浏览器同步计算样式。用 FastDOM 模式:先批量读、再批量写:

// 差:读写在循环中交替
elements.forEach((el) => {
  const height = el.offsetHeight; // 读 → 强制布局
  el.style.height = height * 2 + "px"; // 写
});

// 好:先读后写
const heights = elements.map((el) => el.offsetHeight); // 批量读
elements.forEach((el, i) => {
  el.style.height = heights[i] * 2 + "px"; // 批量写
});

运行时优化

防抖与节流

高频事件(scroll、resize、input)必须做防抖或节流:

// 节流:固定间隔执行
function throttle(fn, ms) {
  let last = 0;
  return (...args) => {
    const now = Date.now();
    if (now - last >= ms) {
      last = now;
      fn(...args);
    }
  };
}

// 防抖:停止后执行
function debounce(fn, ms) {
  let timer;
  return (...args) => {
    clearTimeout(timer);
    timer = setTimeout(() => fn(...args), ms);
  };
}

Web Worker

耗时计算移到 Worker 线程,不阻塞主线程:

const worker = new Worker("sort-worker.js");
worker.postMessage(largeArray);
worker.onmessage = (e) => {
  renderSorted(e.data);
};

虚拟列表

当列表项超过几百条时,不要全部渲染。只渲染视口可见的部分,上下各留少量缓冲项。这是长列表性能问题的标准解法。

核心思路:根据滚动位置计算可见范围,只渲染范围内的 DOM,用 padding 或 transform 模拟完整列表的高度。

网络优化

HTTP/2 多路复用

HTTP/2 下多个请求可以复用一个 TCP 连接,不再需要域名分片。反而,过多域名会增加 DNS 解析和连接开销。尽量用单一域名。

Service Worker 缓存

用 Service Worker 拦截请求,实现离线可用和缓存策略:

self.addEventListener("fetch", (event) => {
  event.respondWith(
    caches.match(event.request).then((cached) => {
      return cached || fetch(event.request);
    }),
  );
});

缓存策略根据资源类型选择:HTML 用 network-first,JS/CSS 用 cache-first,图片用 stale-while-revalidate。

接口合并与按需

一个页面调十个接口比调一个合并接口慢得多。后端提供聚合接口,前端按需请求字段,不要一股脑全拿。

监控与度量

Core Web Vitals

Google 定义的核心指标:

  • LCP(Largest Contentful Paint):最大内容渲染时间,目标 2.5s 内
  • INP(Interaction to Next Paint):交互响应延迟,目标 200ms 内
  • CLS(Cumulative Layout Shift):布局偏移,目标 0.1 内

性能预算

给关键指标设阈值,超出就报警。比如 JS 总量不超过 200KB(压缩后),首屏 LCP 不超过 2.5s。

持续监控

Lighthouse 只能看瞬间快照。真实用户监控(RUM)才能反映实际体验。用 PerformanceObserver 采集字段数据上报:

new PerformanceObserver((list) => {
  for (const entry of list.getEntries()) {
    reportMetric(entry.name, entry.startTime);
  }
}).observe({ type: "largest-contentful-paint", buffered: true });

最后

性能优化没有银弹,每一步都是权衡。先度量,再优化,最后验证效果。盲目优化不如不优化。

保持简单,保持克制。