hi,我是风骨,今天和大家分享一篇有关「前端性能优化」的话题。作为优秀的前端工程师,我们需要时刻关注页面性能指标,并持续优化。
在这篇文章中,我们将深入探讨前端性能优化的各个方面,包括但不限于衡量性能指标、编程技巧、资源加载、代码分割、懒加载、缓存策略等。让我们一起来看看吧!
一、衡量前端性能
要做前端性能优化,首要工作是分析和衡量页面内容,找出网站中需要优化的部分,对症下药。
衡量性能的方式有以下几种:
- 加载时间
- 性能指标
- 长任务卡顿
- 浏览器 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>
统计加载时间结果参考:
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 性能指标、页面内容绘制 的耗时统计 等。
二、代码逻辑
在特定需求场景下,我们可以通过一些 编程技巧 来提升性能网站的运行时性能。如:防抖/节流、图片懒加载、时间切片等。
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 底层,性能优化策略主要体现在:
bailout 策略
:更新前后没有任何信息(props、state、context、type)发生变化,减少组件或子组件的 render 调用;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、控制资源体积
例举一个常见的场景:在 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、Nuxt.js。
六、交互体验
在加载过程中,可以加入一些视觉观感元素,来过渡加载过程,提升使用体验。如:
- 呈现骨架屏(墓碑图)
- 加载消息提示
- 呈现过渡动画
七、打包策略
现在前端框架项目,都是经过构建工具(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
,可以用来拆包和提取公共代码。
- 拆包:将一些模块拆分到单独的 chunk 文件中,如将第三方模块拆分到
vendor.js
中; - 提取公共代码:将多处引入的模块代码,提取到单独的 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、启用代码压缩
使用 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,
...
}),
八、基础设施
公司的基础设施,也是优化的一种手段。
-
开启 HTTP2:在 HTTP/1.x 中,每个请求/响应对都需要一个单独的 TCP 连接。HTTP/2 允许在单个 TCP 连接上并行交错多个请求和响应,减少了连接的开销和延迟。
-
开启 Gzip 压缩:如在 Nginx 服务器上,为 JS、CSS 等静态资源开启 Gzip 压缩,体积会有明显缩小。你可以通过检查网络请求的响应头中的
Content-Encoding: gzip
来确认资源是否已经被 Gzip 压缩。 -
使用 CDN:将静态文件、音视频、图片等分发到全球各地的服务器,通过从附近的 CDN 服务器提供资源,可以减少延迟并提高加载速度。
文末
内容挺长,希望可以给你的工作带来帮助。文章内容你觉得有用,可以点赞支持一下~
如果屏幕前的读者,你有更好的「前端性能优化」策略,欢迎在评论区发表你的观点。