下一篇: 前端动画解决方案

实现一个简单的打包工具


模块化发展

JavaScript 一直因为缺少很多高级特性而被人诟病, 因为缺乏对 模块化开发 的支持, 所以一直很难开发大型应用。在2005年, 谷歌率先在地图中大量使用 Ajax 来无刷新请求数据, 之后的几年大量网站使用此方法来局部更新页面以提升用户体验, 随着 HTML5 规范草案的提出, web应用开始朝 前后端分离 方向发展, 前端开发开始扮演更重要的角色, 而 模块化开发 就是首先要解决的问题

一个页面中不可能手动堆加太多 script 标签, 这样既难管理依赖又容易污染变量且复用性很低, 防止变量污染的方案通常用 命名空间 来区分

例如:

window.app = {
  info: {
    cart: {
      list: [{
        name: 'iphone 5s',
        desc: 'iPhone5s是苹果公司在2013年9月推出的一款手机' 
      }]
    }
  }
}

上面代码中访问数据可能需要 app.info.cart.list[0].desc, 由于开发和维护比较麻烦且容易出错, 所以不是推荐方案

模块 是最小的可复用的独立单元, 它解决了 命名空间 的问题, 代码解耦后的模块更新起来非常容易, 显式模块模式 (Revealing Module Pattern) 也是以前的一个常用方案, 如下图 ⬇︎

// 利用立即调用表达式函数作用域防止变量污染
var mod = (function () {
  var privateName = 'jack';

  function setName (name) {
    privateName = name;
  }

  return {
    setName: setName
  }
})();

mod.setName('jay');

这种写法可以声明私有属性或方法, 然后暴露公共方法到外部, 它允许一个文件中声明多个模块, 但问题在于无法程序化地通过 require 引入这种模块

CommonJS

随着模块化问题的痛点越发明显, CommonJS 规范应运而生, 它规定用 requireexports 来和其他系统交互, require 可引入其他模块的变量 exports, 从一开始 CommonJs 就是为服务端开发所设计, 并且它也是同步加载模块

Nodejs 基本完全遵守 CommonJS 规范, 唯一不同点在于 Nodejs 导出使用的 module.exports, 而 CommonJS 仅仅只是 exportsNodejs 的模块化实现也是同步引入, 并支持循环引用, 但浏览器对 CommonJS 不支持

AMD (Asynchronous Module Definition)

AMD 的出现是为了解决异步加载模块和浏览器支持的问题, 它规定依赖的模块必须提前声明, 并且异步地去拉取依赖, 毁掉的执行一定是在所有依赖加载之后, 此规范主要是以 RequireJS 为代表

require.js 示例代码 ⬇︎

define(?id, [dep1, dep2, dep3, dep4], function (dep1, dep2, dep3, dep4) {
  /*模块代码*/
})

这样解决了变量污染和依赖的问题, 并且允许模块同步引入, 结合自带命令行 r.js 可将依赖文件打包到一个 js 中, 无需异步逐个拉取。 但问题在于, 如果依赖太多, 对应的写法很麻烦, 而且, 这是 AMD 规范, 不能直接引用基于 CommonJS 规范模块

打包工具实现

如果一套系统前端用的 AMD 后端用的 CommonJS, 同是 JavaScript 但却要用到两种模块化开发方式, 写起来会不会很别扭, 并且, 前后端统一用 CommonJS 方式开发可以公用一些模块, 提升代码的复用性

原理

要实现一个功能丰富、高性能、扩展性强的打包器是需要很多资深开发者花费大量精力来完成, 但学习其基本原理其实并不难, 打包工具实质上是对 数据结构进行 深度优先遍历 (DFT), 遍历过程中进行 模块去重 以防止重复引入和执行, 结构具有的特点是 其中的节点可能存在多个父节点, 这正好适合用来描述文件的依赖关系。

假设 util 模块被模块 page 引入, 但 util 模块又引入了 ajaxmsg 模块, 而 ajax 也引入了 msg, 并且很多模块都依赖 events, 那么关系图如下 ⬇︎:

实现复杂模块通常先考虑输入和输出, 然后考虑内部的实现, 打包工具也不例外, 这个过程其实是制定 接口规范, 为了让打包工具具有很好的扩展性, 必须制定一套规范标准化插件。

打包工具都是从 入口文件 开始, 打包完成后返回 立即调用表达式 (IIFE, Immediately Invoked Function Expression), 这个 立即调用表达式 内部已包含了所有引入的模块

一个简单的例子:

// multiple.js
module.exports = function (a, b) {
  return a * b;
}


// square.js
var multiple = require('./multiple');
module.exports = function (n) {
  return multiple(n, n);
}


// entry.js
var multiple = require('./multiple');
var square = require('./square');
var a = multiple(3, 5);
var b = square(a);

console.log(b);

为了不污染全局变量, 每个模块都用闭包实现, 又因为 modulerequire 都是 CommonJS 才有的, 浏览器环境都不存在, 那么打包工具需要提供这些辅助代码, 所以打包后的 multiple.js 应该是 ⬇︎:

// square.js
function anonymous (require, module, module.exports) {
  var multiple = require('./multiple');
  module.exports = function (n) {
    return multiple(n, n);
  }
}

/**
 * multiple.js 和 entry.js 同理
 */

如果打包工具提供的 require 函数能每次直接获取到对应的模块, 那么整个 结构代码就可以按它已有的顺序直接执行, 所以只需要提前分析每个文件中的 require('****') 关键字, 提取其中的路径, 然后找到对应文件并 递归 地分析, 前序遍历 依赖图的时候, 不断获取其依赖, 并把每个依赖的文件添加到 哈希表链表 中保存, 方便后续生成 立即调用表达式

针对不同文件使用不同的相对路径引入同一个模块的去重问题, 例如, 外部用 require('./util/ajax')util 中用 require('./ajax') 都是引入的同一文件, 但传给 require 的路径却不同, 为了解决这种问题可以使用依赖映射表 depMap, depMap 是每个模块都必须有的, 它直接记录当前模块中 require 的路径对应模块的 全局id, 全局id 从入口文件开始不断累加

添加了依赖之后的 square.js 用一个对象将模块再次包裹, 如下图:

// square.js
{
  fn: function (require, module, exports) {
    var multiple = require('./multiple');
    module.exports = function (n) {
      return multiple(n, n);
    }
  },
  depMap: {
    './multiple': n
  }
}

现在可以基本写出上面例子打包后输出的 立即调用表达式 ⬇︎:

;(function (modMap) {
  var cacheMap = {};

  function getExports (i) {
    if (i in cacheMap) return cacheMap[i].exports;

    var module = cacheMap[i] = {
      exports: {}
    }

    var mod = modMap[i];
    mod.fn.call(module.exports, function (path) {
      var id = mod.depMap[path];
      return getExports(id);
    }, module, module.exports);

    return module.exports;
  }

  getExports(0);
})({
  0: {
    fn: function (require, module, exports) {
      var multiple = require('./multiple');
      var square = require('./square');
      var a = multiple(3, 5);
      var b = square(a);

      console.log(b);
    },
    depMap: {
      './multiple': 1,
      './square': 2
    }
  },
  1: {
    fn: function (require, module, exports) {
      module.exports = function (a, b) {
        return a * b;
      }
    },
    depMap: {}
  },
  2: {
    fn: function (require, module, exports) {
      var multiple = require('./multiple');
      module.exports = function (n) {
        return multiple(n, n);
      }
    },
    depMap: {
      './multiple': 1
    }
  }
})

现在已经知道了打包工具的 输入 (入口文件)输出 (类似上面的 立即调用表达式), 打包工具参与的是中间的转换过程

给要实现的打包工具取名叫 packer 😄, 经过整理, 总结出 packer 做的事情主要是这些:

  1. 初始化配置文件 packer.config.js, 包含配置的格式校验、默认参数设置
  2. 根据配置文件指定 转换器 对模块代码转译 (从入口文件开始)
  3. 对每个模块添加统一编号
  4. 对不同相对路径引入的同一文件去重
  5. 分析模块中的依赖
  6. 每个模块都添加依赖映射表
  7. 递归执行 2 ~ 6
  8. 遍历, 生成目标代码
第 1 步

定义配置文件格式规范, 借鉴 webpack 并做了适当的修改

const path = require('path');

module.exports = {
  entry: path.resolve('test/entry.js'),   // 入口文件路径
  output: {
    path: 'dist',                         // 输出目录
    filename: 'bundle.js'                 // 打包后文件
  },
  extensions: ['.js', '/index.js', '.ejs', '/index.ejs'],   // 默认扩展名添加
  alias: {/*别名*/},
  convert: {
    'less|css': function (content) {
      /*对扩展名为 .less 或 .css 的文件设置自定义预编译器, 并将转换后结果返回*/
    },
    js: function (content) {
      /*同上*/
    },
  }
}
第 2 ~ 6 步

在这几步中会把所有相关的 js 文件用一个中间对象保存起来, 方便在最后代码生成阶段获取关键信息, 创建一个类来简单描述 js 文件:

const fs = require('fs');
const path = require('path');

module.exports = class File {
  constructor (id, content) {
    this.id = id;
    this.content = content;
    this.deps = [];
    this.map = {};
  }

  _addDep (id) {
    if (~this.deps.indexOf(id)) return;
    this.deps.push(id);
  }

  // 添加依赖映射
  addDepMap (id, requiredPath) {
    if (typeof requiredPath !== 'string')
      throw new Error('The path must be a string!');

    if (typeof id !== 'number')
      throw new Error('The id must be a number');

    this._addDep(id);
    this.map[requiredPath] = id;
  }
}

开始实现 packer 主框架, 我暂时的构想是, 希望每次打包创建一个 packer 对象以便管理内部各种状态, 因此主模块实际上返回一个 Packer 类, 只需在命令行文件 packer/.bin/cli.jsrequire 并实例化即可, 并且每个实例都基于事件

Packer 的主架构是:

class Packer extends Events {
  constructor (opt) {
    super();

    this._check(opt);
    this._opt = opt;

    this.parseDepGraph(opt.entry);
    this._bundle();
  }

  _check () {
    /*对config格式校验*/
  }

  parseDepGraph () {
    /*解析依赖图*/
  }

  _bundle () {
    /*开始打包、合并*/
  }
}
parseDepGraph
下一篇: 前端动画解决方案