天天看點

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

前端子產品化成為了主流的今天,離不開各種打包工具的貢獻。社群裡面對于webpack,rollup以及後起之秀parcel的介紹層出不窮,對于它們各自的使用配置分析也是汗牛充棟。為了避免成為一位“配置工程師”,我們需要來了解一下打包工具的運作原理,隻有把核心原理搞明白了,在工具的使用上才能更加得心應手。

本文基于

parcel 核心開發者 @ronami 的開源項目 minipack

而來,在其非常詳盡的注釋之上加入更多的了解和說明,友善讀者更好地了解。

1、打包工具核心原理

顧名思義,打包工具就是負責把一些分散的小子產品,按照一定的規則整合成一個大子產品的工具。與此同時,打包工具也會處理好子產品之間的依賴關系,最終這個大子產品将可以被運作在合适的平台中。

打包工具會從一個入口檔案開始,分析它裡面的依賴,并且再進一步地分析依賴中的依賴,不斷重複這個過程,直到把這些依賴關系理清挑明為止。

從上面的描述可以看到,打包工具最核心的部分,其實就是處理好子產品之間的依賴關系,而minipack以及本文所要讨論的,也是集中在子產品依賴關系的知識點當中。

為了簡單起見,minipack項目直接使用ES modules規範,接下來我們建立三個檔案,并且為它們之間建立依賴:

/* name.js */

export const name = 'World'           
/* message.js */

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

export default `hello ${name}!`           

它們的依賴關系非常簡單:entry.js → message.js → name.js,其中entry.js将會成為打包工具的入口檔案。

但是,這裡面的依賴關系隻是我們人類所了解的,如果要讓機器也能夠了解當中的依賴關系,就需要借助一定的手段了。

2、依賴關系解析

建立一個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-core的transformFromAst方法搭配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檔案已經變成了一個典型的子產品,且依賴已經被分析出來了。接下來我們就要遞歸這個過程,把“依賴中的依賴”也都分析出來,也就是下一節要讨論的建立依賴關系圖集。

3、建立依賴關系圖集

建立一個名為

createGragh()

的函數,傳入一個入口檔案的路徑作為參數,然後通過

createAsset()

解析這個檔案使之定義成一個子產品。

接下來,為了能夠挨個挨個地對子產品進行依賴分析,是以我們維護一個數組,首先把第一個子產品傳進去并進行分析。當這個子產品被分析出還有其他依賴子產品的時候,就把這些依賴子產品也放進數組中,然後繼續分析這些新加進去的子產品,直到把所有的依賴以及“依賴中的依賴”都完全分析出來。

與此同時,我們有必要為子產品建立一個

mapping

屬性,用來儲存子產品、依賴、依賴ID之間的依賴關系,例如“ID為0的A子產品依賴于ID為2的B子產品和ID為3的C子產品”就可以表示成下面這個樣子:

搞清楚了個中道理,就可以開始編寫函數了。

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: {} } ]           

現在依賴關系圖集已經建構完成了,接下來就是把它們打包成一個單獨的,可直接運作的檔案啦!

4、進行打包

上一步生成的依賴關系圖集,接下來将通過

CommomJS

規範來實作加載。由于篇幅關系,本文不對

CommomJS

規範進行擴充,有興趣的讀者可以參考@阮一峰 老師的一篇文章

《浏覽器加載 CommonJS 子產品的原理與實作》

,說得非常清晰。簡單來說,就是通過構造一個立即執行函數

(function () {})()

,手動定義

module

exports

require

變量,最後實作代碼在浏覽器運作的目的。

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

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!”。

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

5、歸納總結

經過上面幾個步驟,我們可以知道一個子產品打包工具,第一步會從入口檔案開始,對其進行依賴分析,第二步對其所有依賴再次遞歸進行依賴分析,第三步建構出子產品的依賴圖集,最後一步根據依賴圖集使用

CommonJS

規範建構出最終的代碼。明白了當中每一步的目的,便能夠明白一個打包工具的運作原理。

最後再次感謝

,其源碼有着更為詳細的注釋,非常值得大家閱讀。

原文釋出時間:2018-06-14

原文作者:jrainlau

本文來源

掘金

如需轉載請緊急聯系作者

繼續閱讀