打包工具的配置教程見的多了,但它們的運行原理你知道嗎?

寫於 2018.06.14javascript

前端模塊化成爲了主流的今天,離不開各類打包工具的貢獻。社區裏面對於webpack,rollup以及後起之秀parcel的介紹層出不窮,對於它們各自的使用配置分析也是汗牛充棟。爲了不成爲一位「配置工程師」,咱們須要來了解一下打包工具的運行原理,只有把核心原理搞明白了,在工具的使用上才能更加駕輕就熟。html

本文基於parcel核心開發者@ronami的開源項目minipack而來,在其很是詳盡的註釋之上加入更多的理解和說明,方便讀者更好地理解。前端

一、打包工具核心原理

顧名思義,打包工具就是負責把一些分散的小模塊,按照必定的規則整合成一個大模塊的工具。與此同時,打包工具也會處理好模塊之間的依賴關係,最終這個大模塊將能夠被運行在合適的平臺中。java

打包工具會從一個入口文件開始,分析它裏面的依賴,而且再進一步地分析依賴中的依賴,不斷重複這個過程,直到把這些依賴關係理清挑明爲止。node

從上面的描述能夠看到,打包工具最核心的部分,其實就是處理好模塊之間的依賴關係,而minipack以及本文所要討論的,也是集中在模塊依賴關係的知識點當中。webpack

爲了簡單起見,minipack項目直接使用ES modules規範,接下來咱們新建三個文件,而且爲它們之間創建依賴:git

/* name.js */

export const name = 'World'
複製代碼
/* message.js */

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

export default `Hello ${name}!`
複製代碼
/* entry.js */

import message from './message.js'

console.log(message)
複製代碼

它們的依賴關係很是簡單:entry.jsmessage.jsname.js,其中entry.js將會成爲打包工具的入口文件。github

可是,這裏面的依賴關係只是咱們人類所理解的,若是要讓機器也可以理解當中的依賴關係,就須要藉助必定的手段了。web

二、依賴關係解析

新建一個js文件,命名爲minipack.js,首先引入必要的工具。數組

/* minipack.js */

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

接下來,咱們會撰寫一個函數,這個函數接收一個文件做爲模塊,而後讀取它裏面的內容,分析出其全部的依賴項。固然,咱們能夠經過正則匹配模塊文件裏面的import關鍵字,但這樣作很是不優雅,因此咱們可使用babylon這個js解析器把文件內容轉化成抽象語法樹(AST),直接從AST裏面獲取咱們須要的信息。

獲得了AST以後,就可使用babel-traverse去遍歷這棵AST,獲取當中關鍵的「依賴聲明」,而後把這些依賴都保存在一個數組當中。

最後使用babel-coretransformFromAst方法搭配babel-preset-env插件,把ES6語法轉化成瀏覽器能夠識別的ES5語法,而且爲該js模塊分配一個ID。

let ID = 0

function createAsset (filename) {
  // 讀取文件內容
  const content = fs.readFileSync(filename, 'utf-8')

  // 轉化成AST
  const ast = babylon.parse(content, {
    sourceType: 'module',
  });

  // 該文件的全部依賴
  const dependencies = []

  // 獲取依賴聲明
  traverse(ast, {
    ImportDeclaration: ({ node }) => {
      dependencies.push(node.source.value);
    }
  })

  // 轉化ES6語法到ES5
  const {code} = transformFromAst(ast, null, {
    presets: ['env'],
  })

  // 分配ID
  const id = ID++

  // 返回這個模塊
  return {
    id,
    filename,
    dependencies,
    code,
  }
}
複製代碼

運行createAsset('./example/entry.js'),輸出以下:

{ id: 0,
  filename: './example/entry.js',
  dependencies: [ './message.js' ],
  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文件已經變成了一個典型的模塊,且依賴已經被分析出來了。接下來咱們就要遞歸這個過程,把「依賴中的依賴」也都分析出來,也就是下一節要討論的創建依賴關係圖集。

三、創建依賴關係圖集

新建一個名爲createGragh()的函數,傳入一個入口文件的路徑做爲參數,而後經過createAsset()解析這個文件使之定義成一個模塊。

接下來,爲了可以挨個挨個地對模塊進行依賴分析,因此咱們維護一個數組,首先把第一個模塊傳進去並進行分析。當這個模塊被分析出還有其餘依賴模塊的時候,就把這些依賴模塊也放進數組中,而後繼續分析這些新加進去的模塊,直到把全部的依賴以及「依賴中的依賴」都徹底分析出來。

與此同時,咱們有必要爲模塊新建一個mapping屬性,用來儲存模塊、依賴、依賴ID之間的依賴關係,例如「ID爲0的A模塊依賴於ID爲2的B模塊和ID爲3的C模塊」就能夠表示成下面這個樣子:

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

搞清楚了箇中道理,就能夠開始編寫函數了。

function createGragh (entry) {
  // 解析傳入的文件爲模塊
  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)
      // 生成依賴模塊
      const child = createAsset(absolutePath)
      // 把依賴關係寫入模塊的mapping當中
      asset.mapping[relativePath] = child.id
      // 把這個依賴模塊也推入到queue數組中,以便繼續對其進行以來分析
      queue.push(child)
    })
  }

  // 最後返回這個queue,也就是依賴關係圖集
  return queue
}
複製代碼

可能有讀者對其中的for...of ...循環當中的queue.push有點迷,可是隻要嘗試過下面這段代碼就能搞明白了:

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

for (num of numArr) {
  console.log(num)
  if (num === '3') {
    arr.push('Done!')
  }
}
複製代碼

嘗試運行一下createGraph('./example/entry.js'),就可以看到以下的輸出:

[ { id: 0,
    filename: './example/entry.js',
    dependencies: [ './message.js' ],
    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);',
    mapping: { './message.js': 1 } },
  { id: 1,
    filename: 'example/message.js',
    dependencies: [ './name.js' ],
    code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\n\nvar _name = require("./name.js");\n\nexports.default = "Hello " + _name.name + "!";',
    mapping: { './name.js': 2 } },
  { id: 2,
    filename: 'example/name.js',
    dependencies: [],
    code: '"use strict";\n\nObject.defineProperty(exports, "__esModule", {\n value: true\n});\nvar name = exports.name = \'world\';',
    mapping: {} } ]
複製代碼

如今依賴關係圖集已經構建完成了,接下來就是把它們打包成一個單獨的,可直接運行的文件啦!

四、進行打包

上一步生成的依賴關係圖集,接下來將經過CommomJS規範來實現加載。因爲篇幅關係,本文不對CommomJS規範進行擴展,有興趣的讀者能夠參考@阮一峯 老師的一篇文章《瀏覽器加載 CommonJS 模塊的原理與實現》,說得很是清晰。簡單來講,就是經過構造一個當即執行函數(function () {})(),手動定義moduleexportsrequire變量,最後實現代碼在瀏覽器運行的目的。

接下來就是依據這個規範,經過字符串拼接去構建代碼塊。

function bundle (graph) {
  let modules = ''

  graph.forEach(mod => {
    modules += `${mod.id}: [ function (require, module, exports) { ${mod.code} }, ${JSON.stringify(mod.mapping)}, ],`
  })

  const result = ` (function(modules) { function require(id) { const [fn, mapping] = modules[id]; function localRequire(name) { return require(mapping[name]); } const module = { exports : {} }; fn(localRequire, module, module.exports); return module.exports; } require(0); })({${modules}}) `
  return result
}
複製代碼

最後運行bundle(createGraph('./example/entry.js')),輸出以下:

(function (modules) {
  function require(id) {
    const [fn, mapping] = modules[id];

    function localRequire(name) {
      return require(mapping[name]);
    }

    const module = { exports: {} };

    fn(localRequire, module, module.exports);

    return module.exports;
  }

  require(0);
})({
  0: [
    function (require, module, exports) {
 "use strict";

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

      var _message2 = _interopRequireDefault(_message);

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

      console.log(_message2.default);
    },
    { "./message.js": 1 },
  ], 1: [
    function (require, module, exports) {
 "use strict";

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

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

      exports.default = "Hello " + _name.name + "!";
    },
    { "./name.js": 2 },
  ], 2: [
    function (require, module, exports) {
 "use strict";

      Object.defineProperty(exports, "__esModule", {
        value: true
      });
      var name = exports.name = 'world';
    },
    {},
  ],
})

複製代碼

這段代碼將可以直接在瀏覽器運行,輸出「Hello world!」。

至此,整一個打包工具已經完成。

五、概括總結

通過上面幾個步驟,咱們能夠知道一個模塊打包工具,第一步會從入口文件開始,對其進行依賴分析,第二步對其全部依賴再次遞歸進行依賴分析,第三步構建出模塊的依賴圖集,最後一步根據依賴圖集使用CommonJS規範構建出最終的代碼。明白了當中每一步的目的,便可以明白一個打包工具的運行原理。

最後再次感謝@ronami的開源項目minipack,其源碼有着更爲詳細的註釋,很是值得你們閱讀。

相關文章
相關標籤/搜索