likes
comments
collection
share

5分钟迅速搞懂树摇(Tree Shaking)以及副作用(sideEffects)

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

前言

树摇(Tree Shaking)是一种用于优化 JavaScript 或 TypeScript 代码的技术,它的主要目标是删除未使用的代码(即未引用的模块、变量、函数等),以减小最终生成的代码的体积。树摇是一种基于 ES Module 规范的无用代码删除(Dead Code Elimination ) 技术,它会在运行过程中静态分析模块之间的导入导出,确定 ESM 模块中哪些导出值未曾其他模块使用,并将其删除,以此实现打包产物的优化。

树摇的主要原理是通过静态分析代码的依赖关系来确定哪些模块、变量、函数等在代码中没有被引用,然后将这些未使用的部分从最终的构建输出中删除。

静态分析

静态分析(Static Analysis),也称为静态代码分析,是一种在不运行程序的情况下分析代码的方法。它通过检查代码来识别潜在的错误、漏洞、不规范的编码实践以及其他问题。

静态分析工具(或静态分析器)会在不实际执行程序的情况下分析源代码,以寻找潜在的问题。这些问题可能包括:

  1. 语法错误:检测代码中的拼写错误、语法错误和不完整的语句,以确保代码可以被正确解析和编译。
  2. 代码风格和规范:根据编程规范和最佳实践,检查代码的格式、命名约定、缩进、注释等,以确保代码的一致性和可读性。
  3. 潜在的漏洞:识别可能导致安全漏洞的代码模式,如未经验证的输入、不安全的函数调用、内存泄漏等。
  4. 性能问题:检查代码中的性能瓶颈和低效操作,以提高程序的性能和响应时间。
  5. 代码复杂性:分析代码的复杂性、层次结构和依赖关系,以便进行重构和优化。
  6. 未使用的变量和函数:识别未被引用或调用的变量和函数,以减少不必要的代码。
  7. 依赖关系分析:确定模块之间的依赖关系,以帮助构建系统和包管理器管理依赖关系。

到这里大家可能马上会想到Eslint,是的,它就是静态代码分析器的一种实现。

静态分析有助于提高代码质量、可维护性和安全性,减少了在运行时发现问题所带来的成本和风险。许多编程语言和开发工具都提供了静态分析工具和插件,以帮助开发人员更早地发现和解决问题。这些工具在软件开发生命周期的不同阶段都有应用,从编码和构建到代码审查和持续集成。

为什么是 ES Module ?

  1. 编译时加载:esm 是编译时加载,也就是只有所有import的模块都递归加载完成,才会开始执行
  2. 确定的导入导出:ESM要求所有的导入导出语句只能出现在模块顶层,import 和 export 语句使得模块之间的依赖关系在编译阶段就可以被确定。这意味着在不执行代码的情况下,就可以知道哪些模块被导入,以及它们导出了哪些变量。
  3. 无副作用的模块导入:在 ECMAScript 模块(ESM)中,鼓励编写无副作用的模块,当一个模块导入另一个模块时,通常只会获取该模块的导出内容,这意味着导入模块的操作本身不会产生意外的副作用,不会改变其他模块的状态,也不会执行额外的代码。这有助于确保代码的可静态分析性。

除以上特性外,词法作用域也为静态分析提供了很大帮助,JavaScript 是词法作用域的编程语言,对于 ES6 模块(ECMAScript 模块),词法作用域的概念表现在模块的导入和导出中。具体来说:

  • 模块的导出(使用 export 关键字)是在模块顶部进行的,它们指定了模块可以共享的变量和函数。
  • 模块的导入(使用 import 关键字)是在模块的顶部进行的,它们明确指定了模块依赖关系。
  • 导出和导入的作用域是由它们在代码中的位置决定的。导出的变量和函数只能在模块内部或被导入的模块中访问,而不会受到其他作用域的影响。

为什么 cjs 不行?

虽然cjs模块也有词法作用域加持,但是:

  1. require函数可以动态导入模块,难以静态分析依赖关系。

  2. require是运行时调用,所以require理论上可以运用在代码的任何地方。

  3. require导入的实际模块实例取决于运行时加载结果。

  4. 模块实例不缓存,多次导入可能得到不同实例。

  5. module.exports可以动态修改,难以静态推导模块对外接口。

  6. 通过模块的cache可以导入未声明的模块。

    例如:

    // foo.js
    module.exports = {
      foo: 'foo'
    }
    
    // index.js
    const foo = require('./foo'); // 正常导入foo模块
    
    // 然后可以通过缓存再导入该模块,而不需要声明导入
    const foo2 = require.cache[require.resolve('./foo')];
    

    在index.js中,我们没有导入foo2模块,但可以通过以下方式获取它:

    1. require.resolve获取模块ID,这里是'./foo'
    2. require.cache保存了所有已导入模块的缓存
    3. 所以可以直接从缓存取出'module.exports'对象

    这种方式可以不经过声明的导入就重用模块实例。

    这样会跳过模块的代码执行,但获取的是同一个对象实例,这在CommonJS中是可行的,但对静态分析带来一定困难。

  7. 通过修改require函数可以自定义模块加载逻辑。

  8. 模块执行有副作用,导入顺序影响执行效果。

词法作用域

词法作用域(Lexical Scope)是指代码中变量和函数的作用域由它们在源代码中的位置决定。这种作用域也称为静态作用域。

词法作用域的主要特征是:

  • 函数的作用域在函数定义的时候就决定了,与调用位置无关。
  • 内层作用域可以访问外层作用域,但外层作用域无法访问内层作用域的变量和函数。
  • 查找变量时按照“自内向外”的顺序逐层查找,当前作用域没有找到才向上一层查找。

一个变量的词法作用域是可以通过静态代码分析确定的,不需要等到运行时才能确定。

大多数编程语言如JavaScript、Python等都采用了词法作用域,这对于代码可靠性和可预测非常重要,对机器进行代码静态分析以及开发人员理解很有利。

sideEffects 与副作用

副作用是指模块的执行会导致除了导出值之外的其他不可预测的行为或状态更改。在模块化的上下文中,副作用通常是不推荐的,因为它们会增加代码的复杂性,降低代码的可维护性,并可能导致不可预测的行为。

例如:如果模块中使用了 window 对象来修改全局状态或执行与模块导入无关的操作,那么这个模块就具有副作用。

package.json 文件中,可以使用 "sideEffects" 字段来配置模块的副作用信息。这个字段用于指定哪些模块具有副作用,以及哪些模块是纯粹的无副作用的模块。

"sideEffects" 字段的格式是一个数组,其中可以包含以下几种值:

  1. 字符串值:表示具有副作用的模块路径。如果你希望指定某个模块具有副作用,可以将其路径添加到数组中。例如:

    "sideEffects": [
      "./src/some-module.js"
    ]
    
  2. 字符串值 "*" :表示所有模块都具有副作用,不会进行剪裁。这通常用于禁用树摇优化。例如:

    "sideEffects": ["*"]
    
  3. 布尔值 false:表示所有模块都是无副作用的,可以进行剪裁。这是最常见的用法,通常在生产构建中使用,以确保尽可能减小构建输出的体积。例如:

    "sideEffects": false
    

默认情况下,如果没有在 package.json 文件中定义 "sideEffects" 字段,大多数构建工具会假定所有模块都具有副作用,以确保安全性。为了获得最佳的性能和体积优化,建议在项目中明确配置 "sideEffects" 字段,以告知构建工具哪些模块可以安全地进行树摇。

动态导入对树摇的影响

动态 import 在某种程度上会影响树摇的效果,因为它们的导入是在运行时发生的,而不是在静态分析阶段确定的。树摇的关键特性是在编译时(静态分析阶段)识别未使用的代码并将其删除,但动态 import 的模块加载是在运行时进行的,这意味着编译器无法确定哪些模块会被加载和执行。

以下是关于动态 import 影响树摇的一些场景:

  1. 动态模块路径:如果动态 import 使用变量来指定模块路径,那么编译器通常无法确定要导入哪个模块,因此无法进行树摇。例如:

    const modulePath = './moduleA.js';
    import(modulePath);
    
  2. 条件导入:如果 import 语句位于条件分支中,编译器也难以确定哪个条件分支会执行。例如:

    if (condition) {
      import('./moduleA.js');
    } else {
      import('./moduleB.js');
    }
    
  3. 动态导入的异步性:动态 import 是异步的,导入的模块在需要时才会加载。这意味着在编译时无法确定哪些代码将在加载后执行,因此树摇的精确性受到影响。

虽然动态 import 可能会限制树摇的效果,但仍然有一些情况下可以进行优化:

  • 静态模块路径:如果动态 import 使用的是固定的、静态的模块路径,而不是变量或条件分支,编译器可以进行更准确的树摇。

  • 预加载(preload):某些工具和环境支持预加载动态导入的模块,这意味着它们在主模块加载后会立即加载。这样可以提高树摇的精确性。

  • 工具和优化策略:一些构建工具和优化策略可以通过分析动态 import 的使用情况来尽量提高树摇的效果。例如,Webpack 的 import() 方法可以配置为预加载模块。

树摇

静态分析之后,就可以给无用代码做标记,然后把它删除掉了,这个过程就叫作树摇。

我们来举例理解一下:

假设有一个 JavaScript 模块,其中包含了一些未使用的函数和变量:

// math.js
export function add(a, b) {
  return a + b;
}

export function subtract(a, b) {
  return a - b;
}

export function multiply(a, b) {
  return a * b;
}

另一个模块 app.js 导入了 addsubtract 函数,但没有使用 multiply 函数:

// app.js
import { add, subtract } from './math';

console.log(add(5, 3));
console.log(subtract(10, 4));

如果不进行静态分析和树摇,将这两个模块打包在一起,最终的打包文件可能会包含未使用的 multiply 函数,导致打包文件变得更大。但现代的打包工具(如 Webpack 或 Rollup等)支持使用静态分析来检测未使用的代码并将其删除,从而减小打包文件的体积。

例如Webpack,就会在静态分析标记出未被使用的导出,然后使用插件如 Terser 将其 Shaking 掉。

Dead Code 特征

以下是一些可能被树摇优化掉的 Dead Code 的特征:

  1. 未使用的导出:如果一个模块导出了一些变量或函数,但这些变量或函数在其他模块中从未被使用过,那么这些导出就是 Dead Code,可以被优化掉。可参照上例。

  2. 未引用的代码:如果一个模块中有一些代码片段从未被引用过,那么这些代码就是 Dead Code,可以被优化掉。

    // utils.js
    function foo() {
        console.log('foo');
    }
    
    function bar() {
        console.log('bar');
    }
    
    foo();
    

    在这个例子中,bar 函数从未被调用过,因此它是 Dead Code。在进行树摇优化时,bar 函数会被优化掉。

  3. 副作用无关的代码:如果一个模块中有一些代码片段虽然被执行了,但它们对程序的行为没有任何影响(即没有副作用),那么这些代码就是 Dead Code,可以被优化掉。

    // data.js
    const data = [1, 2, 3];
    
    export function getData() {
        return data;
    }
    
    const result = data.reduce((acc, val) => acc + val, 0);
    console.log(result);
    

    在这个例子中,虽然 reduce 函数被调用了,并且计算出了一个结果,但这个结果并没有被导出或者在其他地方使用。因此,这段代码对程序的行为没有任何影响(即没有副作用),它是 Dead Code。

总结

Tree-Shaking 强依赖于 ESM 模块化方案的静态分析能力,在过往的 CommonJS、AMD、CMD 旧版本模块化方案中,导入导出行为是存在很多动态、难以预测的场景的。而 ESM 方案则从规范层面规避这一行为,它要求所有的导入导出语句只能出现在模块顶层,且导入导出的模块名必须为字符串常量。所以,ESM 下模块之间的依赖关系是高度确定的,与运行状态无关,编译工具只需要对 ESM 模块做静态分析,就可以从代码字面量中推断出哪些模块值未曾被其它模块使用,从而进行无用代码的剔除,即树摇。

总的来说,ESM 模块具有更强大的静态分析能力,可以更好地支持树摇等优化技术,同时提供了清晰、直观的模块化语法。因此,对于现代 JavaScript 开发,特别是在构建大型应用程序时,推荐使用 ESM 模块来提高性能和可维护性。