一份完整的「前端性能优化」手册

11,589 阅读23分钟

hi,我是风骨,今天和大家分享一篇有关「前端性能优化」的话题。作为优秀的前端工程师,我们需要时刻关注页面性能指标,并持续优化

在这篇文章中,我们将深入探讨前端性能优化的各个方面,包括但不限于衡量性能指标、编程技巧、资源加载、代码分割、懒加载、缓存策略等。让我们一起来看看吧!

image.png

一、衡量前端性能

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

衡量性能的方式有以下几种:

  1. 加载时间
  2. 性能指标
  3. 长任务卡顿
  4. 浏览器 Performance 选项卡

1、加载时间

浏览器 PerformanceNavigationTiming 对象提供了关于页面加载性能各种计时的详细信息。(对应旧版本的 performance.timing 对象)比如可以分析 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>

统计加载时间结果参考:

image.png

PS:页面加载性能详细信息参考资料:PerformanceNavigationTiming

2、性能指标

分析以用户为中心的性能指标,包含 FP(首次像素绘制)、FCP(首次内容绘制)、FMP(首次有意义内容绘制)、LCP(页面中最大可见 图片或者文本块 加载时间)等

一般在 客户端渲染单页面应用 中,为了优化首屏渲染白屏时间,会重点关注 FCP(首次内容绘制) 性能指标。该绘制时长越短,说明白屏时间越少,用户打开网站的使用体验就越好。

说明:FCP 首次内容绘制 是指用户在页面中看到了有效内容。比如在 React 框架中,初始时会有一个空 id=root div 元素,此时不会计算 FCP,只有等 id=root 经过 ReactDOM render 以后,页面呈现了文本等有效内容,这时会计算出 FCP。

JS 可以通过 PerformanceObserver 观察 event type paint 来获取 FCP 指标。如下示例,初始放置一个空 div,在 1s 以后给 div 中添加有效内容(模拟框架渲染),FCP 指标会在这时生成。

<!DOCTYPE html>
<html lang="en">
  <head>
    <meta charset="UTF-8" />
    <style>
      /* 设置背景图,生成 FP 指标 */
      #root {
        height: 100px;
        background: #eee;
      }
    </style>
  </head>
  <body>
    <div id="root"></div>
    <script>
      // 模拟框架渲染,1s 后在页面呈现有效内容
      setTimeout(() => {
        root.innerHTML = "content";
      }, 1000);

      window.onload = function () {
        const observer = new PerformanceObserver(function(entryList) {
          const perfEntries = entryList.getEntries();
          for (const perfEntry of perfEntries) {
            if (perfEntry.name === "first-paint") {
              const FP = perfEntry;
              console.log("首次像素绘制 时间:", FP?.startTime); // 674ms(div 设有背景图,会在元素渲染时生成 FP 指标)
            } else if (perfEntry.name === "first-contentful-paint") {
              const FCP = perfEntry;
              console.log("首次内容绘制 时间:", FCP?.startTime); // 1174ms
              observer.disconnect(); // 断开观察,不再观察了
            }
          }
        });

        // 观察 paint 相关性能指标
        observer.observe({ entryTypes: ["paint"] });
      };
    </script>
  </body>
</html>

相关参考资料:
Paint Timing:监控内容绘制
LCP:监视屏幕上触发的元素的最大绘制
FMP:首次有意义绘制指标介绍

3、页面卡顿

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

如下示例,点击按钮执行一个 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>

4、浏览器 Performance 选项卡

除了上述通过代码进行度量性能外,还可以在 浏览器控制台 - Performance 选项卡 中查看和分析页面性能。其中包含 FCP 性能指标、页面内容绘制 的耗时统计 等。

image.png

二、代码逻辑

在特定需求场景下,我们可以通过一些 编程技巧 来提升性能网站的运行时性能。如:防抖/节流、图片懒加载、时间切片等。

1、关注复杂度分析

我们在学习算法的时候入门课就是接触复杂度分析(大 O 表示法),通过它来分析一个算法的 执行效率 和 占用内存 的好坏。

复杂度分析同样可以应用在日常开发中,它能约束你的代码编写逻辑,也会考察你的 编程思维 和 基础内功。

现有一个需求:在一组数据中查询目标值。

如果不注重代码执行效率,可能会写成下面这样,查找 N 个目标值就要执行 N 遍循环,如果不忽略系数,它的时间复杂度为 O(n^2):

const list = [
  { cityId: "bj", cityName: "北京" },
  { cityId: "sh", cityName: "上海" },
  { cityId: "gz", cityName: "广州" },
  { cityId: "sz", cityName: "深圳" },
];

const bj = list.find((item) => item.cityId === "bj");
const sh = list.find((item) => item.cityId === "sh");
const gz = list.find((item) => item.cityId === "gz");
const sz = list.find((item) => item.cityId === "sz");

而对于注重编程思维的工程师,只需进行一轮遍历并将结果存储在 Map 中实现需求(时间复杂度 O(n) ):

const list = [
  { cityId: "bj", cityName: "北京" },
  { cityId: "sh", cityName: "上海" },
  { cityId: "gz", cityName: "广州" },
  { cityId: "sz", cityName: "深圳" },
];

const cityMap = {};

list.forEach((city) => {
  cityMap[city.cityId] = city;
});

const { bj, sh, gz, sz } = cityMap;

2、防抖和节流

防抖和节流大家都不陌生,在用户频繁操作一个功能时,可以适当使用 防抖和节流 优化逻辑执行时机,避免重复执行 JS 逻辑造成页面交互阻塞。

  • 对于防抖,常见的场景是 输入框搜索查询,短时间内重复操作会 清除并重新计时 来执行回调任务;
  • 对于节流,常见的场景是 调整窗口大小,以 固定的短间隔频率 执行回调任务。

3、图片懒加载

图片懒加载也是一种网站优化手段。如果不做控制,图片全部加载发送 HTTP 请求可能会导致网站卡顿崩溃。

首次进入页面,我们只需要加载出首屏区域的图片,其他区域的图片放置一个小的默认图,后续将图片滑动到可视区域后再加载实际的图片。

懒加载可以使用 IntersectionObserver API 实现。

<img src="./loading-url.png" data-src="./url.png" alt="">

const imgList = [...document.querySelectorAll('img')];
const io = new IntersectionObserver((entries) =>{
  entries.forEach(item => {
    // isIntersecting 是一个 Boolean 值,判断目标元素当前是否进入 root 视窗(默认 root 是 window 窗口可视区域)
    if (item.isIntersecting) {
      item.target.src = item.target.dataset.src; // 真实的图片地址存放在 data-src 自定义属性上
      // 图片加载后停止监听该元素,释放内存
      io.unobserve(item.target);
    }
  });
});
// observe 监听所有 img 节点
imgList.forEach(img => io.observe(img));

4、时间切片

我们知道 浏览器的渲染 和 JS 运行在一个单线程上,如果 JS 执行一个 LongTask 长任务,势必会阻塞浏览器渲染,导致用户在界面交互出现卡顿。

因此我们需要避免长任务的执行,或按照一定规则拆分成一个个小任务通过 时间切片 来管理和执行。

有关 时间切片 的详细介绍和使用可以参考这篇文章:React Scheduler - 时间切片

5、Web Worker 子线程

如果说一个长任务仅是做一些计算的逻辑,并不一定非要在主线程上运行,那么可以选择使用 Worker 开启子线程并行执行计算任务。

常见的场景是 大文件切片上传 - 计算文件 hash,文件越大计算 hash 耗时就越长,因此这种耗时的工作可以交给 Web Worker。

利用 Web Worker 计算文件 hash 的详细介绍可以参考这里:大文件切片上传

6、LRU 算法

在做业务功能时为了提升用户体验(提升二次访问速度),会涉及 对已访问数据进行缓存 的操作,缓存存储可以是在 内存变量 或 浏览器本地缓存 中。

无论是哪种存放位置,都不宜无限制的向缓存中存储数据,存储的越多,就会导致计算机运行越来越慢。

LRU(最近最少使用)算法用于管理缓存中的数据,确保缓存集合中数据的条目在一个可控的范围内。

通过 队列 可以实现 LRU 算法。

class LRUCache {
  constructor(max) {
    this.max = max; // 缓存容量
    this.cache = new Map(); // 定义缓存 Map,优化查找速度
    this.keys = []; // 队列,记录最近节点的 key
  }

  // 访问数据
  get(key) {
    if (this.cache.has(key)) {
      // 更新节点到队列尾部
      this.update(key);
      const val = this.cache.get(key);
      return val;
    }
    return undefined;
  }

  // 添加/更新数据
  put(key, val) {
    // 1、更新数据(和 get 相似都是更新节点到队列尾部,不过多了更新 val 操作)
    if (this.cache.has(key)) {
      this.cache.set(key, val);
      this.update(key);
    }
    // 2、添加数据
    else {
      this.cache.set(key, val); // 记录到 Map 中
      this.keys.push(key); // 添加到队列尾部
      // 考虑容量是否超出
      if (this.keys.length > this.max) {
        const oldKey = this.keys.shift(); // 删除队列头部
        this.cache.delete(oldKey);
      }
    }
  }

  // 移动到队列尾部
  update(key) {
    const index = this.keys.indexOf(key);
    this.keys.splice(index, 1); // 删除旧的位置
    this.keys.push(key); // 添加到队列尾部
  }
}

测试用例如下:

const cache = new LRUCache(3);

// 1. 添加新数据,此时缓存未满
cache.put("a", 1);
cache.put("b", 2);
cache.put("c", 3);
console.log(cache.keys); // [a, b, c]

// 2. 访问已有数据
cache.get("b");
console.log(cache.keys); // [a, c, b]

// 3. 添加新数据,此时缓存已满
cache.put("d", 4);
console.log(cache.keys); // [c, b, d]

7、虚拟滚动列表

虚拟滚动列表 是一种用于优化长列表或大量数据展示的前端技术。它的核心思想是只渲染当前可视区域内的数据项,而不是一次性渲染整个列表。

这种方式可以显著减少DOM操作,降低浏览器的渲染负担,从而提高性能和用户体验。

假设我们向服务端查询到 1000 条数据,如果原样将这 1000 个数据渲染在页面上,就会生成 1000 个 DOM 节点

对于用户而言,其实并不关心是否是一次性将数据全部渲染到列表容器中,只要在容器的可视区域内的 DOM 数据能够正常看到就行。

虚拟滚动的核心优化思路:结合滚动条 top 位置和容器的可视区域,计算出这个区间的数据项,渲染到滚动容器中

有关 虚拟滚动列表 的实现原理,可以查阅这篇文章:实现虚拟滚动列表 - 优化超长列表

另外还有一个 虚拟滑动列表 也称为 无限滑动列表,和虚拟滚动列表的实现相似,适合应用在 H5 移动端,类似抖音的 短视频 推荐列表。

三、框架性能优化

现代框架(Vue、React)在渲染流程上都有它的优化策略。掌握框架的 性能优化 API 和 底层运行原理 有助于编写出执行效率更高的程序。

这里以 React 框架为例介绍一些常见的性能优化手段。

1、React 底层性能优化策略

在 React 底层,性能优化策略主要体现在:

  1. bailout 策略更新前后没有任何信息(props、state、context、type)发生变化,减少组件或子组件的 render 调用
  2. eagerState 策略本次触发更新先后值没有发生变化,不开启 render 更新渲染流程,减少不必要的更新

其中 eagerState 策略是指:在调用 setState(value) 时,若更新的 value 值和当前 state value 值相同,则不会进入 render 更新渲染流程。

bailout 策略 则是在开启更新流程后,比较发现组件和子组件身上没有信息发生变化,跳过它们的 render 操作,直接复用节点。

我们在编写组件过程中,应尽可能靠近 bailout 策略 提升执行效率,如 将「变化的部分」与「不变的部分」分离 或使用 React.memo

2、将「变化的部分」与「不变的部分」分离

主要目的是:仅让「变化的部分」的组件执行 render,而「不变的部分」的组件命中 bailout 策略 跳过 render,减少不必要的代码执行

我们先来看一个没有分离的例子:如下示例,在点击 button 触发更新后,除了 App 组件会 render 外,ExpensiveSubtree 组件也会 render。

import React, { useState } from "react";

// 假设这是一个 很庞大、渲染很耗性能 的组件
function ExpensiveSubtree() {
  console.log("Expensive render");
  return <p>ExpensiveSubtree</p>;
}

export default function App() {
  const [num, update] = useState(0);
  console.log("App render ", num);

  return (
    <div>
      <button onClick={() => update(num + 1)}>+ 1</button>
      <p>num is: {num}</p>
      <ExpensiveSubtree />
    </div>
  );
}

为了让 App 和 ExpensiveSubtree 组件命中 bailout 策略,我们将「变化的部分」抽离出来:

import React, { useState, Fragment } from "react";

// 假设这是一个 很庞大、渲染很耗性能 的组件
function ExpensiveSubtree() {
  console.log("Expensive render");
  return <p>ExpensiveSubtree</p>;
}

// 将「变化的部分 - state」抽离出来
function Num() {
  const [num, update] = useState(0);
  return (
    <Fragment>
      <button onClick={() => update(num + 1)}>+ 1</button>
      <p>num is: {num}</p>
    </Fragment>
  );
}

export default function App() {
  console.log("App render ");

  return (
    <div>
      <Num />
      <ExpensiveSubtree />
    </div>
  );
}

现在,App 和 ExpensiveSubtree 仅在首屏渲染进行 render,后续的更新只有 Num 组件进行重新 render。

3、React.memo

从上面 没有分离 的示例中我们也发现了:只要父组件(App)render,即使子组件(ExpensiveSubtree)没有发生状态更新,也会进行 render。

这是因为:父组件(App)在 render 时,调用 React.createElement 会重新给子组件(ExpensiveSubtree)分配一个全新对象 props,导致子组件没有命中 bailout 策略

React.memo 就是为了解决上述情况,它会浅比较 props 下的属性值,若属性值没有发生变化,则复用 props 引用地址,则命中 bailout 策略 跳过组件 render。

import React, { useState, memo } from "react";

// 假设这是一个 很庞大、渲染很耗性能 的组件
function ExpensiveSubtree() {
  console.log("Expensive render");
  return <p>ExpensiveSubtree</p>;
}
const ExpensiveSubtreeMemo = memo(ExpensiveSubtree);

export default function App() {
  const [num, update] = useState(0);
  console.log("App render ", num);

  return (
    <div>
      <button onClick={() => update(num + 1)}>+ 1</button>
      <p>num is: {num}</p>
      <ExpensiveSubtreeMemo />
    </div>
  );
}

4、useMemo 和 useCallback

useMemo 和 useCallback 是 React 函数组件中手动优化的方式。

  • useMemo 可以确保只有当依赖项改变时,回调函数才会执行计算新的结果,对于一些复杂的运算和转换,可以减少不必要的计算,起到优化作用

  • useCallback 可以确保只有当依赖项改变时,回调函数才会重新创建,这样可以避免每次渲染都创建新的函数实例。在将函数作为 props 传递给子组件时非常有用,函数引用没有变化,能够减少子组件不必要的 render

5、批处理更新

以 React17 为例,在异步函数中多次调用 setState 将会开启 多次 更新流程,如下示例,会打印 3 次 count。

import { useState } from "react";
import ReactDOM from "react-dom";

function App() {
  const [count, setCount] = useState(1);
  const onClick = async () => {
    setTimeout(() => {
      setCount((count) => count + 1);
      setCount((count) => count + 1);
      setCount((count) => count + 1);
    });
  };
  console.log(count);

  return <div onClick={onClick}>123, {count}</div>;
}

面对这种情况,我们需要手动给 setCount 添加 batched 批处理上下文,告诉 React 将多次更新合并为一次更新流程。

import { useState } from "react";
import ReactDOM, { unstable_batchedUpdates } from "react-dom";

function App() {
  const [count, setCount] = useState(1);
  const onClick = async () => {
    setTimeout(() => {
      unstable_batchedUpdates(() => {
        setCount((count) => count + 1);
        setCount((count) => count + 1);
        setCount((count) => count + 1);
      });
    });
  };
  console.log(count);

  return <div onClick={onClick}>123, {count}</div>;
}

从 React18 版本起,加入了 Automatic Batching 自动批处理机制,在任何环境下(包括异步回调函数)进行多次更新操作,都会合并为一次更新流程

四、资源加载

资源加载层面的优化,可以从以下方面入手:

  1. 把控资源体积:控制好资源的体积,避免某个资源过大影响页面渲染或其他资源的运行;
  2. 合理使用缓存:利用缓存提升资源的二次访问速度;
  3. 优化资源加载时机:将非首选资源延迟加载、按需加载。

1、控制资源体积

例举一个常见的场景:在 CSR 客户端渲染中,如果 main.js 资源体积过大,会导致在资源加载完成以前,页面会出现长时间的白屏。

因此对于页面的首要资源,可以适当的进行拆包(如 Webpack splitChunks,下文「打包策略」中会介绍),减小资源体积,从而减少页面的白屏时间。

2、合理使用缓存

浏览器会对 HTTP 请求过的文件进行缓存,以便下一次访问时重复使用,减少不必要的数据传输,提高页面访问速度。

缓存分为:强缓存协商缓存,像一些 JS、CSS、图片等资源文件,推荐使用 强缓存,再结合 Webpack 构建生成 hash 文件名使用版本号 在需要更新时跳过缓存。

3、优化资源加载时机

浏览器在解析 HTML 的时候,如果遇到一个没有任何特殊属性的 script 标签,就会暂停解析,先发送网络请求获取该 JS 脚本的代码内容,然后让 JS 引擎执行该代码,当代码执行完毕后恢复执行后续解析。

很明显,这种方式会阻塞后续内容的解析和执行。

我们可以给 script 标签添加 asyncdefer 属性,来改变资源的下载和执行时间,二者区别如下:

  • defer 异步下载资源,要等到整个页面在内存中正常渲染结束(DOM 结构完全生成,以及其他脚本执行完成),才会执行;
  • async 也是异步下载资源,一旦下载完,渲染引擎就会中断渲染,等执行完这个脚本以后,再继续渲染;
  • defer 是 "下载完等渲染完再执行",async 是 "下载完就执行";
  • 另外,如果有多个 defer 脚本,会按照它们在页面出现的顺序加载,而多个 async 脚本无法保证加载顺序。

除了上述优化方式外,我们还可以把 非首要资源 改成 按需加载。我举一个例子:有一个文件预览器,它是由一个 JS 脚本提供,但首屏渲染并不需要加载它,可以在用户实际预览文件的时候进行动态加载,可以再结合 Toast 提示 "资源准备中" 给用户进行友好反馈。

下面是按需加载的代码示例,在首次调用 openFilePreview 预览文件时,加载相关资源:

let previewLoadStatus = "unload"; // "unload" | "loading" | "loaded"
const openFilePreview = () => {
  // 1、避免重复加载
  if (previewLoadStatus === "loading") return;

  const previewScriptUrl = "path/to/your/script.js";
  const previewLinkUrl = "path/to/your/script.css";

  if (previewLoadStatus === "unload") {
    // 2、首次加载资源
    previewLoadStatus = "loading";
    showLoadingToast(); // Toast 提示 "正在初始化工具..."
    Promise.all([
      loadScript(previewScriptUrl),
      loadLink(previewLinkUrl),
      new Promise((resolve) => setTimeout(resolve, 500)),
    ])
      .then(() => {
        // 2.1、加载完成
        previewLoadStatus = "loaded";
        // 调用文件预览功能
        console.log("文件预览功能已加载");
      })
      .catch(() => {
        // 2.2、加载失败,下次重新加载
        previewLoadStatus = "unload";
      })
      .finally(() => {
        hideLoadingToast(); // 隐藏 Toast
      });
  } else {
    // 3、调用文件预览功能
    console.log("文件预览功能已加载");
  }
};

相关工具函数如下:

const loadScript = url => {
  return new Promise((resolve, reject) => {
    const script = document.createElement("script");
    script.src = url;
    script.onload = () => {
      resolve();
    };
    script.onerror = () => {
      reject(new Error(`Failed to load script: ${url}`));
    };
    document.head.appendChild(script);
  });
};

const loadLink = url => {
  return new Promise((resolve, reject) => {
    const link = document.createElement("link");
    link.rel = "stylesheet";
    link.href = url;
    link.onload = () => {
      resolve();
    };
    link.onerror = () => {
      reject(new Error(`Failed to load link: ${url}`));
    };
    document.head.appendChild(link);
  });
};

const showLoadingToast = () => {...};

const hideLoadingToast = () => {...};

五、优化白屏

通常我们会使用类似 create-react-app 等脚手架创建 客户端渲染(CSR)项目,广泛使用在 SPA 单页面应用中。

随着项目的开发和维护,避免不了资源体积过大,导致白屏时间越来越长。上面其实也列举了一些优化白屏的时间,如:把控资源体积、非首选资源按需加载,还有后面要介绍的 路由按需加载

如果是 To C 的应用且注重 SEO 搜索引擎优化,可以使用 服务端渲染(SSR) 来改善白屏问题。如现在成熟的服务端渲染框架:Next.jsNuxt.js

六、交互体验

在加载过程中,可以加入一些视觉观感元素,来过渡加载过程,提升使用体验。如:

  1. 呈现骨架屏(墓碑图)
  2. 加载消息提示
  3. 呈现过渡动画

七、打包策略

现在前端框架项目,都是经过构建工具(Webpack、Vite、Rollup 等)打包生成。这一步也是 优化网站访问速度的主要手段

如果你是使用 Webpack 打包工具,优化和分析构建产物可以使用 webpack-bundle-analyzer 插件。

下面是我经历过后的一些经验和建议,一起来看下~

1、按需加载路由

在一个大型应用中,通常会设定多个路由页面。默认这些页面会被 Webpack 打包到同一个 chunk 文件中(main.js)。

在这种情况下,首屏渲染时就会出现白屏时间过长问题。

因此,我们可以将每个页面路由组件,拆成单独的一个个 chunk 文件,这样 main.js 文件体积降低,在首屏加载时,不会再加载其他页面的资源,从而提升首屏渲染速度。

要将路由组件拆分成 chunk 也很简单,使用异步 API import() 函数导入组件,Webpack 在打包时会将异步导入拆分成单独的 chunk 文件。

const Home = LazyComponent("Home", () => import(/* webpackChunkName: "Home" */ "@/pages/Home"));
... 其他路由组件导入

<Route exact path="/" component={Home} />
... 其他路由组件

LazyComponent 是自行封装的一个懒加载组件,在拿到 import() 异步加载组件内容后,渲染对应组件。

2、合理进行分包

在 Webpack5 中有一个配置选项 splitChunks,可以用来拆包和提取公共代码。

  1. 拆包:将一些模块拆分到单独的 chunk 文件中,如将第三方模块拆分到 vendor.js 中;
  2. 提取公共代码:将多处引入的模块代码,提取到单独的 chunk 文件中,防止代码被重复打包,如拆分到 common.js 中。

如下是一个分包配置的示例:

...
// 拆分 chunks
splitChunks: {
  // cacheGroups 配置拆分(提取)模块的方案(里面每一项代表一个拆分模块的方案)
  cacheGroups: {
    // 禁用默认的缓存组,使用下方自定义配置
    defaultVendors: false,
    default: false,
    // 将 node_modules 中第三方模块抽离到 vendors.js 中
    vendors: {
      // chunk 名称
      name: 'chunk-vendors',
      test: /[\\/]node_modules[\\/]/,
      priority: -10,
      chunks: 'all',
    },
    // 按照模块的被引用次数,将公共模块抽离到 common.js 中
    common: {
      // chunk 名称
      name: 'chunk-common',
      priority: -20,
      chunks: "all",
      // 模块被引入两次及以上,拆分到 common chunk 中
      minChunks: 2,
    },
    // 将 React 生态体系作为一个单独的 chunk,减轻 chunk-vendors 的体积
    react: {
      test: /[\\/]node_modules[\\/](react|react-dom|redux|react-redux|history|react-router|react-router-dom)/,
      name: 'chunk-react-package',
      priority: 0,
      chunks: 'all',
      enforce: true,
    },
  }
},

3、启用代码压缩

使用 TerserWebpackPluginCssMinimizerPlugin 插件对 JS/CSS 代码进行压缩,降低打包资源体积。

const TerserWebpackPlugin = require("terser-webpack-plugin"); // JS 压缩
const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); // CSS 压缩

...
optimization: {
  minimize: isEnvProduction,
  // 配置压缩工具
  minimizer: [
    new TerserWebpackPlugin({
      // 不提取注释到单独的 .txt 文件
      extractComments: false,
      // 使用多进程并发运行以提高构建速度。并发运行的默认数量:os.cpus().length - 1。
      parallel: true,
    }),
    new CssMinimizerPlugin(), // CSS 压缩
  ],
  ...
}

4、配置特定模块查找路径

如果你的项目或老项目中有使用过 moment.js 时,默认 Webpack 打包会包含所有语言包,导致打包文件非常大。通过配置 ContextReplacementPlugin,可以仅包含特定的语言包,如 zh-cn 和 en,从而减小打包文件的大小 ‌。

plugins: [
  new webpack.ContextReplacementPlugin(/moment[/\\]locale$/, /zh-cn|zh-hk|en/),
];

5、externals 指定外部模块

比如 JQuery,一般我们为了减少 vendors chunk 文件的体积,会采用 CDN script 方式引入。

为了在模块中继续使用 import 方式引入 JQuery(import $ from 'jquery'),同时期望 Webpack 不要对路径为 jquery 的模块进行打包,可以配置 externals 外部模块选项。

module.exports = {
  //...
  externals: {
    // jquery 通过 script 引入之后,全局中即有了 jQuery 变量
    jquery: "jQuery",
  },
};

6、静态图片管理

在你的项目中可能有这样一个 loader 配置:使用 url-loader 将小于某一阈值的图片,打包成 base64 形式到 chunk 文件中。

{
  test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.webp$/],
  loader: require.resolve('url-loader'),
  options: {
    limit: '10000', // 大于10k,打包单独静态资源,否则处理成 base64
    name: 'static/media/[name].[contenthash:8].[ext]', // 图片资源添加版本号
  },
},

看着好像没啥问题,不过现在有一场景:有一组(20 个)文件封面图,平均文件体积大小在 9kb 左右,由于不满足阈值,最终会被打包成 base64 形式放到 chunk 中。

这会导致 chunk 文件中多了将近 200kb 的大小。建议这类一组文件图的情况,可以选择存放到 CDN 静态资源服务器上进行使用。

7、明确目标环境

我们编写的 ES6+ 代码会经过 Babel 进行降级转换和 polyfill 后得到 ES5 代码运行在浏览器上,不过这会增大 chunk 资源的体积。

但是如果明确我们的网站仅运行在主流浏览器上,不考虑 IE11,可以将构建目标调整为 ES2015,这样可以减少 Babel 的降级处理和 polyfill 的引入,减小打包后的 chunk 资源体积

以下是一个 Babel 配置的示例:

{
  test: /\.(js|mjs|jsx|ts|tsx)$/,
  include: [path.resolve(context, 'src')],
  loader: require.resolve('babel-loader'),
  options: {
    presets: [
      ['@babel/preset-env', {
        "targets": {
          // - 配置表示支持市场份额大于 0.2% 的浏览器,不包括已停止维护的浏览器和 Opera Mini。(此配置打包后是 es6+ 代码)
          "browsers": ["> 0.2%", "not dead", "not op_mini all"],
          // - 如果期望打包后是 es5 代码,可以使用下面配置,确保 Babel 会将代码转换为 ES5 语法,以兼容 IE 11 及更旧的浏览器。
          // "browsers": ["> 0.2%", "ie 11"]
        },
        // Babel 会根据代码中使用的特性自动引入必要的 polyfill(通过 core-js),以确保这些特性在目标环境中可用。
        "useBuiltIns": "usage",
        "corejs": 3
      }],
      '@babel/preset-react',
      '@babel/preset-typescript',
    ],
    ...
  }
},

8、优化构建速度

打包构建速度过慢,也会影响我们的工作效率。可以从以下几个方向入手:

1、配置模块查找范围

通过 resolve 选项配置模块的查找范围和文件扩展名。

// 模块解析
resolve: {
  // 解析模块时应该搜索的目录
  modules: ['node_modules'],
  extensions: ['.ts', '.tsx', '.js', '.jsx'], // 查找模块时,为不带扩展名的模块路径,指定要查找的文件扩展名
  ...
},

2、配置 babel-loader 编译范围

通过 exclude、include 配置来确保编译尽可能少的文件。

const path = require("path");
module.exports = {
  //...
  module: {
    rules: [
      {
        test: /\.js[x]?$/,
        use: ["babel-loader"],
        include: [path.resolve(__dirname, "src")],
      },
    ],
  },
};

3、开启 babel-loader 编译缓存

设置 cacheDirectory 属性开启编译缓存,避免 Webpack 在每次构建时产生高性能消耗的 Babel 编译过程。

{
  test: /\.(js|mjs|jsx|ts|tsx)$/,
  include: [path.resolve(context, 'src')],
  loader: require.resolve('babel-loader'),
  options: {
    ...
    // 开启编译缓存,缓存 loader 的执行结果(默认缓存目录:node_modules/.cache/babel-loader),提升构建速度(作用和单独使用 cache-loader 一致)
    cacheDirectory: true,
    // 配合 cacheDirectory 使用,设置 false 禁用缓存文件压缩,这会增加缓存文件的大小,但会减少压缩的消耗时间,提升构建速度。
    cacheCompression: false,
  }
},

4. 启用多进程对 JS 代码压缩

在使用 TerserWebpackPlugin 对 JS 代码进行压缩时,默认选项 parallel = true 就开启了多进程并发运行,以提高构建速度。

new TerserWebpackPlugin({
  // 使用多进程并发运行以提高构建速度。并发运行的默认数量:os.cpus().length - 1。
  parallel: true,
  ...
}),

八、基础设施

公司的基础设施,也是优化的一种手段。

  1. 开启 HTTP2:在 HTTP/1.x 中,每个请求/响应对都需要一个单独的 TCP 连接。HTTP/2 允许在单个 TCP 连接上并行交错多个请求和响应,减少了连接的开销和延迟。

  2. 开启 Gzip 压缩:如在 Nginx 服务器上,为 JS、CSS 等静态资源开启 Gzip 压缩,体积会有明显缩小。你可以通过检查网络请求的响应头中的 Content-Encoding: gzip 来确认资源是否已经被 Gzip 压缩。

  3. 使用 CDN:将静态文件、音视频、图片等分发到全球各地的服务器,通过从附近的 CDN 服务器提供资源,可以减少延迟并提高加载速度。

文末

内容挺长,希望可以给你的工作带来帮助。文章内容你觉得有用,可以点赞支持一下~

如果屏幕前的读者,你有更好的「前端性能优化」策略,欢迎在评论区发表你的观点。