文章

深入理解前端构建工具的本质定位:模块、AST 与 Bundle

深入理解前端构建工具的本质定位:模块、AST 与 Bundle

注:
本专栏基于作者当前阶段的理解进行整理与表达。
如有疏漏或不准确之处,欢迎反馈与交流:Issues
在此表示感谢。

引言: 构建工具在解决什么问题?

在现代前端工程体系中,Vite、Webpack、Rollup 等构建工具几乎已经成为项目的基础设施。它们承担了模块解析、依赖管理、代码转换与产物生成等一系列工作,使复杂的前端应用得以稳定的运行。

然而在实际的开发过程中,这些工具更多是以“配置”的形式存在。我们通过调整对应的参数,安装插件,执行构建命令,使得项目得以顺利的运行。然而在这个过程中,却很少有机会从原理层面理解它们的工作方式。这也导致一个问题经常被忽略:

构建工具究竟在构建什么?它解决了什么核心问题?

如果仅仅停留在“代码打包”这一层面,我们很难去理解不同构建工具之间的差异,也无法真正理解其设计上的核心职责与所作的取舍。

要真正弄清楚这一问题,需要回到构建工具底层的三个核心概念:

  • 模块(Module)
  • 抽象语法树(AST)
  • Bundle(最终产物)

构建工具的核心能力,本质上都围绕着这三者展开。

本篇文章将以“模块如何通信 → 依赖如何分析 → 代码如何合并”为主线,逐步拆解前端构建工具的核心原理,尝试从模型层面理解它们的设计逻辑。


一、模块:构建工具真正关心的最小单元

首先需要理解模块这一概念,在构建工具的视角里,模块的本质并不特指某个.js文件,而是:

一个拥有输入(imports)与输出(exports)的独立作用域

在 CommonJS 中,这种输入与输出并非语法结构,而是通过约定的运行时 API 实现的。

它是一个基本的“通信单元”。 在开发环境中,无论是CommonJS还是ES Module,其本质上都在围绕着同一个问题展开:

  • 模块如何引入其他模块的能力?
  • 模块如何向外暴露自身能力?

这正是“模块通信机制”的核心。对于CommonJS与ES Module而言,区别主要在于该机制运作的阶段。


CommonJS:运行时模块系统

对于CommonJS而言:

1
2
3
const path = './math.js'
const { add } = require(path)
module.exports = add

其关键的特征在于:

  • require 是在代码 运行时调用,意味着依赖关系在运行过程中确认
  • 各模块的业务逻辑被收集在各自的独立作用域中,通过require建立通信
  • 通过缓存确保模块只实例化一次

这意味着:
模块系统本身存在于运行阶段


ES Module:可静态分析的模块系统

而 ES Module 则完全不同:

1
2
import { add } from './math.js'
export function calc() {}

它具备几个决定性的特征:

  • import / export静态语法
  • 依赖关系在代码执行前就可以确定
  • 模块结构可以在构建期完整分析

这为后续的打包与优化提供了很好的拓展性。


最小bundler模型:模块通信的实现

浏览器本身并不直接支持CommonJS,且早期也无法直接适配ES Module,模块与模块无法直接实现通信,因此需要借助构建工具将代码重构,将各个模块整合到一个“统一的执行环境”,重新恢复模块之间的通信能力。因此,一个bundler最核心的能力只有一个:

在一个统一的执行环境中,正确地实现模块之间的通信

一个最简的模块运行模型大致可以概况如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
((modules) => {
  const cache = {}

  function require(id) {
    if (cache[id]) return cache[id].exports
    const module = { exports: {} }
    cache[id] = module
    modules[id](module, module.exports, require)
    return module.exports
  }

  // 入口
  require('main.js')
})({
  'main.js': (module, exports, require) => {
    const { add } = require('./math.js')
  },
  'math.js': (module, exports, require) => {
    exports.add = (a, b) => a + b
  },
})

这里已经包含了构建工具的三个基本职责:

  1. 模块注册(modules 对象)
  2. 依赖解析(require 调用)
  3. 模块缓存(cache)

Webpack的运行时模型,可以看作是这一思路的工业化延伸版本。可以发现,require的实现与Node的CommonJS高度相似,这并非巧合,Webpack 的 bundle 在运行模型上,本质是一个借鉴了 CommonJS 思路的模块运行时系统。

这里我们会自然而然地注意到,最小的bundler模型有了,但这个modules又是如何而来的呢?这就涉及到我们下一个关键概念以及bundler的第二职责: 抽象语法树(AST)依赖收集


二、抽象语法树:构建工具“阅读理解能力”

在进行项目构建时,bundler需要对每个模块的代码进行解析,以理解其内部的依赖关系。而这一过程,正是抽象语法树(AST) 发挥作用的时机。

在 bundler 中,AST 主要负责三件事

  1. 结构化JS代码
  2. 标记导入与导出的符号
  3. 为后续的优化(如 tree-shaking)提供依据

AST 并不关心运行时语义,也不参与模块执行。

简单用一句话来定义AST:

AST = 把一段 JS 代码,转换为“可以被程序遍历的结构节点”

举个简单的例子:

1
import { add } from './math.js'

经过parser(如babel)解析后,这段代码会被转换为AST:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
{
  "type": "Program",
  "body": [
    {
      "type": "ImportDeclaration",
      "specifiers": [...],
      "source": {
        "value": "./math.js",
        ...
      }
    }
  ],
  "sourceType": "module"
}

原有的JS语句被转换为结构化的对象。在有了抽象语法树(AST) 之后,bundler就可以通过遍历整个AST,收集ImportDeclaration节点来生成一个依赖关系图,获取模块之间的依赖关系。

在完成依赖关系收集之后,构建工具会根据自身的设计策略对模块进行转换与组织。

Webpack 会保留模块边界,并通过运行时模块系统实现模块通信; 而 Rollup 则倾向于在构建期进行静态展开,从而消除模块边界。


三、Bundle:从“模块集合”到“可执行产物”

当所有模块及其依赖关系被分析完成后,构建工具的目标变成了:

把“模块语法” → “可执行的通信机制” 生成一个在目标环境中可正确运行的代码整体

在这一过程分为了两条路径:

  • 转成 require + runtime(Webpack 路线)
  • 静态展开 + 内联(Rollup 路线)

Webpack

从前文的最小bundler模型继续往下理解,我们可以简单的将Webpack概括成这样一条路线:

  • 构建期通过AST分析模块依赖关系
  • 为模块建立统一的内部标识与映射关系
  • 生成一套运行时模块加载机制(通过require
  • 在浏览器中通过运行时完成模块执行、缓存与按需加载

从这个角度来看,我们可以建立起一个非常重要的心智模型:

bundler 不只是把代码拼在一起,而是要为模块之间的引用关系提供一套可执行的运行时机制。

这个模型对于理解 bundler 的本质非常有效,因为它涵括了三个最核心的运行时职责:

  1. 模块注册:每个模块都需要有一个可被索引的位置
  2. 模块加载 / 依赖解析:当模块被引用时,需要有一套机制把它取出来并执行
  3. 模块缓存:同一模块通常只应初始化一次,并在后续复用其导出结果

需特别注意的是,最小bundler模型主要用于帮助我们快速建立对 Webpack心智模型的理解。解释为什么bundler需要runtime,以及如何实现模块之间的引用关系。

在真实实现中,Webpack 并不只是简单地把 import/export 机械改写成 require/exports
更准确地说,Webpack 在构建阶段会维护完整的模块图(module graph)chunk 结构以及用于运行时定位模块的manifest 信息,而当代码真正进入浏览器之后,再由 webpack 自己的 runtime 去完成模块之间的连接。

换句话说,Webpack 更接近于这样一种机制:

构建期先把模块整理进一套可追踪、可索引的模块系统;运行时再通过 webpack runtime 将这些模块正确地组织、加载和连接起来。

因此,如果只是从“运行时形态”上看,Webpack的bundle确实和CommonJS的模块执行模型十分相似:

  • 都有模块注册表
  • 都有加载函数
  • 都有缓存机制

这也是为什么我们会觉得它“像是”一个更工业化的 require + cache 系统。

但如果进一步看构建阶段,Webpack 又并不等同于“把 ESM 彻底降级成 CommonJS”。
对于ESM,Webpack仍会尽可能保留其可静态分析的结构信息,从而支持依赖分析、作用域追踪以及 tree-shaking 等优化能力;而在运行时层面,再通过 webpack 自己的 runtime 把这些模块组织成一个可执行系统。

因此,Webpack 更准确的定位应该是:

把不同来源、不同形态的模块,整理进一套由 webpack runtime 可管理的模块系统。


Rollup

如果说 Webpack 的思路是:

在构建期整理模块系统,在运行时完成模块之间的连接

那么 Rollup 的核心思路则更偏向于:

尽可能在构建期就把模块之间的连接关系确定并展开,从而减少运行时的模块系统成本。

它同样会在构建期借助AST分析完整的模块依赖关系,但与 Webpack 不同的是,Rollup 更倾向于充分利用 ESM 的静态特性,把原本需要留到运行时处理的很多“模块连接工作”尽可能提前完成。

可以简单的把Rollup理解为这样一条路线:

  • 构建期完成依赖分析
  • 根据静态 import/export 关系追踪依赖引用
  • 将多个模块尽可能合并到更少的作用域结构中
  • 直接生成一份更接近最终执行形态的代码结果

也就是说,Rollup 关注的重点并不是“构造一个通用的运行时模块系统”,而是:

让最终产物尽可能接近“已经没有明显模块边界的可执行代码”。

这也是为什么在讨论 Rollup 时,经常会提到两个关键词:

  • 静态分析
  • 作用域提升(scope hoisting)

举一个最简单的例子:

1
2
3
4
// math.js
export function add(a, b) {
  return a + b
}
1
2
3
// module1.js
import { add } from './math.js'
add(1, 2)
1
2
3
// module2.js
import { add } from './math.js'
add(2, 3)

在这种关系足够静态、足够明确的情况下,Rollup 更倾向于在 bundle 中保留一份 add 的实现,并把调用关系直接组织进最终代码中:

1
2
3
4
5
6
function add(a, b) {
  return a + b
}

add(1, 2)
add(2, 3)

这种结果最容易让人直观感受到 Rollup 的特点:

  • 模块之间的“引用关系”已经被提前消化
  • 最终代码更接近一个直接可执行的整体
  • 不再需要一个显式的 require + cache + modules 式运行时外壳去维持模块通信

同样需要特别注意的是,这里的案例也是“用于建立心智模型的抽象表达”,而不是Rollup对所有场景下产物形态的描述。

在真实实现中,Rollup的核心目标是在静态信息足够充分的前提下,尽可能把模块连接前移到构建期完成。

这意味着:

  • 当模块关系是静态可分析的,Rollup 可以很好地做依赖的追踪与 tree-shaking
  • 当存在动态导入、代码分割、多入口等场景时,最终产物仍然会保留必要的 chunk 边界
  • 当遇到 CommonJS 等非天然静态的模块系统时,通常需要依赖插件完成互操作,且优化空间会明显受限

因此,Rollup 更准确的定位应该是:

以 ESM 的静态结构为基础,尽可能在构建期完成模块连接、裁剪与组织。

如果将webpack 和 Rollup 进行对比的话,可以简单理解为:

  • Webpack:保留模块系统,运行时负责连接
  • Rollup:利用静态结构,构建期尽量完成连接

这也正是两者在设计取向上的根本差异。

Rollup 的优势因此非常明显:

  • 更容易进行精细的 tree-shaking
  • 最终产物往往更紧凑
  • 运行时额外负担更小

但它的边界也同样来自这里:

  • 越依赖静态可分析的模块关系,它越能发挥优势
  • 一旦进入动态性更强、互操作更复杂的场景,这种“尽量前移到构建期”的策略就会受到限制

结语:构建工具的本质定位

到这一步为止,我们应该就对前端构建工具有了一个更为深入的认识了。回到最初的问题,我觉得前端构建工具的本质定位可以归纳为一句话:

在构建期理解模块之间的通信关系,并生成一个在目标环境中高效执行的代码结构

模块定义了通信方式,
AST 提供了理解能力,
Bundle 则是最终的执行形态。

理解了这三点,Vite、Webpack、Rollup 之间的差异,不再是配置项的不同,而是模型选择的不同

这正是前端构建工具的核心所在。



🚀 Hello!看一下作者的其他开源项目!

👉 @ws-flow/client

一个面向事件流的 WebSocket 客户端框架,尝试把通信过程组织为一种可组合的“流”结构。

  • 将不同通信方式(请求 / 推送)统合到同一模型之中
  • 通过事件订阅与规则路由从数据流中获取数据
  • 以流式任务的方式处理持续到达的数据
  • 构建可拓展的中间件管线灵活调整通信逻辑

如果你正在做实时系统或复杂通信层,可能对你有所帮助。

本文由作者按照 CC BY 4.0 进行授权

© . 保留部分权利。

本站采用 Jekyll 主题 Chirpy