手写简易 Bundler:模块图 → 单文件

本节目标

真实打包器用 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 切分不对」,你能直接定位是依赖图问题而非玄学。当然,生产请用成熟打包器(正则拼接会翻车),但「亲手拼一次」换来的是一辈子的通透。模块化学到这,你已经从「用模块」走到了「造模块系统」。