面试官:树摇怎么分析哪些是必要的依赖,如何分析彼此的依赖关系?

54 阅读4分钟

哈哈笑了,根本难得倒我。

Tree Shaking 的核心思想是:

利用 ES6 的模块特性(静态导入),在编译阶段分析哪些模块和导出值没有被用到,然后在打包时将其剔除。

这主要依赖于两点:

  1. ES Module(ES6 模块语法)import / export静态结构,可以在编译时确定依赖关系;

  2. 静态分析(Static Analysis) : Webpack 分析依赖图,只打包实际被使用的代码;

那么为什么 Webpack 可以在编译时确定依赖关系呢?这是个非常关键的问题。Webpack 之所以能够在编译时确定模块的依赖关系,是因为它依赖于 ES Module 的静态结构特性

ES Module(import / export)和 CommonJS(require)的关键区别是:ES Module对依赖的加载是“静态”的,在编译时进行分析,但是CommonJS对依赖的加载是“动态”的,在运行时执行。


Webpack 如何做的(简要流程)

  1. 模块解析 Webpack 使用 acorn@babel/parser 等工具解析每个 JS 文件为抽象语法树(AST):
  2. 依赖图构建 Webpack 根据 AST 构建一个模块依赖图(Module Dependency Graph),每个节点是一个模块,边是依赖关系。
  3. 标记导出是否使用 在构建时,Webpack 会从入口文件递归分析哪些导出被使用:被使用的模块标记为 usedExports = true,没被使用的标记为 usedExports = false
  4. 摇树优化(Tree Shaking) 在优化阶段,Webpack 根据 usedExports 利用 Terser(压缩插件) 剔除没被引用的导出。

那么AST是怎么构建的呢?

这个问题涉及编译器的核心原理,AST(Abstract Syntax Tree,抽象语法树) 是一种用树状结构表示源代码语法结构的数据形式。

在讲AST之前,我们先来了解一下一个完整的编译器整体执行过程:

  1. Parsing(解析过程) :这个过程要经词法分析语法分析构建AST(抽象语法树)一系列操作;
  2. Transformation(转化过程):这个过程就是将上一步解析后的内容,按照编译器指定的规则进行处理,形成一个新的表现形式;(例如你想将JS转成C语言展示出来)
  3. Code Generation(代码生成):将上一步处理好的内容转化为新的代码

image.png

Babel 其实就是一个最常用的Javascript编译器,它能够转译 ECMAScript 2015+ 的代码,使它在旧的浏览器或者环境中也能够运行。

整个 AST 构建过程分为三个主要阶段:

  1. 词法分析(Lexical Analysis): 把代码字符串转成一串Token(词法单元)

例如:

const a = 1 + 2;

会被拆成这些 token:

["const", "a", "=", "1", "+", "2", ";"]

这个阶段的目标是把字符流变成单词流,是语法分析的基础。

  1. 语法分析(Parsing): 把 token 流根据 JavaScript 的语法规则转换为一棵 AST,这一阶段使用上下文无关文法(CFG)构建语法树,描述每个结构的嵌套关系

例如,变量声明 const a = 1 + 2 会被识别为:声明语句(VariableDeclaration)/初始化表达式(BinaryExpression)

这一步一般通过 递归下降解析器(Recursive Descent Parser)LL/LR Parser 实现。

  1. 构建 AST(Abstract Syntax Tree): 将语法分析阶段得到的信息封装成树形结构。

每一个节点是一个对象,包含:type(节点类型)children 或相关属性。


Webpack 是怎么用 AST 的?

Webpack 在解析模块依赖时会:

  1. 调用 acorn@babel/parser 解析模块文件为 AST;
  2. 遍历 AST,查找 ImportDeclarationExportNamedDeclaration 节点;
  3. 构建模块依赖图(Module Graph);
  4. 标记哪些导出值是“used”(用于 Tree Shaking);
  5. 最终传给 Terser 做 dead code elimination。

那么回到最初的问题:Webpack 是如何确定哪些模块是「被用到的(used exports) 」,从而实现 Tree Shaking 的呢?

Webpack 主要在 依赖图构建(ModuleGraph)和标记阶段(usedExports) 完成这个判断:

构建模块依赖图(ModuleGraph)

Webpack 先从入口文件出发,使用 acorn@babel/parser 将 JS 源码解析为 AST。

然后遍历 AST:

// index.js
import { add } from './utils.js';

会生成一个节点:

{
  "type": "ImportDeclaration",
  "source": "./utils.js",
  "specifiers": ["add"]
}

Webpack 记录:index.js 依赖 utils.jsadd

分析模块的导出和引用(usedExports)

Webpack 从入口模块出发,递归地跟踪所有模块的导出与使用情况

以这个 utils.js 为例:

export const add = (a, b) => a + b;
export const sub = (a, b) => a - b;

假设 index.js 只导入了 add,Webpack 会标记:

{
  "add": true,
  "sub": false
}

这个过程的关键点是:ESM 是静态结构,Webpack 可以完全在编译阶段知道你用了哪些导出。

标记模块的导出是否被使用(usedExports)

Webpack 为每个模块都维护一个字段 usedExports,记录哪些导出是“活的”:

module.usedExports = ['add']; //true/false/all

这个字段会传递给后续 Webpack 优化插件,例如:TerserPlugin会读取 usedExports 并把未使用的导出删掉。


总结:Webpack 如何知道模块是否被用?

Webpack 在构建阶段,通过分析每个模块的 AST,构建依赖图,并通过递归分析哪些导出被其他模块引用,来标记 usedExports,从而实现 Tree Shaking。