Tree Shaking In Practice

引言

随着产品的迭代升级和人员变更,我们的代码往往会逐渐变得不可控,有一点表现的尤为明显,即之前的代码逻辑模块被废弃不用,但是该模块又被引入了进来,对应的模块在编译理论中被称之为 Dead Code。我们可以采用相应的方法把这些 Dead Code 删除掉, 这种解决问题的方法被称为 DCE(Dead Code Elimination)。而 DCE 在前端领域中的实现就是常常被提到的Tree Shaking

Tree Shaking 的作用

Tree Shaking 的相关概念最早是在 Rollup 中引入的,那么我们就以 Rollup(^2.78.0) 来举例:

  1. 新建两个文件main.mjsoutput.mjs,两个文件内容如下:
  • output.mjs

    1
    2
    export const a = 1;
    export const b = 2;
  • main.mjs:

    1
    2
    3
    4
    5
    6
    7
    8
    9
    10
    11
    12
    import { a } from './output.mjs';

    console.log(a);
    const c = 3;

    function foo() {
    console.log('aaaa');
    return;
    console.log('bbbb');
    }

    foo();
  1. 执行命令rollup main.mjs --file bundle.js生成bundle.js文件,内容如下:
1
2
3
4
5
6
7
8
9
10
const a = 1;

console.log(a);

function foo() {
console.log('aaaa');
return;
}

foo();

可以看到 output 中的const b = 2;以及 main 中的const c = 3;和 return 之后的console.log('bbbb');在最终打包结果 bundle 中都被移除掉了。对比打包之前和打包之后的结果我们可以总结出 Tree Shaking 主要做了以下几点事情:

  • 消除无用的变量,函数等未被引用的内容
  • 消除代码中不会被执行的逻辑
  • 消除引入的其他模块中不被使用的内容

注意以上列举的几点是 Tree Shaking 通用的作用,但是并不是所有的打包工具都做到了这些,如默认情况下 esbuild 并不能消除掉上述示例代码中 return 之后的部分,但是可以借助工具在压缩阶段去除。

Tree Shaking 流程

主流的打包工具基本上都支持 Tree Shaking,例如 Rollup,Webpack,Esbuild(Vite),开启的方法不尽相同,我们就不一一讨论了。这里着重讨论一下 Webpack 是如何进行 Tree Shaking 的,因为 Webpack 和其他打包工具的 Tree Shaking 流程不太一致。

ESM 支持

开启 Tree Shaking 首先需要确认经过 loader 转换之后最终的产物是 ESM(ES6 Modules)格式,这一点非常重要,因为 Tree Shaking 需要明确的静态的声明和引用,而不是像 CommonJS 格式所支持的可以动态的 require() 相关 module。

另外需要注意的是 webpack 的相关配置会影响如何引入 module,需要确保最终引入的是 ESM 格式的代码,相关的配置为targetresolve.mainFields等,总的来说,当target配置为webworker, web,或者未设置时,包的引入按照 package.json 中定义的 'browser', 'module', 'main'三个字段依次引入,除这三个配置之外则按照'module', 'main'的顺序引入。以Ant Design为例,Antd 在发包的时候提供了两种格式的代码,分别为main字段对应的 commonJS 格式的入口,以及module字段对应的 ESM 格式的入口,在其 package.json 中定义如下:

1
2
3
4
{
"main": "lib/index.js",
"module": "es/index.js"
}

那么我们采用import {xxx} from 'antd'的形式引入 antd 的时候,实际引入的是 module 所定义的内容,这样开启 Tree Shaking 的第一个要求就满足了。

Side Effects

首先我们明确一下什么是Side Effects

A “side effect” is defined as code that performs a special behavior when imported, other than exposing one or more exports. An example of this are polyfills, which affect the global scope and usually do not provide an export.

另外代码中也有对应的 Side Effects,如 esbuild 官方文档中关于这一块的举例:

1
2
3
4
5
6
7
8
9
10
// These are considered side-effect free
let a = 12.34;
let b = 'abcd';
let c = { a: a };

// These are not considered side-effect free
// since they could cause some code to run
let x = 'ab' + cd;
let y = foo.bar;
let z = { [x]: x };

以上具有 Side Effects 的代码不会被打包工具自动 Tree Shaking,但是有些时候我们可以明确的判断有些代码或者模块是不具有 Side Effects 的,那么这个时候我们可以手动「通知」打包工具,那么该怎么「通知」呢?Webpack 提供了两种方式。

第一种是 module 层面的粗粒度的「通知」,可以在package.json中使用sideEffects关键字明确哪些 module 是 Side Effects 的,剩下的就是 Pure 的了,可以放心的进行 Tree Shaking,还是以Ant Design为例,对应的 package.json 定义了如下的 Side Effects:

1
2
3
{
"sideEffects": ["dist/*", "es/**/style/*", "lib/**/style/*", "*.less"]
}

那么这些 glob 格式所匹配的文件都是具有 Side Effects 的 module,不能被 Tree Shaking,在这范围之外可以进行 Tree Shaking。

注意:使用 Webpack 打包时,如果不指定 sideEffects,即package.json中没有 sideEffects 的相关声明,则 Webpack 认为当前的 package 都是 sideEffects 的,不会进行 Tree Shaking

第二种是在代码层面的更细粒度的「通知」,关键字为/*@__PURE__*/或者/*@__PURE__*/形式的注释,如果确认接下来的内容是 PURE 的,可以手动标记,如下所示:

1
2
3
4
5
6
7
8
9
/*@__PURE__*/ console.log('side-effect');

class Impure {
constructor() {
console.log('side-effect');
}
}

/*@__PURE__*/ new Impure();

如上所示,这些代码最终都会被 Tree Shaking 掉

注意:关于/*@__PURE__*//*@__PURE__*/的区别,在 esbuild,webpack,和 rollup 中进行了测试,三种打包工具都成功 Tree Shaking 掉了标记的代码,因此两种注释都是可行的,猜测可能是继承自或者兼容terser annotations的注释格式

其他配置项

明确了如上的两项内容并进行了配置,经过 Webpack 内部的流转,最终我们的代码会将无用的代码「标记」出来,但是 webpack 本身并不会把这些代码删除,而是交给terser进行删除,terser 会在 Compress 阶段删除 Dead Code,当然可以自定义相关的 minimizer,只要支持 DCE 即可。

关于 optimization.usedExports配置项,在 webpack5 中的默认值是 true,即默认开启的,在 webpack v5 之前的需要自行配置为 true

Tree Shaking 相关实践

知道了以上的理论基础,我们尝试在实际项目中进行实践,这里简单说明一下公司前端项目的概况:

  • 公司内部将常用的组件进行抽象封装,并发布了dt-react-component包(需要优化的内容对应的 tag 为 v2.2.0),在此之前一直使用babel-plugin-treasure插件操作 AST,并最终将 dt-react-component 和 antd 未引入的组件删除
  • 公司内部内部从 babel-loader 迁移至了 esbuild-loader,esbuild 本身并不支持 AST 相关操作
  • antd 的 JS 代码默认支持基于 ES modules 的 Tree Shaking

基于以上考虑,我们需要将 antd 和 dt-react-component 进行改造,以支持 Tree Shaking。在实践中我们使用webpack-bundle-analyzer来对打包结果进行分析。

antd 引入问题

初次打包结果如下所示:

1st-build-result

可以看到除了我们需要的antd/es目录下的 module 被引入之外,还有antd/lib目录下的 module 被引入,但是antd/lib目录下的 module 是不应该被引入的,那么问题出在哪呢?
仔细搜索了一下,我们可以发现在 dt-react-component 下的goBack组件引入了antd/lib,这就导致了整个antd/lib全部被引入。我们把这里的引入换成import { Icon } from 'antd';,结果如下:

2nd-build-result

可以看到antd/lib在打包结果中已经不存在了,antd 相关 Tree Shaking 问题得以解决。

dt-react-component 引入问题

现在我们将目光放在 dt-react-component 上,看一下 dt-react-component 的问题。dt-react-component 中有对应的组件progressLine,在我们内部的某个产品线中并没有使用到,却在打包结果中存在,这是什么原因呢? 分析了一下package.json发现并没有声明 sideEffects,我们将 sideEffects 加上:

1
2
3
{
"sideEffects": ["*.scss"]
}

再进行打包,结果如下:

3rd-build-result

可以看到 progressLine 被正确的 Tree Shaking 了。至此 antd 和 dt-react-component 已经可以被正确的 Tree Shaking 了。

总结

Tree Shaking 可以将未被引用的代码消除以减少打包体积,但是 Webpack 中开启时需要注意相关配置,否则无法启用。
对于包发布者而言:

  • 需要在发包时打包 ESM 格式的代码
  • package.json中使用module关键字声明引用
  • 同时注意标注sideEffects

对于包使用者而言:

  • 需要正确的引入 ESM 格式的包
  • 需要注意经过 loader 转换之后的代码符合 ESM 格式
  • 确认 Webpack 配置项中optimization.usedExports配置为 true
  • 并且使用支持 DCE 的 minimizer。

基于以上的配置,包发布者和使用者就可以愉快的享受 Tree Shaking 带来的便利了。

Comments