关于Tree Shaking你所要知道的

1,788 阅读7分钟

持续创作,加速成长!这是我参与「掘金日新计划 · 6 月更文挑战」的第12天,点击查看活动详情

Tree Shaking是dead code elimination 的形象称呼,也就是“死代码”的剔除。

Dead Code 一般具有以下几个特征

  • 代码不会被执行,不可到达
  • 代码执行的结果不会被用到
  • 代码只会影响死变量(只写不读)

通过 Tree Shaking,可以有效减小我们前端应用的体积。那么本文就一起了解一下有关 Tree Shaking 的技术及实现吧~

为什么要使用Tree Shaking

一句话,删除无效代码,减小文件体积。

通常,一个前端工程项目不论其源码有多少大小文件组成,最终都会通过一些打包工具,比如大名鼎鼎的Webpack又或是rollup,将其打包成为一个单文件的bundle.js。这个bundle.js中包含了所有业务代码及其依赖的模块的代码(当然包括node_modules)中的代码。而打包的目的又在于网页加载时对外部资源的请求数目。

可是,当这个bundle.js文件过大时,加载资源的时间过长,降低了用户体验,所以又出现了诸如代码分割(code spliting)、压缩(uglify)、使用CDN资源等方式进行打包输出的优化方式。而在这里,我们讨论的是一个比较原始的方法,即减少源文件本身的体积。

很多人认为开发者在写业务代码时,一些定义了的变量没有使用,Tree Shaking剔除的是这一类代码(比如下面的 myMethod 函数)。

import {methodOne} from "A"

const myMethod = () => {
 console.log("...")
}

methodOne()

的确,Tree Shaking 会剔除这类代码,但是这不是他主要发挥优势的地方。因为像是上面这种例子,但凡有个ESLint配置,都会提醒你删除 😆。

真正发挥其优势的地方在于哪里呢?

试想一下,假设项目引入一个A模块的某个函数(methodOne),如下方的项目代码。


import { methodOne } from "A"

methodOne()

但是有仅仅用到了这一个函数,对于A模块来说,他可能暴露出了很多函数、类等,如下方的A模块对外暴露的代码。

export {
 methodOne,
 methodTwo,
 methodTree,
 ClassOne
}

在这种场景下,如果A模块的methodTwo、methodThree等函数还全部被包含在项目的bundle.js中,真没必要啊 😅。对了,这时候就应该由Three Shaking去解决了。

实现Three Shaking的前提

想要剔除应用代码中的dead code,就两个条件~

  1. 使用ES Module的模块导入/导出语法(import/export)
  2. JS解析工具的配合(Webpack、rollup)

首先来看第一条,ESM的模块导入导出通过一些语法上的限制,使得在解析JS文件时,可以实现静态分析的能力。

ES6模块依赖关系是确定的,和运行时的状态无关,可以进行可靠的静态分析,这就是tree-shaking的基础。

所谓静态分析就是不执行代码,从字面量上对代码进行分析。

静态分析阶段和runtime运行时是一个相对的概念,在代码执行之前,通过静态分析,就可以获得文件之间的依赖关系。

有了依赖关系,就可以知道哪些模块中的哪些函数、变量等被引入使用了,就可以对没有使用到的函数、变量等进行标记。

第二条,列举的两个都是常见的打包工具,但是在这里强调的不是打包的功能,而是他们resolve(解析)文件的能力。上述提到的静态分析和标记都需要他们来实现。

而剔除dead code的过程,以webpack为例,在之前的版本中,需要webpack和uglify插件配合,uglify执行删除的这个动作。在Webpack4及其以上的版本中,则内置了“分析标记 + 删除”的能力。

Tree Shaking | webpack

在上面的这份官档指南中,还提到了基于webpack实现Tree Shaking要注意的细节问题。比如:

  • 不要让babel在转义为ES5时改变源码中ESM的模块导入导出语法
  • 对于有副作用的模块,需要在package.json中的“sideEffects”字段进行标记

具体的实施细节可以参照文档 😉

按需引入与Three Shaking

现在,我们可以换一个角度来看。

假如我们要开发一个组件库A,在应用项目B中引入该组件库,并且实现对组件的按需引入。这个场景和Three Shaking有关系吗?

有。这相当于项目B要使用Three Shaking的能力,组件库A在导出组件时要满足实现Three Shaking的前提。

而这个前提就是使用ESM的export语法,A组件库使用ESM的方式导出组件。

如果A是以CJS的形式(module.exports)进行导出,虽然在ESM中可以进行引入,但却只能使用默认引入的方式 import _ from 'lodash',不支持命名导入,也就不能实现按需加载。

具体的规则可以参考下面的链接。

Node Modules at War: Why CommonJS and ES Modules Can't Get Along

那么,现在就是如何将组件库A中的组件使用ESM进行expprt了。

或许,每次遇到这种工程化问题,我们首先想到的都是webpack,但是很遗憾,webpack 对ESM导出方式的支持还处于实验性进度中。这里涉及到libraryTarget配置项,他的含义是我们开发的库是以什么方式对外暴露API和变量的。具体的配置项如下:

module.exports = {
  //...
  output: {
    libraryTarget: 'module',
  },
  experiments: {
        outputModule: true
  },
};

对于这个配置项的详细说明官档如下:

Output | webpack

好吧,于是社区就出现了一些插件工具试图增强当前webpack的功能,比如这个github.com/purtuga/esm…

或许在这个场景下,webpack并没有那么适合,因为我们的开发目标是一个组件库A,他不需要被打包成为单文件。只要引入他的项目工程做打包的事情就好了。 当然,在应用项目B中,一般我们会在webpack中命中js/ts(x)时,排除(exlude)node_modules 中的模块,那么设计组件库A输出ES时,是需要考虑适配上层业务环境的。换句话说,组件库A输出的代码和应用B经过编译打包后的代码,他们的ES规范是一致的。

让我们参考一下业内组件库是如何去做的吧 😉

  • antd

    antd使用了gulp执行TS的编译和不同类型文件(如Less文件)的处理。组件输出的逻辑可以参考antd-tools中的gulpfile

    antd遵循ES6规范输出的组件在其es目录下,保留了ESM的语法,并且有和源文件一致的目录结构。可以引入到适配高版本浏览器的应用项目。 顺带一提,babel和tsc的配置项中均有是否保留ESM语法("modules"、"module")的选项值,可以在进行TS编译或者ES6转译为CJS时,保留ESM的导入导出语法。

    那么,对antd中的组件按需引入时,可以直接从es目录下引入,从而利用 Tree Shaking。

    import { Button } from 'antd/es/button'
    
  • arco-design

    😆,这个组件库实现ESM输出和antd差不多,具体可见下方链接代码中的buildes 函数。

    arco-cli

  • element-ui

    这里还有个特别一点的,element-ui在组件按需引入的实现上使用了一个babel-plugin。

    实际上,这个库输出的组件均为CJS,为了支持ESM的引入方式,使用一个babel插件(babel-plugin-component)进行语法上的转化。也就是当应用项目转码为CJS时,顺带将引入该组件库的使用的所有import语法转化为require语法。而这里的require会定位到具体的文件路径下,也算是一种按需的方式引入了。

    转换之前:

    import { Button } from 'components'
    

    转换之后:

    var button = require('components/lib/button')
    require('components/lib/button/style.css')
    

    后记

    Tree Shaking 和ESM的导入导出语法相关,这其中涉及了不少工程化的知识。因此,在这里放一些扩展阅读的链接,有助于更好的理解~

    1. Tree-Shaking性能优化实践 - 原理篇 - 掘金
    2. How to transpile ES modules with webpack and Node.js - LogRocket Blog
    3. Reduce JavaScript payloads with tree shaking

    BTW,虽然上述讨论以应用项目打包输出CJS类型的bundle.js文件为主,但ESM在现代浏览器中已经可以通过<script type=”module”></script> 被有效支持了,并且还出现了明星级别的bundless模块加载工具Vite。同时,在Node环境中,通过一些规则,依然可以使用ESM的导入导出方式。