Tree Shaking
Tree shaking 是一个在 JavaScript 语境中常用来描述死代码消除的术语。它依赖于 ES2015 模块语法的静态结构,即 import 和 export。这个名称和概念由 ES2015 模块打包器 rollup 推广开来。
webpack 2 版本内置了对 ES2015 模块(别名 harmony modules)以及未使用的模块导出检测的支持。新的 webpack 4 版本在此能力之上进行了扩展,通过 package.json 的 "sideEffects" 属性向编译器提供提示,用来标记项目中哪些文件是“纯粹”的,因此如果未被使用则可以安全地删除。
T> 本指南的其余部分将基于起步指南。如果你还没有阅读过,请现在阅读。
添加一个工具函数
让我们在项目中添加一个新的工具文件 src/math.js,它导出两个函数:
项目结构
diff
webpack-demo
├── package.json
├── package-lock.json
├── webpack.config.js
├── /dist
│ ├── bundle.js
│ └── index.html
├── /src
│ ├── index.js
+ │ └── math.js
└── /node_modules
src/math.js
js
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
将 mode 配置选项设置为 development,以确保 bundle 未被压缩:
webpack.config.js
diff
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
+ mode: 'development',
+ optimization: {
+ usedExports: true,
+ },
};
配置就绪后,让我们更新入口脚本以使用这些新方法之一,并为了简化而移除 lodash:
src/index.js
diff
- import _ from 'lodash';
+ import { cube } from './math.js';
function component() {
- const element = document.createElement('div');
+ const element = document.createElement('pre');
- // Lodash, now imported by this script
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ element.innerHTML = [
+ 'Hello webpack!',
+ '5 cubed is equal to ' + cube(5)
+ ].join('\n\n');
return element;
}
document.body.appendChild(component());
请注意,我们没有从 src/math.js 模块中 import square 方法。该函数就是所谓的“死代码”,即一个应该被丢弃的未使用的 export。现在让我们运行 npm 脚本 npm run build,并检查输出的 bundle:
dist/bundle.js (大约 90 - 100 行)
{/* eslint-disable no-use-before-define */}
js
/* 1 */
/***/ (function (module, __webpack_exports__, __webpack_require__) {
"use strict";
/* unused harmony export square */
/* harmony export (immutable) */ __webpack_exports__.a = cube;
function square(x) {
return x * x;
}
function cube(x) {
return x * x * x;
}
});
注意上面的 unused harmony export square 注释。如果你看它下面的代码,会发现 square 没有被导入,但是它仍然被包含在 bundle 中。我们将在下一节中解决这个问题。
将文件标记为无副作用(side-effect-free)
在 100% ESM 模块的世界中,识别副作用是 straightforward 的。然而,我们还没有完全达到那个目标,因此,在此期间有必要向 webpack 的编译器提供关于你代码“纯净性”的提示。
实现这一目标的方式是 package.json 中的 "sideEffects" 属性。
json
{
"name": "your-project",
"sideEffects": false
}
上面注记的所有代码都不包含副作用,因此我们可以将此属性标记为 false,以告知 webpack 它可以安全地删除未使用的导出。
T> “副作用”被定义为:在导入时执行了除暴露一个或多个导出之外的特定行为的代码。这方面的例子是 polyfills,它们会影响全局作用域,并且通常不提供导出。
如果你的代码确实有一些副作用,则可以提供一个数组:
json
{
"name": "your-project",
"sideEffects": ["./src/some-side-effectful-file.js"]
}
该数组接受相关文件的简单 glob 模式。它在底层使用 glob-to-regexp(支持:*、**、{a,b}、[a-z])。像 *.css 这样不包含 / 的模式将被视为 **/*.css。
T> 注意,任何导入的文件都受 tree shaking 影响。这意味着如果你在项目中使用类似 css-loader 的工具并导入 CSS 文件,则需要将其添加到副作用列表中,以免在生产模式下被意外删除:
json
{
"name": "your-project",
"sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}
最后,"sideEffects" 也可以从 module.rules 配置选项 中设置。
澄清 tree shaking 和 sideEffects
sideEffects 和 usedExports(更广为人知的 tree shaking)优化是两件不同的事情。
sideEffects 更有效,因为它允许跳过整个模块/文件以及完整的子树。
usedExports 依赖于 terser 来检测语句中的副作用。这在 JavaScript 中是一项艰巨的任务,并且不如直接的 sideEffects 标志有效。它也不能跳过子树/依赖项,因为规范规定副作用需要被评估。虽然导出函数工作正常,但 React 的高阶组件(HOC)在这方面是有问题的。
如果你使用动态 import(),你还可以使用 webpackExports 魔术注释来指定应该暴露的导出,从而允许 webpack 对其他的导出进行 tree shaking。请参阅 魔术注释。
让我们看一个例子:
js
import { Button } from "@shopify/polaris";
预打包的版本看起来像这样:
{/* eslint-disable @stylistic/spaced-comment, prefer-rest-params */}
js
import hoistStatics from "hoist-non-react-statics";
function Button(_ref) {
// ...
}
function merge() {
const _final = {};
for (
let _len = arguments.length, objs = Array.from({ length: _len }), _key = 0;
_key < _len;
_key++
) {
objs[_key] = arguments[_key];
}
for (let _i = 0, _objs = objs; _i < _objs.length; _i++) {
const obj = _objs[_i];
mergeRecursively(_final, obj);
}
return _final;
}
function withAppProvider() {
return function addProvider(WrappedComponent) {
const WithProvider =
/*#__PURE__*/
(function (_React$Component) {
// ...
return WithProvider;
})(Component);
WithProvider.contextTypes = WrappedComponent.contextTypes
? merge(WrappedComponent.contextTypes, polarisAppProviderContextTypes)
: polarisAppProviderContextTypes;
const FinalComponent = hoistStatics(WithProvider, WrappedComponent);
return FinalComponent;
};
}
const Button$1 = withAppProvider()(Button);
export {
// ...,
Button$1,
};
当 Button 未被使用时,你可以有效地移除 export { Button$1 }; 这一行,这留下了所有剩余的代码。所以问题是“这段代码有任何副作用,还是可以被安全地移除?”。很难说,特别是因为 withAppProvider()(Button) 这一行。withAppProvider 被调用,并且其返回值也被调用。调用 merge 或 hoistStatics 是否有副作用?给 WithProvider.contextTypes 赋值(Setter?)或读取 WrappedComponent.contextTypes(Getter?)是否有副作用?
Terser 实际上试图弄清楚这一点,但在很多情况下它并不能确定。这并不意味着 terser 做得不好,因为它无法弄清楚。在像 JavaScript 这样的动态语言中,要可靠地确定这一点太难了。
但是我们可以通过使用 /*#__PURE__*/ 注释来帮助 terser。它将一条语句标记为无副作用。所以一个小小的改动就可以让代码被 tree-shaking:
js
const Button$1 = /* #__PURE__ */ withAppProvider()(Button);
这将允许删除这段代码。但是关于导入仍然存在问题,这些导入需要被包含/评估,因为它们可能包含副作用。
为了解决这个问题,我们使用 package.json 中的 "sideEffects" 属性。
它类似于 /*#__PURE__*/,但是是在模块级别而不是语句级别。它("sideEffects" 属性)表示:“如果没有使用标记为无副作用的模块的直接导出,打包器可以跳过评估该模块的副作用。”。
在 Shopify 的 Polaris 示例中,原始模块看起来像这样:
index.js
js
import "./configure";
export * from "./types";
export * from "./components";
components/index.js
js
// ...
export { default as Breadcrumbs } from "./Breadcrumbs";
export { buttonFrom, buttonsFrom, default as Button } from "./Button";
export { default as ButtonGroup } from "./ButtonGroup";
// ...
package.json
json
// ...
"sideEffects": [
"**/*.css",
"**/*.scss",
"./esnext/index.js",
"./esnext/configure.js"
],
// ...
对于 import { Button } from "@shopify/polaris";,这具有以下含义:
- 包含它:包含该模块,评估它并继续分析依赖项
- 跳过它:不包含它,不评估它,但继续分析依赖项
- 排除它:不包含它,不评估它,也不分析依赖项
具体到每个匹配的资源:
index.js:没有使用直接导出,但被标记为有副作用 -> 包含它configure.js:没有使用导出,但被标记为有副作用 -> 包含它types/index.js:没有使用导出,未被标记为有副作用 -> 排除它components/index.js:没有使用直接导出,未被标记为有副作用,但使用了重新导出的导出 -> 跳过它components/Breadcrumbs.js:没有使用导出,未被标记为有副作用 -> 排除它。这也排除了所有依赖项,如components/Breadcrumbs.css,即使它们被标记为有副作用。components/Button.js:使用了直接导出,未被标记为有副作用 -> 包含它components/Button.css:没有使用导出,但被标记为有副作用 -> 包含它
在这种情况下,只有 4 个模块被包含在 bundle 中:
index.js:几乎是空的configure.jscomponents/Button.jscomponents/Button.css
在此优化之后,其他优化仍然可以应用。例如:来自 Button.js 的 buttonFrom 和 buttonsFrom 导出也未被使用。usedExports 优化会处理它,并且 terser 可能会从模块中删除一些语句。
模块拼接(Module Concatenation)也适用。因此,这 4 个模块加上入口模块(以及可能更多的依赖项)可以被拼接起来。index.js 最终没有生成任何代码。
完整示例:理解带有 CSS 文件的副作用
为了更好地理解 sideEffects 标志的影响,让我们看一个带有 CSS 资产的 npm 包的完整示例,以及它们在 tree shaking 期间可能如何受到影响。我们将创建一个名为 “awesome-ui” 的虚构 UI 组件库。
包结构
我们的示例包看起来像这样:
bash
awesome-ui/
├── package.json
└── dist/
├── index.js
├── components/
│ ├── index.js
│ ├── Button/
│ │ ├── index.js
│ │ └── Button.css
│ ├── Card/
│ │ ├── index.js
│ │ └── Card.css
│ └── Modal/
│ ├── index.js
│ └── Modal.css
└── theme/
├── index.js
└── defaultTheme.css
包文件内容
package.json
json
{
"name": "awesome-ui",
"version": "1.0.0",
"main": "dist/index.js",
"sideEffects": false
}
dist/index.js
js
export * from "./components";
export * from "./theme";
dist/components/index.js
js
export { default as Button } from "./Button";
export { default as Card } from "./Card";
export { default as Modal } from "./Modal";
dist/components/Button/index.js
js
import "./Button.css"; // This has a side effect - it applies styles when imported!
export default function Button(props) {
// Button component implementation
return {
type: "button",
...props,
};
}
dist/components/Button/Button.css
css
.awesome-ui-button {
background-color: #0078d7;
color: white;
padding: 8px 16px;
border-radius: 4px;
border: none;
cursor: pointer;
}
dist/components/Card/index.js 和 dist/components/Modal/index.js 会有类似的结构。
dist/theme/index.js
js
import "./defaultTheme.css"; // This has a side effect!
export const themeColors = {
primary: "#0078d7",
secondary: "#f3f2f1",
danger: "#d13438",
};
消费此包时会发生什么?
现在,想象一个消费者应用,它只想使用 Button 组件:
js
import { Button } from "awesome-ui";
// Use the Button component
在 package.json 中设置 sideEffects: false
当 webpack 在启用 tree shaking 的情况下处理此导入时:
- 它看到仅为 Button 的导入
- 它查看 package.json 并看到
sideEffects: false - 它确定只需要包含 Button 组件代码
- 由于所有文件都被标记为无副作用,它将仅包含 Button 的 JavaScript 代码
- CSS 文件的导入被丢弃! 即使 Button.css 是在 Button/index.js 中导入的,webpack 也假定此导入没有副作用。
结果:Button 组件将渲染,但没有任何样式,因为 Button.css 在 tree shaking 期间被消除了。
此包的正确配置
为了解决这个问题,我们需要更新 package.json,以正确地标记 CSS 文件为具有副作用:
json
{
"name": "awesome-ui",
"version": "1.0.0",
"main": "dist/index.js",
"sideEffects": ["**/*.css"]
}
使用此配置:
- Webpack 仍然识别出只需要 Button 组件
- 但现在它识别出 CSS 文件有副作用
- 因此,在处理 Button/index.js 时,它会包含 Button.css
副作用的决策树
以下是 webpack 在 tree shaking 期间评估模块的方式:
-
该模块的导出是否被直接或间接使用?
- 如果是:包含该模块
- 如果否:继续执行步骤 2
-
该模块是否被标记为具有副作用?
- 如果是(
sideEffects包含此文件或为true):包含该模块 - 如果否(
sideEffects为false或不包含此文件):排除该模块及其依赖项
- 如果是(
对于我们库中具有正确 sideEffects 配置的文件:
dist/index.js:未使用直接导出,无副作用 -> 跳过它dist/components/index.js:未使用直接导出,无副作用 -> 跳过它dist/components/Button/index.js:使用了直接导出 -> 包含dist/components/Button/Button.css:无导出,有副作用 -> 包含dist/components/Card/*:未使用导出,无副作用 -> 排除dist/components/Modal/*:未使用导出,无副作用 -> 排除dist/theme/*:未使用导出,无副作用 -> 排除
现实世界的影响
不正确的副作用配置的影响可能很大:
- CSS 未被包含:组件渲染时没有样式
- 全局 JavaScript 未运行:Polyfills 或全局配置不执行
- 初始化代码被跳过:注册组件或设置事件监听器的函数永远不会运行
这些问题可能特别难以调试,因为它们通常只在启用了 tree shaking 的生产构建中出现。
测试副作用配置
测试你的副作用配置是否正确的一个好方法:
- 创建一个只导入一个组件的最小应用程序
- 使用生产设置(启用 tree shaking)构建它
- 检查所有必要的样式和行为是否正常工作
- 查看生成的 bundle 以确认包含正确的文件
将函数调用标记为无副作用
可以使用 /*#__PURE__*/ 注释告诉 webpack 某个函数调用是无副作用的(纯粹的)。它可以放在函数调用前面,以将其标记为无副作用。传递给函数的参数不会被该注释标记,可能需要单独标记。当未使用变量声明中的初始值被认为是无副作用(纯粹)时,它会被标记为死代码,不执行,并被压缩器(minimizer)删除。此行为在 optimization.innerGraph 设置为 true 时启用。
file.js
js
/* #__PURE__ */ double(55);
将函数声明标记为无副作用
Webpack 还支持 #__NO_SIDE_EFFECTS__ 注解来将函数声明标记为纯粹。以这种方式注解的函数的调用,当它们的返回值未被使用时,可以从 bundle 中消除,即使函数体不能被静态分析为纯粹。这对于工厂函数(factory)或构建器函数(builder)很有用,否则它们的调用点每次都需要 /*#__PURE__*/ 注解。
{/* eslint-disable */}
js
// utils.js
/*#__NO_SIDE_EFFECTS__*/
export function createLogger(prefix) {
return (msg) => console.log(`[${prefix}] ${msg}`);
}
{/* eslint-enable */}
js
// app.js
import { createLogger } from "./utils";
// 被丢弃,因为 `createLogger` 被注解了并且其结果未被使用
const unused = createLogger("debug");
T> 自 webpack 5.108.0 起,该注解会跨模块边界传播,因此在导入模块中的未使用调用也会被 tree-shaken。(在 5.108.0 之前,它只在声明它的模块内生效。)
T> 如果你无法编辑源码来添加注解(例如,函数来自某个依赖项),你可以通过 module.parser.javascript.pureFunctions 从你的配置中标记这些名称为无副作用(5.108.0+)。
压缩输出
我们已经通过使用 import 和 export 语法将“死代码”提示给了编译器,但我们仍然需要将其从 bundle 中删除。为此,将 mode 配置选项设置为 production。
webpack.config.js
diff
import path from 'node:path';
import { fileURLToPath } from 'node:url';
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
- mode: 'development',
- optimization: {
- usedExports: true,
- }
+ mode: 'production',
};
T> 注意,--optimize-minimize 标志也可用于启用 TerserPlugin。
配置完成后,我们可以再次运行 npm run build,看看是否有什么变化。
注意到 dist/bundle.js 有什么不同吗?整个 bundle 现在被压缩和混淆了,但是,如果你仔细看,你不会看到 square 函数被包含在内,但会看到 cube 函数的一个混淆版本(function r(e){return e*e*e}n.a=r)。通过压缩和 tree shaking,我们的 bundle 现在小了几个字节!虽然在这个人为的示例中看起来不多,但在处理具有复杂依赖树的较大应用程序时,tree shaking 可以显著减小 bundle 大小。
T> 需要 ModuleConcatenationPlugin 才能使 tree shaking 工作。它是由 mode: 'production' 添加的。如果你没有使用它,记得手动添加 ModuleConcatenationPlugin。
副作用的常见陷阱
在使用 tree shaking 和 sideEffects 标志时,有几个常见的陷阱需要避免:
1. 过于乐观的 sideEffects: false
在你的 package.json 中设置 sideEffects: false 对于最佳的 tree shaking 来说很诱人,但如果你的代码实际上有副作用,这可能会导致问题。隐藏副作用的例子:
- CSS 导入(如上所述)
- 修改全局对象的 Polyfills
- 注册全局事件监听器的库
- 修改原型链的代码
2. 带有副作用的重新导出
考虑这种模式:
js
// 这个文件有副作用,可能会被跳过
import "./polyfill";
// 重新导出组件
export * from "./components";
如果消费者只导入特定的组件,如果 polyfill 导入没有被正确标记为有副作用,它可能会被完全跳过。
3. 忘记嵌套依赖
你的包可能正确标记了副作用,但如果它依赖于错误标记其副作用的第三方包,你仍然可能会遇到问题。
4. 只在开发模式下测试
Tree shaking 通常只在生产模式下完全激活。只在开发模式下测试可能会隐藏 tree shaking 问题,直到部署。
总结
我们学到的内容是,为了利用 tree shaking,你必须……
- 使用 ES2015 模块语法(即
import和export)。 - 确保没有编译器将你的 ES2015 模块语法转换为 CommonJS 模块(这是流行的 Babel 预设 @babel/preset-env 的默认行为 - 有关更多详细信息,请参阅文档)。
- 在项目中的
package.json文件中添加"sideEffects"属性。 - 小心地正确标记具有副作用的文件,特别是 CSS 导入。
- 使用
productionmode配置选项来启用各种优化,包括压缩和 tree shaking(副作用优化在开发模式下使用该标志值启用)。 - 确保为
devtool设置一个正确的值,因为其中一些在production模式下不能使用。
你可以将你的应用程序想象成一棵树。你实际使用的源代码和库代表了树上绿色、鲜活的叶子。死代码代表了秋天被消耗掉的棕色、枯死的叶子。为了摆脱枯叶,你必须摇动这棵树,让它们掉落。
如果你有兴趣了解更多优化输出(bundle)的方法,请跳转到下一指南,了解为生产环境构建的详细信息。
帮助我们改进文档
发现翻译问题或内容错误?请告诉我们。
