为什么 Webpack tree shaking 失效了?
背景
事情是这样的,上周在分析项目打包代码的时候,突然发现 date-fns 在未压缩之前有 1.5M,通过分析报告可以看到,date-fns 的 locale 模块全部被打包到了 bundle 文件中,导致 bundle 代码体积极大增加:
date-fns 是一个常用的 date 工具库,替代了 moment.js
可是,代码中明明只引入了两种语言(如下),为什么整个 locale 模块都被打进去了?
// language.ts
import {zhCN, enUS} from "date-fns/locale";
export const languages = [zhCN, enUS];
初步怀疑是 webpack 的 tree shaking 不工作了,可仔细一看,又发现有些库并未被全部打包到 bundle 中,也就是说 tree shaking 仍然在正常工作,只不过对 date-fns 的 locale 模块不生效。
因此,怀疑对象又转移到了 date-fns。经过一番调查,我在 date-fns 的 github issue 中发现了关于 tree shaking 的问题,并且还有对应的修复代码:
// language.ts
import zhCN from "date-fns/locale/zh-CN";
import enUS from "date-fns/locale/en-US"
的确,将引入方式改成上面这样之后,代码体积一下从原来的 1.5M 减少到了 28.76KB:
问题解决了,同时我们刚才的猜想也得到了验证,于是很多时候,我们对于一个问题的探索到这一步就停止了。
可是,事实真的如此吗?
Tree Shaking VS 按需引入
让我们再来回顾一下 tree shaking 的概念。 Tree Shaking 的字面意思是「摇树」,就是将应用中一些没有用到的代码「摇」掉。在实际场景中,一个模块可能会导出多个函数,但其中只有一部分被用到了,其他没有被用到的函数就成为了「死代码」。「死代码」就像树上的枯叶,不仅毫无用处,而且还会增加树的负担。因此我们需要把它「摇」掉,不让死代码进入最终的打包文件。
比如 date-fns 的 locale 模块,它 export 了几十种语言,但是我们在项目中用到的只有中文和英语两种:
// date-fns/esm/locale/index.js
export { default as af } from "./af/index.js";
export { default as ar } from "./ar/index.js";
export { default as arDZ } from "./ar-DZ/index.js";
export { default as zhCN } from "./zh-CN/index.js";
export { default as enUS } from "./en-US/index.js";
// ...
// language.ts
import {zhCN, enUS} from "date-fns/locale";
虽然我们只引入了 zhCN 和 enUS 两种语言,但由于 locale 模块中还 export 了很多其他语言,这些语言我们没有用到,因此需要通过 tree shaking 将这些没有用到的语言去掉,以减少 bundle 文件的大小。
再来看看我们前面提到的另一种引入方式:
// language.ts
import zhCN from "date-fns/locale/zh-CN";
import enUS from "date-fns/locale/en-US"
通过这段代码,bundle 文件体积也极大减少了,最终只包含 zhCN 和 enUS 两种 locale。可仔细一想,我们只引入了需要的部分,也就是 locale 中的 zh-CN 和 en-US 模块,而非整个 locale 模块。这不就是按需引入么?既然都没有引入「多余模块」,自然也不需要 tree shaking 帮我们去掉「多余模块」了。
虽然两种方式最终达到的结果是一样的,但是过程和思路却十分不同。比起结果,有时候用「对的方式」解决问题更为重要。因为这样可以避免我们得出错误的结论。
就拿前面的例子来说,如果我们通过按需引入的方式解决了问题,那么我们是否会将结果和原因关联起来,从而得出这样的结论:因为 date-fns 的 issue 导致 webapck 的 tree shaking 不工作?
Tree Shaking 机制
要想搞清楚 tree shaking 失效的原因,首先得知道 tree shaking 是怎么工作的。简单来说,webpack 会从入口文件开始,对你 import 的代码进行静态分析,如果发现某个模块没有被任何地方使用,就会将该模块标记为 unused harmony exports
,并且在生成产物时不再 export 该模块。最后,再将生成产物交给 uglify 或 terser 这样的压缩工具进行处理,此时未被 export 的代码就会被当成死代码删除。
需要注意的是,tree shaking 并不会直接删除代码,只是分析模块依赖关系并去掉未引用代码的 export,真正进行死代码消除的是 uglify 或 terser 这样的压缩工具。
ES6 模块也叫做 harmony modules,因此 harmony export 就是指 ES6 模块的 export
让我们来看一个例子,fn.ts 文件 export 了两个函数 fn1 和 fn2,但是在入口文件 index.ts 中,只用到了 fn1:
// webpack.config.js
module.exports = {
optimization: {
usedExports: true, // 开启 tree shaking
concatenateModules: false, // 关闭模块合并
minimize: false // 关闭代码压缩
}
}
从上面的图中,可以看到__webpack_require__.d
函数调用时只使用了 fn1,也就是说只有 fn1 会被 export 出去。fn2 模块由于没有被 export 也没有被任何地方使用到,因此被 IDE 给「置灰」了,就像是树上的枯叶,等待着被消除。上面这段 webpack 生成代码是未压缩之前的结果,你也可以直接把这段代码交给 rollup 或者 teser,最终 fn2 函数会被消除。
有同学可能要问了,既然 terser 可以消除死代码,那为什么还需要 tree shaking 呢?原因是 terser 不能跨文件去做死代码消除。terser 只会对单个文件代码进行静态分析,然后将未使用到的代码从抽象语法树(AST)中删除。因此,在跨文件场景下,terser 无法得知某个文件中 export 的模块是否会被其他文件使用到,也就无法将其删除。而 tree shaking 会从入口开始,对所有代码进行静态分析,从而得出模块之间的依赖关系,并删除未引用模块的 export,未引用的代码就能够被 teser 识别并消除。
tree shaking 强依赖 ES6 模块的静态结构特性:
- 在编译时就可以明确知道 import 或 export 了哪些模块。
- 只能在文件顶层 import 或 export 模块,不能在条件语句中使用。
这样,ES6 模块的依赖关系在编译时就能够明确,为 tree shaking 进行静态分析打下了坚实的基础。也就是说,只有 ES6 模块才能进行 tree shaking。如果你使用 CommonJS 模块(required/module.exports),是没有办法进行 tree shaking 的。因为 require 方法可以动态地导入一个模块,只有在运行时才能确定导入的模块是什么,无法对它进行静态分析。
Tree Shaking 失效了吗?
回到我们最开始的问题,为什么 date-fns 的 locale 模块被整个打入了 bundle 文件中,难道是因为 tree shaking 失效了吗?其实不是,我们已经知道 tree shaking 的工作只是分析模块依赖并不再 export 未引用到的模块,至于这个模块是否会被删除,是由 terser 这样的压缩工具来决定的。如果某个模块有「副作用」,或者 teser 无法判断它是否有「副作用」,那么就不会删除这个模块。
副作用是函数式编程的一个概念,是指当调用函数时,除了返回函数值之外,还会对主调用函数产生附加的影响。简单来说,就是除了返回函数值之外,还做了一些别的事情。比如打印 Log、读取和修改外部变量等。引申到应用层面,副作用还可能是导入 DOM 操作、引入 Polyfill 等。
为什么有副作用的代码不能被删除呢?举个简单的例子:
const setTitle = () => {
document.title = "Chengdu";
};
const a = setTitle();
在上面的例子中,虽然 a
变量没有被任何地方使用到,但是 setTitle 这个函数调用会产生副作用,也就是将 document 的 title 设置为 「Chengdu」。如果把 a 变量删除,会导致 document 的 title 无法被正确设置。也即是说,删除有副作用的代码可能导致应用程序出现 bug 甚至 crash。
让我们再来看一个 React 高阶组件的例子:
import { CSSProperties, FC } from "react";
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;
}
interface BasicButtonProps {
style?: CSSProperties;
}
const BasicButton: FC<BasicButtonProps> = ({ children, style }) => {
return <button style={style}>{children}</button>;
};
const withColor = (color: string) => {
return (Comp: FC) => {
return (props: any) => {
const nextProps = merge({style: {color}}, props)
return <Comp {...nextProps}/>;
};
};
};
export const RedButton = withColor("red")(BasicButton);
export const DarkButton = withColor("black")(BasicButton);
这段代码中,如果 RedButton 没有被使用,是否能够安全地删除 RedButton 并保留有效代码?答案是不能,因为这里有两个函数调用: withColor 和 withColor 的返回值,这两个函数调用会不会产生副作用?在 withColor 中使用了 merge 方法,merge 方法调用会不会产生副作用?这些 terser 都很难确定,当然这不是 teser 插件的问题,而是因为在 JavaScript 这种动态语言中实在很难确定。
虽然 teser 不知道上面的函数调用是否会产生副作用,但是写代码的我们知道呀。我们可以在函数调用前增加一行注释 /*#__PURE__*/
。通过这行注释,可以告诉 terser:这个函数调用是没有副作用的,请放心地删除它吧!
const DarkButton = /*#__PURE__*/ withColor("black")(BasicButton);
回到前面的问题,为什么 locale 模块被整个打入了 bundle 文件中,难道是因为它里面中包含了会产生副作用的代码?果然,在源码中发现了这样一段可疑代码:
// localize/index.js
import buildLocalizeFn from "../../../_lib/buildLocalizeFn/index.js";
var localize = {
// ...
month: buildLocalizeFn({
values: monthValues,
defaultWidth: 'wide'
}),
day: buildLocalizeFn({
values: dayValues,
defaultWidth: 'wide'
}),
dayPeriod: buildLocalizeFn({
values: dayPeriodValues,
defaultWidth: 'wide',
formattingValues: formattingDayPeriodValues,
defaultFormattingWidth: 'wide'
})
};
export default localize;
这里声明了一个变量 localize,但是这个变量中的属性是通过调用 buildLocalizeFn 函数获取的,调用 buildLocalizeFn 函数会产生副作用么?这个 terser 无法确定,同时我们也没有在函数调用时发现任何 /*#__PURE__*/
注释,那么即便最终这个变量未被使用,terser 也不会删除对应代码。
sideEffects
经过对源码的一番查看,我们又发现 locale 模块的 package.json 中有这样一段代码:
{
"sideEffects": false,
// ...
}
是不是感觉似曾相识?没错,sideEffects 和上面提到的 /*#__PURE__*/
注释有点类似,只不过 sideEffects 作用于模块层面,而 /*#__PURE__*/
注释作用于代码语句层面。
但是,sideEffects 是和 tree shaking (useExports) 不同的另一种优化方式。sideEffects 更为有效,因为它允许跳过整个模块和子文件树。当我们在一个模块的 package.json 中设置 sideEffects: false
,就代表该模块不包含任何副作用,如果它没有被任何地方使用到,打包工具就会跳过对它的副作用分析。当然,如果某个模块的确包含副作用,你也可以通过 sideEffect 告知打包工具:
// package.json
"sideEffects": [
"**/*.css",
"./esnext/index.js",
]
在 webpack 中配置 sideEffects: true
之后,webpack 在分析依赖时就会去识别 package.json 中的副作用标记 (sideEffects),以跳过那些未被使用且不包含副作用的导出模块。
// webpack.config.js
module.exports = {
optimization: {
sideEffects: true,
},
};
注意,这里有两个 sideEffects 配置,一个是在 webpack config 中,一个是在 package.json 中。webpack 中的 sideEffects 表示是否要开启识别 package.json 中 sideEffects 标记的功能,而 package.json 中的 sideEffects 是为了告知打包工具该模块是否包含副作用或者包含哪些副作用。
既然 locale 模块中配置了 sideEffects: false
,那么有没有可能是 webpack 中的 sideEffects 被关了,才导致 webpack 没有识别到 package.json 中的副作用标记?果然,将 webpack 的配置打印出来,发现 sideEffects 被设置成了 false!将 sideEffects 的值修改为 true 之后,还是原来的导入方式,但这次终于没有将整个 locale 模块打包到 bundle 中了:
// language.ts
import {zhCN, enUS} from "date-fns/locale";
export const languages = [zhCN, enUS];
之所以 webpack 被设置了 sideEffects: false
,是因为我们项目用了一个工具 nx.js,它将 webpack 的配置都封装起来了,使用的时候对 webpack 配置是无感知的。所以,这种 all in one 的工具虽然用起来用起来好用,但有时也是真坑啊,debug 起来也麻烦。我个人还是更喜欢纯粹一点的工具,宁可自己花点时间配置,至少这样能明确知道我配置了哪些东西。
不好意思,扯远了。
防止 bundle 文件中打入无用代码的方法
那么,在日常开发过程中,如何避免将无用代码打入 bundle 中呢?
- 使用 ES6 模块语法,确保 tree shaking 能够生效。 特别是三方库,需要保证使用的是它们的 ES6 版本。比如常用的 lodash 就不是 es6 模块,需要用 lodash-es 进行替换。
- 确保 webpack 优先使用 ES6 Module(默认开启)。 检查 webpack 的 mainFields 配置,确保 ES6 模块优先。
- 确保编译器不会把 ES6 模块转换为 CommonJS 模块。使用 babel-loader 或者 ts-loader 时,一定要注意保留 import 和 export,否则 tree shaking 无法生效。还有常用的
@babel/preset-env
,默认会将 ES6 模块转换为 ES5 模块,需要进行如下配置(请参考 bable 官网):
// .babelrc
{
"presets": [
[
"@babel/preset-env",
{
"targets": {
"esmodules": true
}
}
]
]
}
- 高阶组件和高阶函数对 tree shaking 都不太友好,可以使用工具为可能产生副作用的函数调用加上
/*#__PURE__*/
注释。 - 将 webpack mode 选项设置为 production,以启用 tree shaking 和 minification (代码压缩) 。
- 确保 webpack 设置
sideEffect: true
(默认开启)。 - 如果你准备开发一个三方库,可以考虑在 packge.json 中添加
sideEffects
标记,告知打包工具该模块是否包含副作用。 - 入口文件不会被 tree shaking,因此不要在入口文件中增加无用的模块。
- dynamic import 的文件不会被 tree shaking,因此对于动态引入的模块,需采用按需引入。
举个例子,如果采用 dynamic import 的方式来引入 date-fns,整个模块都会被打到 bundle 文件中:
// dynamic import
import("date-fns").then(({getDate})=>{
console.log(getDate(new Date()))
});
但如果在某些场景下实在需要 dynamic import,可以使用按需引入,比如:
// fn.ts
export { getDate } from "date-fns";
// dynamic import
import("./fn").then(({getDate})=>{
console.log(getDate(new Date()))
})
总结
最后,让我们再来回顾一下。从「为什么 tree shaking 失效了」这个问题出发,我们了解到了三种方法,可以避免将未使用到的代码打包到 bundle 文件中:
- 按需引入: 即只引入需要的模块,比如
import zhCN from "date-fns/locale/zh-CN"
- Tree Shaking:
- 原理:分析依赖 -> 标记模块是否被使用 -> 将未被使用模块的 export 语句删除,tree shaking 依赖于 terser/uglify 这样的代码压缩工具,以对其生成产物进行副作用分析和死代码消除。
- 死代码消除:由于一些代码有副作用(比如某些函数调用),即便它没有被使用到,也不会被压缩工具删除。好在我们可以通过
/*#__PURE__*/
注释来标记,明确告知压缩工具这些代码不会产生副作用,这样压缩工具就能放心地将它们删除。 - 使用:在 webpack 配置中,需要开启
useExports: true
才会让 tree shaking 生效。同时,必须使用 ES6 模块。
- sideEffects:
- 原理: 通过在 package.json 中配置 sideEffects 标记,明确告知打包工具某个模块是否会产生副作用以及副作用有哪些,它允许打包工具跳过该模块及其子文件树,不对其进副作用分析,是一种更为有效的方式。
- 使用:除了在 package.json 中配置 sideEffect 告知打包工具该模块包含的副作用信息外,也需要在 webapck 中开启 sideEffects 检查,以识别 package.json 中的副作用标记。
本文示例使用的是 webpack@5.7
转载自:https://juejin.cn/post/7127878140180824100