【转载】一份完整的「前端性能优化」手册
作为优秀的前端工程师,我们需要时刻关注页面性能指标,并持续优化 。
在这篇文章中,我们将深入探讨前端性能优化的各个方面,包括但不限于衡量性能指标、编程技巧、资源加载、代码分割、懒加载、缓存策略等 。让我们一起来看看吧!
比如可以分析 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://picsum11.photos/200/3001" 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>统计加载时间结果参考:

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 指标会在这时生成。
```html <!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>相关参考资料:[PaintTiming:监控内容绘制](https://link.juejin.cn?target=https%3A%2F%2Fw3c.github.io%2Fpaint-timing%2F "https://w3c.github.io/paint-timing/")[LCP:监视屏幕上触发的元素的最大绘制](https://link.juejin.cn?target=https%3A%2F%2Fw3c.github.io%2Flargest-contentful-paint%2F "https://w3c.github.io/largest-contentful-paint/")[FMP:首次有意义绘制指标介绍](https://link.juejin.cn?target=https%3A%2F%2Fdocs.google.com%2Fdocument%2Fd%2F1BR94tJdZLsin5poeet0XoTW60M0SjvOJQttKT-JK8HI%2Fview%23heading%3Dh.tdqghbi9ia5d"https://docs.google.com/document/d/1BR94tJdZLsin5poeet0XoTW60M0SjvOJQttKT-JK8HI/view#heading=h.tdqghbi9ia5d")
### 3、页面卡顿
当一段代码的执行占用主线程时间过长时,用户在页面上的交互就会出现卡顿,我们可以通过监控这类长任务,针对性地进行优化。
如下示例,点击按钮执行一个 1000ms 长任务,我们可以使用 `PerformanceObserver` 观察 `event type longtask`并设置阈值。
```javascript<!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 性能指标、页面内容绘制 的耗时统计 等。
 ,通过它来分析一个算法的 执行效率 和 占用内存 的好坏。
复杂度分析同样可以应用在日常开发中,它能约束你的代码编写逻辑,也会考察你的 编程思维 和 基础内功。
现有一个需求:在一组数据中查询目标值。
如果不注重代码执行效率,可能会写成下面这样,查找 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 实现。
```js<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 -](https://juejin.cn/post/7146004454653820935)[时间切片](https://juejin.cn/post/7146004454653820935)
### 5、Web Worker 子线程
如果说一个长任务仅是做一些计算的逻辑,并不一定非要在主线程上运行,那么可以选择使用 Worker 开启子线程并行执行计算任务。
常见的场景是 **大文件切片上传 - 计算文件 hash** ,文件越大计算 hash 耗时就越长,因此这种耗时的工作可以交给 Web Worker。
利用 Web Worker 计算文件 hash的详细介绍可以参考这里:[大文件切片上传](https://juejin.cn/post/7314571744354271284#heading-34)。
### 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 位置和容器的可视区域,计算出这个区间的数据项,渲染到滚动容器中** 。
有关 虚拟滚动列表 的实现原理,可以查阅这篇文章:[实现虚拟滚动列表 -](https://juejin.cn/post/6972172870164152333)[优化超长列表](https://juejin.cn/post/6972172870164152333)
另外还有一个 **虚拟滑动列表** 也称为 无限滑动列表,和虚拟滚动列表的实现相似,适合应用在 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
ExpensiveSubtree
; }export default function App() { const [num, update] = useState(0); console.log(“App render ”, num);
return (
num is: {num}
为了让 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
面对这种情况,我们需要手动给 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` 标签添加 `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), 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.js](https://link.juejin.cn?target=https%3A%2F%2Fnextjs.org%2F)、[Nuxt.js](https://link.juejin.cn?target=https%3A%2F%2Fwww.nuxtjs.cn%2F)。
## 六、交互体验
在加载过程中,可以加入一些视觉观感元素,来过渡加载过程,提升使用体验。如:
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` 中。
如下是一个分包配置的示例:...// 拆分 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#heading-11