在使用webpack时提供了各种配置,这里结合在业务中常用的配置汇总一下可以进行的一系列的webpack优化
缩小文件搜索范围
其原理是在构建时,会以用户配置的Entry为开始依次递归遍历每个Module,在遍历每个Module时会调用相应合适的Loader对原模块代码进行“翻译”。
优化Loader配置
Loader对文件的转换是比较耗时的,我们可以在loader中通过 test、include、exclude三个配置规则更加精确的命中目标文件,减少文件搜索的范围,举个例子,如果我们以ES6开发的文件为例子在配置Loader时可以这样
module.exports = {module: {rules: [{// 如果项目源码中只有 js 文件就不要写成 /\.jsx?$/,提升正则表达式性能test: /\.js$/,// babel-loader 支持缓存转换出的结果,通过 cacheDirectory 选项开启use: [{loader: 'babel-loader',options: {cacheDirectory: path.resolve(__dirname, '.cache/babel')}}],// 只对项目根目录下的 src 目录中的文件采用 babel-loaderinclude: path.resolve(__dirname, 'src'),},]},
};
上面cacheDirectory可以将 babel 编译的结果缓存到文件系统,下次编译时,如果文件没有变化,直接使用缓存可以显著提升构建速度,一般可以在开发环境中使用,可以提高构建效率,在生产环境关闭,保障获得最新的构建结果
优化resolve.modules 配置
其默认值是[‘node_module’],含义是先去当前目录下的./node_module目录下找对应的模块,如果没有就去找上一级的./node_modules以此类推,这和Node.js的模块寻找很相似。如果当前安装的第三方目录都在当前项目的根目录下就没有必要一层层找,可以直接指明第三方模块的绝对路径,以减少寻找,配置如下:
module.exports = {resolve: {// 使用绝对路径指明第三方模块存放的位置,以减少搜索步骤// 其中 __dirname 表示当前工作目录,也就是项目根目录modules: [path.resolve(__dirname, 'node_modules')]},
};
优化resolveLoader
同理resolveLoader的机制也是一样,指定对应的Loader的安装位置
优化 resolve.mainFields 配置
resolve.mainFields 用于配置第三方模块使用哪个入口文件。在我们使用第三方模块的时候,在第三方模块中的package.json描述这个模块的属性,其中有一些字段用于描述这个模块的入口文件在哪里,而很多三方模块支持多环境的调用,比如axios或者isomorphic-fetch支持浏览器环境和node环境的调用,针对不同的环境就需要调用不同的代码,以 isomorphic-fetch 为例,它是 fetch API 的一个实现,但可同时用于浏览器和 Node.js 环境。 它的 package.json 中就有2个入口文件描述字段:
{"browser": "fetch-npm-browserify.js","main": "fetch-npm-node.js"
}
点我查看isomorphic-fetch配置文件
因为在不同的环境就需要掉不同的代码来执行。而resolve.mainFields 用于配置采用哪个字段作为入口文件的描述。所以我们为了减少搜索的步骤,当我们明确知道第三方模块的描述字段时,可以设置的尽量少,这样速度就会更快,一般而言三方模块都会采用main字段,所以可以这样配置
module.exports = {resolve: {// 只采用 main 字段作为入口文件描述字段,以减少搜索步骤mainFields: ['main'],},
};
优化resolve.extensions配置
在导入语句没带文件后缀时,Webpack 会自动带上后缀后去尝试询问文件是否存在。 resolve.extensions 用于配置在尝试过程中用到的后缀列表,默认是:
extensions: ['.js', '.json']
举一个例子,比如在构建时遇到require(‘./dataList’)时webpack会先去寻找./dataList.js如果没有该文件就会去找./dataList.json文件。如果还是找不到,那就回报错。
如果这个列表越长,或者正确的后缀越靠后,那么寻找的次数就会越多,从而影响构建时的性能。在配置resolve.extensions 时需要遵守以下几点,以做到尽可能的优化构建性能:
- 后缀尝试列表要尽可能的小,不要把项目中不可能存在的情况写到后缀尝试列表中
- 频率出现最高的文件后缀要优先放在最前面,以做到尽快的退出寻找过程
- 在源码中写导入语句时,要尽可能的带上后缀
对应的配置如下:
module.exports = {resolve: {// 尽可能的减少后缀尝试的可能性extensions: ['tsx','ts', 'json'],},
};
优化 module.noParse 配置
该配置项可以让 Webpack 忽略对部分没采用模块化的文件的递归解析处理,这样做的好处是能提高构建性能。 原因是一些库,例如 jQuery 、ChartJS, 它们庞大又没有采用模块化标准,让 Webpack 去解析这些文件耗时又没有意义。
const path = require('path');module.exports = {module: {noParse: [/echart/],},
};
使用DllPlugin
DllPlugin 是 Webpack 5 中的一个插件,用于优化构建速度,尤其是在大型项目中。它允许将一些常用的第三方库(如 React, Lodash 等)预构建成动态链接库(DLL),然后在实际的项目构建中进行引用,从而避免每次构建时都重新打包这些常用库,提高构建效率。
作用
- 提高构建速度:将一些不常改变的库单独打包成 DLL 文件,之后每次构建时只需要引用这个 DLL 文件,不需要重新构建这些依赖。
- 优化缓存:通过分离常用库和项目代码,Webpack 可以更好地利用缓存机制,避免不必要的重复构建。
- 代码拆分:DllPlugin 可以帮助更好地拆分代码,减少每次打包的负担。
如何使用
创建一个 DLL 配置文件
在项目根目录下创建一个新的 webpack.dll.js 配置文件,这个文件将用于专门打包第三方库。
// webpack.dll.js
const path = require('path');
const webpack = require('webpack');module.exports = {entry: {vendor: ['react', 'react-dom', 'lodash'] // 列出你想预构建的库},output: {path: path.resolve(__dirname, 'dist'),filename: '[name].dll.js',library: '[name]_library' // 这会指定 DLL 文件的暴露全局变量名},plugins: [new webpack.DllPlugin({name: '[name]_library',path: path.resolve(__dirname, 'dist', '[name]-manifest.json') // 指定 manifest 文件的路径})]
};
在上面的配置中:
entry 指定了要打包的库(vendor),你可以根据需求添加其他常用的第三方库。
output.library 定义了 DLL 文件暴露的全局变量,[name] 会被替换为 vendor。
DllPlugin 插件的 path 配置会生成一个 vendor-manifest.json 文件,Webpack 在主构建时会根据这个 manifest 文件引用 DLL 文件。
使用 DLL 文件
在主 webpack.config.js 文件中,通过 DllReferencePlugin 引用之前生成的 DLL 文件。
// webpack.config.js
const path = require('path');
const webpack = require('webpack');module.exports = {entry: './src/index.js',output: {path: path.resolve(__dirname, 'dist'),filename: 'bundle.js'},plugins: [// 引用 DllPlugin 生成的 DLL 文件new webpack.DllReferencePlugin({manifest: path.resolve(__dirname, 'dist', 'vendor-manifest.json')})]
};
这里 DllReferencePlugin 会确保你的主项目构建过程中引用 DLL 文件,而不需要重新构建这些库。
运行构建
- 执行 webpack --config webpack.dll.js 来构建 DLL 文件,这将会生成 vendor.dll.js 和 vendor-manifest.json。
- 执行主项目构建:webpack --config webpack.config.js,Webpack 会使用 DLL 文件来加速构建。
使用ParallelUglifyPlugin
当我们代码开发结束上线时,都会有代码压缩这里流程,最常见的代码压缩工具就是uglifyJS,webpack也已经内置了这个工具。当我们在上线时构建代码需要先将代码解析成AST语法树,在去应用各种规则分析和处理AST,这个过程计算量巨大,非常的耗费时间。我们可以使用ParallelUglifyPlugin 多线程并行的去做这些事,这样就能加快构建速度。ParallelUglifyPlugin 会开启多个子进程,把对多个文件的压缩工作分配给多个子进程完成,这样就能更快的完成压缩工作。
使用 ParallelUglifyPlugin 也非常简单,把原来 Webpack 配置文件中内置的 UglifyJsPlugin 去掉后,再替换成 ParallelUglifyPlugin,相关代码如下
const path = require('path');
const DefinePlugin = require('webpack/lib/DefinePlugin');
const ParallelUglifyPlugin = require('webpack-parallel-uglify-plugin');module.exports = {plugins: [// 使用 ParallelUglifyPlugin 并行压缩输出的 JS 代码new ParallelUglifyPlugin({// 传递给 UglifyJS 的参数uglifyJS: {output: {// 最紧凑的输出beautify: false,// 删除所有的注释comments: false,},compress: {// 在UglifyJs删除没有用到的代码时不输出警告warnings: false,// 删除所有的 `console` 语句,可以兼容ie浏览器drop_console: true,// 内嵌定义了但是只用到一次的变量collapse_vars: true,// 提取出出现多次但是没有定义成变量去引用的静态值reduce_vars: true,}},}),],
};
压缩代码
压缩代码 浏览器从服务器访问网页时获取的 JavaScript、CSS 资源都是文本形式的,文件越大网页加载时间越长。 为了提升网页加速速度和减少网络传输流量,可以对这些资源进行压缩。 压缩的方法除了可以通过 GZIP 算法对文件压缩外,还可以对文本本身进行压缩
压缩ES6
虽然当前大多数 JavaScript 引擎还不完全支持 ES6 中的新特性,但在一些特定的运行环境下已经可以直接执行 ES6 代码了,例如最新版的 Chrome、ReactNative 的引擎 JavaScriptCore
运行 ES6 的代码相比于转换后的 ES5 代码有如下优点:
- 一样的逻辑用 ES6 实现的代码量比 ES5 更少;
- JavaScript 引擎对 ES6 中的语法做了性能优化,例如针对 const 申明的变量有更快的读取速度;
压缩ES6需要使用UglifyES而不是UglifyJS(压缩ES5)
压缩CSS
CSS 代码也可以像 JavaScript 那样被压缩,以达到提升加载速度和代码混淆的作用。 目前比较成熟可靠的 CSS 压缩工具是 cssnano,基于 PostCSS
cssnano 能理解 CSS 代码的含义,而不仅仅是删掉空格,例如:
- margin: 10px 20px 10px 20px 被压缩成 margin: 10px 20px;
- color: #ff0000 被压缩成 color:red
Tree shaking
Tree shaking 可以用来剔除js中没有使用到的代码,他依赖ES6的模块化语法,比如通过import和export导出,最早Tree shaking是在Rollup中出现,后来在webpack2.0中引入。
举个例子,比如我们有一个util.js的文件,里面有两个函数
export function funcA() {}export function funcB() {}
在我们的index.js文件中会导入util.js,index.js代码如下
import { funcA } from './util.js
funcA()
经过Tree shaking之后的util的代码如下
export function funcA() {}
因为我们只用到了funcA这一段函数代码,没有用到的funcB代码就会被剔除
需要注意的是要让 Tree Shaking 正常工作的前提是交给 Webpack 的 JavaScript 代码必须是采用 ES6 模块化语法的, 因为 ES6 模块化语法是静态的(导入导出语句中的路径必须是静态的字符串,而且不能放入其它代码块中),这让 Webpack 可以简单的分析出哪些 export 的被 import 过了。 如果你采用 ES5 中的模块化,例如 module.export={…}、require(x+y)、if(x){require(‘./util’)},Webpack 无法分析出哪些代码可以剔除
目前的 Tree Shaking 还有些的局限性,经实验发现:
- 不会对entry入口文件做 Tree Shaking;
- 不会对按序加载出去的代码做 Tree Shaking
使用TreeShaking
webpack5中如果是在生产模式下会自动调用Scope Hoisting
开启 Scope Hoisting
Scope Hoisting 可以让 Webpack 打包出来的代码文件更小、运行的更快, 它又译作 “作用域提升”,是在 Webpack3 中新推出的功能。
什么是 Scope Hoisting
比如有两个文件分别是util.js和index.js,其中代码分别如下
// util.jsexport default 'Hello, Scope hoisting'
//index.js
import str from './util.js'
console.log(str)
以上代码打包之后如下
[(function (module, __webpack_exports__, __webpack_require__) {var __WEBPACK_IMPORTED_MODULE_0__util_js__ = __webpack_require__(1);console.log(__WEBPACK_IMPORTED_MODULE_0__util_js__["a"]);}),(function (module, __webpack_exports__, __webpack_require__) {__webpack_exports__["a"] = ('Hello,Webpack');})
]
开启了Scope hoisting 之后打包的代码如下
[(function (module, __webpack_exports__, __webpack_require__) {var util = ('Hello,Webpack');console.log(util);})
]
可以看到开启 Scope hoisting 之后,函数申明由两个直接变成了一个,util.js中的定义内容直接被注入到了index.js模块中,这样的好处是:
- 代码体积更小,因为函数申明语句会产生大量代码;
- 代码在运行时因为创建的函数作用域更少了,内存开销也随之变小
Scope Hoisting 的基本原理是分析出模块之间的依赖关系,尽可能的把打散的模块合并到一个函数中去,但前提是不能造成代码冗余,所以只有那些被引用了一次的模块才能被合并。
和Tree shaking一样,使用Scope Hoisting需要分析出模块之间的依赖关系,所以源代码必须使用ES6模块化语句。不然无法生效
配置Scope Hoisting
webpack5中如果是在生产模式下会自动调用Scope Hoisting
提取公共代码
在日常开发中一个站点往往是由很多页面组成的,这些页面一般都是采用相同的技术栈和同一套代码样式,并且我们在开发时也会抽离很多可复用的代码。
如果每个页面都把这些公共的代码打包进去就会造成下面的问题:
- 相同的资源被重复加载,浪费用户的流量和服务器的成本
- 每个页面需要加载的资源太大,导致网页首屏加载缓慢,影响用户体验
针对上面的问题我们可以把公共的代码抽离成单独的文件,就可以优化上面的问题,原因是如果用户访问了网站中的某个页面,那么访问这个站点下的其他页面的概率也非常大。用户在第一次访问之后这些公共代码就被缓存起来,这样用户切换到其他页面时,缓存的代码文件就不会被重复加载。可以直接从缓存中获取,这样就有以下好处:
- 减少网络传输流量,降低服务器成本;
- 虽然用户第一次打开网站的速度得不到优化,但之后访问其它页面的速度将大大提升;
在webpack5中可以使用optimization.splitChunks 提取公共代码一个简单的示例如下:
optimization: {moduleIds: 'deterministic',usedExports: true,splitChunks: {chunks: 'all',minSize: 20000,minChunks: 1,maxAsyncRequests: 30,maxInitialRequests: 30,enforceSizeThreshold: 50000,cacheGroups: {// React相关库react: {test: /[\\/]node_modules[\\/](react|react-dom)[\\/]/,name: 'react',priority: 0},// UI库antd: {test: /[\\/]node_modules[\\/]antd[\\/]/,name: 'antd',priority: -1},// 其他第三方库vendors: {test: /[\\/]node_modules[\\/]/,name: 'vendors',priority: -10},// 分离公共组件commons: {name: 'commons',minChunks: 2,priority: -20}}}},
分割代码按需加载
为什么要分割代码按需加载
随着互联网的发展,一个网页需要承载的功能越来越多。 对于采用单页应用作为前端架构的网站来说,会面临着一个网页需要加载的代码量很大的问题,因为许多功能都集中的做到了一个 HTML 里。 这会导致网页加载缓慢、交互卡顿,用户体验将非常糟糕。
导致这个问题的根本原因在于一次性的加载所有功能对应的代码,但其实用户每一阶段只可能使用其中一部分功能。 所以解决以上问题的方法就是用户当前需要用什么功能就只加载这个功能对应的代码,也就是所谓的按需加载。
如何使用按需加载
在给单页应用做按需加载优化时,一般采用以下原则:
- 把整个网站划分成一个个小功能,再按照每个功能的相关程度把它们分成几类;
- 把每一类合并为一个 Chunk,按需加载对应的 Chunk;
- 对于用户首次打开你的网站时需要看到的画面所对应的功能,不要对它们做按需加载,而是放到执行入口所在的 Chunk 中,以降低用户能感知的网页加载时间;
- 对于个别依赖大量代码的功能点,例如依赖 Chart.js 去画图表、依赖 flv.js 去播放视频的功能点,可再对其进行按需加载;
被分割出去的代码的加载需要一定的时机去触发,也就是当用户操作到了或者即将操作到对应的功能时再去加载对应的代码。 被分割出去的代码的加载时机需要开发者自己去根据网页的需求去衡量和确定。
由于被分割出去进行按需加载的代码在加载的过程中也需要耗时,你可以预言用户接下来可能会进行的操作,并提前加载好对应的代码,从而让用户感知不到网络加载时间。
如何用 Webpack 实现按需加载
在 Webpack 5 中,按需加载(Lazy Loading) 是通过 动态导入(Dynamic Import) 来实现的,主要依靠 ES6 提供的 import() 语法。这样可以让 Webpack 在需要的时候才加载特定的模块,从而减少初始加载时间,提高应用的性能
Webpack 遇到 import() 语法时,会自动将导入的模块拆分成一个单独的 chunk,并在运行时动态加载它。
比如现在首页有一个按钮,只有点击这个按钮的时候才会去加载对应的工具文件去调用里面的计算方法。代码大体如下:
const loadDemo = async () => {try {// 使用 webpack 的动态导入语法const { sum, multiply } = await import(/* webpackChunkName: "demo" */ './demo').then(module => module);console.log('Dynamic import result:', sum(2, 3));console.log('Dynamic import result:', multiply(2, 3));} catch (error) {console.error('Failed to load demo module:', error);}
};// 创建一个按钮来触发动态加载
const button = document.createElement('button');
button.textContent = 'Load Demo Module';
button.onclick = loadDemo;
document.body.appendChild(button);
//demo.ts
export const sum = (a: number, b: number): number => {console.log('sum function is loaded dynamically');return a + b;
};export const multiply = (a: number, b: number): number => {console.log('multiply function is loaded dynamically');return a * b;
};
import(‘./demo.js’) 会告诉 Webpack 将 demo.js 单独打包为一个 chunk(如 demo.bundle.js)。
当 btn 按钮被点击时,才会请求加载 demo.bundle.js,这样就实现了按需加载。
webpack输出分析
我们需要对输出结果做分析,以决定下一步的优化方向。
但由于 Webpack 输出的代码可读性非常差而且文件非常大,这会让你非常头疼。 为了更简单直观的分析输出结果,社区中出现了许多可视化的分析工具。这些工具以图形的方式把结果更加直观的展示出来,可以帮助我们快速看到问题所在。 接下来看如何使用这些工具帮我们做优化
在启动 Webpack 时,支持两个参数,分别是:
- –profile:记录下构建过程中的耗时信息;
- –json:以 JSON 的格式输出构建结果,最后只输出一个 .json 文件,这个文件中包括所有构建相关的信息;
在启动 Webpack 时带上以上两个参数,启动命令如下 webpack --profile --json > stats.json,你会发现项目中多出了一个 stats.json 文件。 这个 stats.json 文件是给后面介绍的可视化分析工具使用的。
webpack --profile --json 会输出字符串形式的 JSON,> stats.json 是 UNIX/Linux 系统中的管道命令、含义是把 webpack --profile --json 输出的内容通过管道输出到 stats.json 文件中。
官方的可视化工具
Webpack 官方提供了一个可视化分析工具 Webpack Analyse,它是一个在线 Web 应用。
打开 Webpack Analyse 链接的网页后,你就会看到一个弹窗提示你上传 JSON 文件,这个就是上面生成的json文件,传上去之后会有这样的一个面板展示
可以看到有6个模块,当我们点击module中就可以看到当前各个模块的依赖关系:
点击 Hints,查看输出过程中的耗时分布,效果图如下:
webpack-bundle-analyzer
webpack-bundle-analyzer 是另一个可视化分析工具, 它虽然没有官方那样有那么多功能,但比官方的要更加直观。
先来看下它的效果图:
它能方便的让你知道:
- 打包出的文件中都包含了什么;
- 每个文件的尺寸在总体中的占比,一眼看出哪些文件尺寸大;
- 模块之间的包含关系;
- 每个文件的 Gzip 后的大小;
接入 webpack-bundle-analyzer 的方法很简单,步骤如下:
- 安装 webpack-bundle-analyzer 到全局,执行命令 npm i -g webpack-bundle-analyzer;
- 按照上面提到的方法生成 stats.json 文件;
- 在项目根目录中执行 webpack-bundle-analyzer 后,浏览器会打开对应网页看到以上效果。
如何在webpack中使用
在配置文件中添加下面的配置,
// webpack.config.js
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin;new BundleAnalyzerPlugin({analyzerMode: 'server',analyzerHost: '127.0.0.1',analyzerPort: 8888,reportFilename: 'report.html',defaultSizes: 'parsed',openAnalyzer: true,generateStatsFile: false,statsFilename: 'stats.json',statsOptions: null,logLevel: 'info'}),
在package.json中添加下面的命令
// package.json"analyze": "cross-env NODE_ENV=production webpack --profile --json > stats.json && webpack-bundle-analyzer stats.json"
直接运行即可对当前项目进行分析