極簡Webpack | 手寫打包器

極簡Webpack | 手寫打包器

Webpack是現代JavaScript應用的靜態模打包器。它能夠內建一個被稱爲dependency graph的依賴關係圖並生成一個或多個包。作爲前端開發者,我們經常和它打交道,理解它如何工作可以使我們更好的處理我們的代碼。今天我們通過一個簡化版的模塊打包器來理解一些它的底層邏輯。

模塊打包器摘要步驟

官網給了我們一個簡化版的模塊打包器的例子,大體上分爲三個步驟:

  1. 查找資源依賴:
    通過JavaScript parsers生成的抽象語法樹(AST- abstract syntax tree),來讀取代碼的內容和依賴。
    這裏面會做一些設置模塊唯一標識(a unique Identifier)以及使用Babel將ECMAScript模塊語法轉化爲能在當前瀏覽器運行的語法等操作。
  2. 繪製依賴關係圖
    取出模塊的依賴包以及該依賴包所依賴的其他依賴。找出這個關係的過程稱爲the dependency graph
    這裏會從入口文件開始,使用for循環依次遍歷它的依賴,直到爲空,找出每一個依賴的相對路徑,拼接爲完整路徑,找出該路徑下的資源,依次把他們添加到關係圖當中的隊列裏。
  3. 將依賴封包
    使用我們創建的依賴關係圖生成可以在瀏覽器環境下運行的包。完整內容查看Detailed Explanation of a Simple Module Bundler,視頻鏈接:Live Coding a Simple Module Bundler

手寫一個簡單的Webpack

首先找一個地方創建項目文件夾,隨便命名比如:minipack-demo
而後在文件夾內創建一個package.json文件,文件內容如下:

{
  "name": "minipack",
  "version": "1.0.0",
  "description": "",
  "author": "Lorne Zhang",
  "license": "MIT",
  "dependencies": {
    "babel-core": "^6.26.0",
    "babel-preset-env": "^1.6.1",
    "babel-preset-es2015": "^6.24.1",
    "babel-traverse": "^6.26.0",
    "babylon": "^6.18.0",
    "eslint": "^4.17.0",
    "eslint-config-airbnb-base": "^12.1.0",
    "eslint-plugin-import": "^2.8.0"
  },
  "devDependencies": {
    "eslint-config-prettier": "^2.9.0",
    "eslint-plugin-prettier": "^2.6.0",
    "prettier": "^1.10.2"
  }
}

而後在控制檯執行npm install安裝依賴。

依賴安裝完成後,我們就有了開發環境,接下來我們寫一個簡單的模擬程序,首先創建三個文件JS文件,名稱和內容如下:

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);

如上這三個文件是互相依賴的關係。

而後,我們創建文件bundle.js,用於寫我們的打包代碼:整個項目的目錄結構看起來是這樣的:
minipack-demo-directory

到此我們正式寫我們的核心邏輯–打包器代碼:

bundle.js

/**
 * 模塊打包器將小的程序段編譯成瀏覽器可以執行的更大更復雜的程序。
 * 這些小塊僅僅是Javascript文件和模塊之間的依賴。
 * (https://webpack.js.org/concepts/modules)。
 * 
 * 模塊打包器有一個入口文件的概念。代替在瀏覽器中添加一些腳本標籤
 * 並且讓他們運行,我們告訴打包器哪個文件是應用的主文件,這是引導
 * 整個應用程序的文件。
 * 
 * 我們的打包器將從這個入口文件開始,它嘗試去理解文件直接的依賴。
 * 然後,它嘗試去理解文件依賴的依賴。它會持續的這麼做直到算出應用
 * 的每個模塊以及和其他模塊的依賴關係。
 * 
 * 這種理解工程的方式稱爲 dependency graph(依賴關係圖).
 * 
 * 在這個例子裏,我們講創建一個依賴關係圖並且將它的所有模塊打包到
 * 一個包裏。
 * 
 * 讓我們開始吧
 * 
 * 請注意:這是一個非常簡單的例子。像依賴循環、捕獲模塊導出,解析
 * 每個模塊我們僅僅處理一次,以便讓例子儘可能的簡單。
 */


const fs = require('fs')
const path = require('path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const babel = require('babel-core')

let ID = 0;
// 我們從創建一個函數開始,它會接受一個文件路徑,讀取它的內容
// 並且取出它的依賴
function createAsset(filename) {
  // 讀取文件內容作爲一個字符串
  const content = fs.readFileSync(filename, 'utf-8');

  // 現在,我們嘗試計算出這個文件所依賴的文件。我們可以通過它的字符串導入
  // 的方式來查看。然後,這是笨方法,因此,我們可以使用一個JavaScript解析器。
  //
  // JavaScript解析器是一個可以讀取和理解JavaScript代碼的工具。它可以
  // 幫我們把代碼生成一個更抽象的模型稱爲AST(abstract syntax tree--抽象語法樹)。
  //
  // 我強烈建議你使用AST Explorer(http://astexplorer.net)看看AST的樣子。
  // AST 包含了我們代碼的很多信息。我們可以通過查詢它來理解我們的代碼試圖做什麼。
  const ast = babylon.parse(content, {
    sourceType: 'module',
  });

  // 這個數組將管理這個模塊所依賴的模塊的相對路徑
  const dependencies = [];

  // 我們遍歷AST,試圖理解這個模塊依賴了哪些模塊,爲此,
  // 我們檢查AST上的每一個重要聲明。
  traverse(ast,{
    // EcmaScript 模塊是相當容易的,因爲他們是靜態的。這意味着你並不需要導入一
    // 個變量, 或可選的導入另一個模塊。每次我們看到一個導入聲明,我們僅僅接納
    // 它的值作爲一個依賴.
    ImportDeclaration:({node})=> {
      // 我們把這個值放進一個依賴數組中。
      dependencies.push(node.source.value);
    },
  });
  // 通過創建一個簡單的累加器,我們爲這個模塊分配一個唯一標識符。
  const id = ID++;
  // 我們使用的ECMASript模塊和其他的JavaScript功能可能並沒有被所有的瀏覽器支持。
  // 爲了確保我們的包在所有的瀏覽器都可以運行,我們將用babel轉換它
  //(看https://babeljs.io)
  //
  // ‘presets’操作性是一個規則集合,它告訴babel怎麼樣轉換我們的代碼。我們用
  // ‘babel-preset-env’來把我們的代碼轉換成大多數瀏覽器可以運行的代碼。
  const {code} = babel.transformFromAst(ast, null, {
    presets: ['env'],
  });

  // 返回關於這個模塊的所有信息
  return {
    id,
    filename,
    dependencies,
    code,
  };
}

// 現在我們能夠提取一個模塊的依賴,我們會繼續提取入口文件的依賴。
//
// 然後,我們繼續提取它的依賴的每一個依賴。我們依次進行直到計算出
// 應用程序的每一個依賴並且他們如何依賴其他的模塊。這個理解過程稱
// 依賴圖。
function createGraph(entry) {
  // 從解析依賴的入口文件開始
  const mainAsset = createAsset(entry);

  // 我們使用一個隊列去解析每一個資源的依賴。爲此我們使用入口資源
  // 定義一個數組。
  const queue = [mainAsset];
  // 我們使用一個‘for ... of’循環去迭代這個隊列。最初的隊列只有
  // 一個資源,但是隨着我們的迭代它將加入新資源到隊列中。當隊列爲
  // 空循環終止。
  for(const asset of queue) {
    // 我們的每一個資源有它所依賴的模塊的相對路徑列表。我們要對他
    // 們進行迭代,解析他們用我們的‘createAsset()’函數,追蹤這
    // 個模塊在此對象中的依賴。
    asset.mapping = {};
    // 這是這個模塊所在的目錄
    const dirname = path.dirname(asset.filename);
    // 我們遍歷其依賴項的相對路徑列表。
    asset.dependencies.forEach(relationPath => {
      // 我們的‘createAsset()’函數希望一個絕對路徑。依賴數組是
      // 一個相對路徑依賴數組。這些路徑相對於導入他們的文件。我們
      // 通過加入他的父資源的目錄路徑可以把它的相對路徑轉換爲絕對
      // 路徑。
      const absolutePath = path.join(dirname, relationPath);
      // 解析資源,讀取它的內容,提取它的依賴。
      const child = createAsset(absolutePath);
      // 知道資源所依賴的'子資源'對我們來說是必要的。我們通過給
      // 'mapping'對象用子資源的id添加一個新屬性來表達這種關係。
      asset.mapping[relationPath] = child.id;

      // 最後,我們添加子資源到我們的隊列,因此它的依賴項也將被迭
      // 代和解析。
      queue.push(child);
    });
  }

  // 此時,隊列只是包含目標應用程序中每個模塊的組數。
  return queue;
}

// 接下來,我們定義一個函數,將使用我們的依賴圖並且返回一個瀏覽器可以
// 運行的包。
// 
// 我們的包僅僅是一個自我調用的函數:
//
// (function() {})()
//
// 函數將接收僅僅一個參數:攜帶我們關係圖中每一個模塊信息的對象
function bundle(graph) {
  let modules = '';

  // 在我們得到我們的函數體之前,我們講構造這種參數。請注意我們
  // 正在構建的這個字符串被兩個花括號包裝。因此對於每一個模塊,
  // 我們添加一個這種形式的字符串:‘key:value’。
  graph.forEach(mod => {
    // 關係圖中的每一模塊在這個對象中都有一個入口。我們使用模塊
    // id作爲key,使用一個數組作爲值(我們的每個模塊有兩個值)
    //
    // 第一個值是用函數封裝的每一個模塊的代碼,這是因爲模塊的作
    // 用域應該是:在一個模塊中定義的變量不應該影響其他的作用域
    // 或全局作用域。
    //
    // 我們的模塊,我們轉換他們之後,使用CommonJS 模塊系統:
    // 他們希望一個‘require’,一個‘module’和一個'exports'
    // 對象可用。這些在瀏覽器中通常是不可用的,因此我們將實現
    // 他們並且把他們注入我們的函數封裝器。

    // 對於第二個值,我們字符串化了模塊和它的依賴之間的映射。這
    // 是一個對象,看起來像這樣:
    // {'./relative/path':1 }。
    // 
    // 這是因爲被轉換的模塊代碼以及調用了攜帶相對路徑的‘require’.
    // 當這個函數被調用時,我們應該能夠知道和模塊一致的這個模塊的
    // 相對路徑。
    modules +=  `${mod.id}: [
       function (require, module, exports) {
         ${mod.code}
       },
        ${JSON.stringify(mod.mapping)},
     ],`;
  })

  // 最後,我們實現這個自我調用函數。
  //
  // 我們通過創建‘require()’函數開始:它接受一個模塊id並且查找它
  // 在我們之前構造的模塊對象。我們講解構我兩個值的數組來得到我們的
  // 函數封裝器和映射對象。
  //
  // 我們的模塊代碼調用攜帶相對路徑的‘require()’代替模塊的id。我
  // 們的require函數需要模塊ids。此外,兩個模塊可能‘require()’
  // 相同的相對路徑但意味着不同的模塊。
  //
  // 爲了處理這個,但一個模塊被需要的時候我們創建一個新的,使用專用
  // 的‘require’函數讓它去使用。它將特定於該模塊,並且知道如何使用
  // 模塊的映射對象轉換相對路徑爲模塊的映射對象。
  //
  // 最後,用CommonJS,當一個模塊被需要時,它能夠通過可變的它的
  // ‘exports’導出它的值.'exports'對象,它通過模塊代碼更改之後,
  // 被通過'require()'函數返回。
  const result = `
    (function(modules) {
      function require(id) {
        const [fn, mapping] = modules[id];

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

        const module = { exports: {} };

        fn(localRequire,module,module.exports);

        return module.exports;
      }

      require(0);
    })({${modules}})
  `;
  
  // 我們只是返回結果,OK!
  return result;
}

const graph = createGraph('./example/entry.js');
const result = bundle(graph);

console.log(result);

不再多說,每一步註解已非常詳細。在控制檯執行:

$ node bundle.js

生成如下結果:

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

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

        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';
       },
        {},
     ],})

如上,這是可以在瀏覽器下直接運行的編譯後代碼,將這段代碼丟進瀏覽器的控制檯可以直接查看運行結果:
minipack-bundler-demo

That’s OK!箇中文版完整程序查看minipack-demo

發佈了43 篇原創文章 · 獲贊 10 · 訪問量 2萬+
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章