likes
comments
collection
share

为什么 Webpack tree shaking 失效了?

作者站长头像
站长
· 阅读数 35

背景

事情是这样的,上周在分析项目打包代码的时候,突然发现 date-fns 在未压缩之前有 1.5M,通过分析报告可以看到,date-fns 的 locale 模块全部被打包到了 bundle 文件中,导致 bundle 代码体积极大增加: 为什么 Webpack tree shaking 失效了?

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: 为什么 Webpack tree shaking 失效了?

问题解决了,同时我们刚才的猜想也得到了验证,于是很多时候,我们对于一个问题的探索到这一步就停止了。

可是,事实真的如此吗?

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 这样的压缩工具。 为什么 Webpack tree shaking 失效了?

ES6 模块也叫做 harmony modules,因此 harmony export 就是指 ES6 模块的 export

让我们来看一个例子,fn.ts 文件 export 了两个函数 fn1 和 fn2,但是在入口文件 index.ts 中,只用到了 fn1: 为什么 Webpack tree shaking 失效了?

// 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 函数会被消除。

rollup 传送门 terser 传送门

有同学可能要问了,既然 terser 可以消除死代码,那为什么还需要 tree shaking 呢?原因是 terser 不能跨文件去做死代码消除。terser 只会对单个文件代码进行静态分析,然后将未使用到的代码从抽象语法树(AST)中删除。因此,在跨文件场景下,terser 无法得知某个文件中 export 的模块是否会被其他文件使用到,也就无法将其删除。而 tree shaking 会从入口开始,对所有代码进行静态分析,从而得出模块之间的依赖关系,并删除未引用模块的 export,未引用的代码就能够被 teser 识别并消除。

tree shaking 强依赖 ES6 模块的静态结构特性:

  1. 在编译时就可以明确知道 import 或 export 了哪些模块。
  2. 只能在文件顶层 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,
  // ...
}

为什么 Webpack tree shaking 失效了?

是不是感觉似曾相识?没错,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 tree shaking 失效了?

之所以 webpack 被设置了 sideEffects: false,是因为我们项目用了一个工具 nx.js,它将 webpack 的配置都封装起来了,使用的时候对 webpack 配置是无感知的。所以,这种 all in one 的工具虽然用起来用起来好用,但有时也是真坑啊,debug 起来也麻烦。我个人还是更喜欢纯粹一点的工具,宁可自己花点时间配置,至少这样能明确知道我配置了哪些东西。

不好意思,扯远了。

防止 bundle 文件中打入无用代码的方法

那么,在日常开发过程中,如何避免将无用代码打入 bundle 中呢?

  1. 使用 ES6 模块语法,确保 tree shaking 能够生效。 特别是三方库,需要保证使用的是它们的 ES6 版本。比如常用的 lodash 就不是 es6 模块,需要用 lodash-es 进行替换。
  2. 确保 webpack 优先使用 ES6 Module(默认开启)。 检查 webpack 的 mainFields 配置,确保 ES6 模块优先。
  3. 确保编译器不会把 ES6 模块转换为 CommonJS 模块。使用 babel-loader 或者 ts-loader 时,一定要注意保留 import 和 export,否则 tree shaking 无法生效。还有常用的 @babel/preset-env,默认会将 ES6 模块转换为 ES5 模块,需要进行如下配置(请参考 bable 官网):
// .babelrc
{
  "presets": [
    [
      "@babel/preset-env",
      {
        "targets": {
          "esmodules": true
        }
      }
    ]
  ]
}
  1. 高阶组件和高阶函数对 tree shaking 都不太友好,可以使用工具为可能产生副作用的函数调用加上/*#__PURE__*/ 注释。
  2. 将 webpack mode 选项设置为 production,以启用 tree shaking 和 minification (代码压缩) 。
  3. 确保 webpack 设置 sideEffect: true(默认开启)。
  4. 如果你准备开发一个三方库,可以考虑在 packge.json 中添加 sideEffects 标记,告知打包工具该模块是否包含副作用。
  5. 入口文件不会被 tree shaking,因此不要在入口文件中增加无用的模块。
  6. 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 文件中:

  1. 按需引入: 即只引入需要的模块,比如 import zhCN from "date-fns/locale/zh-CN"
  2. Tree Shaking:
    • 原理:分析依赖 -> 标记模块是否被使用 -> 将未被使用模块的 export 语句删除,tree shaking 依赖于 terser/uglify 这样的代码压缩工具,以对其生成产物进行副作用分析和死代码消除。
    • 死代码消除:由于一些代码有副作用(比如某些函数调用),即便它没有被使用到,也不会被压缩工具删除。好在我们可以通过 /*#__PURE__*/ 注释来标记,明确告知压缩工具这些代码不会产生副作用,这样压缩工具就能放心地将它们删除。
    • 使用:在 webpack 配置中,需要开启 useExports: true才会让 tree shaking 生效。同时,必须使用 ES6 模块。
  3. sideEffects:
    • 原理: 通过在 package.json 中配置 sideEffects 标记,明确告知打包工具某个模块是否会产生副作用以及副作用有哪些,它允许打包工具跳过该模块及其子文件树,不对其进副作用分析,是一种更为有效的方式。
    • 使用:除了在 package.json 中配置 sideEffect 告知打包工具该模块包含的副作用信息外,也需要在 webapck 中开启 sideEffects 检查,以识别 package.json 中的副作用标记。

本文示例使用的是 webpack@5.7

转载自:https://juejin.cn/post/7127878140180824100
评论
请登录