手写简易 Bundler:模块图 → 单文件
本节目标
- 亲手写一个「把多个 ESM 模块合并成一个文件」的迷你打包器
- 用正则/简单解析把所有
import/export改写成「函数调用」 - 体会「打包器 = 依赖图 + 拼接 + 启动」
真实打包器用 AST(见 ast-pro 课程)解析,这里用「示意级」拼接讲清原理。
// 运行环境:Node.js 16+(保存为 .cjs 运行,演示打包思想)
const fs = require('fs')
const path = require('path')
// 极简:把 main.js 和它 import 的模块,拼进一个「模块数组 + 启动函数」
function bundle(entry) {
const modules = []
function add(file) {
const code = fs.readFileSync(file, 'utf8')
const id = modules.length
modules.push({ id, code })
// 真实场景这里会用正则/AST 找 import,递归 add 依赖;示意略
return id
}
const entryId = add(entry)
// 把每个模块的 code 包成函数,启动函数按 entryId 执行
const body = modules.map((m) => `function m${m.id}(require, module, exports){\n${m.code}\n}`).join('\n')
return `(function(){ const cache={}; function require(id){ if(cache[id])return cache[id].exports; const m={exports:{}}; cache[id]=m; m${entryId}(require,m,m.exports); return m.exports } ${body}; require(${entryId}) })()`
}
// 用法:const out = bundle(path.resolve(__dirname,'main.js')); fs.writeFileSync('out.js', out)原理一句话:打包器把「每个源文件」变成「数组里的一个函数」,再加一个「加载器」按依赖图串起来执行——和你手写 CJS 加载器(jsm-l18)是同一个灵魂,只是它自动分析依赖图。
名词解释
简易 Bundler(打包器雏形):把「分散的模块文件」合并成「一个可运行文件」的工具。核心是「建依赖图 → 每个模块包成函数 → 加载器启动」。真实打包器(Rollup/Webpack)用 AST 精确改写 import/export 为函数调用与导出对象,并处理 Tree Shaking、代码分割;我们这里用拼接演示「骨架」。
依赖图驱动:打包的本质是「从入口按 import 画出整张图,再图遍历执行」。所以「模块图」这个概念从 ESM 引擎到打包器到手写加载器贯穿始终——它是模块系统的通用语言。理解图,就理解了打包。
课后练习
练习 1:为什么打包产物里「每个模块都包成函数」?
答案:为了「隔离作用域 + 可缓存」——和手写 CJS 加载器同理。函数作用域让模块顶层变量不外泄,加载器用
module.exports收集导出并缓存。打包器只是把「手工注入 require/module/exports」自动化了。
练习 2:真实打包器和我们的「正则拼接」最大差距在哪?
答案:正确性。正则拼接会误伤字符串里的
import、处理不了动态 import、做不了 Tree Shaking。真实打包器用 AST(见 ast-pro)精确理解语法结构,才能安全改写并优化。所以「示意」懂原理,「生产」还得靠成熟工具。
总结
手写打包器是「打通任督二脉」的一课:你会发现 Vite/Webpack 没那么神秘,它们就是「自动版的手写加载器」。我的观点:理解打包原理最大的好处是破除对构建配置的恐惧——当你知道「bundle 不过是一堆函数 + 一个加载器」,遇到「打包后某模块没执行」「chunk 切分不对」,你能直接定位是依赖图问题而非玄学。当然,生产请用成熟打包器(正则拼接会翻车),但「亲手拼一次」换来的是一辈子的通透。模块化学到这,你已经从「用模块」走到了「造模块系统」。