当我们说到打包时,我们在说什么

背景

从早期的 grunt (我接触前端开发的时候这个已经趋于没落) 到 gulp,再到如今的 webpack,rollup ,parcel,以及 snowpack,vite,如今的前端已经脱离了刀耕火种的时代,各种打包工具层出不穷。前端项目基本离不开打包这个过程。那么当我们说到打包(bundle)时,我们在说的是什么?

这里不对 webpack 或者这些打包工具的使用进行具体说明,本文想探究的是打包的本质。

从模块化说起

在没有模块化之前,前端开发时需要格外注意命名冲突以及文件之间的相互依赖,参考 https://github.com/seajs/seajs/issues/547#issue-11105836。

模块化实际上就是将程序分解成离散的功能块,使代码易于复用,维护和测试。如 less 中通过 @import 引入的样式,ES6 Module / CommonJs 引入的 Js 等都可以称为模块。

然而浏览器不支持模块化,因此我们需要一个打包工具,将代码中诸如 require, @import, import 进来的模块打包成一个或者多个文件(即 bundle)。

打包的本质

参考 webpack 的定义:

本质上,webpack 是一个现代 JavaScript 应用程序的静态模块打包器(module bundler)。当 webpack 处理应用程序时,它会递归地构建一个依赖关系图(dependency graph),其中包含应用程序需要的每个模块,然后将所有这些模块打包成一个或多个 bundle

而至于这个过程中,对代码进行压缩,混淆等等则是锦上添花的功能。

打包的过程

这里以 webpack 为例:

  1. 初始化:从配置文件和 Shell 语句中读取并合并配置参数
  2. 开始编译:根据上面得到的参数初始化 Compiler,加载插件(Plugins),执行 Compiler 的 run 方法开始编译
  3. 入口文件:配置的 entry
  4. 从入口文件开始,针对不同的模块使用对应的 Loader 编译,再找到该模块所依赖的模块,递归这个步骤
  5. 完成编译:经过上述步骤后得到每个模块编译后的内容以及相互之间的依赖关系
  6. 输出:根据依赖关系,将模块组合成一个个代码块(Chunk),最后输出成文件

实现一个简易的打包工具

参考 ronami/minipack 实现一个支持 ES Module 的简易打包工具(这里不考虑循环依赖等情况):

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

引入上述相关依赖:

  • babylon:生成 AST(抽象语法树)
  • babel-traverse:来分析文件的依赖
  • transformFromAst:将 AST 转为 ES5

首先 定义一个函数用于读取文件的内容和依赖:

/**
 * 读取文件内容和依赖
 */
function createAsset(filePath) {
	// 读取文件内容
  const content = fs.readFileSync(filePath, 'utf-8')
  // 生成 AST
  const ast = babylon.parse(content, {
    sourceType: 'module',
  })
  // 当前文件的依赖关系
  const dependencies = []
  traverse(ast, {
    // 当遇到导入的声明时,将其内容 push 到 dependencies 里
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value)
    },
  })
  // 将 AST 转为 ES5
  const { code } = transformFromAst(ast, null, {
    presets: ['env'],
  })
  return {
    filePath,
    dependencies,
    code,
  }
}

Asset 接口如下:

interface Assets {
	// 文件路径
  filePath: string;
  // 文件的依赖
  dependencies: string[];
  // 转为 ES5 后的文件内容
  code: string;
  // 相对路径
  relativePath?: string;
}

例如,我们现在有个文件 entry.js ,内容如下:

// entry.js
import a from './a.js'
console.log(a)

运行 createAsset('./entry.js') 将会返回:

{
  filePath: './entry.js',
  dependencies: [ './a.js' ],
  code: `"use strict";

var _a = require("./a.js");

var _a2 = _interopRequireDefault(_a);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

console.log(_a2.default);`
}

接着定义一个函数用于构建依赖关系图( dependency graph ):

/**
 * 构建依赖关系图
 */
function createGraph(entry) {
  // 从入口文件开始
  const entryAsset = createAsset(entry);
  // 初始时,queue 中只有 entryAsset, 之后分析依赖关系,会将新的 Asset push 到 queue 中用于分析,直到分析完全部依赖
  const queue = [entryAsset]
  // 遍历所有文件依赖关系
  for (const asset of queue) {
    // 获得文件目录
    const dirname = path.dirname(asset.filePath)
    // 遍历当前文件依赖关系
    asset.dependencies.forEach((relativePath) => {
      // 获得绝对路径
      const absolutePath = path.join(dirname, relativePath)
      const childAsset = createAsset(absolutePath)
      childAsset.relativePath = relativePath
      // 将当前文件所依赖的文件的 Asset 也 push 到 queue 中用于遍历
      queue.push(childAsset)
    })
  }
  return queue
}

例如,在上面的基础上,有个 a.js 以及 b.js 文件, 运行 createGraph会返回:

// a.js
import b from './b.js'
const a = '1'
export default a

// b.js
const b = '2'
export default b

// createGraph('./entry.js')
[
  {
    filePath: './entry.js',
    dependencies: [ './a.js' ],
    code: `"use strict";

var _a = require("./a.js");

var _a2 = _interopRequireDefault(_a);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

console.log(_a2.default);`
  },
  {
    filePath: 'a.js',
    dependencies: [ './b.js' ],
    code: `"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});

var _b = require("./b.js");

var _b2 = _interopRequireDefault(_b);

function _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }

var a = '1';

exports.default = a;`,
    relativePath: './a.js'
  },
  {
    filePath: 'b.js',
    dependencies: [],
    code: `"use strict";

Object.defineProperty(exports, "__esModule", {
  value: true
});
var b = '2';

exports.default = b;`,
    relativePath: './b.js'
  }
]

最后实现一个打包函数:

function bundle(entry) {
  const graph = createGraph(entry)
  let modules = ''
  // 构建函数参数
  graph.forEach((mod) => {
    const filePath = mod.relativePath || entry
    // 拼装成 modules
    modules += `'${filePath}': (
      function (module, exports, require) { ${mod.code} }
    ),`
  })
  // 最终结果
  const result = `
    (function(modules) {
			// ast 转 es5 的代码是 commonjs 风格,而浏览器不支持 commonjs,因此自定义 require,以文件名作为 id
      function require(id) {
        const module = { exports : {} }
        modules[id](module, module.exports, require)
        return module.exports
      }
			// 引入入口文件代码
      require('${entry}')
    })({${modules}})
  `
  // 当生成的内容写入到文件中
  fs.writeFileSync('./bundle.js', result)
} 

最终输出一个 IIFE:

;(function (modules) {
  function require(id) {
    const module = { exports: {} }
    modules[id](module, module.exports, require)
    return module.exports
  }
  require('./entry.js')
})({
  './entry.js': function (module, exports, require) {
    'use strict'

    var _a = require('./a.js')

    var _a2 = _interopRequireDefault(_a)

    function _interopRequireDefault(obj) {
      return obj && obj.__esModule ? obj : { default: obj }
    }

    console.log(_a2.default)
  },
  './a.js': function (module, exports, require) {
    'use strict'

    Object.defineProperty(exports, '__esModule', {
      value: true,
    })

    var _b = require('./b.js')

    var _b2 = _interopRequireDefault(_b)

    function _interopRequireDefault(obj) {
      return obj && obj.__esModule ? obj : { default: obj }
    }

    var a = '1'

    exports.default = a
  },
  './b.js': function (module, exports, require) {
    'use strict'

    Object.defineProperty(exports, '__esModule', {
      value: true,
    })
    var b = '2'

    exports.default = b
  },
})