手寫簡易打包工具webpack-demo

uccs發表於2021-10-03

webpack作為一款打包工具,在學習它之前,對它感到特別陌生,最近花了一些時間,學習了下。

學習的最大收穫是手寫一個簡易的打包工具webpack-demo

webpack-demo分為主要分為三個部分:

  • 生成抽象語法樹
  • 獲取各模組依賴
  • 生成瀏覽器能夠執行的程式碼

依賴準備

src目錄下有三個檔案:index.jsmessage.jsword.js。他們的依賴關係是:index.js是入口檔案,其中index.js依賴message.jsmessage.js依賴word.js

index.js

import message from "./message.js";

console.log(message);

message.js

import { word } from "./word.js";

const message = `say ${word}`;

export default message;

word.js

var word = "uccs";

export { word };

現在要要編寫一個bundle.js將這三個檔案打包成瀏覽器能夠執行的檔案。

打包的相關配置項寫在webpack.config.js中。配置比較簡易只有entryoutput

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

程式碼分析

獲取入口檔案的程式碼

通過node提供的fs.readFileSync獲取入口檔案的內容

const fs = require("fs");
const content = fs.readFileSync("./src/index.html", "utf-8");

拿到入口檔案的內容後,就需要獲取到它的依賴./message。因為它是string型別。自然就想到用字串擷取的方式獲取,但是這種方式太過麻煩,假如依賴項有很多的話,這個表示式就會特別複雜。

那有什麼更好的方式可以獲取到它的依賴呢?

生成抽象語法樹

babel提供了一個解析程式碼的工具@babel/parser,這個工具有個方法parse,接收兩個引數:

  • code:原始碼
  • options:原始碼使用ESModule,需要傳入sourceType: module
function getAST(entry) {
  const source = fs.readFileSync(entry, "utf-8");
  return parser.parse(source, {
    sourceType: "module",
  });
}

這個ast是個物件,叫做抽象語法樹,它可以表示當前的這段程式碼。

ast.program.body存放著我們的程式。通過抽象語法樹可以找到宣告的語句,宣告語句放置就是相關的依賴關係。

通過下圖可以看到第一個是import宣告,第二個是表示式語句。

1.png

接下來就是拿到這段程式碼中的所有依賴關係。

一種方式是自己寫遍歷,去遍歷body中的type: ImportDeclaration,這種方式呢有點麻煩。

有沒有更好的方式去獲取呢?

獲取相關依賴

babel就提供一個工具@babel/traverse,可以快速找到ImportDeclaration

traverse接收兩個引數:

  • ast:抽象語法樹
  • options:遍歷,需要找出什麼樣的元素,比如ImportDeclaration,只要抽象語法樹中有ImportDeclaration就會進入這個函式。
function getDependencies(ast, filename) {
  const dependencies = {};
  traverse(ast, {
    ImportDeclaration({ node }) {
      const dirname = path.dirname(filename);
      const newFile = path.join(dirname, node.source.value);
      dependencies[node.source.value] = newFile;
    },
  });
  return dependencies;
}

ImportDeclaration:會接收到一個節點node,會分析出所有的ImportDeclaration

2.png

通過上圖可以看到node.source.value就是依賴。將依賴儲存到dependencies物件中就行了,這裡面的依賴路徑是相對於bundle.js或者是絕對路徑,否則打包會出錯。

程式碼轉換

依賴分析完了之後,原始碼是需要轉換的,因為import語法在瀏覽器中是不能直接執行的。

babel提供了一個工具@babel/core,它是babel的核心模組,提供了一個transformFromAst方法,可以將ast轉換成瀏覽器可以執行的程式碼。

它接收三個引數:

  • ast:抽象語法樹
  • code:不需要,可傳入null
  • options:在轉換的過程中需要用的presents: ["@babel/preset-env"]
function transform(ast) {
  const { code } = babel.transformFromAst(ast, null, {
    presets: ["@babel/preset-env"],
  });
  return code;
}

獲取所有依賴

入口檔案分析好之後,它的相關依賴放在dependencies中。下一步將要去依賴中的模組,一層一層的分析最終把所有模組的資訊都分析出來,如何實現這個功能?

先定義一個buildModule函式,用來獲取entryModuleentryModule包括filenamecodedependencies

function buildModule(filename) {
  let ast = getAST(filename);
  return {
    filename,
    code: transform(ast),
    dependencies: getDependencies(ast, filename),
  };
}

通過遍歷modules獲取所有的模組資訊,當第一次走完for迴圈後,message.js的模組分析被推到modules中,這時候modules的長度變成了2,所以它會繼續執行for迴圈去分析message.js,發現message.js的依賴有word.js,將會呼叫buildModule分析依賴,並推到modules中。modules的長度變成了3,在去分析word.js的依賴,發現沒有依賴了,結束迴圈。

通過不斷的迴圈,最終就可以把入口檔案和它的依賴,以及它依賴的依賴都推到modules中。

const entryModule = this.buildModule(this.entry);
this.modules.push(entryModule);
for (let i = 0; i < this.modules.length; i++) {
  const { dependencies } = this.modules[i];
  if (dependencies) {
    for (let j in dependencies) {
      // 有依賴呼叫 buildmodule 再次分析,儲存到 modules
      this.modules.push(this.buildModule(dependencies[j]));
    }
  }
}

modules是個的陣列,在最終生成瀏覽器可執行程式碼上有點困難,所以這裡做一個轉換

const graphArray = {};
this.modules.forEach((module) => {
  graphArray[module.filename] = {
    code: module.code,
    dependencies: module.dependencies,
  };
});

生成瀏覽器可執行的程式碼

所有的依賴計算完之後,就需要生成瀏覽器能執行的程式碼。

這段程式碼是一個自執行函式,將graph傳入。

graph傳入時需要用JSON.stringify轉換一下,因為在字串中直接傳入物件,會變成[object Object]

在打包後的程式碼中,有個require方法,這個方法瀏覽器是不支援的,所有我們需要定義這個方法。

require在匯入路徑時需要做一個路徑轉換,否在將找不到依賴,所以定義了localRequire

require內部還是一個自執行函式,接收三個引數:localRequireexportscode

const graph = JSON.stringify(graphArray);
const outputPath = path.join(this.output.path, this.output.filename);
const bundle = `
  (function(graph){
    function require(module){
      function localRequire(relativePath){
        return require(graph[module].dependencies[relativePath])
      }
      var exports = {};
      (function(require, exports, code){
        eval(code)
      })(localRequire, exports, graph[module].code)
      return exports;
    }
    require("${this.entry}")
  })(${graph})
`;
fs.writeFileSync(outputPath, bundle, "utf-8");

總結

通過手寫一個簡單的打包工具後,對webpack內部依賴分析、程式碼轉換有了更深的理解,不在是一個可以使用的黑盒了。

參考資料:從基礎到實戰 手把手帶你掌握新版 Webpack4.0

相關文章