前言
早期JavaScript只需要實現簡單的頁面交互,幾行代碼即可搞定。隨着瀏覽器性能的提升以及前端技術的不斷髮展,JavaScript代碼日益膨脹,此時就需要一個完善的模塊化機制來解決這個問題。因此誕生了CommonJS(NodeJS), AMD(sea.js), ES6 Module(ES6, Webpack), CMD(require.js)等模塊化規範。
什麼是模塊化?
模塊化是一種處理複雜系統分解爲更好的可管理模塊的方式,用來分割,組織和打包軟件。每一個模塊完成一個特定的子功能,所有的模塊按照某種方式組裝起來,成爲一個整體,完成整個系統的所有要求功能。
模塊化的好處是什麼?
- 模塊間解耦,提高模塊的複用性。
- 避免命名衝突。
- 分離以及按需加載。
- 提高系統的維護性。
隨着Webpack的盛行,理解Webpack是如何將模塊打包的也是前端人的基本素養,因此本文將從Webpack角度去實現一個類似於Webpack的JS模塊打包器。
JS模塊化的演進
說到模塊化,就不得不提一下JavaScript模塊化的發展進程了。早期JavaScript模塊化模式比較簡單粗暴,將一個模塊定義爲一個全局函數
function module1() {
// code
}
function module2() {
// code
}
這種方案非常簡單,但問題也很明顯:污染全局命名空間,引起命名衝突或數據不安全,而且模塊間的依賴關係並不明顯。
在此基礎上,又有了namespace模式,利用一個對象來對模塊進行包裝
var module1 = {
data: { }, // 數據區域
func1: function() {}
func2: function() {}
}
這種方案的問題依然是數據不安全,外面能直接修改module1
的data
因此又有了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