webpack
作為一款打包工具,在學習它之前,對它感到特別陌生,最近花了一些時間,學習了下。
學習的最大收穫是手寫一個簡易的打包工具webpack-demo
。
webpack-demo
分為主要分為三個部分:
- 生成抽象語法樹
- 獲取各模組依賴
- 生成瀏覽器能夠執行的程式碼
依賴準備
src
目錄下有三個檔案:index.js
、message.js
、word.js
。他們的依賴關係是:index.js
是入口檔案,其中index.js
依賴message.js
,message.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
中。配置比較簡易只有entry
和output
。
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
宣告,第二個是表示式語句。
接下來就是拿到這段程式碼中的所有依賴關係。
一種方式是自己寫遍歷,去遍歷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
。
通過上圖可以看到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
函式,用來獲取entryModule
。entryModule
包括filename
、code
、dependencies
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
內部還是一個自執行函式,接收三個引數:localRequire
、exports
、code
。
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
內部依賴分析、程式碼轉換有了更深的理解,不在是一個可以使用的黑盒了。