Appearance
Webpack
webpack 入门
概念
基本概念
本质上,webpack 是一个用于现代 JavaScript 应用程序的静态模块打包工具。当 webpack 处理应用程序时,它会在内部构建一个 依赖图(dependency graph),此依赖图对应映射到项目所需的每个模块,并生成一个或多个 bundle。
从 v4.0.0 开始,webpack 可以不用再引入一个配置文件来打包项目,然而,它仍然有着 高度可配置性,可以很好满足你的需求。
核心概念
- 入口(entry)
- 指示 webpack 使用哪个模块来作为内部依赖图的开始,默认是
./src/index.js
;
- 指示 webpack 使用哪个模块来作为内部依赖图的开始,默认是
- 输出(output)
- 告诉 webpack 在哪里输出它所创建的 bundle,默认是
./dist/main.js
; - 可使用属性
path
和filename
来指定输出的路径和文件名;
- 告诉 webpack 在哪里输出它所创建的 bundle,默认是
- loader
- webpack 只能解析 JavaScript 和 JSON 文件,这是 webpack 开箱可用的自带能力,loader 让 webpack 能够去处理其它类型的文件,并将他们转换为有效的模块,以供应用程序使用,以及被添加到依赖图中;
- 定义在
module
对象的rules
数组中; - 有两个属性
- test 属性:识别出哪些文件会被转换,支持正则匹配;
- use 属性,定义出在进行转换的时,应该使用哪个 loader,在官网查看相关 loader 介绍;
const path = require("path");
module.exports = {
output: {
filename: "my-first-webpack.bundle.js",
},
module: {
rules: [{ test: /\.txt$/, use: "raw-loader" }],
},
};
- 插件(plugin)
- loader 用于转换某些类型的模块,而插件则可以用于执行范围更广的任务,包括:打包优化,资源管理,注入环境变量;
- webpack 提供许多开箱可用的插件!查阅 插件列表 获取更多
- 模式(mode)
- 指定当前配置文件应用在何种模式,有
development
,production
和none
,默认值为production
;
- 指定当前配置文件应用在何种模式,有
- 浏览器兼容性(browser cpmpatibility)
- webpack 支持所有符合 ES5 标准 的浏览器(不支持 IE8 及以下版本),如果想要支持旧版本浏览器,在使用这些表达式之前,还需要 提前加载 polyfill。
- 环境(environment)
- webpack 运行于 Node.js v8.x+ 版本。
起步
webpack 用于编译 JavaScript 模块。一旦完成 安装,你就可以通过 webpack CLI 或 API 与其配合交互。如果你还不熟悉 webpack,请阅读 核心概念 和 对比,了解为什么要使用 webpack,而不是社区中的其他工具。
基本安装
全局安装
npm install --global webpack
npm install --global webpack-cli
使用
npx webpack
这样即可将以默认配置./src/index.js
为入口,dist/main.js
为出口来进行 js 文件打包;
当然,也可以创建webpack.config.js
文件来指定配置,使用
npx webapck --config webpack.config.js
来执行指定的 webpack 配置文件;
资源处理
加载 css 文件
npm install --save-dev style-loader css-loader
使用 style-loader 和 css-loader 来处理 css 文件,在 webpack 配置文件中进行配置;
const path = require("path");
module.exports = {
entry: "./src/index.js",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "dist"),
},
module: {
rules: [
{
test: /\.css$/i,
use: ["style-loader", "css-loader"],
},
],
},
};
这里,我们使用了配置文件中的module
,并为其配置了一项rules
,rules
中写入配对并处理 css 文件的 loader 规则,注意的是,处理一种文件可使用多个 loader,loader 在use
中被指定,进行文件处理时,是按照该配置项逆序执行相关 loader 的,如上例中,是先执行 css-loader,在执行 style-loader;
前面提到 webpack 默认处理 js 文件,所以要 webpack 能处理 css 文件,那就必须使用 css-loader 将 css 文件转换成 webpack 可处理的 js 代码段(需要注意的是,必须在 js 文件中 import 对应的 css 文件,产生依赖,这样才会被 webpack 打包),至于 style-loader 的话,它的作用是将 css 字符串已<style>标签的形式插入到 index.html 中;
处理 css 文件是如此,同理地,处理其它类型的文件也是同样道理;
加载 images 图像
处理 background 和 icon 之类的图像,我们可以使用 webpack 内置的 asset modules,将这些内容混入我们的系统中,在配置文件中新增 loader 规则;
{
test: /\.(png|svg|jpg|jpeg|gif)$/i,
// webpack5新增的资源处理类型
// asset/resource就是url-loader和file-loader,将文件发送到输出目录,并导出对应的url
type: 'asset/resource',
},
加载 fonts 字体
与加载 images 图像同样道理,在配置文件中加入以下 loader 规则;
{
test: /\.(woff|woff2|eot|ttf|otf)$/i,
type: 'asset/resource',
},
加载数据
可以加载的有用资源还有数据,如 JSON 文件,CSV、TSV 和 XML。类似于 NodeJS,JSON 支持实际上是内置的,也就是说 import Data from './data.json'
默认将正常运行。要导入 CSV、TSV 和 XML,你可以使用 csv-loader 和 xml-loader。让我们处理加载这三类文件:
npm install --save-dev csv-loader xml-loader
在配置文件加入以下 loader 规则;
{
test: /\.(csv|tsv)$/i,
use: ['csv-loader'],
},
{
test: /\.xml$/i,
use: ['xml-loader'],
},
管理输出
多入口
为了处理多个入口文件,需要在配置文件中指定;
const path = require("path");
module.exports = {
entry: {
index: "./src/index.js",
print: "./src/print.js",
},
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "dist"),
},
};
设置 HtmlWebpackPlugin
首先安装插件,该插件的作用是打包后生成对应的 html 文件,并动态引入打包后的 js 文件;
npm install --save-dev html-webpack-plugin
修改配置文件;
const path = require("path");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
entry: {
index: "./src/index.js",
print: "./src/print.js",
},
plugins: [
new HtmlWebpackPlugin({
title: "管理输出",
}),
],
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "dist"),
},
};
清理/dist
文件夹
每次进行 webpack 打包时,上一次生成的资源仍在dist
,当某些图片或资源在此次打包中没用到,上次打包的时候已经打包到dist
目录下了,这就造成了/dist
目录文件臃肿、凌乱,要解决这个问题,比较推荐的做法就是每次构建前,先清理dist
目录下的文件,再进行生成;
clean-webpack-plugin
是一个流行的清理插件,安装和配置它;
npm install --save-dev clean-webpack-plugin
在配置文件中引入,并实例化;
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
在 plugins 中实例化,再运行打包,即可看到效果;
plugins: [
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: 'Output Management',
}),
],
开发环境
针对开发环境进行 webpack 配置,首先要在配置文件中加入mode: 'development'
;
使用 source map
使用 webpack 进行打包时,往往是多个 js 文件打包成一个 bundle.js 文件,打包后的代码不具备可读性,加入了很多的混淆代码,但是在开发过程中,我们有免不了要进行边写边调试,这时候,就需要使用代码映射了,使用代码映射,可以及时定位打包前的代码,然错误代码显示出属于哪个文件、哪一行;
使用source map
进行代码映射,配置文件中新增devtool: 'inline-source-map'
使用开发工具
webpack 提供几种可选方式,帮助你在代码发生变化后自动编译代码(以至于不用每次手动输入 npm run build):
使用 watch mode
可以指示 webpack 去“watch”依赖图中所有文件的更改,如果其中一个文件被更新,代码将重新编译;
我们在package.json
中添加一个用于启动 webpack watch mode 的 npm scripts:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"watch": "webpack --watch",
"build": "webpack"
},
如果不想再 watch 触发增量构建后删除index.html
文件,可以在CleanWebpackPlugin
中配置cleanStaleWebpackAssets: false
;
plugins: [
new CleanWebpackPlugin({ cleanStaleWebpackAssets: false }),
new HtmlWebpackPlugin({
title: 'Development',
}),
],
这样,在命令行执行npm run watch
之后,可以发现,这个命令是一直在运行的,我们修改项目依赖中的文件,并点击保存,可以看到,webpack 在进行自动打包,那我们直接到浏览器中点击刷新,即可看到修改后的内容了;
每次自动打包后,还是要去浏览器进行刷新的,那么我们可以使用webpack-dev-server
来实现实时加载,等 webpack 检测到文件变化并打包之后,会自动刷新浏览器;
使用 webpack-dev-server
webpack-dev-server
为你提供了一个简单的 web server,并且具有 live reloading(实时重新加载) 功能。设置如下:
npm install --save-dev webpack-dev-server
在配置文件webpack.config.js
中加入以下配置,用作告诉 dev.server 从什么位置查找文件:
devServer: {
contentBase: './dist', // 内容目录
open: true, // 设置首次打包自动打开浏览器
},
我们添加一个可以直接运行 dev server 的 script:
"scripts": {
"test": "echo \"Error: no test specified\" && exit 1",
"watch": "webpack --watch",
"start": "webpack serve",
"build": "webpack"
},
使用 webpack-dev-middleware
webpack-dev-middleware
是一个封装器(wrapper),它可以把 webpack 处理过的文件发送到一个 server。 webpack-dev-server
在内部使用了它,然而它也可以作为一个单独的 package 来使用,以便根据需求进行更多自定义设置。下面是一个 webpack-dev-middleware 配合 express server 的示例。
首先,安装 express
和 webpack-dev-middleware
:
npm install --save-dev express webpack-dev-middleware
现在,我们需要调整 webpack 配置文件,以确保 middleware(中间件) 功能能够正确启用:
webpack.config.js
output: {
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
publicPath: '/',
},
我们将会在 server 脚本使用 publicPath
,以确保文件资源能够正确地 serve 在 http://localhost:3000
下,稍后我们会指定 port number(端口号)。接下来是设置自定义 express
server:
项目根目录下,新建server.js
;
const express = require("express");
const webpack = require("webpack");
const webpackDevMiddleware = require("webpack-dev-middleware");
const app = express();
const config = require("./webpack.config.js");
const compiler = webpack(config);
// 告知 express 使用 webpack-dev-middleware,
// 以及将 webpack.config.js 配置文件作为基础配置。
app.use(
webpackDevMiddleware(compiler, {
publicPath: config.output.publicPath,
})
);
// 将文件 serve 到 port 3000。
app.listen(3000, function () {
console.log("Example app listening on port 3000!\n");
});
代码分离
代码分离是 webpack 中最引人注目的特性之一。此特性能够把代码分离到不同的 bundle 中,然后可以按需加载或并行加载这些文件。代码分离可以用于获取更小的 bundle,以及控制资源加载优先级,如果使用合理,会极大影响加载时间。
常用的代码分离方法有三种:
- 入口起点:使用
entry
配置手动地分离代码。 - 防止重复:使用 Entry dependencies 或者
SplitChunksPlugin
去重和分离 chunk。 - 动态导入:通过模块的内联函数调用来分离代码。
入口起点(entry point)
这是迄今为止最简单直观的分离代码的方式。不过,这种方式手动配置较多,并有一些隐患;
使用这种方式进行代码分离,需要在webpack.config.js
配置文件中修改entry
和output
;
entry: {
index: './src/index.js',
another: './src/another-module.js',
},
// 入口文件中引入要进行分隔的代码
// 将输出的文件名由单一的文件名改为动态的文件名
output: {
// filename: 'main.js',
filename: '[name].bundle.js',
path: path.resolve(__dirname, 'dist'),
},
正如前面提到的,这种方式存在一些隐患:
- 如果入口 chunk 之间包含一些重复的模块,那些重复模块都会被引入到各个 bundle 中。
- 这种方法不够灵活,并且不能动态地将核心应用程序逻辑中的代码拆分出来。
防止重复(prevent duplication)
配置 dependOn
option 选项,这样可以在多个 chunk 之间共享模块:
entry: {
// index: './src/index.js',
// another: './src/another-module.js',
index: {
import: './src/index.js',
dependOn: 'shared',
},
another: {
import: './src/another-module.js',
dependOn: 'shared',
},
shared: 'lodash',
},
如果我们要在一个 HTML 页面上使用多个入口时,还需设置 optimization.runtimeChunk: 'single'
,否则还会遇到这里所述的麻烦。
optimization: {
runtimeChunk: 'single',
},
尽管可以在 webpack 中允许每个页面使用多入口,应尽可能避免使用多入口的入口:entry: { page: ['./analytics', './app'] }
。如此,在使用 async
脚本标签时,会有更好的优化以及一致的执行顺序。
splitChunksPlugin
SplitChunksPlugin
插件可以将公共的依赖模块提取到已有的入口 chunk 中,或者提取到一个新生成的 chunk。让我们使用这个插件,将之前的示例中重复的 lodash
模块去除:
optimization: {
splitChunks: {
chunks: 'all',
},
},
使用 optimization.splitChunks
配置选项之后,现在应该可以看出,index.bundle.js
和 another.bundle.js
中已经移除了重复的依赖模块。需要注意的是,插件将 lodash
分离到单独的 chunk,并且将其从 main bundle 中移除,减轻了大小。执行 npm run build
查看效果;
以下是由社区提供,一些对于代码分离很有帮助的 plugin 和 loader:
mini-css-extract-plugin
: 用于将 CSS 从主应用程序中分离。
动态导入(dynamic import)
当涉及到动态代码拆分时,webpack 提供了两个类似的技术。第一种,也是推荐选择的方式是,使用符合 ECMAScript 提案 的 import()
语法 来实现动态导入。第二种,则是 webpack 的遗留功能,使用 webpack 特定的 require.ensure
。让我们先尝试使用第一种……
import()
调用会在内部用到 promises。如果在旧版本浏览器中(例如,IE 11)使用 import()
,记得使用一个 polyfill 库(例如 es6-promise 或 promise-polyfill),来 shim Promise
。
在我们开始之前,先从上述示例的配置中移除掉多余的 entry
和 optimization.splitChunks
,因为接下来的演示中并不需要它们:
const path = require("path");
module.exports = {
mode: "development",
entry: {
index: "./src/index.js",
// another: './src/another-module.js',
},
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "dist"),
},
/* optimization: {
splitChunks: {
chunks: 'all',
},
}, */
};
我们将更新我们的项目,移除现在未使用的文件:
现在,我们不再使用 statically import(静态导入) lodash
,而是通过 dynamic import(动态导入) 来分离出一个 chunk:
// index.js
function getComponent() {
const element = document.createElement("div");
return import("lodash")
.then(({ default: _ }) => {
const element = document.createElement("div");
element.innerHTML = _.join(["Hello", "webpack"], " ");
return element;
})
.catch((error) => "An error occurred while loading the component");
}
getComponent().then((component) => {
document.body.appendChild(component);
});
预获取/预加载模块[prefetch/pre]
在声明 import 时,使用下面的内置指令;
- prefetch(预获取):将来某些导航下可能需要的资源;
- preload(预加载):当前导航下可能需要资源;
下面这个 prefetch 的简单示例中,有一个 HomePage
组件,其内部渲染一个 LoginButton
组件,然后在点击后按需加载 LoginModal
组件。
import(/* webpackPrefetch: true */ "./path/to/LoginModal.js");
这会生成 <link rel="prefetch" href="login-modal-chunk.js">
并追加到页面头部,指示着浏览器在闲置时间预取 login-modal-chunk.js
文件。
只要父 chunk 完成加载,webpack 就会添加 prefetch hint(预取提示)。
与 prefetch 指令相比,preload 指令有许多不同之处:
- preload chunk 会在父 chunk 加载时,以并行方式开始加载。prefetch chunk 会在父 chunk 加载结束后开始加载。
- preload chunk 具有中等优先级,并立即下载。prefetch chunk 在浏览器闲置时下载。
- preload chunk 会在父 chunk 中立即请求,用于当下时刻。prefetch chunk 会用于未来的某个时刻。
- 浏览器支持程度不同。
缓存
如果我们在部署新版本时不更改资源的文件名,浏览器可能会认为它没有被更新,就会使用它的缓存版本
此指南的重点在于通过必要的配置,以确保 webpack 编译生成的文件能够被客户端缓存,而在文件内容变化后,能够请求到新的文件。
输出文件的文件名(output filename)
我们可以通过替换 output.filename
中的 substitutions 设置,来定义输出文件的名称。webpack 提供了一种使用称为 substitution(可替换模板字符串) 的方式,通过带括号字符串来模板化文件名。其中,[contenthash]
substitution 将根据资源内容创建出唯一 hash。当资源内容发生变化时,[contenthash]
也会发生变化。
output: {
filename: '[name].[contenthash].js',
path: path.resolve(__dirname, 'dist'),
},
提取引导模板(extracting boilerplate)
将第三方库(library)(例如 lodash
或 react
)提取到单独的 vendor
chunk 文件中,是比较推荐的做法,这是因为,它们很少像本地的源代码那样频繁修改。因此通过实现以上步骤,利用 client 的长效缓存机制,命中缓存来消除请求,并减少向 server 获取资源,同时还能保证 client 代码和 server 代码版本一致。 这可以通过使用 SplitChunksPlugin 示例 2 中演示的 SplitChunksPlugin
插件的 cacheGroups
选项来实现。我们在 optimization.splitChunks
添加如下 cacheGroups
参数并构建:
optimization: {
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
},
模块标识符(module identifier)
在index.js
中添加一个依赖,重新打包,我们可以看到这三个文件的 hash 都变化了。这是因为每个 module.id
会默认地基于解析顺序(resolve order)进行增量。也就是说,当解析顺序发生变化,ID 也会随之改变。因此,简要概括:
main
bundle 会随着自身的新增内容的修改,而发生变化。vendor
bundle 会随着自身的module.id
的变化,而发生变化。manifest
runtime 会因为现在包含一个新模块的引用,而发生变化。
第一个和最后一个都是符合预期的行为,vendor
hash 发生变化是我们要修复的。我们将 optimization.moduleIds
设置为 'deterministic'
:
optimization: {
moduleIds: 'deterministic',
runtimeChunk: 'single',
splitChunks: {
cacheGroups: {
vendor: {
test: /[\\/]node_modules[\\/]/,
name: 'vendors',
chunks: 'all',
},
},
},
},
这样,改变依赖后,再重新打包,可以看到vender
模块(node modules 模块)的 hash 保持一致;
创建 library
基本配置
现在,让我们以某种方式打包这个 library,能够实现以下几个目标:
- 使用
externals
选项,避免将lodash
打包到应用程序,而使用者会去加载它。 - 将 library 的名称设置为
webpack-numbers
。 - 将 library 暴露为一个名为
webpackNumbers
的变量。 - 能够访问其他 Node.js 中的 library。
此外,consumer(使用者) 应该能够通过以下方式访问 library:
- ES2015 模块。例如
import webpackNumbers from 'webpack-numbers'
。 - CommonJS 模块。例如
require('webpack-numbers')
. - 全局变量,在通过
script
标签引入时。
外部化 lodash
如果执行 webpack
,你会发现创建了一个体积相当大的文件。如果你查看这个文件,会看到 lodash 也被打包到代码中。在这种场景中,我们更倾向于把 lodash
当作 peerDependency
。也就是说,consumer(使用者) 应该已经安装过 lodash
。因此,你就可以放弃控制此外部 library ,而是将控制权让给使用 library 的 consumer。
这可以使用 externals
配置来完成:
webpack.config.js
const path = require("path");
module.exports = {
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "webpack-numbers.js",
},
externals: {
lodash: {
commonjs: "lodash",
commonjs2: "lodash",
amd: "lodash",
root: "_",
},
},
};
外部化的限制
对于想要实现从一个依赖中调用多个文件的那些 library,无法通过在 externals 中指定整个 library
的方式,将它们从 bundle 中排除。而是需要逐个或者使用一个正则表达式,来排除它们。
module.exports = {
//...
externals: [
"library/one",
"library/two",
// 匹配以 "library/" 开始的所有依赖
/^library\/.+$/,
],
};
暴露 library
对于用法广泛的 library,我们希望它能够兼容不同的环境,例如 CommonJS,AMD,Node.js 或者作为一个全局变量。为了让你的 library 能够在各种使用环境中可用,需要在 output
中添加 library
属性:
const path = require("path");
module.exports = {
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "webpack-numbers.js",
library: "webpackNumbers",
},
externals: {
lodash: {
commonjs: "lodash",
commonjs2: "lodash",
amd: "lodash",
root: "_",
},
},
};
这会将你的 library bundle 暴露为名为 webpackNumbers
的全局变量,consumer 通过此名称来 import。为了让 library 和其他环境兼容,则需要在配置中添加 libraryTarget
属性。这个选项可以控制以多种形式暴露 library。
webpack.config.js
const path = require("path");
module.exports = {
entry: "./src/index.js",
output: {
path: path.resolve(__dirname, "dist"),
filename: "webpack-numbers.js",
library: "webpackNumbers",
libraryTarget: "umd",
},
externals: {
lodash: {
commonjs: "lodash",
commonjs2: "lodash",
amd: "lodash",
root: "_",
},
},
};
有以下几种方式暴露 library:
- 变量:作为一个全局变量,通过
script
标签来访问(libraryTarget:'var'
)。 - this:通过
this
对象访问(libraryTarget:'this'
)。 - window:在浏览器中通过
window
对象访问(libraryTarget:'window'
)。 - UMD:在 AMD 或 CommonJS
require
之后可访问(libraryTarget:'umd'
)。
环境变量
想要消除 webpack.config.js
在 开发环境 和 生产环境 之间的差异,你可能需要环境变量(environment variable)。
webpack 命令行 环境配置 的 --env
参数,可以允许你传入任意数量的环境变量。而在 webpack.config.js
中可以访问到这些环境变量。例如,--env production
或 --env NODE_ENV=local
(NODE_ENV
通常约定用于定义环境类型,查看 这里)。
如果设置 env
变量,却没有赋值,--env production
默认表示将 env.production
设置为 true
。还有许多其他可以使用的语法。更多详细信息,请查看 webpack CLI 文档。
对于我们的 webpack 配置,有一个必须要修改之处。通常,module.exports
指向配置对象。要使用 env
变量,你必须将 module.exports
转换成一个函数:
webpack.config.js
const path = require("path");
module.exports = (env) => {
// Use env.<YOUR VARIABLE> here:
console.log("NODE_ENV: ", env.NODE_ENV); // 'local'
console.log("Production: ", env.production); // true
return {
entry: "./src/index.js",
output: {
filename: "bundle.js",
path: path.resolve(__dirname, "dist"),
},
};
};
构建性能
通用环境
无论你是在 开发环境 还是在 生产环境 下运行构建脚本,以下最佳实践都会有所帮助。
更新到最新版本
使用最新的 webpack 版本。我们会经常进行性能优化。webpack 的最新稳定版本是:
将 Node.js 更新到最新版本,也有助于提高性能。除此之外,将你的 package 管理工具(例如 npm
或者 yarn
)更新到最新版本,也有助于提高性能。较新的版本能够建立更高效的模块树以及提高解析速度。
loader
通过使用 include
字段,仅将 loader 应用在实际需要将其转换的模块:
const path = require("path");
module.exports = {
//...
module: {
rules: [
{
test: /\.js$/,
include: path.resolve(__dirname, "src"),
loader: "babel-loader",
},
],
},
};
引导(bootstrap)
每个额外的 loader/plugin 都有其启动时间。尽量少地使用工具。
解析
以下步骤可以提高解析速度:
- 减少
resolve.modules
,resolve.extensions
,resolve.mainFiles
,resolve.descriptionFiles
中条目数量,因为他们会增加文件系统调用的次数。 - 如果你不使用 symlinks(例如
npm link
或者yarn link
),可以设置resolve.symlinks: false
。 - 如果你使用自定义 resolve plugin 规则,并且没有指定 context 上下文,可以设置
resolve.cacheWithContext: false
。
DLL
使用 DllPlugin
为更改不频繁的代码生成单独的编译结果。这可以提高应用程序的编译速度,尽管它增加了构建过程的复杂度;
减少编译结果的整体大小,以提高构建性能,尽量保持 chunk 体积小;
- 使用数量更少/体积更小的 library;
- 在多页面应用程序中使用
splitChunksPlugin
; - 在多页面应用程序中使用
splitChunksPlugin
,并开启async
模式; - 移除未引用代码;
- 只编译你当前正在开发的代码;
worker 池(worker pool)
thread-loader
可以将非常消耗资源的 loader 分流给一个 worker pool;
不要使用太多的 worker,因为 Node.js 的 runtime 和 loader 都有启动开销。最小化 worker 和 main process(主进程) 之间的模块传输。进程间通讯(IPC, inter process communication)是非常消耗资源的。
持久化缓存
在 webpack 配置中使用cache
选项,使用package.json
中的"postinstall"
清除缓存目录;
我们支持 yarn PnP v3
yarn 2 berry
,来进行持久缓存。
自定义 plugin/loader
对它们进行概要分析,以免在此处引入性能问题;
Progress plugin
将Progress plugin
从 webpack 中删除,可以缩短构建时间,请注意,Progress plugin
可能不会为快速构建提供太多价值,因此,请权衡利弊再使用;
开发环境
以下步骤对于开发环境特别有帮助
增量编译
使用 webpack 的 watch mode(监听模式);而不使用其他工具来 watch 文件和调用 webpack;内置的 watch mode 会记录时间戳并将此信息传递给 compilation 以使缓存失效;
在内存中编译
下面几个工具通过在内存中(而不是写入磁盘)编译和 serve 资源来提高性能:
- webpack-dev-server
- webpack-hot-middleware
- webpack-dev-middleware
stats.toJson 加速
webpack4 默认使用stats.toJson()
输出大量数据;除非在增量不走中做必要的统计,否则请避免获取stats
对象的部分内容;webpack-dev-server
在 v3.1.3 以后的版本,包含一个重要的性能修复,即最小化每个增量构建步骤中,从stats
对象获取的数据量;
Devtool
需要注意的是不同的devtool
设置,会导致性能差异;
"eval"
具有最好的性能,但并不能帮助你转译代码;- 如果你能接受稍差一些的 map 质量,可以使用
cheap-source-map
变体配置来提高性能; - 使用
eval-source-map
变体配置进行增量编译;
在大多数情况下,最佳选择是
eval-cheap-module-source-map
。
避免在生产环境下才会用到的工具
某些 utility,plugin 和 loader 都只用在生产环境,例如,在开发环境下使用TerserPlugin
来 minify(压缩)和 mangle(混淆破坏)代码是没有意义的,通常在开发环境下,应该排除以下这些工具:
TerserPlugin
[fullhash]
/[chunkhash]
/[contenthash]
AggressiveSplittingPlugin
ModuleConcatenationPlugin
最小化 entry chunk
webpack 只会在文件系统中输出已经更新的 chunk,某些配置选项(HMR、output.chunkFilename
的[name]
/[chunkhash]
/[contenthash]
,[fullhash]
)来说,除了对已经更新的 chunk 无效之外,对于 entry chunk 也不会生效;
确保在生成 entry chunk 时,尽量减少其体积以提高性能,下面的配置为运行时代码创建了一个额外的 chunk,所以它的生成代价较低:
module.exports = {
// ...
optimization: {
runtimeChunk: true,
},
};
避免额外的优化步骤
webpack 通过执行额外的算法任务,来优化输出结果的体积和加载性能,这些优化适用于小型代码库,但在大型代码库中却非常耗费性能:
module.exports = {
// ...
optimization: {
removeAvailableModules: false,
removeEmptyChunks: false,
splitChunks: false,
},
};
输出结果不携带路径信息
webpack 会在输出的 bundle 中生成路径信息,然而,在打包数千个模块的项目中,这会导致造成垃圾回收性能压力,在options.output.pathinfo
设置中关闭:
module.exports = {
// ...
output: {
pathinfo: false,
},
};
Node.js 版本 8.9.10-9.11.1
Node.js v8.9.10 - v9.11.1 中的 ES2015 Map
和 Set
实现,存在 性能回退。webpack 大量地使用这些数据结构,因此这次回退也会影响编译时间;之前和之后的 Node.js 版本不受影响;
TypeScript loader
你可以为 loader 传入transpileOnly
选项,以缩短使用ts-loader
时的构建时间;使用此选项,会关闭类型检查,如果要再次开启类型检查,请使用ForkTsCheckerWebpackOlugin
;使用此插件会将检查过程移至单独的进程,可以加快 TypeScript 的类型检查和 ESLint 插入的速度;
module.exports = {
// ...
test: /\.tsx?$/,
use: [
{
loader: "ts-loader",
options: {
transpileOnly: true,
},
},
],
};
这是一个关于
ts-loader
完整示例的 Github 仓库。
生产环境
以下步骤对于生产环境特别有帮助;
***要为了很小的性能收益,牺牲应用程序的质量!****注意,在大多数情况下,优化代码质量比构建性能更重要*;
多个 compilation 对象
在创建多个 compilation 时,以下工具可以帮助到你:
parallel-webpack
:它允许在一个 worker 池中运行 compilation;cache-loader
:可以在多个 compilation 之间共享缓存;
Source Maps
source map 相当消耗资源,你真的需要它们?
工具相关问题
下列工具存在某些可能会降低构建性能的问题:
Babel
- 最小化项目中的 preset/plugin 数量;
TypeScript
- 在单独的进程中使用
fork-ts-checker-webpack-plugin
进行类型检查; - 配置 loader 跳过类型检查;
- 使用
ts-loader
时,设置happyPackMode: true
/transpileOnly: true
;
Sass
node-sass
中有个来自 Node.js 线程池的阻塞线程的 bug,当使用thread-loader
时,需要设置workerParallelJobs: 2
;
模块热替换
模块热替换(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新所有类型的模块, 而无需完全刷新;
HMR 不适用于生产环境,这意味着它应当用于开发环境。更多详细信息, 请查看 生产环境 指南。
启动 HMR
此功能可以很大程度提高生产效率。我们要做的就是更新 webpack-dev-server 配置, 然后使用 webpack 内置的 HMR 插件。
devServer: {
contentBase: './dist',
hot: true,
},
Tree Shaking
tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的未引用代码(dead-code)。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import
和 export
。这个术语和概念实际上是由 ES2015 模块打包工具 rollup 普及起来的。
将文件标记为 side-effect-free(无副作用)
通过 package.json 的 "sideEffects"
属性,来实现这种方式;
{
"name": "your-project",
"sideEffects": false
}
如果所有代码都不包含 side effect,我们就可以简单地将该属性标记为 false
,来告知 webpack,它可以安全地删除未用到的 export。
"side effect(副作用)" 的定义是,在导入时会执行特殊行为的代码,而不是仅仅暴露一个 export 或多个 export。举例说明,例如 polyfill,它影响全局作用域,并且通常不提供 export。
如果你的代码确实有一些副作用,可以改为提供一个数组:
{
"name": "your-project",
"sideEffects": ["./src/some-side-effectful-file.js"]
}
注意,所有导入文件都会受到 tree shaking 的影响。这意味着,如果在项目中使用类似 css-loader
并 import 一个 CSS 文件,则需要将其添加到 side effect 列表中,以免在生产模式中无意中将它删除:
{
"name": "your-project",
"sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}
解释 tree shaking 和sideEffects
sideEffects
和 usedExports
(更多被认为是 tree shaking)是两种不同的优化方式。
sideEffects
更为有效 是因为它允许跳过整个模块/文件和整个文件子树。
usedExports
依赖于 terser 去检测语句中的副作用。它是一个 JavaScript 任务而且没有像 sideEffects
一样简单直接。而且它不能跳转子树/依赖由于细则中说副作用需要被评估。尽管导出函数能运作如常,但 React 框架的高阶函数(HOC)在这种情况下是会出问题的。
压缩输出结果
通过 import
和 export
语法,我们已经找出需要删除的“未引用代码(dead code)”,然而,不仅仅是要找出,还要在 bundle 中删除它们。为此,我们需要将 mode
配置选项设置为 production
。
结论
因此,我们学到为了利用 tree shaking 的优势, 你必须...
- 使用 ES2015 模块语法(即
import
和export
)。 - 确保没有编译器将您的 ES2015 模块语法转换为 CommonJS 的(顺带一提,这是现在常用的 @babel/preset-env 的默认行为,详细信息请参阅文档)。
- 在项目的
package.json
文件中,添加"sideEffects"
属性。 - 使用
mode
为"production"
的配置项以启用更多优化项,包括压缩代码与 tree shaking。
你可以将应用程序想象成一棵树。绿色表示实际用到的 source code(源码) 和 library(库),是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动这棵树,使它们落下。
如果你对优化输出很感兴趣,请进入到下个指南,来了解 生产环境 构建的详细细节。
生产环境
在本指南中,我们将深入一些最佳实践和工具,将站点或应用程序构建到生产环境中。
配置
development(开发环境) 和 production(生产环境) 这两个环境下的构建目标存在着巨大差异。在开发环境中,我们需要:强大的 source map 和一个有着 live reloading(实时重新加载) 或 hot module replacement(热模块替换) 能力的 localhost server。而生产环境目标则转移至其他方面,关注点在于压缩 bundle、更轻量的 source map、资源优化等,通过这些优化方式改善加载时间。由于要遵循逻辑分离,我们通常建议为每个环境编写彼此独立的 webpack 配置。
虽然,以上我们将生产环境和开发环境做了略微区分,但是,请注意,我们还是会遵循不重复原则(Don't repeat yourself - DRY),保留一个 "common(通用)" 配置。为了将这些配置合并在一起,我们将使用一个名为 webpack-merge
的工具。此工具会引用 "common" 配置,因此我们不必再在环境特定(environment-specific)的配置中编写重复代码。
我们先从安装 webpack-merge
开始,并将之前指南中已经成型的那些代码进行分离:
npm install --save-dev webpack-merge
webpack.common.js
const path = require("path");
const { CleanWebpackPlugin } = require("clean-webpack-plugin");
const HtmlWebpackPlugin = require("html-webpack-plugin");
module.exports = {
entry: {
app: "./src/index.js",
},
plugins: [
// 对于 CleanWebpackPlugin 的 v2 versions 以下版本,使用 new CleanWebpackPlugin(['dist/*'])
new CleanWebpackPlugin(),
new HtmlWebpackPlugin({
title: "Production",
}),
],
output: {
filename: "[name].bundle.js",
path: path.resolve(__dirname, "dist"),
},
};
webpack.dev.js
const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");
module.exports = merge(common, {
mode: "development",
devtool: "inline-source-map",
devServer: {
contentBase: "./dist",
},
});
webpack.prod.js
const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");
module.exports = merge(common, {
mode: "production",
});
现在,在 webpack.common.js
中,我们设置了 entry
和 output
配置,并且在其中引入这两个环境公用的全部插件。在 webpack.dev.js
中,我们将 mode
设置为 development
,并且为此环境添加了推荐的 devtool
(强大的 source map)和简单的 devServer
配置。最后,在 webpack.prod.js
中,我们将 mode
设置为 production
,其中会引入之前在 tree shaking 指南中介绍过的 TerserPlugin
。
注意,在环境特定的配置中使用 merge()
功能,可以很方便地引用 webpack.dev.js
和 webpack.prod.js
中公用的 common 配置。webpack-merge
工具提供了各种 merge(合并) 高级功能,但是在我们的用例中,无需用到这些功能。
NPM Script
现在,我们把 scripts
重新指向到新配置。让 npm start
script 中 webpack-dev-server
, 使用 webpack.dev.js
, 而让 npm run build
script 使用 webpack.prod.js
:
"start": "webpack serve --open --config webpack.dev.js",
"build": "webpack --config webpack.prod.js"
指定 mode
许多 library 通过与 process.
环境变量关联,以决定 library 中应该引用哪些内容。例如,当process.
没有被设置为 'production'
时,某些 library 为了使调试变得容易,可能会添加额外的 log(日志记录) 和 test(测试) 功能。并且,在使用 process.
时,一些 library 可能针对具体用户的环境,删除或添加一些重要代码,以进行代码执行方面的优化。从 webpack v4 开始, 指定 mode
会自动地配置 DefinePlugin
:
webpack.prod.js
const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");
module.exports = merge(common, {
mode: "production",
});
压缩(Minification)
webpack v4+ will minify your code by default in production mode
.
注意,虽然生产环境下默认使用 TerserPlugin
,并且也是代码压缩方面比较好的选择,但是还有一些其他可选择项。以下有几个同样很受欢迎的插件:
如果决定尝试一些其他压缩插件,只要确保新插件也会按照 tree shake 指南中所陈述的具有删除未引用代码(dead code)的能力,并将它作为 optimization.minimizer
。
源码映射(Source Mapping)
我们鼓励你在生产环境中启用 source map,因为它们对 debug(调试源码) 和运行 benchmark tests(基准测试) 很有帮助。虽然有着如此强大的功能,然而还是应该针对生产环境用途,选择一个可以快速构建的推荐配置(更多选项请查看 devtool
)。对于本指南,我们将在生产环境中使用 source-map
选项,而不是我们在开发环境中用到的 inline-source-map
:
webpack.prod.js
const { merge } = require("webpack-merge");
const common = require("./webpack.common.js");
module.exports = merge(common, {
mode: "production",
devtool: "source-map",
});
避免在生产中使用 inline-***
和 eval-***
,因为它们会增加 bundle 体积大小,并降低整体性能。
压缩 CSS
将生产环境下的 CSS 进行压缩会非常重要,请查看 在生产环境下压缩 章节。
为了压缩输出文件,请使用类似于 css-minimizer-webpack-plugin 这样的插件。
webpack.config.js
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
const CssMinimizerPlugin = require("css-minimizer-webpack-plugin");
module.exports = {
plugins: [
new MiniCssExtractPlugin({
filename: "[name].css",
chunkFilename: "[id].css",
}),
],
module: {
rules: [
{
test: /\.css$/,
use: [MiniCssExtractPlugin.loader, "css-loader"],
},
],
},
optimization: {
minimizer: [
// For webpack@5 you can use the `...` syntax to extend existing minimizers (i.e. `terser-webpack-plugin`), uncomment the next line
// `...`
new CssMinimizerPlugin(),
],
},
};
这将只在生产模式下启用 CSS 压缩优化。如果你需要在开发模式下使用,请设置 optimization.minimize
选项为 true。
CLI 替代选项
以上所述也可以通过命令行实现。例如,--optimize-minimize
标记将在幕后引用 TerserPlugin
。和以上描述的 DefinePlugin
实例相同,--define process.
也会做同样的事情。而且,webpack -p
将自动地配置上述这两个标记,从而调用需要引入的插件。
虽然这种简写方式很好,但通常我们建议只使用配置方式,因为在这两种方式中,配置方式能够更准确地理解现在正在做的事情。配置方式还为可以让你更加细微地控制这两个插件中的其他选项。
懒加载
懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。
Shimming 预置依赖
webpack
compiler 能够识别遵循 ES2015 模块语法、CommonJS 或 AMD 规范编写的模块。然而,一些 third party(第三方库) 可能会引用一些全局依赖(例如 jQuery
中的 $
)。因此这些 library 也可能会创建一些需要导出的全局变量。这些 "broken modules(不符合规范的模块)" 就是 shimming(预置依赖) 发挥作用的地方。
Shimming 预置全局变量
让我们开始第一个 shimming 全局变量的用例;
还记得我们之前用过的 lodash
吗?出于演示目的,例如把这个应用程序中的模块依赖,改为一个全局变量依赖。要实现这些,我们需要使用 ProvidePlugin
插件。
使用 ProvidePlugin
后,能够在 webpack 编译的每个模块中,通过访问一个变量来获取一个 package。如果 webpack 看到模块中用到这个变量,它将在最终 bundle 中引入给定的 package。让我们先移除 lodash
的 import
语句,改为通过插件提供它:
webpack.config.js
const path = require("path");
const webpack = require("webpack");
module.exports = {
entry: "./src/index.js",
output: {
filename: "main.js",
path: path.resolve(__dirname, "dist"),
},
plugins: [
new webpack.ProvidePlugin({
_: "lodash",
}),
],
};
我们本质上所做的,就是告诉 webpack……
如果你遇到了至少一处用到
_
变量的模块实例,那请你将lodash
package 引入进来,并将其提供给需要用到它的模块。
还可以使用 ProvidePlugin
暴露出某个模块中单个导出,通过配置一个“数组路径”(例如 [module, child, ...children?]
)实现此功能。所以,我们假想如下,无论 join
方法在何处调用,我们都只会获取到 lodash
中提供的 join
方法。
webpack.config.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
plugins: [
new webpack.ProvidePlugin({
_: 'lodash',
join: ['lodash', 'join'],
}),
],
};
这样就能很好的与 tree shaking 配合,将 lodash
library 中的其余没有用到的导出去除。
细粒度 Shimming
一些遗留模块依赖的 this
指向的是 window
对象。在接下来的用例中,调整我们的 index.js
:
function component() {
const element = document.createElement("div");
element.innerHTML = join(["Hello", "webpack"], " ");
// 假设我们处于 `window` 上下文
this.alert("Hmmm, this probably isn't a great idea...");
return element;
}
document.body.appendChild(component());
当模块运行在 CommonJS 上下文中,这将会变成一个问题,也就是说此时的 this
指向的是 module.exports
。在这种情况下,你可以通过使用 imports-loader
覆盖 this
指向:
webpack.config.js
const path = require("path");
const webpack = require("webpack");
module.exports = {
entry: "./src/index.js",
output: {
filename: "main.js",
path: path.resolve(__dirname, "dist"),
},
module: {
rules: [
{
test: require.resolve("./src/index.js"),
use: "imports-loader?wrapper=window",
},
],
},
plugins: [
new webpack.ProvidePlugin({
join: ["lodash", "join"],
}),
],
};
全局 Exports
让我们假设,某个 library 创建出一个全局变量,它期望 consumer(使用者) 使用这个变量。为此,我们可以在项目配置中,添加一个小模块来演示说明:
src/globals.js
const file = "blah.txt";
const helpers = {
test: function () {
console.log("test something");
},
parse: function () {
console.log("parse something");
},
};
你可能从来没有在自己的源码中做过这些事情,但是你也许遇到过一个老旧的 library,和上面所展示的代码类似。在这种情况下,我们可以使用 exports-loader
,将一个全局变量作为一个普通的模块来导出。例如,为了将 file
导出为 file
以及将 helpers.parse
导出为 parse
,做如下调整:
webpack.config.js
const path = require('path');
const webpack = require('webpack');
module.exports = {
entry: './src/index.js',
output: {
filename: 'main.js',
path: path.resolve(__dirname, 'dist'),
},
module: {
rules: [
{
test: require.resolve('./src/index.js'),
use: 'imports-loader?wrapper=window',
},
+ {
+ test: require.resolve('./src/globals.js'),
+ use:
+ 'exports-loader?type=commonjs&exports[]=file&exports[]=multiple|helpers.parse|parse',
+ },
],
},
plugins: [
new webpack.ProvidePlugin({
join: ['lodash', 'join'],
}),
],
};
此时,在我们的 entry 入口文件中(即 src/index.js
),可以使用 const { file, parse } = require('./globals.js');
,可以保证一切将顺利运行。
加载 Polyfills
目前为止我们所讨论的所有内容都是处理那些遗留的 package,让我们进入到第二个话题:polyfill。
有很多方法来加载 polyfill。例如,想要引入 babel-polyfill
我们只需如下操作:
npm install --save babel-polyfill
然后,使用 import
将其引入到我们的主 bundle 文件:
src/index.js
+import 'babel-polyfill';
+
function component() {
const element = document.createElement('div');
element.innerHTML = join(['Hello', 'webpack'], ' ');
// Assume we are in the context of `window`
this.alert("Hmmm, this probably isn't a great idea...");
return element;
}
document.body.appendChild(component());
注意,我们没有将 import
绑定到某个变量。这是因为 polyfill 直接基于自身执行,并且是在基础代码执行之前,这样通过这些预置,我们就可以假定已经具有某些原生功能。
webpack 插件应用
webpack 打包分析
const BundleAnalyzerPlugin =
require("webpack-bundle-analyzer").BundleAnalyzerPlugin;
chainwebpack: (config) => {
config.plugin("bundleAnalysis").use(BundleAnalyzerPlugin);
};