手写 mini 打包工具

最近在看 webpack 的源码,所以先浏览了几遍中文官网,发现中文官网有很多错误的地方,给他们提了 pr 也没人合代码,下定决心弃掉中文官网,因此从英文官网开始看起,偶然间发现了一个 youtube 的视频Live Coding a Simple Module Bundler,这个视频的内容是讲解如何编写一个 mini 版本的 webpack。看起来很简单,但是很有趣,写篇读后感吧。

准备

首先,我们得知道什么是 module。module 就是对外形成封闭的作用域,通过一定的 API 去暴露内部细节,而且 module 是存在相互依赖关系的,所以我们需要以下的数据结构来表示 module。

{
  id: 0
  filename: 'file1.js',
  dependencies: []
}

dependencies 字段来收集依赖。

收集依赖

const fs = require('fs')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const { transformFromAst } = require('babel-core')
const path = require('path')
let aid = 0

const createAsset = (filename) => {
  const id = aid++
  const dependencies = []
  const content = fs.readFileSync(filename, 'utf-8')

  // 转化成ast
  const ast = babylon.parse(content, {
    sourceType: 'module'
  })
  
  // 查找出依赖的文件名
  traverse(ast, {
    ImportDeclaration ({ node }) {
      dependencies.push(node.source.value)
    } 
  })

  // 转化 ES6 的语法
  const { code } = transformFromAst(ast, null, {
    presets: ['env']
  })

  return {
    id,
    filename,
    dependencies,
    code
  }
}

我们得通过一个函数来收集依赖,命名为 createAsset,入参是文件路径,通过 fs.readFileSync 得到文件内容。这个时候,我们需要知道到底依赖了哪些其他模块,也就是检验 import 语法后的值,引入了 babylon 这个工具,将文件内容字符串转化成 AST,并且通过 babel-traverse 去遍历生成的 AST,在遍历的时候,就能找到依赖。其中由于我们模块使用了 ES6 的语法,所以用 babel 转化成 ES5 的语法,最后就得出了这个 module 的信息。

从上面看,这只是解析一个 module 的函数,由于 module 存在各种依赖的关系,我们怎么得到所有的模块的依赖关系呢。

// 生成 graph

const createGraph = (entry) => {
  // 先得到入口 module 的信息
  const mainAsset = createAsset(entry)
  const queue = [mainAsset]

  // 递归分析模块之间的依赖
  for (const asset of queue) {
    asset.mapping = {}
    const dirname = path.dirname(asset.filename)
    asset.dependencies.forEach((relativePath) => {
      const absolutePath = path.join(dirname, relativePath)

      // 得到当前模块 asset 依赖的模块
      const child = createAsset(absolutePath)
      asset.mapping[relativePath] = child.id

      // 递归分析 child 模块依赖的模块
      queue.push(child)
    })
  }
  return queue
}

可以看到上面的实现很巧妙,利用一个数组实现了递归的依赖收集。

bundle

得到了依赖图之后,我们就需要打包并且生成依赖了。

const bundle = (graph) => {
  let modules = ''
  graph.forEach((mod) => {
    modules += `${mod.id}: [
      function (module, exports, require) {
        ${mod.code}
      },
      ${JSON.stringify(mod.mapping)}
    ],
    `
  })
  const ret = `(function(modules){
    function require (id) {
      const [fn, mapping] = modules[id];
      function localRequire(name) {
        return require(mapping[name]);
      }
      let module = {
        exports: {},
        loaded: false
      };
      fn(module, module.exports, localRequire);
      
      module.loaded = true;
      return module.exports
    }
  
    require(0)
  })({${modules}});`

  fs.writeFile(path.join(__dirname, '../example/bundle.js'), ret)
}

最后输出一个拥有自执行函数的文件,生成的文件内容类似于如下:

(function(modules) {

function require (id) {
  const [fn, mapping] = modules[id];
  function localRequire(name) {
    return require(mapping[name]);
  }
  let module = {
    exports: {},
    loaded: false
  };
  fn(module, module.exports, localRequire);

  module.loaded = true;
  return module.exports
}
  // 程序启动
  require(0)

})({
  0: [
    function (module, exports, require) {
      var a = require('./a.js')
      console.log(a.name)
    }, 
    {
      './a.js': 1
    }
  ],
  1: [
    function (module, exports, require) {
      exports.name = 'jizhi'
    }, 
    {

    }
  ]
})

最后大工告成,完整的例子在这。