100行代碼教你實現類Webpack的JS打包器

前言

​ 早期JavaScript只需要實現簡單的頁面交互,幾行代碼即可搞定。隨着瀏覽器性能的提升以及前端技術的不斷髮展,JavaScript代碼日益膨脹,此時就需要一個完善的模塊化機制來解決這個問題。因此誕生了CommonJS(NodeJS), AMD(sea.js), ES6 Module(ES6, Webpack), CMD(require.js)等模塊化規範。

什麼是模塊化?

模塊化是一種處理複雜系統分解爲更好的可管理模塊的方式,用來分割,組織和打包軟件。每一個模塊完成一個特定的子功能,所有的模塊按照某種方式組裝起來,成爲一個整體,完成整個系統的所有要求功能。

模塊化的好處是什麼?

  1. 模塊間解耦,提高模塊的複用性。
  2. 避免命名衝突。
  3. 分離以及按需加載。
  4. 提高系統的維護性。

隨着Webpack的盛行,理解Webpack是如何將模塊打包的也是前端人的基本素養,因此本文將從Webpack角度去實現一個類似於Webpack的JS模塊打包器。

JS模塊化的演進

說到模塊化,就不得不提一下JavaScript模塊化的發展進程了。早期JavaScript模塊化模式比較簡單粗暴,將一個模塊定義爲一個全局函數

function module1() {
  // code
}
function module2() {
	// code  
}

這種方案非常簡單,但問題也很明顯:污染全局命名空間,引起命名衝突或數據不安全,而且模塊間的依賴關係並不明顯

在此基礎上,又有了namespace模式,利用一個對象來對模塊進行包裝

var module1 = {
  data: {  }, // 數據區域
  func1: function() {}
  func2: function() {}
}

這種方案的問題依然是數據不安全,外面能直接修改module1data

因此又有了IIFE模式,利用自執行函數(閉包)

!function(window) {
  var data = {};
  function func1() {
    data.hello = "hello";
  }
  function func2() {
    data.world = "world";
  }
  window.module1 = { func1, func2 };
} (window)

數據定義爲私有,外部只能通過模塊暴露的方法來對data進行操作,但這依然沒有解決模塊依賴的問題

基於IIFE,又提出了一種新的模塊化方案,即在IIFE的基礎上引入了依賴(現代模塊化的基石,Webpack、NodeJS等模塊化都是基於此實現的)

!function (window, module2) {
  var data = {};
  function func1() {
    data.world = "world";
    module2.hello();
  }
  window.module1 = { func1 };
} (window, { hello: function() {}, });

這樣使IIFE模塊化的依賴關係變得更明顯,又保證了IIFE模塊化獨有的特性。這種模塊化方案也是本文JS模塊打包器的模塊化思路。

打包器設計思路

一、原理

使用引用依賴的IIFE模塊化方案,首先將每一個模塊都封裝成一個閉包函數,並傳入require,module,exports參數

function (require, module, exports) {
  // 模塊化代碼
  var module1 = require("module2");
  console.log(module1.add(1, 2));
  exports.sub = function (a, b) { return a - b }
}

並且使用一個modules對象來管理每個模塊

var modules = {
  "module1": [
    ["module2"], // dependencies,模塊依賴數組
    function (require, module, exports) {
      // 模塊化代碼
      var module1 = require("module2");
      console.log(module1.add(1, 2));
      exports.sub = function (a, b) { return a - b }      
    }
  ],
  "module2": [
    [],
    function (require, module, exports) {
      exports.add = function (a, b) {
        return a + b
      }
    }
  ], 
}

這裏的重點在於實現require函數來加載每一個模塊

function require(moduleId) {
  var deps = modules[moduleId][0]; // modules就是上面的modules,而moduleId就是上面的"module2" 
  var fn = modules[moduleId][1]; // 封裝的閉包函數
  var module = { exports: {} };
  fn(require, module, module.exports); // 核心在這,將函數執行,並將require傳進去
  return module.exports; 
}

稍微組織一下代碼

!function (modules) {
  function require (moduleId) {
    var deps = modules[moduleId][0];
    var fn = modules[moduleId][1];
    var module = { exports: {} };
    fn(require, module, module.exports);
    return module.exports;
  }
  // 從根模塊依次加載
  require("module1"); // 假設module1是根模塊
}({
  "module1": [
    ["module2"], // dependencies,依賴模塊數組
    function (require, module, exports) {
      var module2 = require("module2");
      console.log("module2: ", module2.add(1, 2));
      exports.sub = function (a, b) {
        return a - b
      }
    }
  ],
  "module2": [
    [],
    function (require, module, exports) {
      exports.add = function (a, b) {
        return a + b;
      }
    }
  ],
});

運行如下
在這裏插入圖片描述
這就是本文的目標,將多個js文件打包成類似上面這樣的模塊化代碼。

二、準備工作

目錄結構如下

​ mypack

​     I__ src // 打包目錄

    ​ |__ tools

        ​ |__ a.js

        ​ |__ b.js

    ​ |__ func

        ​ |__ func.js

    ​ |__ add.js

    ​ |__ index.js // 模塊打包入口

​ |__ index.js // 打包器代碼

​ |__ config.js // 打包配置

在config.js中可以先寫上打包配置

const path = require("path");
module.exports = {
  entry: "src/index.js",
  output: {
    filename: "bundle.js",
    path: path.resolve(__dirname, "./dist")
  }
};

三、從文件中解析出模塊

每個文件使用ES6語法,並使用ES6模塊化規範(import/exports),引入和導出模塊使用

import { add } from "../add";
export default function func() { 
	console.log("func");
}

那麼首先需要將ES6代碼轉碼成ES5,並將"…/add" import的相對路徑解析出來。讀者可能已經想到了,使用babel就能做到這件事,因此安裝babel依賴

npm install --save @babel/core @babel/traverse @babel/preset-env

babel core用於解析出抽象語法樹ast並重新生成新的code,traverse用於遍歷抽象語法樹

使用transformFileAsync來生成抽象語法樹,其語法格式爲

transformFileAsync(filename: string, options?: Object)

傳入js文件和options異步生成ast

const { transformFileAsync } = require("@babel/core");

transformFileAsync("./src/index.js", { sourceType: "module", ast: true }).then((result) => {
  console.log(result.ast);
});

ast: true開啓ast支持,默認爲false,返回的結果中ast爲null,sourceType: "module"表示使用ES6 module

sourceType可以是 “script” | “module” | “unambiguous”,默認其實是"module"

  • "script": 使用正常的script標籤裏的js語法解析文件,沒有import/export,並且不是嚴格模式
  • "module": 使用ES6 module解析文件,自動爲嚴格模式,支持import/export語法
  • "unambiguous": babel根據文件中是否出現import/exports來確定是否處於"module"模式還是"script"模式

我們可以看到解析出來的ast長什麼樣
在這裏插入圖片描述

console.log打印的不完全,建議去astexplorer網站上去看

在這裏插入圖片描述
抽象語法樹ast有了,那麼只需要遍歷這顆語法樹,然後找到import語句的位置,並將import的文件找出來,細心的讀者可能發現了,上圖下面的箭頭就指着不就是嘛?沒錯,這裏可以使用babel traverse 去遍歷ast,traverse其實是根據對應的type來遍歷ast的,type就是上圖第一個箭頭指着的,import表達式的type就是ImportDeclaration,在上面代碼的基礎上,將

const { transformFileAsync } = require("@babel/core");
const traverser = require("@babel/traverse");

transformFileAsync("./src/index.js", { sourceType: "module", ast: true }).then((result) => {
  traverser.default(result.ast, {
    ImportDeclaration({ node }) { // import
      console.log(node.source.value); // 打印出import的文件
    }
  });
});

結果如下
在這裏插入圖片描述
我們可以正常的從文件中解析出使用了某個模塊(文件),現在只需要將ES6的代碼轉回ES5就可以了

const { transformFileAsync, transformFromAstAsync } = require("@babel/core");

transformFileAsync("./src/index.js", { sourceType: "module", ast: true }).then(({ast}) => {
  // 使用@babel/preset-env插件來轉化代碼
  transformFromAstAsync(ast, null, { presets: ["@babel/preset-env"], }).then(({code}) => {
    console.log(code);
  });
});

執行代碼
在這裏插入圖片描述

編譯後的代碼正好有個require函數,這也是上面實現的require函數。
整理了一下思路,現在稍微組織一下代碼,將上面的內容封裝成一個解析js文件的依賴和編譯回ES5代碼的函數,

const { transformFileAsync, transformFromAstAsync } = require("@babel/core");
const traverser = require("@babel/traverse");

async function getDepsAndCode(filename) {
  const { ast } = await transformFileAsync(filename, { sourceType: "module", ast: true });
  const { code } = await transformFromAstAsync(ast, null, { presets: ["@babel/preset-env"] });
  const deps = [];
  traverser.default(ast, {
    ImportDeclaration({node}) {
      deps.push(node.source.value);
    }
  });
  return { code, deps }
}

async function main () {
  const { code, deps } = await getDepsAndCode("./src/index.js");
  console.log(code);
  console.log();
  console.log(deps);
}
main().catch(console.error);

運行結果如下
在這裏插入圖片描述

四、依賴圖構建

讀取一個文件,可以解析出它的依賴,接下來就是從入口依次構建依賴圖,其實就是類似於上文中的modules的結構

const graph = {
  "./src/index.js": { // 以文件絕對路徑當作moduleId可以避免出現同名模塊
    code,
    mapping: { // 相對路徑到絕對路徑的一個映射,因爲使用的絕對路徑當moduleId,但require時還是用相對路徑
      "./add": "src/add"
    }
  },
  "src/add": {
    code,
    mapping: {}
  }
};

從入口文件遞歸(深搜)構建graph,在搜索時有個要點,就是要把當前的目錄給記錄下來,保證求絕對路徑時能正確

const path = require("path");
const config = require("./config");

function resolveJsFile(filename) {
  if (fs.existsSync(filename)) return filename;
  if (fs.existsSync(filename + ".js")) return filename + ".js";
  return filename;
}

async function makeDepsGraph(entry) {
  const graph = {};
  async function makeDepsGraph (filename) {
    if (graph[filename]) return; // 防止重複加載模塊
    const mapping = {}; // 定義相對路徑到絕對路徑的一個映射
    const dirname = path.dirname(filename); // 注意保存上一個目錄名,這樣能找到模塊的絕對路徑
    const { code, deps } = await getDepsAndCode(resolveJsFile(filename));
    graph[filename] = { code }; // 解決循環依賴
    for (let dep of deps) {
      mapping[dep] = path.join(dirname, dep); // dep是相對路徑,path.join(dirname, dep)是絕對路徑
      await makeDepsGraph(mapping[dep]); // 深搜,不使用廣搜
    }
    graph[filename].mapping = mapping;
  }
  await makeDepsGraph(entry);
  return graph;
}

async function main () {
  const graph = await makeDepsGraph(config.entry);
  console.log(graph);
}
main().catch(console.error);

執行結果
在這裏插入圖片描述

五、生成bundle.js

上一步構建出依賴圖graph,接下來就是生成bundle代碼,按照上面介紹的原理,我們可以根據graph生成modules

let modules = "";
for (let filename of Object.keys(graph)) {
  modules += `'${ filename }': {
   mapping: ${ JSON.stringify(graph[filename].mapping) },
   fn: function (require, module, exports) {
     ${ graph[filename].code }
   }  
  },`
}

有了modules,在包裹上一個自執行函數即可生成bundle

還記得require函數嘛

function require(moduleId) {
  var deps = modules[moduleId][0]; // modules就是上面的modules,而moduleId就是上面的"module2" 
  var fn = modules[moduleId][1]; // 封裝的閉包函數
  var module = { exports: {} };
  fn(require, module, module.exports); // 核心在這,將函數執行,並將require傳進去
  return module.exports; 
}

這裏的require函數沒有對模塊進行緩存並且沒有對循環依賴進行處理

循環依賴:即A依賴B,而B又依賴了A,舉個例子

a.js

import { b } from "./b";
b();
export function a() { console.log("a") }

b.js

import { a } from "./a";
a();
export function b() { console.log("b") }

如果直接使用上面的require函數的話,會一直在這兩個模塊中來回require,直到棧溢出

因此,對require函數進行改進

var cache = {}; // 緩存模塊
var count = {}; // 模塊計數,大於2表示存在循環引用
function require(moduleId) {
  if (cache[moduleId]) return cache[moduleId];
  count[moduleId] || (count[moduleId] = 0);
  count[moduleId] ++;
  
  var mapping = modules[moduleId].mapping;
  var fn = modules[moduleId].fn;
  function _require(id) { // id是相對路徑
    var mId = mapping[id]; // 使用mapping映射爲絕對路徑
    if (count[mId] >= 2) return {}; // 循環引用返回空對象
    return require(mId);
  }
  var module = { exports: {} };
  fn(_require, module, module.exports);
  return module.exports;
}

因此bundle也變成了

const bundle = `
   !function (modules) { 
      var cache = {}; 
      var count = {};
      function require(moduleId) {
        if (cache[moduleId]) return cache[moduleId];
        count[moduleId] || (count[moduleId] = 0);
        count[moduleId] ++;
    
        var mapping = modules[moduleId].mapping;
        var fn = modules[moduleId].fn;
        function _require(id) { 
          var mId = mapping[id]; 
          if (count[mId] >= 2) return {};
          return require(mId);
        }
    
        var module = { exports: {} };
        fn(_require, module, module.exports);
        return module.exports;
      }
      require('${entry}');
   } ({${modules}})`; 

整理一下代碼,將bundle寫入到相應的文件中,代碼如下

async function writeJsBundle (entry) {
  const graph = await makeDepsGraph(entry);
  let modules = "";
  for (let filename of Object.keys(graph)) {
    modules += `'${ filename }': {
     mapping: ${ JSON.stringify(graph[filename].mapping) },
     fn: function (require, module, exports) {
       ${ graph[filename].code }
     }  
    },`
  }

  const bundle = `
   !function (modules) {
      var cache = {}; 
      var count = {};
      function require(moduleId) {
        if (cache[moduleId]) return cache[moduleId];
        count[moduleId] || (count[moduleId] = 0);
        count[moduleId] ++;
    
        var mapping = modules[moduleId].mapping;
        var fn = modules[moduleId].fn;
        function _require(id) { 
          var mId = mapping[id]; 
          if (count[mId] >= 2) return {};
          return require(mId);
        }
    
        var module = { exports: {} };
        fn(_require, module, module.exports);
        return module.exports;
      }
      require('${ entry }');
    } ({${ modules }})`;
  await mkdir(config.output.path); 
  await writeFile(`${ config.output.path }/${ config.output.filename }`, bundle); 
}

完整代碼

完整的代碼如下,本人從不騙人,說好100行就100行😊😊😊

const fs = require("fs");
const path = require("path");
const { promisify } = require("util");
const { transformFileAsync, transformFromAstAsync } = require("@babel/core");
const traverser = require("@babel/traverse");
const config = require("./config");
const mkOneDir = promisify(fs.mkdir);
const writeFile = promisify(fs.writeFile);

async function mkdir (dir) {
  const dirs = dir.split("/").filter(Boolean);
  let cur = "";
  for (let d of dirs) {
    cur += d;
    if (!fs.existsSync(cur)) await mkOneDir(cur);
    cur += "/"
  }
}

async function getDepsAndCode (filename) {
  const { ast } = await transformFileAsync(filename, { sourceType: "module", ast: true });
  const { code } = await transformFromAstAsync(ast, null, { presets: ["@babel/preset-env"] });
  const deps = [];
  traverser.default(ast, {
    ImportDeclaration ({ node }) { deps.push(node.source.value); }
  });
  return { code, deps }
}

function resolveJsFile (filename) {
  if (fs.existsSync(filename)) return filename;
  if (fs.existsSync(filename + ".js")) return filename + ".js";
  return filename;
}

async function makeDepsGraph (entry) {
  const graph = {};

  async function makeDepsGraph (filename) {
    if (graph[filename]) return; 
    const mapping = {}; 
    const dirname = path.dirname(filename);
    const { code, deps } = await getDepsAndCode(resolveJsFile(filename));
    graph[filename] = { code }; 
    for (let dep of deps) {
      mapping[dep] = path.join(dirname, dep); 
      await makeDepsGraph(mapping[dep]); 
    }
    graph[filename].mapping = mapping;
  }

  await makeDepsGraph(entry);
  return graph;
}

async function writeJsBundle (entry) {
  const graph = await makeDepsGraph(entry);
  let modules = "";
  for (let filename of Object.keys(graph)) {
    modules += `'${ filename }': {
     mapping: ${ JSON.stringify(graph[filename].mapping) },
     fn: function (require, module, exports) {
       ${ graph[filename].code }
     }  
    },`
  }

  const bundle = `
   !function (modules) {
      var cache = {}; 
      var count = {};
      
      function require(moduleId) {
        if (cache[moduleId]) return cache[moduleId];
        count[moduleId] || (count[moduleId] = 0);
        count[moduleId] ++;
        var mapping = modules[moduleId].mapping;
        var fn = modules[moduleId].fn;
        
        function _require(id) { 
          var mId = mapping[id]; 
          if (count[mId] >= 2) return {};
          return require(mId);
        }
        var module = { exports: {} };
        fn(_require, module, module.exports);
        return module.exports;
      }
      
      require('${ entry }');
    } ({${ modules }})`;
  await mkdir(config.output.path);
  await writeFile(`${ config.output.path }/${ config.output.filename }`, bundle);
}

async function main () {
  await writeJsBundle(config.entry);
}

main().catch(console.error);

github地址

https://github.com/sundial-dreams/mypack

參考

babel: https://babeljs.io/

前端模塊化: https://juejin.im/post/5c17ad756fb9a049ff4e0a62

手寫一個js打包器: https://juejin.im/post/5e04c935e51d4557ea02c097

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