打包工具运行原理你知道吗?

前端模块化成为了主流的今天,离不开各种打包工具的贡献。社区里面对于webpack,rollup以及后起之秀parcel的介绍层出不穷,对于它们各自的使用配置分析也是汗牛充栋。为了避免成为一位“配置工程师”,我们需要来了解一下打包工具的运行原理,只有把核心原理搞明白了,在工具的使用上才能更加得心应手。

本文基于parcel核心开发者@ronami的开源项目minipack而来,在其非常详尽的注释之上加入更多的理解和说明,方便读者更好地理解。

1、打包工具核心原理

顾名思义,打包工具就是负责把一些分散的小模块,按照一定的规则整合成一个大模块的工具。与此同时,打包工具也会处理好模块之间的依赖关系,最终这个大模块将可以被运行在合适的平台中。

打包工具会从一个入口文件开始,分析它里面的依赖,并且再进一步地分析依赖中的依赖,不断重复这个过程,直到把这些依赖关系理清挑明为止。

从上面的描述可以看到,打包工具最核心的部分,其实就是处理好模块之间的依赖关系,而minipack以及本文所要讨论的,也是集中在模块依赖关系的知识点当中。

为了简单起见,minipack项目直接使用ES modules规范,接下来我们新建三个文件,并且为它们之间建立依赖:

  1. /* name.js */

  2. export const name = 'World'

  1. /* message.js */

  2. import { name } from './name.js'

  3. export default `Hello ${name}!`

  1. /* entry.js */

  2. import message from './message.js'

  3. console.log(message)

它们的依赖关系非常简单: entry.js → message.js → name.js,其中 entry.js将会成为打包工具的入口文件。

但是,这里面的依赖关系只是我们人类所理解的,如果要让机器也能够理解当中的依赖关系,就需要借助一定的手段了。

2、依赖关系解析

新建一个js文件,命名为 minipack.js,首先引入必要的工具。

  1. /* minipack.js */

  2. const fs = require('fs')

  3. const path = require('path')

  4. const babylon = require('babylon')

  5. const traverse = require('babel-traverse').default

  6. const { transformFromAst } = require('babel-core')

接下来,我们会撰写一个函数,这个函数接收一个文件作为模块,然后读取它里面的内容,分析出其所有的依赖项。当然,我们可以通过正则匹配模块文件里面的 import关键字,但这样做非常不优雅,所以我们可以使用 babylon这个js解析器把文件内容转化成抽象语法树(AST),直接从AST里面获取我们需要的信息。

得到了AST之后,就可以使用 babel-traverse去遍历这棵AST,获取当中关键的“依赖声明”,然后把这些依赖都保存在一个数组当中。

最后使用 babel-core的 transformFromAst方法搭配 babel-preset-env插件,把ES6语法转化成浏览器可以识别的ES5语法,并且为该js模块分配一个ID。

  1. let ID = 0

  2. function createAsset (filename) {

  3.  // 读取文件内容

  4.  const content = fs.readFileSync(filename, 'utf-8')

  5.  // 转化成AST

  6.  const ast = babylon.parse(content, {

  7.    sourceType: 'module',

  8.  });

  9.  // 该文件的所有依赖

  10.  const dependencies = []

  11.  // 获取依赖声明

  12.  traverse(ast, {

  13.    ImportDeclaration: ({ node }) => {

  14.      dependencies.push(node.source.value);

  15.    }

  16.  })

  17.  // 转化ES6语法到ES5

  18.  const {code} = transformFromAst(ast, null, {

  19.    presets: ['env'],

  20.  })

  21.  // 分配ID

  22.  const id = ID++

  23.  // 返回这个模块

  24.  return {

  25.    id,

  26.    filename,

  27.    dependencies,

  28.    code,

  29.  }

  30. }

运行 createAsset('./example/entry.js'),输出如下:

  1. { id: 0,

  2.  filename: './example/entry.js',

  3.  dependencies: [ './message.js' ],

  4.  code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);' }

可见 entry.js文件已经变成了一个典型的模块,且依赖已经被分析出来了。接下来我们就要递归这个过程,把“依赖中的依赖”也都分析出来,也就是下一节要讨论的建立依赖关系图集。

3、建立依赖关系图集

新建一个名为 createGragh()的函数,传入一个入口文件的路径作为参数,然后通过 createAsset()解析这个文件使之定义成一个模块。

接下来,为了能够挨个挨个地对模块进行依赖分析,所以我们维护一个数组,首先把第一个模块传进去并进行分析。当这个模块被分析出还有其他依赖模块的时候,就把这些依赖模块也放进数组中,然后继续分析这些新加进去的模块,直到把所有的依赖以及“依赖中的依赖”都完全分析出来。

与此同时,我们有必要为模块新建一个 mapping属性,用来储存模块、依赖、依赖ID之间的依赖关系,例如“ID为0的A模块依赖于ID为2的B模块和ID为3的C模块”就可以表示成下面这个样子:

  1. {

  2.  0: [function A () {}, { 'B.js': 2, 'C.js': 3 }]

  3. }

搞清楚了个中道理,就可以开始编写函数了。

  1. function createGragh (entry) {

  2.  // 解析传入的文件为模块

  3.  const mainAsset = createAsset(entry)

  4.  // 维护一个数组,传入第一个模块

  5.  const queue = [mainAsset]

  6.  // 遍历数组,分析每一个模块是否还有其它依赖,若有则把依赖模块推进数组

  7.  for (const asset of queue) {

  8.    asset.mapping = {}

  9.    // 由于依赖的路径是相对于当前模块,所以要把相对路径都处理为绝对路径

  10.    const dirname = path.dirname(asset.filename)

  11.    // 遍历当前模块的依赖项并继续分析

  12.    asset.dependencies.forEach(relativePath => {

  13.      // 构造绝对路径

  14.      const absolutePath = path.join(dirname, relativePath)

  15.      // 生成依赖模块

  16.      const child = createAsset(absolutePath)

  17.      // 把依赖关系写入模块的mapping当中

  18.      asset.mapping[relativePath] = child.id

  19.      // 把这个依赖模块也推入到queue数组中,以便继续对其进行以来分析

  20.      queue.push(child)

  21.    })

  22.  }

  23.  // 最后返回这个queue,也就是依赖关系图集

  24.  return queue

  25. }

可能有读者对其中的 for...of ...循环当中的 queue.push有点迷,但是只要尝试过下面这段代码就能搞明白了:

  1. var numArr = ['1', '2', '3']

  2. for (num of numArr) {

  3.  console.log(num)

  4.  if (num === '3') {

  5.    arr.push('Done!')

  6.  }

  7. }

尝试运行一下 createGraph('./example/entry.js'),就能够看到如下的输出:

  1. [ { id: 0,

  2.    filename: './example/entry.js',

  3.    dependencies: [ './message.js' ],

  4.    code: '"use strict";\n\nvar _message = require("./message.js");\n\nvar _message2 = _interopRequireDefault(_message);\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { default: obj }; }\n\nconsole.log(_message2.default);',

  5.    mapping: { './message.js': 1 } },

  6.  { id: 1,

  7.    filename: 'example/message.js',

  8.    dependencies: [ './name.js' ],

  9.    code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\n\nvar _name = require("./name.js");\n\nexports.default = "Hello " + _name.name + "!";',

  10.    mapping: { './name.js': 2 } },

  11.  { id: 2,

  12.    filename: 'example/name.js',

  13.    dependencies: [],

  14.    code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n  value: true\n});\nvar name = exports.name = \'world\';',

  15.    mapping: {} } ]

现在依赖关系图集已经构建完成了,接下来就是把它们打包成一个单独的,可直接运行的文件啦!

4、进行打包

上一步生成的依赖关系图集,接下来将通过 CommomJS规范来实现加载。由于篇幅关系,本文不对 CommomJS规范进行扩展,有兴趣的读者可以参考@阮一峰 老师的一篇文章《浏览器加载 CommonJS 模块的原理与实现》,说得非常清晰。简单来说,就是通过构造一个立即执行函数 (function () {})(),手动定义 module, exports和 require变量,最后实现代码在浏览器运行的目的。

接下来就是依据这个规范,通过字符串拼接去构建代码块。

  1. function bundle (graph) {

  2.  let modules = ''

  3.  graph.forEach(mod => {

  4.    modules += `${mod.id}: [

  5.      function (require, module, exports) { ${mod.code} },

  6.      ${JSON.stringify(mod.mapping)},

  7.    ],`

  8.  })

  9.  const result = `

  10.    (function(modules) {

  11.      function require(id) {

  12.        const [fn, mapping] = modules[id];

  13.        function localRequire(name) {

  14.          return require(mapping[name]);

  15.        }

  16.        const module = { exports : {} };

  17.        fn(localRequire, module, module.exports);

  18.        return module.exports;

  19.      }

  20.      require(0);

  21.    })({${modules}})

  22.  `

  23.  return result

  24. }

最后运行 bundle(createGraph('./example/entry.js')),输出如下:

  1. (function (modules) {

  2.  function require(id) {

  3.    const [fn, mapping] = modules[id];

  4.    function localRequire(name) {

  5.      return require(mapping[name]);

  6.    }

  7.    const module = { exports: {} };

  8.    fn(localRequire, module, module.exports);

  9.    return module.exports;

  10.  }

  11.  require(0);

  12. })({

  13.  0: [

  14.    function (require, module, exports) {

  15.      "use strict";

  16.      var _message = require("./message.js");

  17.      var _message2 = _interopRequireDefault(_message);

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

  19.      console.log(_message2.default);

  20.    },

  21.    { "./message.js": 1 },

  22.  ], 1: [

  23.    function (require, module, exports) {

  24.      "use strict";

  25.      Object.defineProperty(exports, "__esModule", {

  26.        value: true

  27.      });

  28.      var _name = require("./name.js");

  29.      exports.default = "Hello " + _name.name + "!";

  30.    },

  31.    { "./name.js": 2 },

  32.  ], 2: [

  33.    function (require, module, exports) {

  34.      "use strict";

  35.      Object.defineProperty(exports, "__esModule", {

  36.        value: true

  37.      });

  38.      var name = exports.name = 'world';

  39.    },

  40.    {},

  41.  ],

  42. })

这段代码将能够直接在浏览器运行,输出“Hello world!”。

至此,整一个打包工具已经完成。

5、归纳总结

经过上面几个步骤,我们可以知道一个模块打包工具,第一步会从入口文件开始,对其进行依赖分析,第二步对其所有依赖再次递归进行依赖分析,第三步构建出模块的依赖图集,最后一步根据依赖图集使用 CommonJS规范构建出最终的代码。明白了当中每一步的目的,便能够明白一个打包工具的运行原理。

最后编辑于
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 210,978评论 6 490
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 89,954评论 2 384
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 156,623评论 0 345
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,324评论 1 282
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,390评论 5 384
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,741评论 1 289
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 38,892评论 3 405
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,655评论 0 266
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,104评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,451评论 2 325
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,569评论 1 340
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,254评论 4 328
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 39,834评论 3 312
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,725评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 31,950评论 1 264
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,260评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,446评论 2 348

推荐阅读更多精彩内容