乐趣区

关于babel:构建模块打包器

本文已整顿到 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('创立胜利!')
})

查看示例

退出移动版