窥探原理:实现一个简单的前端代码打包器 Roid

roid

roid 是一个极其简单的打包软件,使用 node.js 开发而成,看完本文,你能够实现一个很是简单的,可是又有实际用途的前端代码打包工具。css

若是不想看教程,直接看代码的(所有注释):点击地址前端

为何要写 roid ?

咱们天天都面对前端的这几款编译工具,可是在大量交谈中我得知,并非不少人知道这些打包软件背后的工做原理,所以有了这个 project 出现。诚然,你并不须要了解太多编译原理之类的事情,若是你在此以前对 node.js 极为熟悉,那么你对前端打包工具必定能很是好的理解。node

弄清楚打包工具的背后原理,有利于咱们实现各类神奇的自动化、工程化东西,好比表单的双向绑定,自创 JavaScript 语法,又如蚂蚁金服 ant 中大名鼎鼎的 import 插件,甚至是前端文件自动扫描载入等,可以极大的提高咱们工做效率。webpack

不废话,咱们直接开始。git

从一个自增 id 开始

const { readFileSync, writeFileSync } = require('fs')
const path = require('path')
const traverse = require('babel-traverse').default
const { transformFromAst, transform } = require('babel-core')

let ID = 0

// 当前用户的操做的目录
const currentPath = process.cwd()

id:全局的自增 id ,记录每个载入的模块的 id ,咱们将全部的模块都用惟一标识符进行标示,所以自增 id 是最有效也是最直观的,有多少个模块,一统计就出来了。github

解析单个文件模块

function parseDependecies(filename) {
  const rawCode = readFileSync(filename, 'utf-8')

  const ast = transform(rawCode).ast

  const dependencies = []

  traverse(ast, {
    ImportDeclaration(path) {
      const sourcePath = path.node.source.value
      dependencies.push(sourcePath)
    }
  })

  // 当咱们完成依赖的收集之后,咱们就能够把咱们的代码从 AST 转换成 CommenJS 的代码
  // 这样子兼容性更高,更好
  const es5Code = transformFromAst(ast, null, {
    presets: ['env']
  }).code

  // 还记得咱们的 webpack-loader 系统吗?
  // 具体实现就是在这里能够实现
  // 经过将文件名和代码都传入 loader 中,进行判断,甚至用户定义行为再进行转换
  // 就能够实现 loader 的机制,固然,咱们在这里,就作一个弱智版的 loader 就能够了
  // parcel 在这里的优化技巧是颇有意思的,在 webpack 中,咱们每个 loader 之间传递的是转换好的代码
  // 而不是 AST,那么咱们必需要在每个 loader 进行 code -> AST 的转换,这样时很是耗时的
  // parcel 的作法其实就是将 AST 直接传递,而不是转换好的代码,这样,速度就快起来了
  const customCode = loader(filename, es5Code)

  // 最后模块导出
  return {
    id: ID++,
    code: customCode,
    dependencies,
    filename
  }
}

首先,咱们对每个文件进行处理。由于这只是一个简单版本的 bundler ,所以,咱们并不考虑如何去解析 cssmdtxt 等等之类的格式,咱们专心处理好 js 文件的打包,由于对于其余文件而言,处理起来过程不太同样,用文件后缀很容易将他们区分进行不一样的处理,在这个版本,咱们仍是专一 jsweb

const rawCode = readFileSync(filename, 'utf-8') 函数注入一个 filename 顾名思义,就是文件名,读取其的文件文本内容,而后对其进行 AST 的解析。咱们使用 babeltransform 方法去转换咱们的原始代码,经过转换之后,咱们的代码变成了抽象语法树( AST ),你能够经过 https://astexplorer.net/, 这个可视化的网站,看看 AST 生成的是什么。npm

当咱们解析完之后,咱们就能够提取当前文件中的 dependenciesdependencies 翻译为依赖,也就是咱们文件中全部的 import xxxx from xxxx,咱们将这些依赖都放在 dependencies 的数组里面,以后统一进行导出。数组

而后经过 traverse 遍历咱们的代码。traverse 函数是一个遍历 AST 的方法,由 babel-traverse 提供,他的遍历模式是经典的 visitor 模式
visitor 模式就是定义一系列的 visitor ,当碰到 ASTtype === visitor 名字时,就会进入这个 visitor 的函数。类型为 ImportDeclaration 的 AST 节点,其实就是咱们的 import xxx from xxxx,最后将地址 push 到 dependencies 中.bash

最后导出的时候,不要忘记了,每导出一个文件模块,咱们都往全局自增 id+ 1,以保证每个文件模块的惟一性。

解析全部文件,生成依赖图

function parseGraph(entry) {
  // 从 entry 出发,首先收集 entry 文件的依赖
  const entryAsset = parseDependecies(path.resolve(currentPath, entry))

  // graph 实际上是一个数组,咱们将最开始的入口模块放在最开头
  const graph = [entryAsset]

  for (const asset of graph) {
    if (!asset.idMapping) asset.idMapping = {}

    // 获取 asset 中文件对应的文件夹
    const dir = path.dirname(asset.filename)

    // 每一个文件都会被 parse 出一个 dependencise,他是一个数组,在以前的函数中已经讲到
    // 所以,咱们要遍历这个数组,将有用的信息所有取出来
    // 值得关注的是 asset.idMapping[dependencyPath] = denpendencyAsset.id 操做
    // 咱们往下看
    asset.dependencies.forEach(dependencyPath => {
      // 获取文件中模块的绝对路径,好比 import ABC from './world'
      // 会转换成 /User/xxxx/desktop/xproject/world 这样的形式
      const absolutePath = path.resolve(dir, dependencyPath)

      // 解析这些依赖
      const denpendencyAsset = parseDependecies(absolutePath)

      // 获取惟一 id
      const id = denpendencyAsset.id

      // 这里是重要的点了,咱们解析每解析一个模块,咱们就将他记录在这个文件模块 asset 下的 idMapping 中
      // 以后咱们 require 的时候,可以经过这个 id 值,找到这个模块对应的代码,并进行运行
      asset.idMapping[dependencyPath] = denpendencyAsset.id

      // 将解析的模块推入 graph 中去
      graph.push(denpendencyAsset)
    })
  }

  // 返回这个 graph
  return graph
}

接下来,咱们对模块进行更高级的处理。咱们以前已经写了一个 parseDependecies 函数,那么如今咱们要来写一个 parseGraph 函数,咱们将全部文件模块组成的集合叫作 graph(依赖图),用于描述咱们这个项目的全部的依赖关系,parseGraphentry (入口) 出发,一直手机完全部的以来文件为止.

在这里咱们使用 for of 循环而不是 forEach ,缘由是由于咱们在循环之中会不断的向 graph 中,push 进东西,graph 会不断增长,用 for of 会一直持续这个循环直到 graph 不会再被推动去东西,这就意味着,全部的依赖已经解析完毕,graph 数组数量不会继续增长,可是用 forEach 是不行的,只会遍历一次。

for of 循环中,asset 表明解析好的模块,里面有 filename , code , dependencies 等东西 asset.idMapping 是一个不太好理解的概念,咱们每个文件都会进行 import 操做,import 操做在以后会被转换成 require 每个文件中的 requirepath 其实会对应一个数字自增 id,这个自增 id 其实就是咱们一开始的时候设置的 id,咱们经过将 path-id 利用键值对,对应起来,以后咱们在文件中 require 就可以轻松的找到文件的代码,解释这么啰嗦的缘由是每每模块之间的引用是错中复杂的,这恰巧是这个概念难以解释的缘由。

最后,生成 bundle

function build(graph) {
  // 咱们的 modules 就是一个字符串
  let modules = ''

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

  const wrap = `
  (function(modules) {
    function require(id) {
      const [fn, idMapping] = modules[id];
      function childRequire(filename) {
        return require(idMapping[filename]);
      }
      const newModule = {exports: {}};
      fn(childRequire, newModule, newModule.exports);
      return newModule.exports
    }
    require(0);
  })({${modules}});` // 注意这里须要给 modules 加上一个 {}
  return wrap
}

// 这是一个 loader 的最简单实现
function loader(filename, code) {
  if (/index/.test(filename)) {
    console.log('this is loader ')
  }
  return code
}

// 最后咱们导出咱们的 bundler
module.exports = entry => {
  const graph = parseGraph(entry)
  const bundle = build(graph)
  return bundle
}

咱们完成了 graph 的收集,那么就到咱们真正的代码打包了,这个函数使用了大量的字符串处理,大家不要以为奇怪,为何代码和字符串能够混起来写,若是你跳出写代码的范畴,看咱们的代码,实际上,代码就是字符串,只不过他经过特殊的语言形式组织起来而已,对于脚本语言 JS 来讲,字符串拼接成代码,而后跑起来,这种操做在前端很是的常见,我认为,这种思惟的转换,是拥有自动化、工程化的第一步。

咱们将 graph 中全部的 asset 取出来,而后使用 node.js 制造模块的方法来将一份代码包起来,我以前作过一个《庖丁解牛:教你如何实现》node.js 模块的文章,不懂的能够去看看,https://zhuanlan.zhihu.com/p/...

在这里简单讲述,咱们将转换好的源码,放进一个 function(require,module,exports){} 函数中,这个函数的参数就是咱们随处可用的 requiremodule,以及 exports,这就是为何咱们能够随处使用这三个玩意的缘由,由于咱们每个文件的代码终将被这样一个函数包裹起来,不过这段代码中比较奇怪的是,咱们将代码封装成了 1:[...],2:[...]的形式,咱们在最后导入模块的时候,会为这个字符串加上一个 {},变成 {1:[...],2:[...]},你没看错,这是一个对象,这个对象里用数字做为 key,一个二维元组做为值:

  • [0] 第一个就是咱们被包裹的代码
  • [1] 第二个就是咱们的 mapping

立刻要见到曙光了,这一段代码实际上才是模块引入的核心逻辑,咱们制造一个顶层的 require 函数,这个函数接收一个 id 做为值,而且返回一个全新的 module 对象,咱们倒入咱们刚刚制做好的模块,给他加上 {},使其成为 {1:[...],2:[...]} 这样一个完整的形式。

而后塞入咱们的当即执行函数中(function(modules) {...})(),在 (function(modules) {...})() 中,咱们先调用 require(0),理由很简单,由于咱们的主模块永远是排在第一位的,紧接着,在咱们的 require 函数中,咱们拿到外部传进来的 modules,利用咱们一直在说的全局数字 id 获取咱们的模块,每一个模块获取出来的就是一个二维元组。

而后,咱们要制造一个 子require,这么作的缘由是咱们在文件中使用 require 时,咱们通常 require 的是地址,而顶层的 require 函数参数时 id
不要担忧,咱们以前的 idMapping 在这里就用上了,经过用户 require 进来的地址,在 idMapping 中找到 id

而后递归调用 require(id),就可以实现模块的自动倒入了,接下来制造一个 const newModule = {exports: {}};,运行咱们的函数 fn(childRequire, newModule, newModule.exports);,将应该丢进去的丢进去,最后 return newModule.exports 这个模块的 exports 对象。

这里的逻辑其实跟 node.js 差异不太大。

最后写一点测试

测试的代码,我已经放在了仓库里,想测试一下的同窗能够去仓库中自行提取。

打满注释的代码也放在仓库了,点击地址

git clone https://github.com/Foveluy/roid.git
npm i
node ./src/_test.js ./example/index.js

输出

this is loader

hello zheng Fang!
welcome to roid, I'm zheng Fang

if you love roid and learnt any thing, please give me a star
https://github.com/Foveluy/roid

参考

  1. https://github.com/blackLearn...
  2. https://github.com/ronami/min...
相关文章
相关标签/搜索