模塊打包器的實現(一)

什麼是打包器

一個完整的 JavaScript 項目(比如各種前端SPA)由各種各樣的資源模塊(module)組成,包括 JavaScript 代碼,CSS 樣式以及圖片等各種文件。打包器(module bundler)可以分析入口文件(entry)引用了哪些模塊,找到對應的文件,將其合併到一起。這樣執行輸出文件(output)的時候,一個完整的項目會呈現出來。

單頁面應用包含大量 JavaScript 代碼,爲了合理地管理代碼,開發時會將代碼拆分到不同文件裏面。在各個模塊的代碼編寫完成之後,bundler 可以幫我們把各個分散的 JS 文件合併起來,輸出一個完整的 JS 文件。

對於樣式文件和圖片等資源,我們也可以指定如何處理它們。一般的處理方式是直接插入到 HTML 或者 JS 文件中,或者通過指定文件網絡地址(public path),在需要該文件的時候瀏覽器會通過網絡請求獲取到這些資源。

功能完備的打包器可以把各種資源模塊聚集到一起,生成完整的 web app。但本文作爲開篇只討論如何實現一個 JavaSript bundler。

過程分析

一個最簡單的 JS bundler 可以幫助我們:

  • 找到 entry JavaScript 文件引用到的所有其他 JS 文件,並將其合併到目標 JS 文件(output)中。
  • 保證各個模塊的 JavaScript 代碼都在自己的作用域中執行,避免命名衝突。

爲了保證每個模塊的 JS 代碼都在自己的作用域中執行,可以參考 Node 執行 JS 代碼的方式。可以概括爲 5 步:

  • resolve:通過 require 中的 string 定位到文件的真實地址。
  • load:加載這個文件。
  • wrap:將引入的代碼包含在一個函數中,保證定義的變量只作用在本文件中。
  • execute:執行代碼。
  • cache:緩存執行結果。

而打包的流程可以概括爲:

  • 找到起始文件的依賴文件,將其加載並描述爲一個資源模塊(asset),其包含的信息包括:
    • id:唯一 id
    • filename:絕對文件路徑
    • code:模塊代碼,將其包含在一個函數裏面。並且需要把 ESM 的 import 和 export 改成 require 和 exports,這樣可以執行函數參數裏面的 require 和 exports,函數參數的 require 可以幫我們通過相對路徑找到實際文件。
    • dependencies:引入的模塊。
    • mapping:記錄以來模塊的相對路徑和其模塊 id 的對應關係。
  • 當依賴的文件有其他依賴的時候,繼續加載依賴文件。最終生成一個依賴圖(dependency graph),包含所有模塊之間的依賴關係。
  • 拼湊一個完整 string,包含所有模塊信息,並且執行起始文件。輸出這個 string。

代碼實現

轉換 JS 文件爲資源模塊(asset):

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

const parser = require('babel-parser');
const { transformFromAst } = require('@babel/core');
const traverse = require('@babel/traverse').default;

let ID = 0;

function createAsset(filename) {
  const content = fs.readFileSync(filename, 'utf-8');
  const ast = parser.parse(content, {
    sourceType: 'module',
  });
  
  const dependencies = [];
  traverse(ast, {
    importDelcaration: ({ node }) => {
      dependencies.push(node.source.value);
    }
  });
  
  const { code } = tranformFromAst(ast, null, {
    presets: ['@babel/preset-env'],
  });
  
  return {
    id: ID++,
    filename,
    dependencies,
    code
  };
}

生成依賴圖:

function createGraph(entry) {
  const mainAsset = createAsset(entry);
  
  const queue = [mainAsset];
  
  queue.forEach(asset => {
    asset.mapping = {};
    const dirname = path.dirname(asset.filename);
    
    asset.dependencies.forEach((relativePath) => {
      const filename = path.join(dirname, relativePath);
      const child = createAsset(filename);
     
      asset.mapping[relativePath] = child.id;
      queue.push(child);
    });
  });
  
  return queue;
}

合併 string:

function createBundle(entry) {
  const graph = createGraph(entry);
  
  let modules = '{';
  
  graph.forEach((asset) => {
    modules +=
      `${asset.id}: [function (requre, module, exports) { ${asset.code} }, ${JSON.stringify(asset.mapping)}],`;
  });
  
  modules += '}';
  
  const result = `function(modules) {
    function require(id) {
      const [fn, mapping] = modules[id];
      
      function localRequire(relativePath) {
        require(mapping[relativePath]));
      }
      
      const module = { exports: {} };
      
      fn(localRequire, module, exports);
      
      return moduele.exports;
    }
    
    require(0);
  }(${modules)`;
}

備註項目依賴

"dependencies": {
    "@babel/core": "7.9.6",
    "@babel/parser": "7.9.6",
    "@babel/preset-env": "7.9.6",
    "@babel/traverse": "7.9.6"
  }

附錄

發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章