webpack 性能优化方案 - 代码分离(分包)
摘要
代码分离(分包)
概念:将代码分离到不同的产物包中,之后可以通过按需或者并行的方式来加载这些文件。
Webpack 常用的代码分离方式:
1-多入口起点:配置多入口:使用entry配置不同的入口文件。手动来分离代码。
2-动态导入:动态导入:import() 语法动态导入模块
3-防止重复:使用Entry Dependencies
或者SplitChunksPlugin
去重和分离代码。
optimization
optimization.chunkIds:用于告知webpack模块的id采用什么算法生成。
optimization.runtimeChunk:配置runtime相关的代码,决定是否将其抽取到一个单独的chunk中
Prefetch和Preload
- prefetch(预获取):在浏览器空闲时进行低优先级的资源预加载。
- preload(预加载):用于提示浏览器尽早加载某些资源。
实践经验:
- 开发中推荐使用 prefetch。prefetch会在浏览器闲置时下载并缓存,从而在用户导航到需要这些资源的页面时能够更快地加载。
CDN
概念:内容分发网络,是一种通过分布式的服务器来加速网络内容交付的技术。
使用场景:
- 部署静态资源到CDN服务器上,然后引入对应资源
- CDN引入依赖包
代码分离
概念
问题背景:
Webpack打包后只有一个文件 bundle.js
,然后将它引入到用户界面。
问题是,bundle.js
这个文件很大,浏览器加载会很耗时间,就会有一个白屏显示。
**解决方案:**代码分离(Code Splitting)
概念:
代码分离(Code Splitting)的主要目的是将代码分离到不同的bundle中,之后可以通过按需或者并行的方式来加载这些文件。
举例:
比如默认情况下,所有的JavaScript代码(业务代码、第三方依赖、暂时没有用到的模块)在首页全部都加载,就会影响首页 的加载速度。
代码分离可以分出更小的bundle,以及控制资源加载优先级,提供代码的加载性能。
Webpack中常用的代码分离有三种:
多入口起点:使用entry配置手动分离代码。
动态导入:通过模块的内联函数调用来分离代码。
SplitChunks 防止重复:使用Entry Dependencies
或者SplitChunksPlugin
去重和分离代码。
多入口起点
配置多入口
**概念:**配置多入口。
**具体操作:**比如配置一个index.js和main.js的入口。要点如下:
- 配置 entry,设定分包
- 配置 output,设定分包产物的名称
filename: '[name]-bundle.js'
webpack.config.js
const path = require('path')
const HtmlWebpackPlugin = require('html-webpack-plugin')module.exports = {mode: 'development',devtool: false,/***************************************************************************/entry: {index: './src/index.js',main: './src/main.js'},output: {path: path.resolve(__dirname, './build'),filename: '[name]-bundle.js', clean: true},/***************************************************************************/resolve: {},devServer: {},module: {},plugins: []
}
index.html
进行包的引入
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><script defer src="./build/index-bundle.js"></script><script defer src="./build/main-bundle.js"></script>
</head>
<body><div id="root"></div>
</body>
</html>
入口依赖
问题背景:
假如我们的index.js
和main.js
都依赖两个库:lodash、dayjs
如果我们单纯地进行入口分离,那么打包后的两个bunlde都会有一份lodash和dayjs。
这样会造成资源重复占用。
**解决方案:**入口依赖(Entry Dependencies)
通过 shared
来让两份入口文件共享相同包。
具体操作:
webpack.config.js
entry: {index: {import: './src/index.js',dependOn: 'shared'},main: {import: './src/main.js',dependOn: 'shared'},shared: ['lodash', 'dayjs']
},
npm run build
之后的产出如下:
.
├── index-bundle.js
├── index.html
├── main-bundle.js
└── shared-bundle.js
可以发现,产物中多了一份 shared-bundle.js
,该文件是 index.js
和main.js
产物文件的共享的依赖包。
另外一个代码拆分的方式是动态导入:
动态导入
**概念:**webpack提供两种动态导入的方式:
- 第一种,使用ECMAScript中的 import() 语法来完成,也是目前推荐的方式;
- 第二种,使用webpack遗留的 require.ensure,目前已经不推荐使用;
使用方式:
比如我们有一个模块 bar.js,我们希望在代码运行时来加载它。
由于并不确定这个模块中的代码一定会用到,所以最好将它拆分成一个独立的js文件,从而保证不用到该内容时,浏览器不需要加载和处理该文件的js代码。
这个时候我们就可以使用动态导入。
注意:
- 在webpack中,通过动态导入时可以获取到一个对象,以下案例中是
res
。 - 真正导出的内容,在该对象的default属性中,所以我们需要做一个简单的解构;
案例:
概述:main.js
作为入口文件,通过动态引入 about
和 category
两份文件。
当加载页面时,浏览器并不会加载以上两份文件。而当用户点击按钮时,才会加载上述文件。
main.js
// index.js作为入口
const message = "Hello Main"
console.log(message)const btn1 = document.createElement('button')
const btn2 = document.createElement('button')
btn1.textContent = '关于'
btn2.textContent = '分类'
document.body.append(btn1)
document.body.append(btn2)btn1.onclick = () => {import('./router/about').then(res => {res.about()const name = res.defaultconsole.log(name)})
}btn2.onclick = () => {import('./router/category')
}
about.js
const h1 = document.createElement('h1')
h1.textContent = "About Page"
document.body.append(h1) function about() {console.log('about function exec~')
}const name = 'About'export {about
}export default name
如下所示:
1-页面一开始没加载 src_router_about_js-bundle.js
,当用户点击按钮后才进行加载
2-点击按钮后,加载about
,然后执行相关代码
btn1.onclick = () => {import('./router/about').then(res => {res.about()const name = res.defaultconsole.log(name)})
}
动态导入的文件命名:
以上动态导入的文件,在 Webpack 打包后的文件产物如下:
src_router_about_js-bundle.js
src_router_category_js-bundle.js
接下来进行一定优化。
1-chunkFilename
重命名
我们可以通过配置chunkFilename
属性来进行重命名;
webpack.config.js
module.exports = {...output: {path: path.resolve(__dirname, './build'),filename: '[name]-bundle.js',chunkFilename: '[name]_chunk.js', // 重新命名clean: true},}
在 Webpack 打包后的文件产物如下:
- 文件以 chunk 而非 bundle 为结尾
- 文件获取到的 [name] 是和id的名称保持一致
src_router_about_js_src_router_about_js_chunk.js
src_router_category_js_src_router_category_js_chunk.js
2-魔法注释(magic comments)
如果我们希望修改name的值,可以通过魔法注释(magic comments)的方式来处理
main.js
// index.js作为入口
const message = "Hello Main"
console.log(message)const btn1 = document.createElement('button')
const btn2 = document.createElement('button')
btn1.textContent = '关于'
btn2.textContent = '分类'
document.body.append(btn1)
document.body.append(btn2)btn1.onclick = () => {import(/*webpackChunkName: "About"*/'./router/about').then(res => {res.about()const name = res.defaultconsole.log(name)})
}btn2.onclick = () => {import(/*webpackChunkName: "Category"*/'./router/category')
}
在 Webpack 打包后的文件产物如下:
About_chunk.js
Category_chunk.js
另外一种分包的模式是splitChunk:
SplitChunks
问题背景:
main.js
的依赖包文件和代码文件,即如下A和B两部分。打包时候都会被包入main-bundle.js
我们想要将依赖包和核心代码文件进行分开打包,就可以通过 SplitChunks 来进行打包。
概念:
SplitChunks
用于代码分割,从而优化打包输出,来提高加载效率和重复使用率。
SplitChunks
底层是使用 SplitChunksPlugin 来实现,该插件webpack已经默认安装和集成。
我们只需要提供SplitChunksPlugin相关的配置信息即可。
基础配置:
Webpack提供了SplitChunksPlugin
默认的配置,我们也可以手动来修改它的配置:
optimization: {splitChunks: {chunks: 'async'}
},
比如默认配置中,chunks仅仅针对于异步(async)请求,我们可以设置为initial或者all。
配置属性:
Chunks
: 指定哪些类型的代码块要进行分割
- async:默认值,只分割异步加载的代码块
- initial:只分割同步加载块
- all:同时分割同步和异步块
minSize
: 设置生成代码块的最小体积,只有大于这个字节数的代码块才会被生成。
如果一个包拆分出来达不到minSize,那么这个包就不会拆分。
maxSize
:尝试将大于此大小的代码块进一步细分,虽然这不会总能保证结果符合预期,但在某种程度上可以减少大代码块的生成。
cacheGroups
:允许您对特定的模块进行更详细的控制。您可以创建自定义规则,如:
lodash在拆分之后,并不会立即打包,而是会等到有没有其他符合规则的包一起来打包。
cacheGroups: {vendors: {test: /[\\/]node_modules[\\/]/,priority: -10},default: {minChunks: 2,priority: -20,reuseExistingChunk: true}
}
test
:匹配条件,通常是正则表达式,用于指定哪些模块适用该缓存组。
name
: 拆分包的name属性;
filename
: 拆分包的名称,可以自己使用placeholder属性;
**自定义配置:**以下定义几个关键属性:
optimization: {splitChunks: {chunks: 'all',minSize: 100,cacheGroups: {utils: {test: /utils/,filename: "[id]_utils.js"},vendors: {test: /[\\/]node_modules[\\/]/,filename: "[id]_vendors.js"}}}
},
注释提取
概念:
在 Webpack 中,将注释单独提取出来通常是为了增强代码的可读性、减少打包文件的体积,或者是为了与开源要求相符(例如某些库需要保留版权信息)。
通常,这种操作会应用于生产环境中,以优化最终输出。
默认情况下,webpack在进行分包时,有对包中的注释进行单独提取。
**实现方式:**使用 TerserPlugin
TerserPlugin
是 Webpack 默认使用的压缩工具,它允许我们自定义如何处理注释。在其配置中,有一个 extractComments
选项可以用于将注释提取到单独的文件中。
安装 terser-webpack-plugin
const TerserPlugin = require('terser-webpack-plugin')
webpack.config.js
// 优化配置
optimization: {splitChunks: {chunks: 'all',minSize: 100,cacheGroups: {utils: {test: /utils/,filename: "[id]_utils.js"},vendors: {test: /[\\/]node_modules[\\/]/,filename: "[id]_vendors.js"}}},minimize: true,minimizer: [new TerserPlugin({extractComments: true})]
},
解释
- minimize:开启代码压缩。
- extractComments:指定如何提取注释。
- terserOptions.output.comments:控制输出中保留哪些类型的注释。可以传递正则表达式,匹配需要保留的注释格式。
打包后产物的文件树,如下所示:
.
├── About_About_chunk.js
├── About_About_chunk.js.LICENSE.txt
├── Category_Category_chunk.js
├── Category_Category_chunk.js.LICENSE.txt
├── index.html
├── main-bundle.js
├── main-bundle.js.LICENSE.txt
├── vendors-node_modules_dayjs_dayjs_min_js-node_modules_lodash_lodash_js_vendors.js
└── vendors-node_modules_dayjs_dayjs_min_js-node_modules_lodash_lodash_js_vendors.js.LICENSE.txt
optimization
chunkIds
**概念:**optimization.chunkIds配置用于告知webpack模块的id采用什么算法生成。
有三个比较常见的值:
-
named:development下的默认值,一个可读的名称的id;
-
natural:按照数字的顺序使用id。产物名称的数字不是固定的。可能这次是这样,下次修改一下文件内容,数字就会发生变化。
-
deterministic:确定性的,在不同的编译中不变的短数字id
deterministic在webpack4中是没有这个值的。那个时候如果使用natural,在某些编译发生变化时,就会有问题。
思考:
当把产物部署到服务器上时,如果文件名固定,用户请求后,往后就可以通过缓存来进行读取。
如果采用 natural 的方式,则可能会导致文件名不固定,难以从缓存中读取,从而导致性能问题。
配置:
webpack.config.js
module.exports = {//...optimization: {chunkIds: 'named',},
};
**最佳实践:**开发过程中,我们推荐使用named; 打包过程中,我们推荐使用deterministic。
runtimeChunk
**概念:**配置runtime相关的代码,决定是否将其抽取到一个单独的chunk中
运行时代码(runtime):指的是在运行环境中,对模块进行解析、加载、模块信息相关的代码。
比如我们的component、bar两个通过import函数相关的代码加载,就是通过runtime代码完成的。
分离运行时代码原因:
- 缓存优化:将运行时代码分离可以减少不必要的缓存失效。例如,如果仅仅因为一个小模块的修改导致整个 bundle 文件的 hash 发生变化,那么所有内容都需要重新下载。如果 runtime 被分离,即使有小的改动,主代码文件的缓存仍然可以被更有效地利用。
- 提升加载速度:通过将 runtime 拆分到一个单独的块,可以更快地加载基础功能,而不必等待整个应用程序的主逻辑下载完毕。
抽离出来后,有利于浏览器缓存的策略:
比如我们修改了业务代码(main),那么runtime和component、bar的chunk是不需要重新加载的
比如我们修改了component、bar的代码,那么main中的代码是不需要重新加载的;
配置:
webpack.config.js
module.exports = {
// other configuration options...
optimization: {runtimeChunk: 'single',// 将运行时代码提取到一个单独的 chunk 中},
};
设置的值:
- true/multiple:针对每个入口打包一个runtime文件;
- single:打包一个runtime文件;
- 对象:name属性决定runtimeChunk的名称;
实际效果
单一 runtimeChunk:
当配置为 'single'
时,Webpack 会生成一个名为 runtime~[name].js
的文件,其中 [name]
通常是 main
或其他指定名称。这使得所有的入口点共享这一个 runtime 文件,从而提升缓存效率。
多 runtimeChunk:
当配置为 'multiple'
时,每个入口都会有自己的 runtime 文件,比如 runtime~entry1.js
和 runtime~entry2.js
,确保它们各自独立且不会互相影响。
Prefetch & Preload
问题背景:
当用户点击以下菜单栏时,会执行 1-下载文件 2-浏览器解析 3-展示页面
当用户进入首页,还没有点击菜单栏时,浏览器处于闲置状态,可以提前获取菜单栏的资源,这种操作称作预获取。
**版本日志:**webpack v4.6.0+ 增加了对预获取和预加载的支持。
概述:
在 Webpack 中,Prefetch
和 Preload
是两种用于优化资源加载的技术。
它们通过提前告知浏览器未来可能需要加载的资源来加速页面性能。
Prefetch
作用:Prefetch 会在浏览器空闲时进行低优先级的资源预加载。
在当前页面需要这些资源之前,浏览器会预先下载并缓存,从而在用户导航到需要这些资源的页面时能够更快地加载。
适用场景:
Prefetch 适用于那些不确定是否需要立即加载的资源,也就是未来可能会使用但当前不需要的资源。例如,单页应用中暂时未访问的路由组件。
Webpack 使用方式:
Webpack 提供了一个魔法注释来启用 prefetch,即在动态导入模块时添加 webpackPrefetch: true
注释。
// Example of using prefetch in Webpack
import(/* webpackPrefetch: true */ './someModule');
浏览器行为:
资源被标记为 prefetch
后,会在浏览器空闲时以低优先级进行加载,不会阻塞关键渲染路径。
Preload
作用:
Preload 是一种高优先级的加载指令,用于提示浏览器尽早加载某些资源。这些资源是当前页面中的关键资源,能够提高首屏加载速度。
适用场景:
适合那些需要立即在页面上投入使用的资源。例如,大型图片、字体文件或其他关键的 CSS/JavaScript 文件。
Webpack 使用方式:
同样通过魔法注释实现,在动态导入模块时加上 webpackPreload: true
注释。
// Example of using preload in Webpack
import(/* webpackPreload: true */ './importantModule');
浏览器行为:
资源被标记为 preload
后,浏览器会立即开始下载这些资源。由于优先级较高,如果该资源非关键路径可能导致带宽资源竞争,因此需要谨慎使用。
区别总结:
- 优先级不同:Preload 的优先级比 Prefetch 高,意味着 Preload 的资源会尽快下载,而 Prefetch 的资源会在空闲时下载。
- 适用场景:Prefetch 适用于未来可能需要的资源,而 Preload 适用于当前(或即将)需要的资源。
- 浏览器支持:不同浏览器对这两种技术的支持可能略有差异,但现代浏览器大多都支持。
合理地利用 Prefetch
和 Preload
能够显著提升网页加载性能,尤其是在复杂的应用场景下,通过优化资源的加载顺序,可以有效减少用户等待时间,提高用户体验。
文件配置:
main.js
设定预获取
btn1.onclick = () => {import(/* webpackChunkName: "About" *//* webpackPrefetch: true */'./router/about').then(res => {res.about()const name = res.defaultconsole.log(name)})
}btn2.onclick = () => {import(/* webpackChunkName: "Category" */ /* webpackPrefetch: true */'./router/category')
}
反馈:
一开始浏览器会获取 About 和 Category 两份文件
当用户点击后,会从缓存中加载相应文件
实践经验:
- 开发中推荐使用 prefetch。prefetch会在浏览器闲置时下载并缓存,从而在用户导航到需要这些资源的页面时能够更快地加载。
- 预加载则是用于子包需要和主包一块下载下来的情况。
CDN
概念
概念:
CDN,全称为内容分发网络(Content Delivery Network),是一种通过分布在多个地理位置的服务器来加速互联网上内容交付的技术。其主要目的是提高访问速度、降低网络延迟、减轻服务器压力,并提升用户体验。
工作原理:
节点分布:
- CDN 由多个分布在不同地理位置的边缘服务器组成,这些服务器通常位于靠近用户的地方。
缓存机制:
- 当用户请求某资源时,CDN 会判断该内容是否已经存在于离用户最近的缓存节点中。若存在,则直接从该节点返回资源;否则,从源服务器获取内容并缓存,以便后续请求更快地响应
负载均衡:
- CDN 可以智能地选择合适的节点来响应用户请求,以达到负载均衡的效果,确保各节点不被某一单独请求压垮。
优点:
加速访问:
- 由于 CDN 节点广泛分布在全球,用户请求可以路由到最近的服务器节点,显著缩短了数据传输距离,提高了访问速度。
减少带宽消耗:
- 通过缓存静态内容,重复访问的流量被分散到各个 CDN 节点,而不是完全依赖源站,进而降低了带宽成本。
提高可用性和冗余:
- CDN 提供了多路径、多节点的网络结构,即使一个或多个节点出现故障,系统仍能高效运行。
增强安全性:
- CDN 可以提供额外的安全层,如 DDoS 防护、Web 应用防火墙(WAF)等,有助于抵御恶意攻击。
使用方式:
在开发中,我们使用CDN主要是两种方式:
方式一:打包的所有静态资源,放到CDN服务器, 用户所有资源都是通过CDN服务器加载的;
方式二:一些第三方资源放到CDN服务器上;
购买CDN服务器
如果所有的静态资源都想要放到CDN服务器上,我们需要购买自己的CDN服务器;
目前阿里、腾讯、亚马逊、Google等都可以购买CDN服务器;
我们可以直接修改publicPath,在打包时添加上自己的CDN地址;
实现步骤:
第一步,我们可以通过webpack配置,来排除一些库的打包:
module.exports = {...output: {publicPath: 'http://xxx.com/' // CDN服务器地址},...
}
第二步,在html模块中,加入CDN服务器地址:
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title><script defer="defer" src="http://xxx.com/cdn/a.js"></script><script defer="defer" src="http://xxx.com/cdn/b.js"></script><script defer="defer" src="http://xxx.com/cdn/c.js"></script>
</head>
<body><div id="root"></div>
</body>
</html>
第三方库的CDN服务器
通常一些比较出名的开源框架都会将打包后的源码放到一些比较出名的、免费的CDN服务器上。
国际上使用比较多的是unpkg、JSDelivr、cdnjs。国内也有一个比较好用的CDN是bootcdn。
使用方式:
1-排除需要通过CDN引入的包
webpack.config.js
module.exports = {// 排除某些包不需要进行打包externals: {react: "React",// key属性名: 排除的框架的名称// value值: 从CDN地址请求下来的js中提供对应的名称axios: "axios"},
}
2-通过 CDN 引入依赖包
index.html
<!DOCTYPE html>
<html lang="en">
<head><meta charset="UTF-8"><meta http-equiv="X-UA-Compatible" content="IE=edge"><meta name="viewport" content="width=device-width, initial-scale=1.0"><title>Document</title>
</head>
<body><div id="root"></div><script src="https://cdn.bootcdn.net/ajax/libs/axios/1.2.0/axios.min.js"></script><script src="https://cdn.bootcdn.net/ajax/libs/react/18.2.0/umd/react.production.min.js"></script>
</body>
</html>