侧边栏

Tree-Shaking原理

发布于 | 分类于 前端/模块化

JavaScript文件的大小是前端性能优化中非常重要的一部分。

Tree Shaking是一个用于优化JavaScript 代码体积的技术,主要通过在构建过程中移除未使用的代码,从而减小最终打包文件的大小,提高网页加载速度和性能。

参考:

需要被删除的代码

在源代码中,有两种代码是可以被删除的

无法被执行的代码

无法被执行的代码也被成为死代码dead code,一般出现在无法到达的条件判断、未使用的变量或函数、在函数return之后的代码等。

比如

js

if(false) {
    console.log('1') // 无法被执行
}

这段代码无论如何也不会被执行,类似的还有

js

if(1 === 2) {
    console.log('1') // 无法被执行
}

function foo(){
    console.log('1')
    return 
    console.log(2) // 无法被执行
}
foo()

这些无法被执行的代码,无论存在与否,都不影响应用的正常运行,为了减少打包文件的体积,当然是可以被删除的。

这个工作一般由代码压缩工具如uglifyterser等完成,他们甚至还提供了一些更高级的配置,可以删除诸如console.log等用户无感知、也不影响程序运行的代码

未被执行的代码

还有一种代码,虽然它会被执行,但是它是否执行都不会影响程序,比如下面这段代码,虽然我们定义了一个变量,但没有在后续的代码中使用它。

js
const largeString = '很长很长的文本' // largeString变量没有被使用

这行赋值代码会正常运行,但即使删掉他也不会影响整个程序,所以按道理来说,为了减少最终打包文件的体积,也应该删掉他。

在单个文件中,这种判断应该是比较简单的

js
function double(a) {
    return a * 2
}
if(1 === 2) {
    double(10)
}

比如上面的double不会调用,或者在dead code中被调用,都是可以分析出来的,这种情况下就认为double函数没有被执行,可以被删除。

但在某些情况下,这种没有被使用的函数和变量,并不是那么容易区分出来。

一种情况是在函数式编程中,要分析某段代码是否是dead code,就比较麻烦,比如下面这种代码

js
function double(a) {
    return a * 2
}
const arr = [1, 2, 3]
arr.map(item => {
    if (item > 10) {
        return double(item)
    }
    return item
});

虽然我们都知道对于arr数组来说,item > 10是不可能被执行的,但程序需要到运行的时候才知道,单纯从源代码来说,分析这段代码会比较麻烦。

另外一种情况是,虽然在当前文件没有被使用,但这些变量或函数作为了当前文件模块导出

js
export const largeString = '很长很长的文本'

对于这个largeString而言,虽然它没有在当前模块文件内使用,但是通过export导出了出去,那么这个导出去的变量是否可以被删除呢?

这取决于其他文件是否有使用这个变量,如果没有使用,那么可以移除;如果已经使用,那就不能被删除。

上面这种,找到那些未被执行的代码,然后将他们删除的技术,就是本文要提到的Tree Shaking

一个简单的例子,假设有如下模块:

js
// utils.js
export function usedFunction() {
  console.log('This function is used');
}

export function unusedFunction() {
  console.log('This function is unused');
}

其使用情况为

js
// main.js
import { usedFunction } from './utils.js';

usedFunction();

在构建过程中,Tree Shaking 会分析 main.js 的依赖,发现 unusedFunction 没有被使用,从而将其移除,减少打包后的代码体积。

接下来看看相关的原理。

为什么会存在未被执行的代码

严格来说,无法被执行的代码,和未被执行的代码,在编码阶段是可以通过人工控制、手动删除的。

我们及时将这类代码删掉,不就不需要Tree Shaking了吗?

在实际开发中,dead code的场景并不少见

比如我们通过环境变量可以在开发环境注入一些额外的代码,比如添加移动端的调试工具eruda,或者是一些mock文件

js
if (import.meta.env.DEV) {
    import('eruda')
    import('./mock')
}

在构建时,由于import.meta.env.DEV会被转换成false,整个代码块变成了死代码,这种情况下,dead code是可以被清理掉的。

自动的 Tree Shaking总比每次构建的时候手动移除开发环境的代码要好的多吧。

另外一种场景就是在开发模块的时候,一个模块要提供多个接口,而用户可能只会使用其中的一部分接口。

作为开发者,我们无法预设用户会使用哪些;作为包的使用者,我们希望只将这个模块中用到的部分进行打包。

所以,一个好的包,一般是需要支持TreeShaking的,在后面的章节会继续提到。接下来我们先看看Tree Shaking的原理。

找到未被执行的代码

在不知道具体原理的情况下想一下,我们该如何实现上述的功能呢?

先想一下最朴素的功能。

在构建阶段,我们从入口文件开始,分析每个文件的依赖文件,以及依赖的具体字段。这是一个深度遍历的过程,遍历完成之后,我们可以统计有哪些模块,每个模块的哪些导出是被使用了的。

这样,就可以将模块文件中那些被使用了的导出保留,而那些没被使用的导出,就可以移除。

看起来很简单,那为什么现在才实现呢?

在上面的过程中,分析依赖的模块文件比较简单,都是有具体的语法,比如import或者require,对应的字符串就是模块文件;

而分析依赖的模块的具体字段,则需要一点处理,比如const {a} = require('xxx')这行代码,就可以认为xxx模块的a方法被使用了,那么这个模块的a方法,就不能被删除。

js是一门灵活的动态语言,对于某个模块而言,有很多种调用方式

js
const { a } = rquire('xx')
const a = require('xxx').a
const mod =require('xxxx')
mod.x
mod['xx']

由于历史原因,JavaScript社区实现了很多种模块方案,比如CommonJS、UMD等,每种模块的使用方法都有差异,有的模块调用甚至都是在运行的时候才知道的,要一一分析每个模块每个导出的使用情况,是比较复杂的,比如

  • require只是一个普通的函数调用,可以在任意位置使用,甚至可以动态生成模块路径,这种动态行为使得静态分析工具难以确定哪些模块和函数在编译时被使用,从而难以安全地移除未使用的代码。
  • 通过对module.exports赋值,可以对模块导出进行动态修改,进一步增加了静态分析的难度

ESM模块

在ES6中,提出了ESM的规范,包括importexport

具体来说,ESM有下面这些特性

  • import 和 export 语句只能出现在模块的顶层作用域
  • import的模块名只能是字符串常量
  • 模块的依赖关系在解析模块时立即确定

ESM的模块语法是静态的,这意味着模块依赖关系在编译时是确定的,并且不会在运行时改变。

这种特性,为打包器提供了一种能够分析每个模块导出是否被使用的方案

js
// ESM中,引入模块的方法是比较固定的,这种写法就可以认为xxx模块的a方法被使用了
import { a } from 'xxx'

静态分析

通过ESM的模块语法,打包器可以对整个项目的源代码进行静态分析。

静态分析的主要目标是识别和移除未使用的代码,在不执行代码的情况下,检查代码的结构、依赖关系和语法,以便发现哪些代码是被引用的,哪些是未使用的。

下面简单描述一下静态分析实现Tree Shaking的过程

首先静态分析工具会解析源代码,将其转换为抽象语法树(Abstract Syntax Tree,AST)。AST 是一种树状的结构,表示代码的语法结构。每个节点代表源代码中的一个元素(如变量、函数、表达式等)。

然后回分析模块依赖,通过 AST,分析工具可以识别出代码中的模块依赖。对于每个 import 和 export 语句,工具会跟踪它们的引用关系。

接着是标记和引用,静态分析工具会标记代码中所有被引用的部分。这些被引用的代码将被认为是“活跃的”,即它们在运行时会被执行。

最后,任何未被标记的代码将被视为未使用的死代码,打包工具(如 Webpack 或 Rollup)将在打包过程中移除这些未使用的代码。

所以,理论上,如果打包器对CJS模块的某些语法进行了分析,比如只处理

js
const { a } = require('xxx')

而不处理其他情况

js
const { a } = require(someDynamicPath)

似乎也可以对CJS模块进行Tree Shaking。但实际上这种分析还是有很多漏洞,如果在代码的某个运行逻辑里面添加了require('xxx'),那么这个模块还是不能被优化,单独分析头部的require是没有意义的。

换句话说,在CJS模块中,只有开发者自己可以确定某个模块的代码是否需要被删除。

webpack和rollup

目前主流的构建工具都支持Tree Shaking,具体使用可以参考文档

本文列举的一些例子,都可以通过这些工具进行测试,动手试试,验证一下更容易理解一些原理哦

不生效的Tree Shaking

那么,是不是我们只要按照ESM规范来编写模块,就可以实现完美的Tree Shaking了呢?

实际上并不是这样,在业务代码中,可能会由于各种各样的情况导致Tree Shaking不生效。

动态import

ESM中除了静态的 import 语句之外,还有一种称为动态导入(Dynamic Import)的语法。这种动态导入语法使用 import() 函数,可以在运行时按需加载模块

参考:[import() MDN文档] (https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Operators/import)

js
import('./module.js').then(module => {
  module.default()
})

动态加载主要适合用于以下场景:

  • 代码拆分(Code Splitting):按需加载模块,减少初始加载时间。
  • 条件加载:根据某些条件动态加载特定模块。
  • 延迟加载:仅在需要时才加载某个模块,优化性能。

除了返回的是Promise之外,import()表现与require很像

  • 可以在任何地方使用
  • 可以使用动态模块地址,比如通过变量拼接模块路径

这导致静态分析非常困难,因为不知道用户具体会用到模块中的哪些部分,因此在默认情况下,打包器对于动态import的tree shaking是很有限的,

js
import { unique } from 'lodash-es' // 会进行tree shaking

const { unique } = await import ('lodash-es') // 不会进行tree shaking

一种变通的办法是

js
// ./lodash-util.js
export { unique } from 'lodash-es'

// main.js
const { unique } = await import ('./lodash-util.js') // 现在不会全部打包lodash-es,

但实际上tree shaking是在lodash-util.js这个文件里面的静态import完成的,上面的import ('./lodash-util.js')还是会将lodash-util.js文件完全打包。

参考这个issue,在[email protected][email protected]之后的版本中,都会尽量对动态的import进行有效的tree shaking。

副作用

在进行静态分析时,需要特别注意代码中的副作用(Side Effects)。

副作用函数和纯函数原本是函数式编程中的两个概念。

纯函数与副作用函数

有副作用的函数是指在执行过程中,会对函数外部的状态进行修改,或依赖外部状态

比如下面这种会修改外部变量的函数,每次调用都会修改外部状态,因此即使是相同的参数调用也会得到不同的结果

js
let counter = 0

function incrementCounter(x) {
  counter += x
  return counter
}

incrementCounter(1)
incrementCounter(2)

纯函数是指在相同的输入下,总是返回相同的输出,并且在执行过程中不产生任何副作用。换句话说,纯函数不依赖也不修改其作用域之外的状态。

js
function multiply(a, b) {
  return a * b
}

multiply(2, 3) // 总是返回 6
multiply(2, 3) // 总是返回 6

副作用不被tree shaking

在Tree Shaking中,副作用是指代码执行过程中对外部状态产生的影响,比如修改全局变量、执行 I/O 操作、操作DOM、网络请求等。

对于纯函数的调用,无论调用多少次multiply(2, 3),只要该纯函数的返回值没有被使用,上面的代码就可以被移除。

但是要加上了一个console.log(),情况就不一样了,console.log被认为是有副作用的代码

js
function multiply(a, b) {
  return a * b
}

console.log(multiply(2, 3))

所以这段代码就不会被tree shaking。

现代打包工具(如 Webpack、Rollup)在处理 Tree Shaking 时,

  • 会尽量精确地分析代码,确定哪些部分确实有副作用,有副作用的代码会被保留
  • 如果无法确定该还是是否有副作用,那么相关的代码也会保留,这被称作保守决策

为了确保安全地移除未使用的代码,开发者需要告知打包工具哪些代码可能包含副作用

手动标记副作用

比如Vue的defineComponent方法,由于定义组件的时候是一个函数调用

js
defineComponent({...})

构建工具可能会认为这段代码是有副作用的,就会保留。

但实际上这段代码并没有副作用,可以通过行内注释/*#__PURE__*/的形式告诉构建工具。这样,当该函数的返回值没有被使用的话,整段代码就可以被移除

export default /*#__PURE__*/ defineComponent(/* ... */)

具体可以参考Vue官方文档

对于模块开发者而言,可以在package.json中的sideEffects字段进行声明整个包的副作用,帮助构建工具识别该模块是否有副作用

json
{
  "name": "my-library",
  "sideEffects": false
}

如果是有副作用

json
{
  "name": "my-library",
  "sideEffects": [
    "./src/someModule.js"
  ]
}

可以通过一个数组显式声明那些有副作用的文件,让构建工具对整个文件的代码进行保留。

小结

虽然Tree Shaking的工作是有构建工具来完成的,但我们作为代码的编写者,也需要了解其内部的基础原理,以及一些注意事项,让Tree Shaking的效果能够达到预期

  • 使用ESM模块,只有ESM模块才可以进行Tree Shaking,且进行使用静态的import,动态import的效果有限
  • 使用第三方模块时,需要注意其是否有副作用,然后评估包的大小和功能,看看是否有备用选项
  • 自己编写代码时,尽量编写纯函数,不仅方便Tree Shaking,也方便编写测试用例,对于纯函数的调用,可以通过行内注释/*#__PURE__*/显式声明
  • 自己编写模块时,记得在package.json也手动声明sideEffects

OK,接下来就是练习了~

你要请我喝一杯奶茶?

版权声明:自由转载-非商用-保持署名和原文链接。

本站文章均为本人原创,参考文章我都会在文中进行声明,也请您转载时附上署名。