上一篇: 实现一个打包工具 2下一篇: 前端动画解决方案

实现一个打包工具


模块化发展

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 才有的, 浏览器环境都不存在, 那么打包工具需要提供这些辅助代码, 所以打包后的 square.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 的路径却不同, 为了解决这种问题可以得到模块的 绝对路径, 并将其作为 key 放到全局的 modMap 中, 然后每个模块要添加自己的依赖映射表 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 = {};

  // 通过 id 获取模块对应的 exports
  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. 遍历, 生成目标代码sourceMap
第 1 步

定义配置文件 packer.config.js 格式规范, 借鉴 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 (file) {
      /*对扩展名为 .less 或 .css 的文件设置自定义预编译器, 并将转换后结果返回*/
    },
    js: function (file) {
      /*同上*/
    },
  }
}
第 2 ~ 7 步

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

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

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

    Object.defineProperty(this, 'rawContent', {
      writable: false,
      value: content,
      enumerable: false,
    })
  }

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

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

    if (typeof id !== 'number')
      throw new TypeError('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 (cfg) {
    super();

    this._check(cfg);
    this.cfg = cfg;
    this._extensions = cfg.extensions;
    this._aliasMap = cfg.alias;
    this.id = 0;

    this.fileMap = {};
    this.fileList = [];
    this._bundle();
  }

  _bundle () {
    this.loadDep(cfg.entry);
    this._generate();
  }

  _check () {
    /*对 packer.config.js 中参数格式检查*/
  }

  loadDep (fullPath) {
    /*解析依赖图*/
  }

  _generate () {
    /*打包、合并*/
  }
}
loadDep

loadDep 是整个打包器的核心, 它直接决定打包流程, 其中很重要的一环是 解析, 提取 js 文件中的 require('****') 的路径直接用正则是不现实的, 因为 require 可能出现在 字符串、注释中, 正则无法判断解析位置的状态, 这里使用 Babel 的解析器 babylon 将其解析成 抽象语法树 (AST) 再对其遍历

使用 写一个模板引擎 的方法来提取 require('****'), 性能会远高于解析器生成 AST, 但为了方便就直接用解析器来做

单独创建 parse.js, 后面不管如何实现 parse.js, 反正这个模块只负责对传入的代码识别 require 并返回路径组成的数组, 有没有发现这样 解耦 之后逻辑清晰多了 😀

代码如下 ⬇︎:

// parse.js

const babylon = require('@babel/parser');
const traverse = require('@babel/traverse').default;

module.exports = function (code) {
  const ast = babylon.parse(code);

  let dirs = [];
  traverse(ast, {
    CallExpression (path) {
      let node = path.node;
      let callee = node.callee;
      let args = node.arguments;
      let arg = args[0];

      if (callee.name === 'require'
        && args.length === 1
        && arg.type === 'StringLiteral'
      ) {
        dirs.push(arg.value.trim());
      }
    }
  });

  return dirs;
}

简单测试下:

// test.js

const parse = require('../lib/parse');

const code = `"
  var ajax = require("ajax");
  var res = ajax({
    url: 'abc.com/list',
    data: require('../data/baseinfo'), // require('../user-info')
    success: res => {
      if (res.code === 0) {
        let html = require('js/tpl/product/list')(res.data);
        console.log(html)
      }
    }
  })

  console.log(require('api'))
`;


console.log(parse(code));
node test.js
[
  'ajax',
  '../data/baseinfo',
  'js/tpl/product/list',
  'api'
]

Perfect !

现在可以根据前面列出的步骤写出 loadDep 方法

class Packer {
  /* ... */
  loadDep (requiredPath, lastFile = null) {
    if (!requiredPath) return;

    let fullPath = requiredPath;
    if (lastFile) {
      let dir = path.dirname(lastFile.fullPath);
      fullPath = path.join(dir, requiredPath);
    }

    if (fullPath in this.fileMap) {
      let id = this.fileMap[fullPath].id;
      lastFile && lastFile.addDepMap(id, requiredPath);
      return;
    }

    lastFile && lastFile.addDepMap(++this.id, requiredPath);

    let content = fs.readFileSync(fullPath);   // 先读取文件内容
    let file = this.fileMap[fullPath] = new File(fullPath, this.id, content);
    this.fileList.push(file);

    let pathList;
    try {
      pathList = parse(content + '');
    } catch (e) {
      e.message = `${fullPath} parse error!\n`
      console.error(e);
      return;
    }

    pathList.forEach(p => this.loadDep(p, file));
  }
}

执行 loadDep 后打印一下 this.fileList 里面是什么:

[{
  id: 0,
  content: 'var multiple = require(\'./multiple\');\nvar square = require(\'./square.js\');\nvar a = multiple(3, 5);\nvar b = square(a);\n\nconsole.log(b);',
  fullPath: '/Users/flfwzgl/Documents/packer/test/src/index.js',
  deps: [ 1, 2 ],
  map: { './multiple': 1, './square.js': 2 }
}, {
  id: 1,
  content: 'module.exports = function (a, b) {\n  return a * b;\n}',
  fullPath: '/Users/flfwzgl/Documents/packer/test/src/multiple.js',
  deps: [],
  map: {}
}, {
  id: 2,
  content: 'var multiple = require(\'./multiple\');\nmodule.exports = function (n) {\n  return multiple(n, n);\n}',
  fullPath: '/Users/flfwzgl/Documents/packer/test/src/square.js',
  deps: [ 1 ],
  map: { './multiple': 1 }
}]
_generate

现在只需遍历 fileList 并按照上面 输出的立即调用表达式 的格式组装代码即可, 组装单独放到 _generate 方法完成

_generate 示例代码如下:

let template = `';(function (modMap, entryList) {
  var cacheMap = {};

  // 通过 id 获取模块对应的 exports
  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);
})({
/*mods*/
});`

class Packer {
  /* ... */
  _generate () {
    let res = this.fileList.map(file => {
      return `  ${file.id}: {
    fn: function (require, module, exports) {
${file.content + ''}
    },
    depMap: ${JSON.stringify(file.map)}
  }`
    });

    let code = template.replace('/*mods*/', res.join(',\n'))

    console.log(code);
  }
}

运行下看最终输出是什么样, 结果如下图所示 ⬇︎:

上图是打包后生成的立即调用表达式, 复制到控制台阔以正常运行 😄 🎉。 图中虚线部分是每个模块实际的代码, 纯打包的情况下打包工具尽可能保持模块内代码不变, 如果盲目对其缩进则可能导致反引号多行字符串出错

至此, 一个打包工具最基本的功能已经完成, 下面会在此基础上使其功能丰富起来

辅助功能实现

  1. 扩展名省略
  2. 模块别名
  3. 转换器
  4. 版本号添加
1-2. 扩展名省略、模块别名

loadDep 中 第 6 ~ 10 行中的 fullPath 只是简单地相对于 lastFile 设置, 但路径可能是 模块名、相对路径、绝对路径, 或者是通过 config.extensions 省略掉了扩展名, 还可能是 config.alias 里面的别名, 所以这里需要做一个判断

假设 config.extensionsconfig.alias 是如下配置:

module.exports = {
  extensions: ['.js', '/index.js', '.ejs', '/index.ejs'], // 只要路径没有扩展名, 都逐个尝试, 直到找到位置
  alias: {
    'vue$': 'vue/dist/vue.esm.js',  // 用 vue 或 vue/ 完全匹配
    '@': './src',                   // 用 @ 和 @/ 完全匹配 或者 用 @/ 匹配开头
    'π/': './common/a',             // 如果是 / 结尾, 则用 'π//' 完全匹配 或 匹配开头', require('π/') 无效, require('π//') 有效
    '/$': '.src/abc'                // 严格匹配 //, 例如 require('//')
  },
}

因此 config.alias 需要提前做处理, 在 Packerconstructor 中添加一个 _initPathMatch 方法来提前设置一个 this._aliasList 以便匹配的时候使用:

class Packer {
  constructor (cfg) {
    /* ... */
    this._extensions = cfg.extensions;
    this._aliasMap = cfg.alias;

    this._initPathMatch();

    this.fileMap = {};
    this.fileList = [];
    this._bundle();
    /* ... */
  }

  _bunle () {
    this.loadDep(cfg.entry);
  }

  _initPathMatch () {
    this._extensionList = this._extensions.filter(e => !!e.trim());
    this._aliasList = Object.keys(this._aliasMap).filter(e => !!e.trim()).map(key => {
      let alias = key.trim()
        , ch = alias.charAt(alias.length - 1)
        , rstr
        , rPrefix
        , substituent = this._aliasMap[key]

      if (ch === '$') {
        alias = alias.substring(0, alias.length - 1);
        ch = alias.charAt(alias.length - 1);

        rstr = ch === '/'
          ? '^' + alias + '/$'
          : '^' + alias + '/?$'
      } else {
        rstr = ch === '/'
          ? '^' + alias + '/$|^' + alias + '/'
          : '^' + alias + '/?$|^' + alias + '/'
      }

      rPrefix = new RegExp(rstr);

      return {
        rPrefix,
        substituent
      }
    });
  }
}

在执行 this._initPathMatch 之后得到如下结果的 this._aliasList:

{
  'vue$': 'vue/dist/vue.esm.js',
  '@': './src',
  'π/': './common/a',
  '/$': '.src/abc'
}
  ⬇︎
[{
  rPrefix: /^vue\/?$/,
  substituent: 'vue/dist/vue.esm.js'
}, {
  rPrefix: /^@\/?$|^@//,
  substituent: './src'
}, {
  rPrefix: /^π\/\/$|^π\//,
  substituent: './common/a'
}, {
  rPrefix: /^\/\/$/,
  substituent: '.src/abc'
}]

改造后的代码如下:

class Packer {
  /* ... */
  loadDep (requiredPath, lastFile = null) {
    if (!requiredPath) return;

    let fullPath = this._getFullPath(requiredPath, lastFile);

    if (fullPath in this.fileMap) {
      let id = this.fileMap[fullPath].id;
      lastFile && lastFile.addDepMap(id, requiredPath);
      return;
    }

    lastFile && lastFile.addDepMap(++this.id, requiredPath);

    let content = fs.readFileSync(fullPath);  // 先读取文件内容
    let file = this.fileMap[fullPath] = new File(fullPath, this.id, content);
    this.fileList.push(file);

    let pathList;
    try {
      pathList = parse(content + '');
    } catch (e) {
      e.message = `${fullPath} parse error!\n`
      console.error(e);
      return;
    }

    pathList.forEach(p => this.loadDep(p, file));
  }

  _getFullPath (requiredPath, lastFile = null) {
    requiredPath = requiredPath.trim();

    // 入口文件情况
    if (!lastFile) {
      try {
        return require.resolve(requiredPath);
      } catch (err) {
        err.message = `Can't resolve '${requiredPath}'\n`.red;
        throw err;
      }
    }

    let fullPath = requiredPath;

    for (let e of this._aliasList) {
      let stop = false;
      fullPath = requiredPath.replace(e.rPrefix, _ => {
        stop = true;
        e.substituent
      });
      if (stop) break;
    }

    if (/^\.{1,2}\//.test(fullPath)) {
      let dir = path.dirname(lastFile.fullPath);
      fullPath = path.join(dir, fullPath);
      try {
        return require.resolve(fullPath);
      } catch (err) {}
    }

    try {
      return require.resolve(fullPath);
    } catch (err) {}

    if (!path.extname(fullPath)) {
      for (let ext of this._extensionList) {
        try {
          let tmp = fullPath + ext;
          return require.resolve(tmp);
        } catch (err) {}
      }
    }

    throw new Error(`Can't resolve '${requiredPath}' in '${lastFile.fullPath}'\n`.red)
  }
}
3. 转换器

这里的 转换器 对应 webpack 中的 loader, 可以像 webpack 那样制定一套标准, 使 转换器 作为独立的 node_module, 打包时候根据 config.convert 传入的模块名加载对应转换器并对代码转换

但这里我想让它灵活性变得更强, 所以任何代码的转译都让用户在 packer.config.js 中手动实现, 匹配到扩展名之后自动执行对应的函数, 并把前面的 file 对象传入, 例子如下:

module.exports = {
  entry: path.resolve('test/src/index.js'),
  output: {
    path: 'dist',
    filename: 'a.js'
  },

  extensions: ['.js', '/index.js', '.ejs', '/index.ejs'],

  convert: {
    js: function (file) {
      /* 自由转换 */
    }
  }
}

除此之外, loadDep 只会对 jsjsxtstsxcoffeecjsx 这几种扩展名解析依赖图, 对应 lessscss 等非 js预编译语言则直接用对应的转换器解析依赖图, 所以 parse 之前应该判断扩展名,

代码修改如下 ⬇︎:

class Packer {
  constructor (cfg) {
    /* ... */
    this._extensions = cfg.extensions || [];
    this._aliasMap = cfg.alias || {};
    this._convertMap = cfg.convert || {};

    this._initPathMatch();
    this._initConvert();

    this.fileMap = {};
    this.fileList = [];
    this.loadDep(cfg.entry);
    /* ... */
  }

  _initConvert () {
    /**
     * {js: fn, 'less|css': fn}
     * ⬇︎
     * [{
     *   rext: /\.js$/,
     *   converter: fn
     * }, {
     *   rext: /\.(?:less|css)$/
     *   converter: fn
     * }]
     */
    this._converterList = Object.keys(this._convertMap).filter(e => !!e.trim()).map(key => {
      let ext = key.trim().replace(/^\|+|\|+$/g, ''), rext

      rext = ~ext.indexOf('|')
        ? new RegExp('\.(?:' + ext + ')$')
        : new RegExp('\.' + ext + '$')

      return { rext, converter: this._convertMap[key] }
    })
  }

  loadDep (requiredPath, lastFile = null) {
    if (!requiredPath) return;

    let fullPath = this._getFullPath(requiredPath, lastFile);

    if (fullPath in this.fileMap) {
      let id = this.fileMap[fullPath].id;
      lastFile && lastFile.addDepMap(id, requiredPath);
      return;
    }

    lastFile && lastFile.addDepMap(++this.id, requiredPath);

    let content = fs.readFileSync(fullPath);  // 先读取文件内容
    let file = this.fileMap[fullPath] = new File(fullPath, this.id, content);
    this.fileList.push(file);

    if (this._converterList.length) {
      for (let {rext, converter} of this._converterList) {
        if (rext.test(file.ext)) {
          converter.call(this, file);
          break;
        }
      }
    }

    if (!/^\.(?:jsx?|tsx?|coffee|cjsx)$/.test(file.ext))
      return;

    let pathList;
    try {
      pathList = parse(file.content + '');
    } catch (e) {
      e.message = `${fullPath} parse error!\n`
      throw e;
    }

    pathList.forEach(p => this.loadDep(p, file));
  }
}
async/await 让转换器支持异步操作
class Packer {
  async _bundle () {
    await this.loadDep(cfg.entry);
    await this._generate();
  }

  async loadDep () {
    if (!requiredPath) return;

    let fullPath = this._getFullPath(requiredPath, lastFile);

    if (fullPath in this.fileMap) {
      let id = this.fileMap[fullPath].id;
      lastFile && lastFile.addDepMap(id, requiredPath);
      return;
    }

    lastFile && lastFile.addDepMap(++this.id, requiredPath);

    let content = fs.readFileSync(fullPath);  // 先读取文件内容
    let file = this.fileMap[fullPath] = new File(fullPath, this.id, content);
    this.fileList.push(file);

    if (this._converterList.length) {
      for (let {rext, converter} of this._converterList) {
        if (rext.test(file.ext)) {
          await converter.call(this, file);
          break;
        }
      }
    }

    if (!rParseExt.test(file.ext))
      return;

    let pathList;
    try {
      pathList = parse(file.content + '');
    } catch (e) {
      e.message = `${fullPath} parse error!\n`
      throw e;
    }

    for (let p of pathList) {
      await this.loadDep(p, file);
    }
  }
}

现在完善转换器:

const path = require('path');
const {transformSync} = require("@babel/core");
const less = require('less');

module.exports = {
  entry: path.resolve('test/converter/index.js'),
  output: {
    path: 'dist',
    filename: 'bundle.js'
  },
  convert: {
    js (file) {
      if (/node_modules/.test(file.fullPath)) return;
      return new Promise((r, j) => {
        transform(file.rawContent, {
          presets: ["@babel/preset-env"],
          plugins: [
            "@babel/plugin-transform-runtime",
          ]
        }, (err, res) => {
          if (err) j(err);

          let {code} = res;
          file.content = code;
          r();
        });
      });
    },
    async 'css|less' (file) {
      let text = file.content;
      if (file.ext === '.less')
        text = await less.render(text);

      // css文件模块还要单独处理
      file.content = `var el = document.createElement('style');
  style.textContent = "${text.replace(/"/g, '\"')}";
  document.getElementsByTagName('head')[0].appendChild(el);`
    }
  }
}
计算文件md5

为了最大限度地提升性能, 每个文件需要独立的 md5, 新建 md5.js:

// md5.js
const crypto = require('crypto');
module.exports = content => crypto.createHash('md5').update(content).digest('hex');

改造一下 File 类, 让 hash 属性作为计算属性, 以便需要的时候直接获取:

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

module.exports = class File {
  constructor (fullPath, id, content) {
    /* ... */
  }

  get hash () {
    return md5(this.content);;
  }

  /* ... */
}

多入口文件支持

一个应用可能有多个入口文件, 就好比 html 中有多个 script 标签并且它们可能都有自己的依赖, 依赖还可能重复。比如第一二个 script 标签是基础库 jQuerylodash, 或者是一些 polyfill 文件如 core-js

针对这种场景, 需要让 packer 支持多入口文件, 改造代码如下:

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

    this._check(cfg);
    this.cfg = cfg;
    this._extensions = cfg.extensions;
    this._aliasMap = cfg.alias;
    this.id = 0;

    this.fileMap = {};
    this.fileList = [];
    this._bundle();
  }

  _bundle () {
    let entry = this._entry;
    if (typeof entry === 'string') {
      await this.loadDep(entry);
    } else if (Array.isArray(entry)) {
      entry.forEach((e, i) => {
        if (typeof e !== 'string')
          throw new Error(`the ${i}th entry must be a string!`);
      });

      for (let e of entry)
        await this.loadDep(entry);
    } else if (entry && typeof entry === 'object') {
      // 多页应用情况 
    }

    let code = this._generate();
    console.log(code);
  }

  async loadDep (requiredPath, lastFile = null) {
    if (!requiredPath) return;

    let fullPath = this._getFullPath(requiredPath, lastFile);

    if (fullPath in this.fileMap) {
      let id = this.fileMap[fullPath].id;
      lastFile && lastFile.addDepMap(id, requiredPath);
      return;
    }

    lastFile && lastFile.addDepMap(++this.id, requiredPath);
    if (lastFile) {
      lastFile.addDepMap(++this.id, requiredPath);
    } else {
      this.entries.push(this.id);
    }

    let content = fs.readFileSync(fullPath);  // 先读取文件内容
    let file = this.fileMap[fullPath] = new File(fullPath, this.id, content);
    this.fileList.push(file);

    if (this._converterList.length) {
      for (let {rext, converter} of this._converterList) {
        if (rext.test(file.ext)) {
          await converter.call(this, file);
          break;
        }
      }
    }

    if (!rParseExt.test(file.ext))
      return;

    let pathList;
    try {
      pathList = parse(file.content + '');
    } catch (e) {
      e.message = `${fullPath} parse error!\n`
      throw e;
    }

    for (let p of pathList) {
      await this.loadDep(p, file);
    }
  }

  _generate () {
    let res = this.fileList.map(file => {
      return `  ${file.id}: {
    fn: function (require, module, exports) {
${file.content.toString().cyan}
    },
    depMap: ${JSON.stringify(file.map).cyan}
  }`
    });

    let code = template.replace('/*mods*/', res.join(',\n'))
      .replace('/*entries*/', JSON.stringify(this.entries))
  }
}

并且 IIFE 的模板也要做修改:

let template = `';(function (modMap, entryList) {
  var cacheMap = {};

  // 通过 id 获取模块对应的 exports
  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;
  }

  var entries = /*entries*/;
  for (var i = 0, l = entries.length; i < l; i++)
    getExports(i);
})({
/*mods*/
});`
我突然发现这篇文章怎么超长了

待解决问题

  1. 热模块替换(HMR)
  2. sourceMap
  3. 多页应用支持, 抽离公共模块、样式文件
  4. 推送更新

这些问题会在下一篇 实现一个打包工具 2 中得到解决

上一篇: 实现一个打包工具 2下一篇: 前端动画解决方案