🤖 作者簡介:水煮白菜王,一位前端勸退師 👻
👀 文章專欄: 前端專欄 ,記錄一下平時在博客寫作中,總結出的一些開發技巧和知識歸納總結?。
感謝支持💕💕💕
目錄
- Webpack核心機制
- Tapable:Webpack 插件系統的“心臟”
- Tapabel提供的鉤子及示例
- 源碼解讀
- 編譯構建
- compile
- make
- 1. Module
- 2. loader-runner
- 3. acorn
- 4. Chunk生成算法
- eal
- emit
- 總結
- Webpack打包機制
- 簡單版打包模型步驟
- 單個文件的依賴模塊Map
- 單個文件的依賴模塊Map
- 輸出立即執行函數
- webpack打包流程概括
- 實現一個丐版Webpack
- 開始
- 接下來我們再來逐行解析 bundle 函數
- 如果你覺得這篇文章對你有幫助,請點贊 👍、收藏 👏 并關注我!👀
Webpack核心機制
Webpack 本質上是一個高度可配置且可擴展的模塊捆綁器,它采用了一種基于事件流的編程范例。Webpack 的運作依賴于一系列插件來完成各種任務,從簡單的文件轉換到復雜的構建優化。
Webpack 主要使用 Compiler 和 Compilation 兩個類來控制整個生命周期。它們都繼承自 Tapable 并利用它注冊構建過程中的各個階段所需觸發的事件。
Tapable:Webpack 插件系統的“心臟”
Tapable 是一個類似于 Node.js 的 EventEmitter 的庫,主要用于管理鉤子函數的發布與訂閱。在 Webpack 的插件系統中,Tapable 扮演著核心調度者的角色。
Tapabel提供的鉤子及示例
Tapable 提供了多種類型的鉤子(Hook)以便掛載,適用于不同的執行場景(同步 / 異步、串行 / 并發、是否支持熔斷等):
const {SyncHook, // 同步鉤子:依次執行所有訂閱者SyncBailHook, // 同步熔斷鉤子:一旦某個訂閱者返回非 undefined 值則停止執行SyncWaterfallHook, // 同步流水鉤子:前一個訂閱者的返回值作為參數傳給下一個SyncLoopHook, // 同步循環鉤子:重復執行訂閱者直到返回 undefinedAsyncParallelHook, // 異步并發鉤子:并行執行所有訂閱者(不關心順序)AsyncParallelBailHook, // 異步并發熔斷鉤子:任意一個訂閱者返回非 undefined 則立即結束AsyncSeriesHook, // 異步串行鉤子:按順序依次執行每個訂閱者AsyncSeriesBailHook, // 異步串行熔斷鉤子:同 SyncBailHook,但為異步模式AsyncSeriesWaterfallHook // 異步串行流水鉤子:同 SyncWaterfallHook,但為異步模式
} = require("tapable");
Tabpack 提供了同步&異步綁定鉤子的方法對比如下:
類型 | 綁定方法 | 執行方法 |
---|---|---|
同步 (Sync) | .tap(name, fn) | .call(args...) |
異步 (Async) | .apAsync(name, fn) /.tapPromise(name, fn) | .callAsync(args..., cb) / .promise(args...) |
Tabpack 同步簡單示例:
const { SyncHook } = require("tapable");// 創建一個帶有三個參數的同步鉤子
const demohook = new SyncHook(["arg1", "arg2", "arg3"]);// 注冊監聽函數 綁定事件到webpack事件流
demohook.tap("hook1", (arg1, arg2, arg3) => {console.log("接收到參數:", arg1, arg2, arg3);
});// 觸發鉤子 執行綁定的事件
demohook.call(1, 2, 3);
// 輸出: 接收到參數:1 2 3
源碼解讀
- 初始化啟動之Webpack的入口文件
● 追本溯源,第一步我們要找到Webpack的入口文件。
● 當通過命令行啟動Webpack后,npm會讓命令行工具進入node_modules.bin 目錄。
● 然后查找是否存在 webpack.sh 或者 webpack.cmd 文件,如果存在,就執行它們,不存在就會拋出錯誤。
● 實際的入口文件是:node_modules/webpack/bin/webpack.js
,讓我們來看一下里面的核心函數。
// node_modules/webpack/bin/webpack.js
// 正常執行返回
process.exitCode = 0;
// 運行某個命令
const runCommand = (command, args) => {...}
// 判斷某個包是否安裝
const isInstalled = packageName => {...}
// webpack可用的CLI:webpacl-cli和webpack-command
const CLIs = {...}
// 判斷是否兩個CLI是否安裝了
const installedClis = CLIs.filter(cli=>cli.installed);
// 根據安裝數量進行處理
if (installedClis.length === 0) {...} else if (installedClis.length === 1) {...} else {...}
啟動后,Webpack最終會找到 webpack-cli /webpack-command的 npm 包,并且 執行 CLI。
- webpack-cli
搞清楚了Webpack啟動的入口文件后,接下來讓我們把目光轉移到webpack-cli,看看它做了哪些動作。
● 引入 yargs,對命令行進行定制分析命令行參數,對各個參數進行轉換,組成編譯配置項引用webpack,根據配置項進行編譯和構建
● webpack-cli 會處理不需要經過編譯的命令。
// node_modules/webpack-cli/bin/cli.js
const {NON_COMPILATION_ARGS} = require("./utils/constants");
const NON_COMPILATION_CMD = process.argv.find(arg => {if (arg === "serve") {global.process.argv = global.process.argv.filter(a => a !== "serve");process.argv = global.process.argv;}return NON_COMPILATION_ARGS.find(a => a === arg);
});
if (NON_COMPILATION_CMD) {return require("./utils/prompt-command")(NON_COMPILATION_CMD,...process.argv);
}
webpack-cli提供的不需要編譯的命令如下
// node_modules/webpack-cli/bin/untils/constants.js
const NON_COMPILATION_ARGS = ["init", // 創建一份webpack配置文件"migrate", // 進行webpack版本遷移"add", // 往webpack配置文件中增加屬性"remove", // 往webpack配置文件中刪除屬性"serve", // 運行webpack-serve"generate-loader", // 生成webpack loader代碼"generate-plugin", // 生成webpack plugin代碼"info" // 返回與本地環境相關的一些信息
];
webpack-cli 使用命令行工具包yargs
// node_modules/webpack-cli/bin/config/config-yargs.js
const {CONFIG_GROUP,BASIC_GROUP,MODULE_GROUP,OUTPUT_GROUP,ADVANCED_GROUP,RESOLVE_GROUP,OPTIMIZE_GROUP,DISPLAY_GROUP
} = GROUPS;
● webpack-cli對配置文件和命令行參數進行轉換最終生成配置選項參數 options,最終會根據配置參數實例化webpack對象,然后執行構建流程。
● 除此之外,讓我們回到node_modules/webpack/lib/webpack.js里來看一下Webpack還做了哪些準備工作。
// node_modules/webpack/lib/webpack.js
const webpack = (options, callback) => {...options = new WebpackOptionsDefaulter().process(options);compiler = new Compiler(options.context);new NodeEnvironmentPlugin().apply(compiler);...compiler.options = new WebpackOptionsApply().process(options, compiler);...webpack.WebpackOptionsDefaulter = WebpackOptionsDefaulter;webpack.WebpackOptionsApply = WebpackOptionsApply;...webpack.NodeEnvironmentPlugin = NodeEnvironmentPlugin;
}
WebpackOptionsDefaulter的功能是設置一些默認的Options(代碼比較多,可自行查看node_modules/webpack/lib/WebpackOptionsDefaulter.js)
// node_modules/webpack/lib/node/NodeEnvironmentPlugin.js
class NodeEnvironmentPlugin {apply(compiler) {... compiler.hooks.beforeRun.tap("NodeEnvironmentPlugin", compiler => {if (compiler.inputFileSystem === inputFileSystem) inputFileSystem.purge();});}
}
從上面的代碼我們可以知道,NodeEnvironmentPlugin插件監聽了beforeRun鉤子,它的作用是清除緩存。
- WebpackOptionsApply
WebpackOptionsApply會將所有的配置options參數轉換成webpack內部插件。
使用默認插件列表:
● output.library -> LibraryTemplatePlugin
● externals -> ExternalsPlugin
● devtool -> EvalDevtoolModulePlugin, SourceMapDevToolPlugin
● AMDPlugin, CommonJsPlugin
● RemoveEmptyChunksPlugin
// node_modules/webpack/lib/WebpackOptionsApply.js
new EntryOptionPlugin().apply(compiler);
compiler.hooks.entryOption.call(options.context, options.entry);
- EntryOptionPlugin
下來讓我們進入EntryOptionPlugin插件,看看它做了哪些動作。
// node_modules/webpack/lib/EntryOptionPlugin.js
module.exports = class EntryOptionPlugin {apply(compiler) {compiler.hooks.entryOption.tap("EntryOptionPlugin", (context, entry) => {if (typeof entry === "string" || Array.isArray(entry)) {itemToPlugin(context, entry, "main").apply(compiler);} else if (typeof entry === "object") {for (const name of Object.keys(entry)) {itemToPlugin(context, entry[name], name).apply(compiler);}} else if (typeof entry === "function") {new DynamicEntryPlugin(context, entry).apply(compiler);}return true;});}
};
● 如果是數組,則轉換成多個entry來處理,如果是對象則轉換成一個個entry來處理。
● compiler實例化是在node_modules/webpack/lib/webpack.js里完成的。通過EntryOptionPlugin插件進行參數校驗。通過WebpackOptionsDefaulter將傳入的參數和默認參數進行合并成為新的options,創建compiler,以及相關plugin,最后通過
● WebpackOptionsApply將所有的配置options參數轉換成Webpack內部插件。
● 再次來到我們的node_modules/webpack/lib/webpack.js中
if (options.watch === true || (Array.isArray(options) && options.some(o => o.watch))) {const watchOptions = Array.isArray(options)? options.map(o => o.watchOptions || {}): options.watchOptions || {};return compiler.watch(watchOptions, callback);
}
compiler.run(callback);
實例compiler后會根據options的watch判斷是否啟動了watch,如果啟動watch了就調用compiler.watch來監控構建文件,否則啟動compiler.run來構建文件。
編譯構建
compile
首先會實例化NormalModuleFactory和ContextModuleFactory。然后進入到run方法。
// node_modules/webpack/lib/Compiler.js
run(callback) { ...// beforeRun 如上文NodeEnvironmentPlugin插件清除緩存this.hooks.beforeRun.callAsync(this, err => {if (err) return finalCallback(err);// 執行run Hook開始編譯this.hooks.run.callAsync(this, err => {if (err) return finalCallback(err);this.readRecords(err => {if (err) return finalCallback(err);// 執行compilethis.compile(onCompiled);});});});
}
在執行this.hooks.compile之前會執行this.hooks.beforeCompile,來對編譯之前需要處理的插件進行執行。緊接著this.hooks.compile執行后會實例化Compilation對象
// node_modules/webpack/lib/compiler.js
compile(callback) {const params = this.newCompilationParams();this.hooks.beforeCompile.callAsync(params, err => {if (err) return callback(err);// 進入compile階段this.hooks.compile.call(params);const compilation = this.newCompilation(params);// 進入make階段this.hooks.make.callAsync(compilation, err => {if (err) return callback(err);compilation.finish(err => {if (err) return callback(err);// 進入seal階段compilation.seal(err => {if (err) return callback(err);this.hooks.afterCompile.callAsync(compilation, err => {if (err) return callback(err);return callback(null, compilation);})})})})})
}
make
● 一個新的Compilation創建完畢,將從Entry開始讀取文件,根據文件類型和配置的Loader對文件進行編譯,編譯完成后再找出該文件依賴的文件,遞歸的編譯和解析。
● 我們來看一下make鉤子被監聽的地方。
● 如代碼中注釋所示,addEntry是make構建階段真正開始的標志
// node_modules/webpack/lib/SingleEntryPlugin.js
compiler.hooks.make.tapAsync("SingleEntryPlugin",(compilation, callback) => {const { entry, name, context } = this;cosnt dep = SingleEntryPlugin.createDependency(entry, name);// make構建階段開始標志 compilation.addEntry(context, dep, name, callback);}
)
addEntry實際上調用了_addModuleChain方法,_addModuleChain方法將模塊添加到依賴列表中去,同時進行模塊構建。構建時會執行如下函數:
// node_modules/webpack/lib/Compilation.js
// addEntry -> addModuleChain
_addModuleChain(context, dependency, onModule, callback) {...this.buildModule(module, false, null, null, err => {...})...}
如果模塊構建完成,會觸發finishModules。
// node_modules/webpack/lib/Compilation.js
finish(callback) {const modules = this.modules;this.hooks.finishModules.callAsync(modules, err => {if (err) return callback(err);for (let index = 0; index < modules.length; index++) {const module = modules[index]; this.reportDependencyErrorsAndWarnings(module, [module]);}callback();})
}
1. Module
● Module包括NormalModule(普通模塊)、ContextModule(./src/a ./src/b)、ExternalModule(module.exports=jQuery)、DelegatedModule(manifest)以及MultiModule(entry:[‘a’, ‘b’])。
● 本文以NormalModule(普通模塊)為例子,看一下構建(Compilation)的過程。
使用 loader-runner 運行 loadersLoader轉換完后,使用 acorn 解析生成AST使用 ParserPlugins 添加依賴
2. loader-runner
// node_modules/webpack/lib/NormalModule.js
const { getContext, runLoaders } = require("loader-runner");
doBuild(){...runLoaders(...)...}
...
try {const result = this.parser.parse()
}
doBuild會去加載資源,doBuild中會傳入資源路徑和插件資源去調用loader-runner插件的runLoaders方法去加載和執行loader
3. acorn
// node_modules/webpack/lib/Parser.jsconst acorn = require("acorn");
使用acorn解析轉換后的內容,輸出對應的抽象語法樹(AST)。
// node_modules/webpack/lib/Compilation.js
this.hooks.buildModule.call(module);
...
if (error) {this.hooks.failedModule.call(module, error);return callback(error);
}
this.hooks.succeedModule.call(module);
return callback();
● 成功就觸發succeedModule,失敗就觸發failedModule。
● 最終將上述階段生成的產物存放到Compilation.js的this.modules = [];上。
完成后就到了seal階段。
這里補充介紹一下Chunk生成的算法
4. Chunk生成算法
● webpack首先會將entry中對應的module都生成一個新的chunk。
● 遍歷module的依賴列表,將依賴的module也加入到chunk中。
● 如果一個依賴module是動態引入的模塊,會根據這個module創建一個新的chunk,繼續遍歷依賴。
● 重復上面的過程,直至得到所有的chunk。
eal
● 所有模塊及其依賴的模塊都通過Loader轉換完成,根據依賴關系開始生成Chunk。
● seal階段也做了大量的的優化工作,進行了hash的創建以及對內容進行生成(createModuleAssets)。
// node_modules/webpack/lib/Compilation.jsthis.createHash();
this.modifyHash();
this.createModuleAssets();
// node_modules/webpack/lib/Compilation.js
createModuleAssets(){for (let i = 0; i < this.modules.length; i++) {const module = this.modules[i];if (module.buildInfo.assets) {for (const assetName of Object.keys(module.buildInfo.assets)) {const fileName = this.getPath(assetName);this.assets[fileName] = module.buildInfo.assets[assetName];this.hooks.moduleAsset.call(module, fileName);}}}
}
seal階段經歷了很多的優化,比如tree shaking就是在這個階段執行。最終生成的代碼會存放在Compilation的assets屬性上
emit
將輸出的內容輸出到磁盤,創建目錄生成文件,文件生成階段結束。
// node_modules/webpack/lib/compiler.js
this.hooks.emit.callAsync(compilation, err => {if (err) return callback(err);outputPath = compilation.getPath(this.outputPath);this.outputFileSystem.mkdirp(outputPath, emitFiles);
})
總結
Webpack在啟動階段對配置參數和命令行參數以及默認參數進行了合并,并進行了插件的初始化工作。完成初始化的工作后調用Compiler的run開啟Webpack編譯構建過程,構建主要流程包括compile、make、build、seal、emit等階段。
Webpack打包機制
webpack是一個打包模塊化 JavaScript 的工具,在 webpack里一切文件皆模塊,通過 Loader 轉換文件,通過 Plugin 注入鉤子,最后輸出由多個模塊組合成的文件。webpack專注于構建模塊化項目。
簡單版打包模型步驟
從簡單的入手看,當 webpack 的配置只有一個出口時,不考慮分包的情況,其實我們只得到了一個bundle.js的文件,這個文件里包含了我們所有用到的js模塊,可以直接被加載執行。那么,我可以分析一下它的打包思路,大概有以下4步:
- 利用
babel
完成代碼轉換及解析,并生成單個文件的依賴模塊Map
- 從入口開始遞歸分析,并生成整個項目的依賴圖譜
- 將各個引用模塊打包為一個立即執行函數
- 將最終的
bundle
文件寫入bundle.js
中
單個文件的依賴模塊Map
我們會可以使用這幾個包:
@babel/parser
:負責將代碼解析為抽象語法樹@babel/traverse
:遍歷抽象語法樹的工具,我們可以在語法樹中解析特定的節點,然后做一些操作,如ImportDeclaration
獲取通過import
引入的模塊,FunctionDeclaration
獲取函數@babel/core
:代碼轉換,如ES6的代碼轉為ES5的模式
由這幾個模塊的作用,其實已經可以推斷出應該怎樣獲取單個文件的依賴模塊了,轉為
Ast->遍歷Ast->調用ImportDeclaration
。代碼如下:
// exportDependencies.js
const fs = require('fs')
const path = require('path')
const parser = require('@babel/parser')
const traverse = require('@babel/traverse').default
const babel = require('@babel/core')const exportDependencies = (filename)=>{const content = fs.readFileSync(filename,'utf-8')// 轉為Astconst ast = parser.parse(content, {sourceType : 'module'//babel官方規定必須加這個參數,不然無法識別ES Module})const dependencies = {}//遍歷AST抽象語法樹traverse(ast, {//調用ImportDeclaration獲取通過import引入的模塊ImportDeclaration({node}){const dirname = path.dirname(filename)const newFile = './' + path.join(dirname, node.source.value)//保存所依賴的模塊dependencies[node.source.value] = newFile}})//通過@babel/core和@babel/preset-env進行代碼的轉換const {code} = babel.transformFromAst(ast, null, {presets: ["@babel/preset-env"]})return{filename,//該文件名dependencies,//該文件所依賴的模塊集合(鍵值對存儲)code//轉換后的代碼}
}
module.exports = exportDependencies
試跑工作:
//info.js
const a = 1
export a
// index.js
import info from'./info.js'
console.log(info)//testExport.js
const exportDependencies = require('./exportDependencies')
console.log(exportDependencies('./src/index.js'))
單個文件的依賴模塊Map
有了獲取單個文件依賴的基礎,我們就可以在這基礎上,進一步得出整個項目的模塊依賴圖譜了。首先,從入口開始計算,得到entryMap
,然后遍歷entryMap.dependencies
,取出其value(即依賴的模塊的路徑),然后再獲取這個依賴模塊的依賴圖譜,以此類推遞歸下去即可,代碼如下:
const exportDependencies = require('./exportDependencies')//entry為入口文件路徑
const exportGraph = (entry)=>{const entryModule = exportDependencies(entry)const graphArray = [entryModule]for(let i = 0; i < graphArray.length; i++){const item = graphArray[i];//拿到文件所依賴的模塊集合,dependencies的值參考exportDependenciesconst { dependencies } = item;for(let j in dependencies){graphArray.push(exportDependencies(dependencies[j]))//關鍵代碼,目的是將入口模塊及其所有相關的模塊放入數組}}//接下來生成圖譜const graph = {}graphArray.forEach(item => {graph[item.filename] = {dependencies: item.dependencies,code: item.code}})//可以看出,graph其實是 文件路徑名:文件內容 的集合return graph
}
module.exports = exportGraph
輸出立即執行函數
首先,我們的代碼被加載到頁面中的時候,是需要立即執行的。所以輸出的bundle.js
實質上要是一個立即執行函數。我們主要注意以下幾點:
- 我們寫模塊的時候,用的是
import/export.
經轉換后,變成了require/exports
- 我們要讓
require/exports
能正常運行,那么我們得定義這兩個東西,并加到bundle.js里 - 在依賴圖譜里,代碼都成了字符串。要執行,可以使用
eval
因此,我們要做這些工作:
- 定義一個
require
函數,require
函數的本質是執行一個模塊的代碼,然后將相應變量掛載到exports
對象上 - 獲取整個項目的依賴圖譜,從入口開始,調用
require
方法。完整代碼如下:
const exportGraph = require('./exportGraph')
// 寫入文件,可以用fs.writeFileSync等方法,寫入到output.path中
const exportBundle = require('./exportBundle')const exportCode = (entry)=>{//要先把對象轉換為字符串,不然在下面的模板字符串中會默認調取對象的toString方法,參數變成[Object object]const graph = JSON.stringify(exportGraph(entry))exportBundle(`(function(graph) {//require函數的本質是執行一個模塊的代碼,然后將相應變量掛載到exports對象上function require(module) {//localRequire的本質是拿到依賴包的exports變量function localRequire(relativePath) {return require(graph[module].dependencies[relativePath]);}var exports = {};(function(require, exports, code) {eval(code);})(localRequire, exports, graph[module].code);return exports;//函數返回指向局部變量,形成閉包,exports變量在函數執行后不會被摧毀}require('${entry}')})(${graph})`)
}
module.exports = exportCode
至此,簡單打包完成,跑出結果。bundle.js的文件內容為:
(function(graph) {//require函數的本質是執行一個模塊的代碼,然后將相應變量掛載到exports對象上function require(module) {//localRequire的本質是拿到依賴包的exports變量function localRequire(relativePath) {returnrequire(graph[module].dependencies[relativePath]);}var exports = {};(function(require, exports, code) {eval(code);})(localRequire, exports, graph[module].code);return exports;//函數返回指向局部變量,形成閉包,exports變量在函數執行后不會被摧毀}require('./src/index.js')
})({"./src/index.js":{"dependencies":{"./info.js":"./src/info.js"},"code":"\"use strict\";\n\nvar _info = _interopRequireDefault(require(\"./info.js\"));\n\nfunction _interopRequireDefault(obj) { return obj && obj.__esModule ? obj : { \"default\": obj }; }\n\nconsole.log(_info[\"default\"]);"},"./src/info.js":{"dependencies":{"./name.js":"./src/name.js"},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports[\"default\"] = void 0;\n\nvar _name = require(\"./name.js\");\n\nvar info = \"\".concat(_name.name, \" is beautiful\");\nvar _default = info;\nexports[\"default\"] = _default;"},"./src/name.js":{"dependencies":{},"code":"\"use strict\";\n\nObject.defineProperty(exports, \"__esModule\", {\n value: true\n});\nexports.name = void 0;\nvar name = 'winty';\nexports.name = name;"}})
webpack打包流程概括
webpack的運行流程是一個串行的過程,從啟動到結束會依次執行以下流程:
- 初始化參數:Webpack啟動時,依據命令行參數和配置文件設置編譯所需的各項基本參數,確保準備好開始構建。
- 開始編譯: 用上一步得到的參數初始Compiler對象,加載所有配置的插件,通 過執行對象的run方法開始執行編譯
- 確定入口: 根據配置中的 Entry 找出所有入口文件
- 編譯模塊: 從入口文件出發,調用所有配置的 Loader 對模塊進行編譯,再找出該模塊依賴的模塊,再遞歸本步驟直到所有入口依賴的文件都經過了本步驟的處理
- 完成模塊編譯: 在經過第4步使用 Loader 翻譯完所有模塊后, 得到了每個模塊被編譯后的最終內容及它們之間的依賴關系
- 輸出資源 :根據入口和模塊之間的依賴關系,組裝成一個個包含多個模塊的 Chunk,再將每個 Chunk 轉換成一個單獨的文件加入輸出列表中,這是可以修改輸出內容的最后機會
- 輸出完成: 在確定好輸出內容后,根據配置確定輸出的路徑和文件名,將文件的內容寫入文件系統中。
在以上過程中, Webpack 會在特定的時間點廣播特定的事件,插件在監聽到感興趣的事件后會執行特定的邏輯,井且插件可以調用 Webpack 提供的 API 改變 Webpack 的運行結果。其實以上7個步驟,可以簡單歸納為初始化、編譯、輸出,三個過程,而這個過程其實就是前面說的基本模型的擴展。
實現一個丐版Webpack
該工具可以實現以下兩個功能
● 將 ES6 轉換為 ES5
● 支持在 JS 文件中 import CSS 文件
通過這個工具的實現,可以更好地理解打包工具背后的運行原理。
開始
由于需要將 ES6 轉換為 ES5,我們首先需要安裝一些 Babel 相關的依賴包:
yarn add babylon babel-traverse babel-core babel-preset-env
接下來我們將這些工具引入文件中
const fs = require('fs')
const path = require('path')
const babylon = require('babylon')
const traverse = require('babel-traverse').default
const { transformFromAst } = require('babel-core')
第一步:首先,實現如何使用 Babel 解析并轉換代碼
function readCode(filePath) {// 讀取文件內容const content = fs.readFileSync(filePath, 'utf-8')// 生成 ASTconst ast = babylon.parse(content, {sourceType: 'module'})// 尋找當前文件的依賴關系const dependencies = []traverse(ast, {ImportDeclaration: ({ node }) => {dependencies.push(node.source.value)}})// 通過 AST 將代碼轉為 ES5const { code } = transformFromAst(ast, null, {presets: ['env']})return {filePath,dependencies,code}
}
● 首先我們傳入一個文件路徑參數,通過 fs 模塊讀取其內容。
● 接下來我們通過 babylon 解析代碼生成抽象語法樹(AST),用于分析是否存在其他導入文件。
● 通過 babel-traverse 遍歷 AST,提取出所有依賴路徑。
● 通過 dependencies 來存儲文件中的依賴,最終調用 transformFromAst 將 AST 轉換為 ES5 代碼。
● 最后函數返回了一個對象,對象中包含了當前文件路徑、當前文件依賴和當前文件轉換后的代碼
接下來我們需要構建一個函數來處理整個依賴圖譜,這個函數的功能有以下幾點
● 調用 readCode 函數,傳入入口文件
● 分析入口文件的依賴
● 識別 JS 和 CSS 文件
function getDependencies(entry) {// 讀取入口文件const entryObject = readCode(entry)const dependencies = [entryObject]// 遍歷所有文件依賴關系for (const asset of dependencies) {// 獲得文件目錄const dirname = path.dirname(asset.filePath)// 遍歷當前文件依賴關系asset.dependencies.forEach(relativePath => {// 獲得絕對路徑const absolutePath = path.join(dirname, relativePath)// CSS 文件邏輯就是將代碼插入到 `style` 標簽中if (/\.css$/.test(absolutePath)) {const content = fs.readFileSync(absolutePath, 'utf-8')const code = `const style = document.createElement('style')style.innerText = ${JSON.stringify(content).replace(/\\r\\n/g, '')}document.head.appendChild(style)`dependencies.push({filePath: absolutePath,relativePath,dependencies: [],code})} else {// JS 代碼需要繼續查找是否有依賴關系const child = readCode(absolutePath)child.relativePath = relativePathdependencies.push(child)}})}return dependencies
}
● 首先我們讀取入口文件,然后創建一個數組,該數組的目的是存儲代碼中涉及到的所有文件
● 接下來我們遍歷這個數組,一開始這個數組中只有入口文件,在遍歷的過程中,如果入口文件有依賴其他的文件,那么就會被 push 到這個數組中
● 在遍歷的過程中,我們先獲得該文件對應的目錄,然后遍歷當前文件的依賴關系
● 在遍歷當前文件依賴關系的過程中,首先生成依賴文件的絕對路徑,然后判斷當前文件是 CSS 文件還是 JS 文件
● 如果是 CSS 文件的話,我們就不能用 Babel 去編譯了,只需要讀取 CSS 文件中的代碼,然后創建一個 <style>
標簽,將代碼插入進標簽并且放入 head 中即可
● 如果是 JS 文件的話,我們還需要分析 JS 文件是否還有別的依賴關系
● 最后將讀取文件后的對象 push 進數組中,此時已經獲取一個包含所有依賴項的對象數組。
● 現在我們已經獲取到了所有的依賴文件,接下來就是實現打包的功能了
第三步:打包依賴,模擬 CommonJS 運行環境
現在我們已經收集了完整的依賴圖,下一步是將這些模塊打包成一個可以在瀏覽器中運行的單文件。
function bundle(dependencies, entry) {let modules = ''// 構建函數參數,生成的結構為// { './entry.js': function(module, exports, require) { 代碼 } }dependencies.forEach(dep => {const filePath = dep.relativePath || entrymodules += `'${filePath}': (function (module, exports, require) { ${dep.code} }),`})// 構建 require 函數,目的是為了獲取模塊暴露出來的內容const result = `(function(modules) {function require(id) {const module = { exports : {} }modules[id](module, module.exports, require)return module.exports}require('${entry}')})({${modules}})`// 當生成的內容寫入到文件中fs.writeFileSync('./bundle.js', result)
}
這段代碼需要結合著 Babel 轉換后的代碼來看,這樣大家就能理解為什么需要這樣寫了,
代碼結構與 Babel 編譯后的 CommonJS 代碼相對應,目的是在瀏覽器端模擬模塊化運行環境。
示例:Babel 轉換后的代碼如下
// entry.js
var _a = require('./a.js')
var _a2 = _interopRequireDefault(_a)
function _interopRequireDefault(obj) {return obj && obj.__esModule ? obj : { default: obj }
}
console.log(_a2.default)
// a.js
Object.defineProperty(exports, '__esModule', {value: true
})
var a = 1
exports.default = a
Babel 將我們 ES6的模塊化代碼轉換為了 CommonJS的代碼,但是瀏覽器是不支持 CommonJS 的,所以如果這段代碼需要在瀏覽器環境下運行的話,我們需要手動實現 CommonJS 相關的類似機制,這就是 bundle 函數做的大部分事情。bundle 函數正是為此而設計。
接下來我們再來逐行解析 bundle 函數
● 首先遍歷所有依賴文件,構建出一個函數參數對象
● 對象的屬性就是當前文件的相對路徑,屬性值是一個函數,函數體是當前文件下的代碼,函數接受三個參數 module、exports、 require
○ module 參數對應 CommonJS 中的 module
○ exports 參數對應 CommonJS 中的 module.export
○ require 參數對應我們自己創建的 require 函數
● 接下來就是構造一個使用參數的函數了,函數做的事情很簡單,就是內部創建一個 require函數,然后調用 require(entry),也就是 require(‘./entry.js’),這樣就會從函數參數中找到 ./entry.js 對應的函數并執行,最后將導出的內容通過 module.export 的方式讓外部獲取到
● 最后再將打包出來的內容寫入到單獨的文件中
如果你對于上面的實現還有疑惑的話,可以閱讀下打包后的部分簡化代碼
;(function(modules) {function require(id) {// 構造一個 CommonJS 導出代碼const module = { exports: {} }// 去參數中獲取文件對應的函數并執行modules[id](module, module.exports, require)return module.exports}require('./entry.js')
})({'./entry.js': function(module, exports, require) {// 這里繼續通過構造的 require 去找到 a.js 文件對應的函數var _a = require('./a.js')console.log(_a2.default)},'./a.js': function(module, exports, require) {var a = 1// 將 require 函數中的變量 module 變成了這樣的結構// module.exports = 1// 這樣就能在外部取到導出的內容了exports.default = a}// 省略
})
盡管這個“丐版 Webpack”僅用了不到百行代碼實現,但它涵蓋了現代打包工具的核心思想:
● 找出入口文件所有的依賴關系。
● 將不同類型的資源統一處理
● 然后通過構建 CommonJS 代碼來獲取 exports 導出的內容。
這為我們理解打包工具的工作原理提供了很好的入門視角。