摇树优化 (Tree shaking) 是 JavaScript 上下文中常用的术语,用于指代死代码消除。它依赖于 ES2015 模块语法的静态结构,即 import
和 export
。这个名称和概念由 ES2015 模块打包器 rollup 推广开来。
webpack 2 版本内置支持 ES2015 模块(别名 harmony modules)以及未使用的模块导出检测。新的 webpack 4 版本通过 package.json
的 "sideEffects"
属性向编译器提供提示,以表示项目中哪些文件是“纯粹的”,因此在未使用时可以安全地移除,从而扩展了这一功能。
让我们在项目中添加一个新工具文件 src/math.js
,它导出两个函数:
项目
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
export function square(x) {
return x * x;
}
export function cube(x) {
return x * x * x;
}
将 mode
配置选项设置为 development,以确保打包后的文件未被压缩。
webpack.config.js
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
+ mode: 'development',
+ optimization: {
+ usedExports: true,
+ },
};
有了这些,让我们更新入口脚本,以利用其中一个新方法,并为简化起见删除 lodash
:
src/index.js
- 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());
请注意,我们**没有 import
src/math.js
模块中的 square
方法**。该函数被称为“死代码”,即未使用的 export
,应该被删除。现在,让我们运行 npm 脚本 npm run build
,并检查输出的打包文件:
dist/bundle.js (大约第 90 - 100 行)
/* 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
并没有被导入,但它仍然包含在打包文件中。我们将在下一节中修复此问题。
在一个 100% ESM 模块的世界中,识别副作用是直截了当的。然而,我们还没到那一步,所以在此期间,需要向 webpack 编译器提供关于代码“纯粹性”的提示。
实现此目的的方法是使用 package.json
的 "sideEffects"
属性。
{
"name": "your-project",
"sideEffects": false
}
上面提到的所有代码都不包含副作用,因此我们可以将该属性标记为 false
,以通知 webpack 它可以安全地移除未使用的导出。
如果您的代码确实存在一些副作用,则可以改为提供一个数组:
{
"name": "your-project",
"sideEffects": ["./src/some-side-effectful-file.js"]
}
该数组接受简单 glob 模式来匹配相关文件。它底层使用 glob-to-regexp(支持:*
、**
、{a,b}
、[a-z]
)。不包含 /
的模式(如 *.css
)将被视为 **/*.css
。
{
"name": "your-project",
"sideEffects": ["./src/some-side-effectful-file.js", "*.css"]
}
最后,"sideEffects"
也可以通过 module.rules
配置选项进行设置。
sideEffects
sideEffects
和 usedExports
(更广为人知的是摇树优化)优化是两个不同的概念。
sideEffects
效率更高,因为它允许跳过整个模块/文件和完整的子树。
usedExports
依赖 terser 来检测语句中的副作用。在 JavaScript 中这是一项艰巨的任务,并且不如直接使用 sideEffects
标志有效。它也无法跳过子树/依赖项,因为规范规定必须评估副作用。虽然导出函数运行良好,但 React 的高阶组件 (HOC) 在这方面存在问题。
让我们举个例子:
import { Button } from '@shopify/polaris';
预打包版本如下:
import hoistStatics from 'hoist-non-react-statics';
function Button(_ref) {
// ...
}
function merge() {
var _final = {};
for (
var _len = arguments.length, objs = new Array(_len), _key = 0;
_key < _len;
_key++
) {
objs[_key] = arguments[_key];
}
for (var _i = 0, _objs = objs; _i < _objs.length; _i++) {
var obj = _objs[_i];
mergeRecursively(_final, obj);
}
return _final;
}
function withAppProvider() {
return function addProvider(WrappedComponent) {
var WithProvider =
/*#__PURE__*/
(function (_React$Component) {
// ...
return WithProvider;
})(Component);
WithProvider.contextTypes = WrappedComponent.contextTypes
? merge(WrappedComponent.contextTypes, polarisAppProviderContextTypes)
: polarisAppProviderContextTypes;
var FinalComponent = hoistStatics(WithProvider, WrappedComponent);
return FinalComponent;
};
}
var 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。它将一个语句标记为无副作用。因此,一个小小的更改就可以实现对代码的摇树优化:
var Button$1 = /*#__PURE__*/ withAppProvider()(Button);
这将允许删除这段代码。但对于导入,仍存在需要包含/评估的问题,因为它们可能包含副作用。
为了解决这个问题,我们使用 package.json
中的 "sideEffects"
属性。
它类似于 /*#__PURE__*/
,但作用于模块级别而不是语句级别。它("sideEffects"
属性)表示:“如果未使用标记为无副作用的模块中的任何直接导出,则打包器可以跳过评估该模块的副作用。”
在 Shopify 的 Polaris 示例中,原始模块如下所示:
index.js
import './configure';
export * from './types';
export * from './components';
components/index.js
// ...
export { default as Breadcrumbs } from './Breadcrumbs';
export { default as Button, buttonFrom, buttonsFrom } from './Button';
export { default as ButtonGroup } from './ButtonGroup';
// ...
package.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 个模块被包含到打包文件中:
index.js
:几乎为空configure.js
components/Button.js
components/Button.css
在此优化之后,其他优化仍可应用。例如:Button.js
中的 buttonFrom
和 buttonsFrom
导出也未使用。usedExports
优化会识别它们,terser 可能会从模块中删除一些语句。
模块合并 (Module Concatenation) 也适用。因此,这 4 个模块加上入口模块(以及可能更多的依赖项)可以被合并。最终,index.js
没有生成任何代码。
为了更好地理解 sideEffects
标志的影响,让我们看一个包含 CSS 资源的 npm 包的完整示例,以及它们在摇树优化过程中可能受到的影响。我们将创建一个名为“awesome-ui”的虚构 UI 组件库。
我们的示例包结构如下:
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
{
"name": "awesome-ui",
"version": "1.0.0",
"main": "dist/index.js",
"sideEffects": false
}
dist/index.js
export * from './components';
export * from './theme';
dist/components/index.js
export { default as Button } from './Button';
export { default as Card } from './Card';
export { default as Modal } from './Modal';
dist/components/Button/index.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
.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
import './defaultTheme.css'; // This has a side effect!
export const themeColors = {
primary: '#0078d7',
secondary: '#f3f2f1',
danger: '#d13438',
};
现在,假设一个消费者应用程序只想使用 Button 组件:
import { Button } from 'awesome-ui';
// Use the Button component
sideEffects: false
当 webpack 在启用摇树优化的情况下处理此导入时:
sideEffects: false
。结果:Button 组件将渲染,但没有任何样式,因为 Button.css 在摇树优化期间被移除了。
为了解决这个问题,我们需要更新 package.json,以正确地将 CSS 文件标记为具有副作用:
{
"name": "awesome-ui",
"version": "1.0.0",
"main": "dist/index.js",
"sideEffects": ["**/*.css"]
}
使用此配置后:
以下是 webpack 在摇树优化期间评估模块的方式:
此模块的导出是否直接或间接被使用?
该模块是否标记为有副作用?
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/*
:没有导出被使用,没有副作用 -> 排除不正确的副作用配置可能会产生重大影响:
这些问题可能特别难以调试,因为它们通常只在启用摇树优化的生产构建中出现。
测试副作用配置是否正确的好方法:
可以通过使用 /*#__PURE__*/
注释来告诉 webpack 一个函数调用是无副作用的(纯粹的)。它可以放在函数调用之前,以将其标记为无副作用。传递给函数的参数不会被此注释标记,可能需要单独标记。当未使用的变量声明中的初始值被认为是无副作用的(纯粹的)时,它会被标记为死代码,不执行并由压缩器删除。当 optimization.innerGraph
设置为 true
时,此行为启用。
file.js
/*#__PURE__*/ double(55);
我们已经通过使用 import
和 export
语法,将“死代码”标记为待删除,但我们仍然需要将其从打包文件中删除。要做到这一点,将 mode
配置选项设置为 production
。
webpack.config.js
const path = require('path');
module.exports = {
entry: './src/index.js',
output: {
filename: 'bundle.js',
path: path.resolve(__dirname, 'dist'),
},
- mode: 'development',
- optimization: {
- usedExports: true,
- }
+ mode: 'production',
};
处理完这些后,我们可以再次运行 npm run build
,看看是否有任何变化。
注意到 dist/bundle.js
有什么不同了吗?整个打包文件现在被压缩和混淆了,但如果您仔细查看,会发现 square
函数没有被包含,但会看到 cube
函数的混淆版本(function r(e){return e*e*e}n.a=r
)。通过压缩和摇树优化,我们的打包文件现在小了几字节!虽然在这个人为的例子中看起来不多,但在处理具有复杂依赖树的大型应用程序时,摇树优化可以显著减小打包文件的大小。
在使用摇树优化和 sideEffects
标志时,有几个常见陷阱需要避免:
sideEffects: false
在您的 package.json 中设置 sideEffects: false
对于实现最佳摇树优化很有诱惑力,但这可能会在您的代码实际存在副作用时导致问题。隐藏副作用的例子包括:
考虑这种模式:
// This file has side effects that might be skipped
import './polyfill';
// Re-export components
export * from './components';
如果消费者只导入特定的组件,并且 polyfill 导入没有被正确标记为有副作用,它可能会被完全跳过。
您的包可能正确标记了副作用,但如果它依赖于错误标记其副作用的第三方包,您可能仍然会遇到问题。
摇树优化通常只在生产模式下完全激活。仅在开发模式下测试可能会隐藏摇树优化问题,直到部署。
我们学到的是,为了利用摇树优化,您必须...
import
和 export
)。package.json
文件中添加一个 "sideEffects"
属性。production
mode
配置选项以启用各种优化,包括压缩和摇树优化(副作用优化在开发模式下通过标志值启用)。devtool
设置正确的值,因为其中一些不能在 production
模式下使用。您可以将您的应用程序想象成一棵树。您实际使用的源代码和库代表了树的绿色、有生命的叶子。死代码代表了秋天被消耗的树的棕色、枯死的叶子。为了摆脱枯叶,您必须摇动树,使它们落下。
如果您对其他优化输出的方法感兴趣,请跳转到下一指南,了解有关为生产环境构建的详细信息。