跳到主要内容

webpack优化(3)

webpack 文档提供了一些优化构建性能的建议 —— webpack - 构建性能,对于一些小型项目来说,这些建议很有用!

优化模块解析规则​

rule.include 和 rule.exclude​

在使用 loader 的时候通过include或者exclude属性传入必要的路径和文件,避免全局匹配,可以提升 webpack 构建的速度。

例如babel-loader忽略node_modules内部的模块

module.exports = {
module: {
rules: [
{
test: /\.m?jsx?$/,
exclude: /(node_modules)/, //剔除node_modules
loader: "babel-loader",
options: {
presets: [
[
"@babel/preset-env",
{
modules: false,
},
],
["@babel/preset-react"],
],
plugins: [
"@babel/plugin-proposal-class-properties",
isDevelopment && require.resolve("react-refresh/babel"),
].filter(Boolean),
},
},
],
},
};

在匹配图片文件的时候,指定具体的文件夹:

module.exports = {
module: {
rules: [
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.svg$/i],
include: path.resolve(__dirname, "src/assets/images"), //仅包含图片文件夹
use: [
{
loader: "url-loader",
options: {
limit: 10 * 1024,
name: "static/images/[name].[contenthash:8].[ext]",
},
},
{
loader: "image-webpack-loader",
options: {
disable: isDevelopment,
},
},
],
},
],
},
};

resolve.modules​

resolve.modules指定 webpack 解析模块时应该搜索的目录,默认是['node_modules'],也就是 webpack 会首先查找项目根目录的node_modules文件夹去寻找模块,如果找不到就往上一级目录 ../node_modules 中去找,实际上这种查找是不需要的,毕竟项目一般也不会使用层层嵌套的结构。

可以通过path.resolve来构造绝对路径值,这样就只会在给定目录中查找模块了。并且,有时候这个配置比alias还简洁,比方说对于项目中封装通用业务组件库位于src/components下面,然后通过resolve.modules配置模块解析规则如下

module.exports = {
resolve: {
modules: [
path.resolve(__dirname, "./src/components"),
path.resolve(__dirname, "./src"),
path.resolve(__dirname, "node_modules"),
],
},
};

这时候在编写页面的时候,如果import一个src/components下里面的组件,就不用带任何路径前缀直接写文件夹就行了,nice!不过这时候的弊端就是如果你的组件和第三方库的组件同名,就会把第三方库的组件覆盖掉了。

import { Button } from "Button/index.jsx";

这个配置加上上文提到的rule.resolve.extensions,结合在一起就是神器,对于src/components的组件,例如src/components/Button,直接一句话就搞定,webpack 会自动查找src/components/Button文件夹下的index文件。

import { Button } from "Button";

在开发若干个有相互依赖关系的库的时候,通常都会采用 symlink 的方式互相引用。对于单个项目来说,直接将resolve.symlinks禁用比较好。

module.exports = {
resolve: {
symlinks: false,
},
};

module.noParse​

module.noParse指定一个字符串或者正则表达式,对于匹配的模块名,webpack 不会去解析它们。这在忽略一些大型库时,可以提高构建性能。

module.exports = {
module: {
noParse: /jquery|lodash/,
},
};

利用缓存​

合理利用缓存可以加快 webpack 二次构建的速度,尽管在首次使用这些 loader 或者 plugin 的时候耗时可能会很长,但是后续执行可能会带来飞速提升。

优化 babel-loader​

babel-loader是最主要的 loader,它需要处理大量的 JS,JSX 等涉及 JS 的模块,可以通过其内置的缓存配置来缓存其执行结果。

babel-loader支持以下有关缓存的配置项:

  • cacheDirectory:默认是false,当设置成true以后,babel-loader会使用默认的缓存目录 node_modules/.cache/babel-loader缓存 loader 的执行结果,在以后的 webpack 打包的时候,将会尝试读取缓存,来避免每次都重新编译
  • cacheIdentifier:用来指定缓存的标识符,默认是由 @babel/core 版本号,babel-loader 版本号,.babelrc 文件内容(存在的情况下),环境变量 BABEL_ENV 的值(没有时降级到 NODE_ENV)组成的一个字符串,通过改变cacheIdentifier来使缓存失效
  • cacheCompression:默认是true,会使用 Gzip 压缩每个 Babel 编译的结果
module.exports = {
module: {
rules: [
{
test: /\.m?jsx?$/,
loader: "babel-loader",
options: {
//...
cacheDirectory: true,
},
},
],
},
};

优化 eslint-loader​

如果项目在使用eslint-loader,一定要使用exclude忽略node_modules文件夹,并开启cache:true缓存项,因为eslint-loader检查代码真的超级耗时间!

过去的项目使用经验,大概是 10 分钟的打包时间,eslint-loader在不开启缓存的时候,占用了 1/5 的打包时间!

image-20200912230721400

cache-loader​

cache-loader是一个 webpack loader,可以缓存 loader 的执行结果到本地磁盘文件夹中或者放在一个数据库中。但是本身来说,缓存到本地文件夹中涉及到 IO 操作,在 webpack 执行打包的过程中会带来更大的内存占用问题,所以,只在需要编译大量模块的 loader 中使用cache-loader。

  • babel-loader:需要编译大量 JS 代码,但是上文说了,它默认带有缓存选项
  • css-loader:可能会需要;其他类似的处理 CSS 预处理器的 loader 也可以使用
  • image-webpack-loader:压缩图片的 loader,这个耗时比较多,可以考虑缓存其执行结果

配置项​

cache-loader虽然已经接近一年没有更新过了,但是它是 webpack 团队维护的,所以还是值得信赖的。

配置项类型默认值含义
cacheContext{String}undefined相对于设置的路径生成缓存
cacheKey{Function(options, request) -> {String}}undefined重写生成缓存项的键的函数
cacheDirectory{String}findCacheDir({ name: 'cache-loader' }) or os.tmpdir()设置缓存写入和读取的目录
cacheIdentifier{String}cache-loader:{version} {process.env.NODE_ENV}设置一个用于生成 hash 的标识符
compare{Function(stats, dep) -> {Boolean}}undefined修改缓存比较的函数,如果返回true就表示使用缓存而不是去执行 loader 生成新资源
precision{Number}0Round mtime by this number of milliseconds both for stats and dep before passing those params to the comparing function
read{Function(cacheKey, callback) -> {void}}undefined使用函数生成一个新的内容来覆盖缓存的内容
readOnly{Boolean}false如果不希望更新缓存,只是读取它可以将这个配置项设为true
write{Function(cacheKey, data, callback) -> {void}}undefined使用函数生成新资源来替换缓存内容

使用​

yarn add cache-loader -D

使用的时候在耗时长的 loader 前面添加cache-loader就可以了,至于如何分析 loader 的执行时间,可以借助speed-measure-webpack-plugin这个工具。

下面是我在image-webpack-loader这个负责压缩图片的 loader 前添加的配置,一定要注意在image-webpack-loader前引入cache-loader,我现在还不明白cache-loader到底什么原理,我测试是在url-loader前使用cache-loader打包会丢失图片。

module.exports = {
module: {
rules: [
{
test: [/\.bmp$/, /\.gif$/, /\.jpe?g$/, /\.png$/, /\.svg$/i],
include: path.resolve(__dirname, "src/assets/images"),
use: [
{
loader: "url-loader",
options: {
limit: 10 * 1024, //10KB
name: "static/images/[name].[contenthash:8].[ext]",
},
},
"cache-loader", //引入cache-loader
{
loader: "image-webpack-loader",
options: {
disable: isDevelopment, //开发环境下禁用压缩图片
},
},
],
},
],
},
};

在引入cache-loader前,我的页面中包含一张7MB左右的图片需要压缩,打包的耗时是这样的:

image-20200912165834559

使用cache-loader打包一次后,再次打包,明显缩短了image-webpack-loader的处理时长。

image-20200912165944112

多线程打包​

thread-loader​

thread-loader是 webpack 团队提供的一个 webpack loader,如果正在使用happypack也建议迁移到thread-loader上来,happypack 已经不再维护了。

thread-loader利用的是 nodejs 的 worker pool 机制,或者叫 thread pool;当使用 node 启动 webpack 打包程序的时候,webpack 主程序会运行在事件循环的主线程上,还有 worker pool 负责处理高成本的任务。

配置项​

配置项类型含义
workersNumber产生的 worker 的数量,默认是cpu 核心数 - 1
workerParallelJobsNumber一个 worker 进程中并行执行工作的数量;默认是20
workerNodeArgsArray额外的 node.js 参数,例如['--max-old-space-size=1024']
poolRespawnBoolean允许重新开启一个死掉的 work 池;重新开启会减慢整个编译速度,并且开发环境应该设置为false
poolTimeoutNumber设置超过一定时间自动终止 worker,默认是500ms,可以设置为Infinity,这样会让 worker 一直处于活跃状态
poolParallelJobsNumber池分配给 worker 的工作数量,默认是200,减少的话会降低效率但是会使分配更加合理
nameStringworker 池的名称,可用于创建具有其他相同选项的不同 worker pool

max-old-space-size这个额外的 nodejs 参数,在 nodejs 文档中介绍的有,这个 CLI 参数是配置 V8 引擎在执行 JS 时候最大可以使用的系统内存的,单位是MB,默认值是512MB。在 nodejs 执行的时候,如果内存限制太小,V8 就不得不频繁执行 GC 来释放哪些用不到的变量占用的内存,当程序需要的内存太大时,有时候 V8 会直接终止程序执行。stack overflow 上有一个相关问题 —— Node.js recommended “max-old-space-size”。

webpack 打包程序也是一样,对于超出内存限制,打包程序会被终止。

image-20200912190631480

解决这种问题的方法就是通过在package.json的 npm- script 中指定 CLI 参数,例如:

  "scripts": {
"build": "node --max-old-space-size=8192 scripts/build.js"
},

使用​

在其它 loader 的配置前面添加thread-loader,则它们将会在一个 worker pool 中运行。每个 worker 都是一个独立的 node.js 进程,开启 worker 本身会产生额外开销的,每一个 worker 都会产生大于600ms的延迟。

此外对使用线程池执行的 loader 也会有限制:

  • loader 不能产生新的文件
  • loader 不能使用自定义的 loader API
  • loader 不能获取 webpack 的配置
yarn add thread-loader -D
module.exports = {
module: {
rules: [
{
test: /\.js$/,
include: path.resolve("src"),
use: [
"thread-loader",
"babel-loader", //在babel-loader前使用
],
},
],
},
};

为了避免开启 worker 前耗时过长,可以预热 worker pool,例如对于要使用的 loader,提前将其加载到 node.js 模块高速缓存中。

const threadLoader = require("thread-loader");

threadLoader.warmup(
{
// worker的配置项,可以是传递给loader的配置项
},
[
// loader
"babel-loader",
"sass-loader",
]
);

经过我在babel-loader和eslint-loader前添加thread-loader并开启线程预热以后,确实让相关 loader 的执行时间减少了大概 10 几秒的样子,总体来说影响不是很明显。

image-20200913003400905image-20200913003347937

DLL​

DLL(Dynamic-link library,动态链接库)这个词来源于微软的打包技术。其实 DLL 有点类似于按需加载的意味,把一些共享的代码抽成 DLL,当可执行文件调用到 DLL 中的函数时,操作系统才会把 DLL 文件加载到内存中。

不过,JS 不存在 DLL 这种东西,使用DllPlugin 只是让一些第三方库提前打包出来形成一个library,因为在一个项目中,往往它们基本上是不会频繁升级的,提前打包出来这样让 webpack 更多的去做项目代码打包的事情,极大的加快构建速度。有点类似于代码拆分的意味,但是 code splitting 还是会在 webpack 每次构建的时候都去打包,无法加快 webpack 的构建速度。

library​

library也就是 JS 库,比如lodash等都属于一个 JS 库,webpack 提供了专门用于打包 JS 库的处理。其实 DLL 就是利用打包 library 的方式将项目代码抽成一个 library,并通过暴露 library 的形式让项目中其它模块可以使用到这些 library。

DllPlugin​

DllPlugin是 webpack 内置的负责将代码抽出来单独打包的 webpack plugin。要使用DllPlugin,需要新建一个新的 webpack 配置文件,专门用于处理第三方库的打包。

配置项​

配置项类型是否必填含义
contextStringnomanifest 文件中请求的 context;默认是 webpack 的context配置项,也就是webpack.config.js所在的当前目录
formatBooleanno是否格式化manifest.json;默认是false
nameStringyes暴露出的 DLL 的函数名
pathStringyes输出的manifest.json的绝对路径
entryOnlyBooleanno默认是 true,仅暴露入口
typeStringno生成的 DLL bundle 的类型

使用​

现在尝试配置DllPlugin把react的库文件从打包流程中抽取出来,在项目根目录新建一个webpack.dll.config.js的配置文件,使用DllPlugin。

const path = require("path");
const webpack = require("webpack");
const { CleanWebpackPlugin } = require("clean-webpack-plugin"); //清理build文件夹

module.exports = {
mode: "production",
entry: {
react: ["react", "react-dom"],
},
output: {
path: path.resolve(__dirname, "dll"),
filename: "[name].[contenthash].dll.js",
library: "_[name]_dll",
},
plugins: [
new CleanWebpackPlugin(),
new webpack.DllPlugin({
context: __dirname,
path: path.resolve(__dirname, "dll/[name]-manifest.json"),
name: "_[name]_dll",
}),
],
};

这里的配置有几点需要注意:

  • entry需要使用对象的配置形式,并且每个属性的值都必须是数组,即使内部只包含单个模块也必须放在数组里
  • output.library不要和原模块的名称一样,最好加个前缀或者后缀,例如react本身暴露的模块名是react,那么这里就不能再使用react
  • output.library就是上文提到的暴露 library 的形式,这样其他模块就可以使用output.library链接到抽取出来的 ”DLL“,这个属性值必须和DllPlugin内部的name配置项保持一致

最后结合 npm-scripts 在package.json中将执行这个单独的webpack.dll.config.js的命令写入进去。

  "scripts": {
"dll": "webpack --config webpack.dll.config.js",
},

现在在控制台执行yarn dll,就会在项目根目录的dll文件夹中生成打包的 DLL 文件了,同时还会包含一些manifest.json文件,用于DllReferencePlugin。

image-20200913191529903

打开react.dll.xx.js看一下,内部确实包含了react.production.min.js和react-dom.production.min.js生产版本的代码。再打开manifest.json文件,内部包含了模块 id,DLL 的 名称,DLL 包含的所有模块。

image-20200913192529421

DllReferencePlugin​

DllReferencePlugin负责根据DllPlugin 生成的manifest.json将项目 chunk 和 DLL 链接在一起。

配置项​

配置项类型是否必填含义
contextStringyesmanifest.json文件中请求的 context;默认是 webpack 的context配置项,也就是webpack.config.js所在的当前目录
scopeStringnoDLL 中内容的前缀
extensionsArrayno用于解析 DLL bundle 中模块的扩展名,仅在使用scope时使用
contentStringno请求到模块 id 的映射,默认是manifest.json文件内部的content
nameStringno暴露出的 DLL 的函数名称,默认是manifest.json文件内部的name
manifestObjectyesString
sourceTypeStringnoDLL 是如何暴露自己模块的,见 —— output.libraryTarget

Note:需要特别注意的一点这里的context这个配置项是必填的,并且必须指向manifest.json所在的目录的绝对路径。webpack 文档中给的说明context的 example 比较模糊,按照use-dll-without-scope这个例子看比较清楚一点context的用法。

使用​

DllReferencePlugin使用相对简单,在项目本来的webpack.config.js中按照 plugin 引入即可。如果有多个抽取的 DLL,可以使用多次。

module.exports = {
plugin: [
new webpack.DllReferencePlugin({
context: path.resolve(__dirname, "./dll"),
manifest: require("./dll/react-manifest.json"),
}),
new webpack.DllReferencePlugin({
context: path.resolve(__dirname, "./dll"),
manifest: require("./dll/other-manifest.json"),
}),
//...
],
};

这样再执行yarn build打包,webpack 就会自动跳对react模块了。打包信息显示 webpack external(使用外部拓展)了react_dll。

image-20200913194138841

在webpack.config.js内部不使用DllReferencePlugin时,整个打包过程需要 5S 左右的时间,使用之后,减少了 2S。

image-20200913194517301image-20200913194635839

复制 DLL 文件​

第三方库的 DLL 文件,需要插入 HTML 中,并且复制到项目的build目录下,否则项目无法运行。

使用copy-webpack-plugin将 DLL 复制到build目录,使用html-webpack-tags-plugin将额外的<script>标签插入到 HTML 页面中,html-webpack-tags-plugin需要配合 HtmlWebpackPlugin 一起使用。

yarn add copy-webpack-plugin html-webpack-tags-plugin -D
module.exports = {
plugin: [
new CopyWebpackPlugin({
patterns: [
{ from: "./dll/react.dffd2b4e9672e773b9c9.dll.js", to: "static/js" },
],
}),
new HtmlWebpackPlugin({
inject: true,
template: "./public/index.html",
favicon: "./public/favicon.ico",
}),
new HtmlWebpackTagsPlugin({
append: true,
publicPath: "static/js", //dll.js文件的路径前缀
tags: ["react.dffd2b4e9672e773b9c9.dll.js"],
}),
],
};

image-20200913230740962

和 SplitChunksPlugin 的冲突​

经过我的测试,如果 webpack 配置了SplitChunksPlugin来抽取node_modules中的代码,和DllReferencePlugin确实是有冲突的。

因为我的测试项目很小,只引入了react和react-dom这两个第三方库,而现在我又通过DllPlugin把它们两个抽取了出来单独打包,所以项目就不应该存在 vendor chunk 了才对,但是无论开发环境还是生产环境,配置了如下的SplitChunksPlugin都会把react部分打包进来。

module.exports = {
optimization: {
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: "vendors",
chunks: "all",
},
},
},
},
};

// 或者是
module.exports = {
optimization: {
splitChunks: {
chunks: "all",
},
},
};

image-20200913232708859

image-20200913232837318

externals​

externals可以直接将某些模块在打包过程中剔除,这样减少 webpack 打包时候的工作量,从而加快构建速度。

配置项​

正则表达式​

可以为externals指定一个正则表达式,则所有匹配名称的模块在打包的时候都会被忽略

module.exports = {
externals: /react/,
};

对象​

可以为externals指定一个对象形式,属性的键表示忽略的模块名,属性的名称表示library暴露的全局变量,例如使用 React 会去这样导入其内部的React和ReactDOM API

import React from "react";
import ReactDOM from "react-dom";

那么通过externals就可以像下面这样去配置,react和"react-dom"表示 webpack 打包要排除上面import中from的模块,为了替换这些模块,需要使用React和ReactDOM这样的全局变量。

module.exports = {
externals: {
react: "React",
"react-dom": "ReactDOM",
},
};

这样执行yarn build打包以后,可以看到 webpack external 了React和ReactDOM的全局变量。

image-20200914154732218

为了能够找到React和ReactDOM这样的全局变量,需要将 React 库放在 HTML 中通过<script>全局引入。

<script crossorigin src="react.production.min.js"></script>
<script crossorigin src="react-dom.production.min.js"></script>

对于具有父级模块的结构,也可以传入一个数组作为属性名,其中./math属于父模块,表示subtract只使用了./math内部的other模块,所以这个结果最终被编译成require('./math').subtract。

module.exports = {
//...
externals: {
subtract: ["./math", "other"],
},
};

可能并不需要 DllPlugin​

DllPlugin本身更多的用处是把第三方库抽成单独的library,而且还需要暴露全局变量,即通过<script>的方式全局注入,然后通过DllReferencePlugin引用library。

那为什么不直接用 CDN +externals的方式呢?把资源通过 CDN 引入,然后直接externals忽略模块打包完事了呀!

CDN 的配置可以使用很多方式,首先可以直接将 CDN 资源手动放在 HTML 模板页面,通过HtmlWebpackPlugin就会自动生成带有 CDN 资源的 HTML 页面。

例如在上文配置了externals忽略 React 的时候,在 HTML 页面添加 React 的 jsDelivr 的 CDN 链接:

<script crossorigin src="https://cdn.jsdelivr.net/npm/[email protected]/umd/react.production.min.js"></script>
<script crossorigin src="https://cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.production.min.js"></script>

也可以借助HtmlWebpackPlugin的自定义模板来解决,HtmlWebpackPlugin默认是支持 ejs 模板,对传入HtmlWebpackPlugin的配置项可以在 HTML 中<%= htmlWebpackPlugin.options.xxx %>的形式去访问。

这里还得区分 React 开发环境和生产环境的版本,如果在开发环境使用了生产环境的 React,有一个区别是浏览器的 React devtools 插件就检测不到应用是在开发环境,因此也就没用了。

module.exports = {
plugins: [
new HtmlWebpackPlugin({
inject: true,
template: "./public/index.html",
favicon: "./public/favicon.ico",
cdn: {
script: [
isDevelopment
? "https://cdn.jsdelivr.net/npm/[email protected]/umd/react.development.js"
: "https://cdn.jsdelivr.net/npm/[email protected]/umd/react.production.min.js",
isDevelopment
? "https://cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.development.js"
: "https://cdn.jsdelivr.net/npm/[email protected]/umd/react-dom.production.min.js",
],
},
}),
],
};

修改 HTML 页面,ejs 本身很简单,直接在 HTML 里写的每一行 JS 代码用<% ... %>包起来就行,如果是变量,用<%= ... %>包起来。

<!DOCTYPE html>
<html lang="zh-hans">
<head>
<meta charset="utf-8" />
<title>toycra</title>
</head>
<body>
<div id="root"></div>
<!--CDN-->
<% if (htmlWebpackPlugin.options.cdn) { %> <% for(let src of
htmlWebpackPlugin.options.cdn.script) { %>
<script crossorigin="anonymous" src="<%=src%>"></script>
<% } %> <% } %>
</body>
</html>

现在执行打包,React 模块就被externals的配置忽略掉了,其使用 CDN 方式全局注入。

image-20200914181903091