Skip to content

本文由 简悦 SimpRead 转码, 原文地址 mp.weixin.qq.com

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

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

image.png

一、衡量前端性能

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

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

  1. 加载时间

  2. 性能指标

  3. 长任务卡顿

  4. 浏览器 Performance 选项卡

1、加载时间

在 window.performance.timing 变量对象中记录了加载页面期间的各种计时信息。比如可以分析 DOM 树构建完成的时间(DOMContentLoaded) 和 页面完整的加载时间(load

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

  • DOMContentLoadedTime 的打印时间近为 178ms,表示 DOM 树构建完成的时间;

  • loadTime 的打印时间近为 1201ms,它会在页面所有资源( 图片资源)加载完成后执行。

<!DOCTYPE html><html lang="en">  <head>    <meta charset="UTF-8" />  </head>  <body>    <img src="https://picsum.photos/200/300" alt="" />    <script>      window.onload = () => {        const {          // 开始发起 HTTP 请求文档的时间          fetchStart,          // DOMContentLoaded 事件发生的时间          domContentLoadedEventStart,          // window.onload 事件发生的时间          loadEventStart,        } = performance.timing;        // DOM 树构建完成后触发 DOMContentLoaded 事件        const DOMContentLoadedTime = domContentLoadedEventStart - fetchStart;        console.log(`DOMContentLoaded 的执行时间:${DOMContentLoadedTime}ms`);        // 页面完整的加载时间        const loadTime = loadEventStart - fetchStart;        console.log(`页面完整的加载时间:${loadTime}ms`);      };    </script>  </body></html>

有关参考资料:PerformanceTiming[1]

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 (list) {          const perfEntries = list.getEntries();          for (const perfEntry of perfEntries) {            if (perfEntry.name === "first-paint") {              const FP = perfEntry;              console.log("首次像素 绘制时间:", FP?.startTime); // 674ms(div 设有背景图,会在元素渲染时生成 FP 指标)            } elseif (perfEntry.name === "first-contentful-paint") {              const FCP = perfEntry;              console.log("首次有效内容的绘制 绘制时间:", FCP?.startTime); // 1174ms              observer.disconnect(); // 断开观察,不再观察了            }          }        });        // 观察 paint 相关性能指标        observer.observe({ entryTypes: ["paint"] });      };    </script>  </body></html>

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 长任务,势必会阻塞浏览器渲染,导致用户在界面交互出现卡顿。

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

5、Web Worker 子线程

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

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

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

6、LRU 算法

实现业务功能时有时会涉及 已访问数据的缓存 操作,缓存存储可以是在 内存变量 或 浏览器本地缓存 中。

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

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

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

class LRUCache {constructor(max) {    this.max = max; // 缓存容量    this.cache = newMap(); // 定义缓存 Map,优化查找速度    this.keys = []; // 队列,记录最近节点的 key  }// 访问数据get(key) {    if (this.cache.has(key)) {      // 更新节点到队列尾部      this.update(key);      const val = this.cache.get(key);      return val;    }    returnundefined;  }// 添加/更新数据  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]

三、框架性能优化

现代框架(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>;}exportdefaultfunction 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>  );}exportdefaultfunction 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);exportdefaultfunction 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 批处理上下文,将多次更新合并为一次更新流程。

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 spliceChunks,下文「打包策略」中会介绍),减小资源体积,从而减少页面的白屏时间。

2、合理使用缓存

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

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

3、优化资源加载时机

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

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

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

  • 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),      newPromise((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 => {returnnewPromise((resolve, reject) => {    const script = document.createElement("script");    script.src = url;    script.onload = () => {      resolve();    };    script.onerror = () => {      reject(newError(`Failed to load script: ${url}`));    };    document.head.appendChild(script);  });};const loadLink = url => {returnnewPromise((resolve, reject) => {    const link = document.createElement("link");    link.rel = "stylesheet";    link.href = url;    link.onload = () => {      resolve();    };    link.onerror = () => {      reject(newError(`Failed to load link: ${url}`));    };    document.head.appendChild(link);  });};const showLoadingToast = () => {...};const hideLoadingToast = () => {...};

五、优化白屏

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

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

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

六、交互体验

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

  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 中有一个配置选项 spliceChunks,可以用来拆包和提取公共代码。

  1. 拆包:将一些模块拆分到单独的 chunk 文件中,如将第三方模块拆分到 vendor.js 中;

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

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

...// 拆分 chunkssplitChunks: {// 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、启用代码压缩

使用 TerserWebpackPlugin 和 CssMinimizerPlugin 插件对 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 服务器提供资源,可以减少延迟并提高加载速度。

文末

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

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

作者:风骨

https://juejin.cn/post/7429128606749949978