构建模块打包器

本文的模块打包器来自示例 Minipack,我们将来了解它是如何一步步实现的。

首先,我们先来了解实现一个模块打包器所需要依赖的 babel 插件:

  • @babel/traverse — 维护整个树的状态,负责替换、删除和添加节点。
  • @babel/core — Babel 编译器核心。
  • @babel/parser — Babel 中使用的 JavaScript 解析器。
  • @babel/preset-env — 每个环境的 Babel 预设。可根据目标浏览器或运行时环境自动确定所需的 Babel 插件和 polyfills,从而将 ES6+ 编译至 ES5。

我们替换了该示例的旧的,已经被并入 babel 内的插件。

构建一个简单的模块打包器只需要三个步骤:

  • 利用 babel 完成代码转换,并生成单个文件的依赖
  • 生成依赖图谱
  • 生成最后打包代码

转换代码、生成依赖

首先,我们创建一个 createAsset() 函数,该函数将接受 filename 参数(文件路径),读取内容并提取它的依赖关系。

const fs = require('fs')

function createAsset(filename) {
  const content = fs.readFileSync(filename, 'utf-8')
}

我们使用 fs.readFileSync 读取文件,并返回文件内容。

根据其内容,我们可以获取到 import 字符串(依赖的文件)。

下面,我们将用到 JavaScript 解析器 — @babel/parser,它是读取和理解 JavaScript 代码的工具。它生成一个更抽象的模型,称为 AST(抽象语法树)。

AST 包含很多关于我们代码的信息。我们可以打印它了解我们的代码正在尝试做什么。

const parser = require('@babel/parser')

function createAsset(filename) {
  // ...
  const ast = parser.parse(content, {
    sourceType: 'module' // 识别 ES 模块
  })

  console.log(ast)
}

接下来,我们遍历 AST 来试着理解这个模块依赖哪些模块。

const traverse = require('@babel/traverse').default

function createAsset(filename) {
  // ...
  // 存放模块的相对路径
  const dependencies = []

  traverse(ast, {
    // 获取通过 import 引入的模块
    ImportDeclaration({ node }) {
      // 保存所依赖的模块
      dependencies.push(node.source.value)
    }
  })
}

我们知道 ES 模块是静态的,这意味着你不能 import 一个变量,或者有条件地 import 另一个模块。每当我们看到 import 语句时,我们就可以把它的值算作一个依赖。

我们还通过增加一个简单的计数器为这个模块分配一个唯一的标识符:

let ID = 0

function createAsset(filename) {
  // ...
  const id = ID++
}

我们使用 ES 模块和其他 JavaScript 功能,可能不支持所有浏览器。

为了确保我们的 bundle 在所有浏览器中运行,我们将使用 babel 核心库 @babel/core 来编译它。

const { transformFromAst } = require('@babel/core')

function createAsset(filename) {
  // ...
  const { code } = transformFromAst(ast, null, {
    presets: ['@babel/preset-env']
  })

  return {
    id,
    filename,
    dependencies,
    code
  }
}

presets 选项是一组规则,告诉 babel 如何编译我们的代码。

它内部使用 babel-preset-env 包将我们的代码转换为浏览器可以运行的东西。

最后,我们返回有关此模块的所有信息。

  • id — 模块的唯一 ID
  • filename — 模块的相对文件路径
  • dependencies — 当前模块的依赖模块,如果没有,返回空数组 []
  • code — 模块编译后的代码

生成依赖图谱

现在我们可以提取单个模块的依赖关系,我们将从 entry 入口文件的依赖关系开始。

我们将提取它的每一个依赖关系的依赖关系,依次循环,直到我们了解应用程序中的每个模块以及它们如何相互依赖。这个项目的理解被称为依赖图谱。

首先,我们编写一个 createGraph() 函数,传入入口文件,并解析整个文件。

function createGraph(entry) {
  const mainAsset = createAsset(entry)

  const queue = [mainAsset]
}

上面代码中,我们还定义一个只有入口资源的 queue 数组,它用来解析每个资源的依赖关系。

使用一个 for ... of 循环遍历 queue

最初 queue 只有一个资源,但是当我们迭代它时,我们会将额外的新资源,推入 queue 中。

const path = require('path')
function createGraph(entry) {
  // ...

  for (const asset of queue) {
    // 存放依赖模块和对应的唯一 ID
    asset.mapping = {}
    // 模块所在的目录
    const dirname = path.dirname(asset.filename)
    // 遍历其相关路径的列表,获取它们的依赖关系
    asset.dependencies.forEach((relativePath) => {
      // createAsset 需要一个绝对路径,但该依赖关系保存了一个相对路径的数组,这些路径相对于它们的文件
      // 我们可以通过将相对路径与父资源目录的路径连接,将相对路径转变为绝对路径
      const absolutePath = path.join(dirname, relativePath)
      // 解析资源,读取其内容并提取其依赖关系
      const child = createAsset(absolutePath)
      // 了解 asset 依赖取决于 child 这一点对我们来说很重要
      // 通过给 asset.mapping 对象增加一个新的属性 child.id 来表达这种一一对应的关系
      asset.mapping[relativePath] = child.id
      // 最后,我们将 child 这个资源推入 queue,这样它的依赖关系也将被迭代和解析
      queue.push(child)
    })
  }

  return queue
}

到这一步,queue 就是一个包含目标应用中每个模块的数组,这就是我们的依赖关系图谱。

生成 bundle

最后一步,我们定义一个 bundle 函数,它将使用我们的 graph,并返回一个可以在浏览器中运行的包。

function bundle(graph) {
  let modules = ''

  graph.forEach((mod) => {
    modules += `${mod.id}: [
      function (require, module, exports) { ${mod.code} },
      ${JSON.stringify(mod.mapping)},
    ],`
  })
}

graph 中的每个模块在这个对象中都有一个entry(也就是 filename)。我们使用模块的 id 作为 key 和一个数组作为 value(用数组因为我们在每个模块中有 2 个值)。

  • 第一个值是用函数包装的每个模块的代码。这是因为模块应该被限定范围:在一个模块中定义变量不会影响其他模块或全局范围。我们的模块在我们将它们被 babel 转译后,使用 CommonJS 模块系统:它们期望 requiremoduleexports 对象可用。而这些方法在浏览器中通常不可用,所以我们将它们实现并将它们注入到函数包装中。
  • 对于第二个值,我们用 stringify 解析模块及其依赖之间的关系(也就是上文的 asset.mapping)。解析后的对象看起来像这样:{'./relative/path': 1}。这是因为我们的模块被转换后会通过相对路径来调用 require()。当调用这个函数时,我们应该能够知道依赖图谱中的哪个模块对应于该模块的相对路径。

推荐:阮一峰老师的浏览器加载 CommonJS 模块的原理与实现

接着,创建一个 IIFE 自执行函数。其中,创建一个 require() 函数:它接受一个模块 ID,并在我们之前构建的 modules 对象查找它。

function bundle(graph) {
  // ...
  const result = `
    (function(modules) {
      function require(id) {
        const [fn, mapping] = modules[id];
        function localRequire(name) {
          return require(mapping[name]);
        }
        const module = { exports : {} };
        fn(localRequire, module, module.exports);
        return module.exports;
      }
      require(0);
    })({${modules}})
    `
  // 返回最终结果
  return result
}

通过解构 const [fn, mapping] = modules[id] 来获得我们的包装函数和 mappings 对象。

我们模块的代码使用相对文件路径而不是模块 ID 调用 require()。但我们的 require 函数接收模块 ID。此外,两个模块可能 require() 具有相同的相对路径,但表示两个不同的模块。

为了解决这个问题,当需要一个模块时,我们会创建一个新的专用 require 函数供其使用。它将特定于该模块,并且将知道通过使用模块的 mapping 对象将其相对路径转换为 ID。mapping 对象正是这样的,即特定模块的相对路径和模块 ID 之间的映射。

最后,使用 CommonJS,当模块需要被导出时,它可以通过改变 exports 对象来暴露模块的值。

require 函数最后会返回 exports 对象。

你可以创建一个文件保存打包后的内容,在页面中引入这个包即可。

const graph = createGraph('./example/entry.js')
const result = bundle(graph)

fs.writeFile('./dist/main.js', result, (err) => {
  if (err) throw err
  process.stdout.write('创建成功!')
})

本文的示例 👉 地址


本文首发 blog,如果喜欢或者有所启发,欢迎 Star,对作者也是一种鼓励。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
平台声明:文章内容(如有图片或视频亦包括在内)由作者上传并发布,文章内容仅代表作者本人观点,简书系信息发布平台,仅提供信息存储服务。
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 230,578评论 6 544
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 99,701评论 3 429
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 178,691评论 0 383
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 63,974评论 1 318
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 72,694评论 6 413
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 56,026评论 1 329
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 44,015评论 3 450
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 43,193评论 0 290
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 49,719评论 1 336
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 41,442评论 3 360
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 43,668评论 1 374
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 39,151评论 5 365
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 44,846评论 3 351
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 35,255评论 0 28
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 36,592评论 1 295
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 52,394评论 3 400
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 48,635评论 2 380

推荐阅读更多精彩内容