写在前面
在现在前端工程化的大背景下,webpack成为了最常用的打包工具之一,有一社区或者优秀团队,也都以Webpack为基础构建自己的脚手架,比如我们所熟知对的vue-cli,umijs等,通常情况下,这些脚手架多多少少会为我们配置好一些关于打包优化的东西,如果你的项目并不复杂,可能很长一段时间你都无法感知打包优化的重要性,如果忽然遇到了打包优化的问题,可能太过让人措手不及,不管你使用的是社区优秀的脚手架,还是自己基于webpack搭建的项目或者脚手架,搞懂webpack打包优化,会让我拥有解决更多高级问题的能力,也会让你的项目更加“丝滑”。
webpack打包优化
打包优化主要从两个方面下手
- 打包速度,优化打包速度,主要是提升了我们的开发效率,更快的打包构建过程,将让你保持一颗愉悦的心
- 打包大小,优化打包体积,主要是提升产品的使用体验,降低服务器资源成本,更快的页面加载,将让产品显得更加“丝滑”,同时也可以让打包更快
打包速度优化
当我们做一些较大型项目的打包时,经常会遇到,打包时间过长的
问题,让人焦急不已,那么我们就要采用一些手段来提升webpack的打包。
跟上技术的迭代(webapck,Node, Npm)
如果想要提升打包的速度,将打包技术生态中涉及的技术版本更新将是一个最简单的方式,那么为什么更新版本会提升打包速度呢?
Webpack的每次更新,必然会更新底层的一些打包原理和api来提升打包速度,更新Webpack版本将有助于提升打包速度,同事,webpack又是运行在Node环境下,如果Node版本提升,其运行效率也会提升,那么webpack运行在node之上也会有所提升的,同样,我们使用更新的Npm或者Yarn的包管理工具的话,新的包管理工具会更快的帮我们分析一下包的依赖或者包的引入,这样也会间接的提升webpack的打包速度。
在尽可能少的模块上使用Loader
{
test: /\.js$/,
loader: 'babel-loader',
}
看上面的代码,是我们在配置bable-loader
时的代码,如果这样配置的话,那么整个项目的js文件,都会做babel-loader
的转译,但实际上,node_modules
中的包都是帮我们转译过的,重复的转译,势必会降低webapck的打包速度,这时候我们就要通过设置babel-loader
的作用范围来提升打包速度。
{
test: /\.js$/,
exclude: /node_modules/,
loader: 'babel-loader',
}
通过上面的配置,我们就不用再对node_modules
中js文件做转译了,当然了,除了exclude
选项排除某个范围,我们还可以通过include
选项去指定某个范围,比如上面的代码也可以改成
{
test: /\.js$/,
include: path.resolve(__dirname, '../src'),
loader: 'babel-loader',
}
所以,我们可以通过合理的使用exclude
或者include
这样的配置项,去指定某一个loader的执行范围,从而降低了loader的执行频率,loader的编译过程被少量的执行了,那么webpack的打包速度自然也会得到提升。
不光babel-loader
,其他loader也是可以通过具体的项目分析,做这样的配置的。
将babel编译过的文件缓存起来
babel-loader为我们提供了cacheDirectory
参数,可以参考官网对其做相应配置
Plugin尽可能精简并且可靠
我们应该尽可能少的使用Plugin,并且还要保证其可靠性,举个栗子。
我们在生产环境下的打包一般会需要通过MiniCssExtractPlugin
和OptimizeCSSAssetsPlugin
两个插件来做样式代码的分离或者压缩,这也是十分必要的,当然,如果你在本地环境下使用了CSS代码的分离压缩,不但没有必要(因为本地代码只有自己看,也不去在意其是否压缩),反而会降低打包的效率,因为Webpack插件是基于webpack打包过程事件流的,没一个插件的执行,都会消耗性能,降低效率,所以,如果非必要,就不要去使用一些插件了,如果你很有必要去使用某个插件,那么最好是使用Webapck官网提供的插件,因为官方的插件是经过一些专门的性能测试的,相对于第三方的插件来说,性能会高一些,而第三方的插件,很有可能性能得不到保证,降低你的打包速度,所以,在使用一个插件之前,一定要做好选择哦!
resolve参数合理配置
- extensions
resolve参数是一个webpack配置项,我们先开介绍一下这个配置项的使用,比如现在有下面的文件目录
|--src
|--index.js
|--child.jsx
我们想要在index.js中使用child.jsx可以这样使用
import Child from './child.jsx'
但是我们可以通过配置resolve
选项,来达到下面这样的引用方式
import Child from './child'
如下:
module.exports = {
resolve: {
extensions: ['.js', '.jsx']
},
}
上面的意思是,我们遇到'./child'这样的字段后,会去当前目录下查找'js'后缀的文件,没有找到再去查找'jsx'后缀的文件,这样我们就可以省去在引用的过程中写前缀了,但是,有些同学可能会不合理的配置resolve
,比如
module.exports = {
resolve: {
extensions: ['css','jpg','.js', '.jsx']
},
}
如果像上面这样配置,那么在你引入一个文件的时候,就会按照上面的裂变挨个的去查找,实际上,这样是有性能损耗的
所以,一般情况下,我们只有遇到js或者jsx或者vue等等这样逻辑型文件的时候才去配置到resolve中,像css这样的文件就不去配置了这样,不但开发起来方便一些,同事性能上也会得到一些平衡。
- mainFiles
在平时开发中大家一定也遇到过这样的引用
import Child from './components/'
这时候,会自动找到'components'文件夹下的'index.js'文件,假如我们现在的文件目录如下
|--src
|--components
|--child.jsx
|--index.jsx
我们在index.jsx中想要通过
import Child from './components/'
上面这种引用方式引入'components'下的‘child.jsx’文件,那么我们可以做下面这样的配置
module.exports = {
resolve: {
extensions: ['.js', '.jsx'],
+++ mainFiles: ['index', 'child']
},
}
这样,我们在引用一个文件夹时,他就会默认去找下面的index.js找不到再去找child.js了。
但是,这样又会带来性能问题,通过上面的配置后,每次我们引入一个路径的话,都会去做一遍文件的匹配,所以我们要根据自己的需要,平衡好性能和开发方便后再做相应的配置,一般来说,我们不需要配置这个项
- alias
在一些社区脚手架中,我们还会见到下面这样的引用方式
import Child from '@/component/'
其配置如下
module.exports = {
resolve: {
extensions: ['.js', '.jsx'],
+++ alias: {
'@': path.resolve(__dirname, '../src')
}
},
}
意思是,我们用‘@’代替了根目录下的src目录,这样你会在开发的时候提升一些开发效率。同样,他也会带来一些性能上的问题,所以,大家依然需要平衡好开发效率和打包效率,有针对性的去使用
通过上面,举了三个栗子,说明了resolve配置项对于开发效率的提升帮助,同事他也具有一点的性能问题,大家在使用的过程中,要在做好平衡,按照需要去做相应的配置。
使用DllPlugin提高打包速度
我先对我手上一个简单的项目做个打包,记录下打包时间如下
基本时间稳定在1500ms,我们暂认定当前情况下的打包速度为1300ms,我的代码现在是这样的
import React from 'react'
import ReactDom from 'react-dom'
import _ from 'lodash'
const App = () => {
return (
<div>
<div>{_.join(['hello','world'], ' ')}</div>
</div>
)
}
ReactDom.render(<App/>, document.getElementById('root'))
其中像react
,react-dom
,lodash
这样的库,是基本不会改变的,但是现在,我们每一次打包都要对其进行分析,都要消耗一定的时间,于是我们就想,可以把第三方库单独打包为一个文件,只在第一次打包的时候做分析,后面就使用第一次打包的结果这样就可以提高打包速度了,我们以这个为思路,展开这次的优化。
- 配置第三方库单独打包
我们再创建一个webpack.dll.js的配置文件,内容如下
const path = require('path')
module.exports = {
mode: 'production',
entry: {
vendors: ['react', 'react-dom', 'lodash']
},
output: {
filename: '[name].dll.js',
path: path.resolve(__dirname, '../dll'),
library: '[name]'
}
}
上面的意思是,我们将几个第三方库做单独的打包,并以Library的形式导出,这时候会在根目录下生成一个'dll'的文件。我们期望将该文件在最终生成的index.html中以全局变量的形式引入。所以还需要在原有的打包配置中,配置一个插件,来动态的引入我们生成的第三方库,因为现在的第三方库是以Library的形式存在于项目中,并以一个‘vendors’变量全局暴露。这样我们就可以以全局变量的形式访问第三方库
- 配置add-asset-html-webpack-plugin
我们安装这个webapck插件,并配置如下
module.exports = {
plugins: [
new AddAssetHtmlWebpackPlugin({
filepath: path.resolve(__dirname, '../dll/vendors.dll.js')
})
]
}
意思是我们通过上面这个插件,就可以为生成的index.html引入我们单独打包的第三方库,配置成功后,启动项目你会发现源码中已经引入‘vendors.dll.js’了。
并且在也可以全局访问一个‘vendors’变量(因为我们是以Library的形式打包,并暴露出一个vendors变量)
到这里,我们实现了一个第三方模块只打包一次的目标,但是现在还不能满足我们最初的,‘第三方模块只打包一次,且以后每次都使用’的目标,现在我们的项目中,其中还是使用的'node_modules'里面的内容,那么怎么才能让业务代码使用我们处理过的第三方模块呢?
- 使用Dllplugin做分析
我们使用Dllplugin生成一个映射,操作如下
对webpack.dll.js做下修改
const path = require('path')
const webpack = require('webpack')
module.exports = {
mode: 'production',
entry: {
vendors: ['react', 'react-dom', 'lodash']
},
output: {
filename: '[name].dll.js',
path: path.resolve(__dirname, '../dll'),
library: '[name]'
},
plugins: [
new webpack.DllPlugin({
name: '[name]',
path: path.resolve(__dirname, '../dll/[name].manifest.json')
})
]
}
我们配置一个Dllplugin
插件,需要注意的是DllPlugin
中的name属性,一定要个output
中的library属性一致,意思是,我们要对生成的library做一个分析分析的结果放到dll下的‘vendors.manifest.json’中。这时候再运行dll打包,就会看到这个'vendors.manifest.json'文件了。
到这里我们想利用上面生成的全局变量,和现在生成的映射文件,我们是否可以实现在业务代码中,如果发现引用的模块是来自我们处理过的第三方模块,就使用我们已经打包过的包,反之才从node_modules中取
- 配置DllReferencePlugin
要想实现上面的设想,我们还需要在打包配置文件中,做DllReferencePlugin
插件的配置
module.exports = {
plugins: [
new webpack.DllReferencePlugin({
manifest: path.resolve(__dirname, '../dll/vendors.manifest.json')
})
]
}
做了上面的配置后,我们打包时的原理变成了这样:在打包时,当遇到第三方模块,他会去到映射文件中去找是否存在于我们单独打包的第三方库中,如果存在,就从上面操作中暴露的全局变量中取,如果不存在,才从node_moudules中取,这时候,我们做一下打包时间对比
时间变成了900多毫秒,可以把上面的配置注释掉,再去看一下打包时间
时间又变成了1400多毫秒,由此可见,使用DllPlugin
对于性能的提升还是很明显的。
这个配置项讲的有点绕,下面针对这个插件的配置,我们做个小总结
- 通过dll配置文件单独将第三方库打包为一个library形式,暴露一个全局变量出来
- 通过
DllPlugin
插件,对打包文件做一个分析,生成一个映射文件 - 在项目打包配置文件中,配置
AddAssetHtmlWebpackPlugin
和DllReferencePlugin
,将映射关系引入进index.html中
主要操作就是上面的三点了。下面我再对这个插件做一点扩展,上面我们是把三个第三方模块都打包到了,其实我们可以分开打包
module.exports = {
entry: {
lodash: ['lodash'],
react: ['react', 'react-dom']
},
}
分开后,自然生成的library文件不一样了,映射文件也不一样了,所以我们还得再业务打包文件中做出更改
module.exports = {
plugins: [
new AddAssetHtmlWebpackPlugin({
filepath: path.resolve(__dirname, '../dll/lodash.dll.js')
}),
new AddAssetHtmlWebpackPlugin({
filepath: path.resolve(__dirname, '../dll/react.dll.js')
}),
new webpack.DllReferencePlugin({
manifest: path.resolve(__dirname, '../dll/lodash.manifest.json')
}),
new webpack.DllReferencePlugin({
manifest: path.resolve(__dirname, '../dll/react.manifest.json')
})
]
}
大家一定也发现了,其实这样的配置看起来是很臃肿的,于是我们可以这样修改我们的配置
const plugins = [ // 定义一个数组,将基础的插件写入
new HtmlWebpackPlugin({
template: 'src/index.html'
}),
new CleanWebpackPlugin(),
new webpack.ProvidePlugin({
$: 'jquery'
})
]
// 利用NodeJs文件模块,分析dll文件夹下的文件,并动态插入
const files = fs.readdirSync(path.resolve(__dirname, '../dll'))
console.log(files) // 可以在这里查看结果感受一下
files.forEach(file => {
if(/.*\.dll.js/.test(file)) {
plugins.push(
new AddAssetHtmlWebpackPlugin({
filepath: path.resolve(__dirname, '../dll', file)
})
)
}
if(/.*\.manifest.json/.test(file)) {
plugins.push(
new webpack.DllReferencePlugin({
manifest: path.resolve(__dirname, '../dll', file)
})
)
}
})
这样我们就不用手写的,如果你的dll于变动,只需要重新打包dll即可,不用再手动修改插件了。
thread-loader和happypack
因为受限于Node的单线程运行,所以webpack的打包也是单线程的,使用HappyPack
可以将Loader
的同步执行转为并行,从而执行Loader时的编译等待时间
同时也可以使用webpack4官网提供的thread-loader
来对有些耗时的loader做相应的处理,这里我将不再带大家熟悉其API,可以到对应的官网去参照其使用方法。
合理使用Source Map
Source Map为我们打包后的代码和源码提供了一种个映射关系,但是Source Map也会造成一些性能的问题,为了同时兼顾打包性能和开发调试方便,请使用合理的Source Map配置,这里可以参考我之前关于Source Map的讲解SourceMap配置
开发环境内存编译
我们知道,我们在本地的项目中,一般使用dev Server在本地起一个服务,而使用dev Server是不需要将dist文件打包进硬盘的,而是打包进内存里,从内存里读取文件的速度肯定是比硬盘快的多的,因为平时大家有意无意的已经这么实践,这里还是要提一下,知道其中的优化点
开发环境无用插件剔除
有些Webpack插件是针对于线上打包模式的,比如代码压缩,比如CSS分离压缩等,但是如果你在本地环境使用了这样的插件,将降低你的打包速度,同时有些插件在本地模式下使用,也是没有意义的,比如代码压缩。
降低打包体积
降低打包体积,不仅可以让打包后的项目运行更快,还可以对打包速度有所提升。我在下面将做详细的介绍
上面为大家介绍了几种提升打包速度的方法,用来优化我们本地开发的效率,其中将到的DllPlugin也是内容比较多,需要主要的是,这个插件仅在开发环境下生效,并且在开发中,随着后续weebpack版本的更新,可能会引入一些缓存机制,到时候DllPlugin就不再使用了,这里我们大篇幅介绍他,希望大家能认识到并熟悉这种方式,用不用看大家
打包大小优化
上面提到,打包大小的优化主要对于产品的体验有很大的提升,那么我们有哪些手段可以控制打包的大小,从而让产品运行很流畅呢?
tree shaking
我们知道,webapck4默认在production
模式下开启tree Shaking,用来删除调那些无效的引入,从而减小打包代码的体积,当然你也可以尝试在本地模式下配置,不过没啥太实际的作用,具体可参考我之前关于Tree Shaking的讲解文章。
代码压缩
webpack4在production
模式下默认开启代码压缩。这一点大家要知道
代码分割
我们可以使用代码分割,将固定不变的一些代码如node_moudles中的代码单独打包,从而降低main.js的大小,利用浏览器的缓存机制,提高首屏加载的速度。具体的代码,可以看我之前关于Split code的讲解文章文章一、文章二
按需加载
按需加载,也是个比较大的概念了,我举几个常见的按需加载场景。
- polyfill按需加载
我们知道,polyfill实际是一种webpack shaming方案,如果我们不做处理,将是全量的引入所有的转译语法,但实际项目中,我们不一定都用的到,这时候需要做一下按需加载的配置,可以配置@babel/preset-env
的useBuiltIns:usage
,具体的内容可以参考我之前关于babel的文章讲解babel
- UI组件库的按需加载
现在社区的大部分组件都是支持按需加载配置,或者tree shaking的,这样我们就不需要将整个UI库引入了,因为你可能项目中用不到所有的,具体我们可以参考babel-plugin-import的使用方法,或者组件库推荐的按需加载方案
- 路由按需加载
路由的按需加载也叫路由懒加载,也就是,只有当我们访问到该页面时,才加载该页面的资源,这个方案其实不影响打包大小,算是一种代码分割的方案,我们通过异步的加载路由下对应的组件资源,利用代码分割单独打包。这里可以自己去看一下,不同的框架对应的路由懒加载方案
写在后面
本文用很大的篇幅介绍了Webapck的性能优化,尽量避免知识点过散,不利于总结,其实,关于webpack的打包优化方案,还有好多,甚至到webpack5的时候,webpack的打包性能又会优化不少,像上面提到的DllPlugiin可能将不再使用,随着技术的更新,Webpack优化的手段也将越来越丰富,大家可以根据自己的需要去拓展更多的优化手段。