likes
comments
collection
share

构建模块打包器

作者站长头像
站长
· 阅读数 23

本文已整理到 Github,地址 👉 blog

如果我的内容帮助到了您,欢迎点个 Star 🎉🎉🎉 鼓励鼓励 :) ~~

我希望我的内容可以帮助你。现在我专注于前端领域,但我也将分享我在有限的时间内看到和感受到的东西。


本文的模块打包器来自示例 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('创建成功!')
})
查看示例