前端性能优化

328 阅读14分钟

一、衡量性能指标

要做前端性能优化,首要工作是分析和衡量页面内容,找出网站中需要优化的部分,对症下药。

image.png

  1. 加载时间

浏览器 PerformanceNavigationTiming 对象提供了关于页面加载性能各种计时的详细信息。比如可以分析 DOM 树构建完成的时间( DOMContentLoaded ) 和 页面完整的加载时间( load

如下示例,在 DOM 树中增加一个 <img /> 标签来渲染图片,其中:

  • DOMContentLoaded,是一个 DOM 事件,当浏览器完成 HTML 文档的解析,构建完成 DOM 树后触发,但不包含图片、CSS、JavaScript 等外部资源的加载。
  • onLoad,是一个 JS 事件,它在页面的所有资源(包括 HTML、CSS、图片、JavaScript 等)完全加载完成后触发。
<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
  </head>
  <body>
    <img src="https://picsum.photos/200/300" alt="" />
    
    <script>
      window.addEventListener("load", () => {
        // 新版浏览器 API:PerformanceNavigationTiming 提供了关于页面加载性能的详细信息,替代旧的 performance.timing
        if (performance.getEntriesByType) {
          const perfEntries = performance.getEntriesByType("navigation");
          if (perfEntries.length > 0) {
            const navigationEntry = perfEntries[0];
            const { domContentLoadedEventStart, loadEventStart, fetchStart } = navigationEntry;

            const DOMContentLoadedTime = domContentLoadedEventStart - fetchStart;
            console.log(`DOMContentLoaded 的执行时间:${DOMContentLoadedTime}ms`);

            const loadTime = loadEventStart - fetchStart;
            console.log(`load 页面完整的加载时间:${loadTime}ms`);
          }
        }
        // 旧版浏览器降级使用 performance.timing
        else {
          const { fetchStart, domContentLoadedEventStart, loadEventStart } = performance.timing;

          const DOMContentLoadedTime = domContentLoadedEventStart - fetchStart;
          console.log(`DOMContentLoaded 的执行时间:${DOMContentLoadedTime}ms`);

          const loadTime = loadEventStart - fetchStart;
          console.log(`load 页面完整的加载时间:${loadTime}ms`);
        }
      });
    </script>
  </body>
</html>

统计加载时间结果:

  1. 页面卡顿

当一段代码的执行占用主线程时间过长时,用户在页面上的交互就会出现卡顿,我们可以通过监控这类长任务,针对性地进行优化。

如下示例,点击按钮执行一个 1000ms 长任务,我们可以使用 PerformanceObserver 观察 event type longtask 并设置阈值。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
  </head>
  <body>
    <button id="longTaskBtn">执行longTask</button>

    <script>
      // 默认长任务
      const longTaskBtn = document.getElementById("longTaskBtn");
      function longTask() {
        const start = Date.now();
        console.log("longTask开始 start");
        while (Date.now() < 1000 + start) {}
        console.log("longTask结束 end,耗时:", Date.now() - start);
      }
      longTaskBtn.addEventListener("click", longTask);
    </script>

    <script>
      // 观察长任务
      new PerformanceObserver((list) => {
        list.getEntries().forEach((entry) => {
          // 设定卡顿阈值:执行时长大于 500 ms
          if (entry.duration > 500) {
            console.log("执行的长任务耗时:", entry.duration);
          }
        });
      }).observe({ entryTypes: ["longtask"] });
    </script>
  </body>
</html>

3. ## 性能指标

RAIL 模型

以用户为中心的性能指标

  • First Paint 首次绘制(FP)
  • First contentful paint 首次内容绘制 (FCP)
  • Largest contentful paint 最大内容绘制 (LCP)
  • First input delay 首次输入延迟 (FID)
  • Time to Interactive 可交互时间 (TTI)
  • Total blocking time 总阻塞时间 (TBT)
  • Cumulative layout shift 累积布局偏移 (CLS)

FP:首次绘制

FP 全称为 First Paint,即首次绘制,表示浏览器首次将像素渲染到屏幕上的时间点。在性能统计指标中,从用户开始访问 Web 页面的时间点到FP的时间点这段时间可以被视为白屏时间,即用户看到的都是没有任何内容的白色屏幕。FP 指标反映了页面的白屏时间,白屏时间的长短直接影响了用户的体验和满意度。

首次绘制与其他性能指标如 First Contentful Paint (FCP) 和 Largest Contentful Paint (LCP) 相关但有所不同:

  • FCP(首次内容绘制):指的是页面首次绘制文本或图像的时间点,通常在 FP 之后发生,因为它涉及到更具体的页面内容。
  • LCP(最大内容绘制):指的是页面上最大的文本块或图像元素完成绘制的时间点,它关注的是页面主要内容的可见性。

FCP:首次内容绘制

FCP 全称为 First Contentful Paint,即首次内容绘制,表示页面绘制其第一个非白色元素(如文本、图像、非空白 canvas 或 SVG)所需的时间。

在下图中,FCP 发生在第二张图时:

这个指标直接关系到用户的体验。如果 FCP 时间过长,用户将会面对长时间的空白页面,进而可能误以为网站故障,甚至选择离开并寻找其他选项。虽然 FCP 并不涵盖整个页面的加载时间,但它却反映了用户开始与页面进行视觉交互的速度。

在 Chrome DevTools 的 Lighthouse 面板中可以测量 FCP 得分:

理想的 FCP 时间应控制在 1.8 秒之内:

LCP:最大内容绘制

LCP 全称为 Largest Contentful Paint,即最大内容绘制,用于记录视窗内最大的元素绘制的时间,这个时间会随着页面渲染变化而变化,因为页面中的最大元素在渲染过程中可能会发生改变。另外,该指标会在用户第一次交互后停止记录。

在下图中,LCP 发生在第三张图时:

根据规范,LCP 考虑的元素类型包括:

  • 元素。
  • 元素内嵌的 元素。
  • 元素。
  • 使用 url() 函数加载背景图片的元素。
  • 包含文本节点或其他内嵌文本元素子元素的块级元素。

在 Chrome DevTools 的 Lighthouse 面板中可以测量 LCP 得分:

理想的 LCP 时间应控制在 2.5 秒之内:

FID:首次输入延迟

FID 全称为 First Input Delay,即首次输入延迟,是衡量网页性能的一个重要指标,它反映了用户在页面加载过程中首次与页面交互时的体验。FID 特别关注用户首次点击按钮、链接、输入字段等可交互元素时,页面响应这些交互所需的时间。

FID 仅测量输入延迟,即用户输入和浏览器开始执行事件处理程序之间的时间。

INP 是 FID 的继任指标。虽然两者都是响应能力指标,但 FID 仅测量了页面上首次互动的输入延迟,而 INP 则通过考虑所有页面互动(从输入延迟到运行事件处理程序所需的时间,再到浏览器绘制下一帧)来改进FID。这使得 INP 成为更可靠的整体响应能力指标。

理想的响应用户交互在 100ms 以内:

TTI:可交互时间

TTI 全称为 Time To Interactive,即可交互时间,用于评估页面从开始加载到用户可以顺畅地与之交互的时间点。TTI 特别关注页面的响应性和交互性,它试图捕捉用户能够开始与页面进行流畅交互的瞬间。

这个指标计算过程略微复杂,它需要满足以下几个条件

  1. 从 FCP 指标后开始计算
  2. 持续 5 秒内无长任务(执行时间超过 50 ms)且无两个以上正在进行中的 GET 请求
  3. 往前回溯至 5 秒前的最后一个长任务结束的时间

TTI 时间主要取决于以下因素:

  • 页面布局稳定,所有可见的内容都已经被加载。
  • 主线程空闲。如果还在加载脚本或处理其他任务,那么应用将不会处于交互状态。用户的点击和其他操作将被忽略(或排队)直到线程空闲。

TBT:总阻塞时间

TBT 全称为 Total Blocking Time,即总阻塞时间,用于衡量的是长任务对主线程的阻塞时间总和。即从首次内容绘制(FCP)到页面达到可交互时间(TTI)期间,主线程因运行长任务而被阻塞的总时间,因此,TBT 会对首次输入延迟有很大影响。长任务是指那些执行时间超过 50 毫秒的 JavaScript 任务,因为它们可能会阻塞页面的渲染和响应,从而影响用户体验。

在 Chrome DevTools 的 Lighthouse 面板中可以测量 TBT 得分:

减少 TBT 时间的方式包括:

  • 减轻第三方代码的影响
  • 缩短JavaScript执行时间
  • 减少主线程的工作量
  • 控制请求数量和传输大小

理想情况下,TBT 在移动设备上应低于 300 毫秒,在桌面 Web 上应低于 100 毫秒。

CLS:累积布局偏移

CLS 全称为 Cumulative Layout Shift,即累积布局偏移,用于衡量一个页面在加载过程中,由于内容的加载和渲染,导致页面布局发生多次变化的情况。具体来说,CLS 指标衡量的是页面中可见元素在加载过程中由于内容加载而发生的位置偏移。这些元素可能因为图片、广告、视频等资源的加载而发生移动。如果一个页面的元素在加载过程中频繁移动,那么这个页面的 CLS 值就会比较高,这通常不是一个好的用户体验。

注意:只有意外的布局变化才会计入 CLS 分数。如果内容在用户交互(例如点击)后移动,则不会增加 CLS。

在 Chrome DevTools 的 Lighthouse 面板中可以测量 CLS 得分

CLS 的值范围通常是从 0 到 1,其中 0 表示没有布局偏移,1 表示布局偏移非常严重。一个较低的 CLS 值意味着页面在加载过程中布局稳定,用户可以更流畅地浏览页面。

理想的 CLS 时间应控制在 0.1 之内:

CLS 分数是通过将影响分数与距离分数相乘来计算的,其中:

  • 影响分数:视口中会移动的不稳定元素的总面积占比。如果页面加载过程中有覆盖视口 60% 面积的元素移动了,那么影响分数就是 0.6。
  • 距离分数:视口中任何单一元素移动的最大距离与视口高度的比值。假设一个元素从位置(0, 100)移动到(0, 500),这导致了 400px 的垂直偏移。如果视口的高度是 1000px,那么距离分数就是 400px / 1000px = 0.4。

则累积布局偏移分数是 0.6 x 0.4 = 0.375。

影响 CLS 分数的常见的原因主要有:

  • 图片、视频和iframe没有预先设定尺寸,导致页面加载时元素位置变化。
  • 网络字体加载过程中可能产生文本短暂消失或样式未加载的文本闪烁,影响布局稳定性。
  • 动态内容(如广告、通知、订阅等)注入DOM后,尤其是网络请求之后,往往会导致页面布局发生突变。
  1. 性能监测工具

  1. 浏览器原生工具

    1. Chrome DevTools(Performance 面板) :提供了详细的性能分析功能,包括页面加载过程中的各个阶段的时间消耗、资源加载情况、JavaScript 执行情况等。
    2. Firefox DevTools:同样具备强大的性能监测能力,能帮助开发者分析页面的性能瓶颈。
  2. 开源库 / 框架

    1. Web Vitals(Google 官方库) :可埋点监测 LCP、FID 等核心指标,方便开发者在项目中集成性能监测功能。

    2. SpeedCurve、Datadog:这些工具提供了更全面的性能监测和分析功能,支持实时监控和报警。

二、性能优化的手段

  1. 网络层面

网络层面的性能优化,无疑是如何让资源体积更小加载更快,以下四方面做出建议:

  • 构建优化:基于构建工具(Webpack/Rollup/Parcel/Esbuild/Vite/Gulp)
  • 图像优化:基于图像类型(JPG/PNG/SVG/WebP/Base64)
  • 分发优化:基于内容分发网络(CDN)
  • 缓存优化:基于浏览器缓存(强缓存/协商缓存)
  1. 构建优化

我们目前用vite较多,针对vite进行分析

优化和分析构建产物可以使用 rollup-plugin-visualizer 插件

优化点做法
✅ Tree Shaking删除未使用代码(使用 ES6 import/export 模块规范)
✅ 压缩 JS使用 TerserWebpackPluginCssMinimizerPlugin 插件对 JS/CSS 代码进行压缩,降低打包资源体积
✅ 压缩 CSS
✅ 压缩图片目前vite暂时未遇到很好用的插件
✅ 按需加载路由将路由组件拆分成 chunk 也很简单,使用异步 API import() 函数导入组件,Vite 在打包时会将异步导入拆分成单独的 chunk 文件
✅ 使用按需加载库例如 lodash-es + babel-plugin-lodash
✅ 忽略 day.js/Moment.js 的多语言使用vite-plugin-ignore插件忽略
✅ 使用 CDNs将大库(如 React、Vue)用 CDN 引入,设置为 externals
  1. 图像优化

该策略主要围绕图像类型做相关处理,同时也是接入成本较低的性能优化策略。只需做到以下两点即可。

  • 图像选型:了解所有图像类型的特点及其何种应用场景最合适
  • 图像压缩:在部署到生产环境前使用工具或脚本对其压缩处理

图像选型一定要知道每种图像类型的体积/质量/兼容/请求/压缩/透明/场景等参数相对值,这样才能迅速做出判断在何种场景使用何种类型的图像。

image.png

图像压缩可在上述构建策略-压缩资源里完成,也可自行使用工具完成。由于现在大部分webpack图像压缩工具不是安装失败就是各种环境问题,所以笔者还是推荐在发布项目到生产服前使用图像压缩工具处理,这样运行稳定也不会增加打包时间。

好用的图像压缩工具如下:

工具开源收费API免费体验
QuickPicture✖️✔️✖️可压缩类型较多,压缩质感较好,有体积限制,有数量限制
ShrinkMe✖️✖️✖️可压缩类型较多,压缩质感一般,无数量限制,有体积限制
Squoosh✔️✖️✔️可压缩类型较少,压缩质感一般,无数量限制,有体积限制
TinyJpg✖️✔️✔️可压缩类型较少,压缩质感很好,有数量限制,有体积限制
TinyPng✖️✔️✔️可压缩类型较少,压缩质感很好,有数量限制,有体积限制
Zhitu✖️✖️✖️可压缩类型一般,压缩质感一般,有数量限制,有体积限制

图像策略也许处理一张图像就能完爆所有构建策略,因此是一种很廉价但极有效的性能优化策略

  1. 分发策略

CDN 优化(内容分发网络)是部署在全球各地的缓存服务器集群,将静态资源(图片、JS、CSS 等)缓存到离用户更近的服务器,提升加载速度,减少延迟。

优化点说明配置建议
① 使用 CDN 加速静态资源加快加载速度(图片、JS、CSS、字体等)将资源上传到 CDN 服务商(如阿里云、腾讯云、七牛、Cloudflare)
② 设置域名分发资源使用独立域名,防止与主站 Cookie 冲突如静态资源用 static.example.com
③ DNS 预解析提前建立连接HTML 添加:<link rel="dns-prefetch" href="//``static.example.com``">
④ preconnect提前建立 TCP + TLS 连接
⑤ CDN 缓存控制控制资源更新策略在 CDN 控制台设置 Cache-ControlExpires
  1. 缓存策略

浏览器缓存通过存储资源副本(CSS、JS、图片等),避免重复请求服务器,提升性能。

类型说明特点
① 强缓存(强制使用缓存)不发请求,直接读取缓存资源Cache-ControlExpires 控制
② 协商缓存(可能使用缓存发送请求,服务端判断是否使用缓存ETagLast-Modified 控制

更多可以看:juejin.cn/post/746885…

  1. 渲染层面

渲染层面的性能优化,无疑是如何让代码解析更好执行更快

  1. 关键渲染路径优化

  • 优化 CSS 加载:
<!-- 阻塞渲染的CSS -->
<link rel="stylesheet" href="critical.css" media="all" />
<!-- 非关键CSS异步加载 -->
<link rel="stylesheet" href="non-critical.css" media="print" onload="this.media='all'" />
  • JS 异步/延迟加载:
<script defer src="main.js"></script> <!-- 延迟执行 -->
<script async src="analytics.js"></script> <!-- 异步加载 -->

2. ### 减少重排(Reflow)与重绘(Repaint)

  • 避免频繁操作 DOM:
// ❌ 糟糕写法(触发多次重排)
for (let i = 0; i < 100; i++) {
  element.style.width = i + 'px';
}

// ✅ 使用 requestAnimationFrame 或 CSS3 动画
function animate() {
  element.style.transform = `translateX(${pos}px)`;
  pos++;
  requestAnimationFrame(animate);
}
  • 使用 transform 和 opacity(触发 GPU 加速,跳过重排)
  1. 虚拟列表优化长列表

// React 示例:react-window
import { FixedSizeList } from 'react-window';
<List height={400} itemCount={1000} itemSize={50}>
  {({ index, style }) => <div style={style}>Item {index}</div>}
</List>

4. ### 代码分割与懒加载

  • 路由级懒加载(React/Vue):
// React
const Home = React.lazy(() => import('./Home'));
// Vue
const Home = () => import('./Home.vue');
  • 图片懒加载:
<img data-src="image.jpg" class="lazyload" />
<script>
  const observer = new IntersectionObserver((entries) => {
    entries.forEach(entry => {
      if (entry.isIntersecting) {
        entry.target.src = entry.target.dataset.src;
        observer.unobserve(entry.target);
      }
    });
  });
  document.querySelectorAll('.lazyload').forEach(img => observer.observe(img));
</script>

5. ### 防抖(Debounce)与节流(Throttle)

// 防抖(连续触发只执行最后一次)
function debounce(fn, delay) {
  let timer;
  return () => {
    clearTimeout(timer);
    timer = setTimeout(fn, delay);
  };
}

// 节流(固定时间执行一次)
function throttle(fn, interval) {
  let lastTime = 0;
  return () => {
    const now = Date.now();
    if (now - lastTime >= interval) {
      fn();
      lastTime = now;
    }
  };
}

6. ### Web Worker 处理 CPU 密集型任务

// 主线程
const worker = new Worker('worker.js');
worker.postMessage({ data: largeArray });
worker.onmessage = (e) => console.log(e.data);

// worker.js
self.onmessage = (e) => {
  const result = heavyCalculation(e.data);
  self.postMessage(result);
};

7. ### 避免内存泄漏

  • 未移除的事件监听器

    •   DOM元素绑定事件后未在组件销毁或页面切换时解绑,导致持续占用内存。
  • 未清理的定时器/异步任务

    •   setInterval或setTimeout未及时清除,回调函数持续运行。
  • 闭包保留外部引用 (相关示例及解决)

    •   闭包中引用的变量未被释放,导致关联对象无法回收。
  • DOM元素残留引用

    •   已移除的DOM元素仍被JavaScript变量引用,无法被垃圾回收。
  • 全局变量滥用

    •   意外创建的全局变量(如未声明变量、this指向全局)长期占用内存。
  • 框架组件未正确卸载

    •   React/Vue组件未在unmount阶段清理副作用(如事件、订阅)。
  1. 服务端渲染(SSR)

  • Next.js (React) / Nuxt.js (Vue) 提升首屏速度
  • 流式 SSR:逐步发送 HTML 片段