tree shaking 是一个术语,通常用于描述移除 JavaScript 上下文中的死代码。它依赖于 ES2015 模块语法的 静态结构 特性,例如 import
和 export
。这个术语和概念实际上是由 ES2015 模块捆绑器 rollup 普及起来的。
webpack 2 正式版本内置支持 ES2015 模块(也叫做 harmony module)与对未使用模块的检测能力。webpack 4 正式版本扩展了此检测能力:通过 package.json
的 "sideEffects"
属性作为标记,向编译器提供提示,表明项目中的哪些文件是纯正的 ES2015 模块,由此可以安全地删除文件中未使用的部分。
在项目中添加一个新的通用模块文件 src/math.js
,并导出两个函数:
project
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,以确定 bundle 不会被压缩:
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 现在使用 import 引入
- element.innerHTML = _.join(['Hello', 'webpack'], ' ');
+ element.innerHTML = [
+ '你好 webpack!',
+ '5 的立方等于 ' + cube(5)
+ ].join('\n\n');
return element;
}
document.body.appendChild(component());
注意,我们 没有从 src/math.js
模块中 import
另外一个 square
方法。这个没有引用的函数就是所谓的 死代码,即应当删除掉未被引用的 export
。现在运行 npm script npm run build
,并查看输出的 bundle:
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
,但它仍然被包含在 bundle 中。我们将在后面的章节解决这个问题。
在一个纯粹的 ES 模块世界中,很容易识别出哪些文件有副作用。然而,我们的项目无法达到这种纯度,所以,此时有必要提示 webpack 编译器哪些代码是纯粹的。
通过 package.json 的 "sideEffects"
属性即可实现此目的。
{
"name": "your-project",
"sideEffects": false
}
如果所有代码都不包含副作用,我们就可以简单地将该属性标记为 false
以告知 webpack 可以安全地删除未使用的导出内容。
如果某些代码确实存在一些副作用,可以将 sideEffects
指定为一个数组:
{
"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"]
}
最后,还可以在 module.rules
配置选项 中设置 "sideEffects"
。
sideEffects
sideEffects
和 usedExports
(更多地被称为 tree shaking)是两种不同的优化方式。
sideEffects
更为有效 是因为它允许跳过整个模块/文件和整个文件子树。
usedExports
依赖于 terser 检测语句中的副作用。它是一个 JavaScript 任务而且不像 sideEffects
一样简单直接。并且由于规范认为副作用需要被评估,因此它不能跳过子树/依赖项。尽管导出函数能正常运行,但 React 的高阶组件在这种情况下会出问题。
让我们来看一个例子:
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
被调用了,并且其返回值(译注:请注意,withAppProvider
的返回值是一个函数)也被调用了。那么当执行 withAppProvider
及其返回值时,调用 merge
与 hoistStatics
会有任何副作用吗?读取 WrappedComponent.contextTypes
(Getter)或向 WithProvider.contextTypes
(Setter)赋值时会有任何副作用吗?
实际上,usedExports
依赖的 terser 就尝试去解决这些问题,但在许多情况下它仍然不确定函数的调用是否有副作用。但这并不意味着 terser 会由于无法解决这些问题而运作得不好。根本原因在于像 JavaScript 这类动态语言中很难可靠确定这一点。
但我们可以通过 /*#__PURE__*/
注释来帮助 terser。这个注释的作用是标记此语句没有副作用。这样一个简单的改变就能够 tree-shake 下面的代码了:
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 个模块被导入到 bundle 中:
index.js
configure.js
components/Button.js
components/Button.css
在这次的优化后,其它的优化项目都可以应用。例如:从 Button.js
导出的 buttonFrom
和 buttonsFrom
也没有被使用。usedExports
优化会捡起这些代码而且 terser 能够从 bundle 中将这些语句摘除。
由于模块合并也会生效,所以这 4 个模块与入口模块(也可能有更多的依赖)会被合并。index.js
最终没有生成代码。
通过 /*#__PURE__*/
注释可以告诉 webpack 某个函数调用无副作用。它可以被放到函数调用之前,用来标记此函数调用是无副作用的。传入函数的参数无法被刚才的注释所标记,需要单独对每一个参数进行标记。如果一个没被使用的变量定义的初始值被认为是无副作用的,它会被标记为死代码,不会被执行且会被压缩工具清除掉。当 optimization.innerGraph
被设置成 true
时这个行为将被启用。
file.js
/*#__PURE__*/ double(55);
通过 import
和 export
语法,我们已经找出需要删除的死代码,然而,不仅仅是要找出,还应在 bundle 中删除它们。为此,我们需要将 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
中的差异了吗?现在整个 bundle 都已经被压缩和混淆破坏,但是如果仔细观察,则不会看到引入了 square
函数,但能看到 cube
函数的破坏版本(function r(e){return e*e*e}n.a=r
)。现在通过代码压缩与 tree shaking,我们的 bundle 缩小了几个字节!虽然在这个特定示例中,可能看起来没有减少很多,但是,在有着复杂依赖树的大型应用程序上运行 tree shaking 时,会对 bundle 产生显著的体积优化。
我们学到为了利用 tree shaking 的优势,必须:
import
和 export
);@babel/preset-env
的默认行为,请参阅 文档 以了解更多信息)。package.json
文件中添加 "sideEffects"
属性。mode
为 "production"
的配置项以启用 更多优化项,包括压缩代码与 tree shaking。你可以将应用程序想象成一棵树。绿色表示实际用到的源码和库,是树上活的树叶。灰色表示未引用代码,是秋天树上枯萎的树叶。为了除去死去的树叶,你必须摇动(shake)这棵树,使它们落下。
如果你对优化输出很感兴趣,请进入到下个指南,来了解 生产环境 构建的详细细节。