Webapck5核心打包原理全流程解析

語言: CN / TW / HK

theme: jzman highlight: atelier-sulphurpool-dark


「這是我參與11月更文挑戰的第4天,活動詳情檢視:2021最後一次更文挑戰」。

寫在前邊

Webpack在前端前端構建工具中可以堪稱中流砥柱般的存在,日常業務開發、前端基建工具、高階前端面試...任何場景都會出現它的身影。

也許對於它的內部實現機制你也許會感到疑惑,日常工作中基於Webpack Plugin/Loader之類查閱API仍然不明白各個引數的含義和應用方式。

其實這一切原因本質上都是基於Webpack工作流沒有一個清晰的認知導致了所謂的“面對API無從下手”開發。

文章中我們會從如何實現模組分析專案打包的角度出發,使用最通俗,最簡潔,最明瞭的程式碼帶你揭開Webpack背後的神祕面紗,帶你實現一個簡易版Webpack,從此對於任何webpack相關底層開發瞭然於胸。

這裡我們只講「乾貨」,用最通俗易懂的程式碼帶你走進webpack的工作流。

我希望你能掌握的前置知識

Tapable包本質上是為我們更方面建立自定義事件和觸發自定義事件的庫,類似於Nodejs中的EventEmitter Api

Webpack中的外掛機制就是基於Tapable實現與打包流程解耦,外掛的所有形式都是基於Tapable實現。

基於學習目的我們會著重於Webpack Node Api流程去講解,實際上我們在前端日常使用的npm run build命令也是通過環境變數呼叫bin指令碼去呼叫Node Api去執行編譯打包。

Webpack內部的AST分析同樣依賴於Babel進行處理,如果你對Babel不是很熟悉。我建議你可以先去閱讀下這兩篇文章「前端基建」帶你在Babel的世界中暢遊# 從Tree Shaking來走進Babel外掛開發者的世界

當然後續我也會去詳解這些內容在Webpack中的應用,但是我更加希望在閱讀文章之前你可以去點一點上方的文件稍微瞭解一下前置知識。

流程梳理

在開始之前我們先對於整個打包流程進行一次梳理。

這裡僅僅是一個全流程的梳理,現在你沒有必要非常詳細的去思考每一個步驟發生了什麼,我們會在接下來的步驟中去一步一步帶你串聯它們。

image.png

整體我們將會從上邊5個方面來分析Webpack打包流程:

  1. 初始化引數階段。

    這一步會從我們配置的webpack.config.js中讀取到對應的配置引數和shell命令中傳入的引數進行合併得到最終打包配置引數。

  2. 開始編譯準備階段

    這一步我們會通過呼叫webpack()方法返回一個compiler方法,建立我們的compiler物件,並且註冊各個Webpack Plugin。找到配置入口中的entry程式碼,呼叫compiler.run()方法進行編譯。

  3. 模組編譯階段

    從入口模組進行分析,呼叫匹配檔案的loaders對檔案進行處理。同時分析模組依賴的模組,遞迴進行模組編譯工作。

  4. 完成編譯階段

    在遞迴完成後,每個引用模組通過loaders處理完成同時得到模組之間的相互依賴關係。

  5. 輸出檔案階段

    整理模組依賴關係,同時將處理後的檔案輸出到ouput的磁碟目錄中。

接下來讓我們詳細的去探索每一步究竟發生了什麼。

建立目錄

工欲善其事,必先利其器。首先讓我們建立一個良好的目錄來管理我們需要實現的Packing tool吧!

讓我們來建立這樣一個目錄:

image.png

  • webpack/core存放我們自己將要實現的webpack核心程式碼。
  • webpack/example存放我們將用來打包的例項專案。
    • webpack/example/webpak.config.js配置檔案.
    • webpack/example/src/entry1第一個入口檔案
    • webpack/example/src/entry1第二個入口檔案
    • webpack/example/src/index.js模組檔案
  • webpack/loaders存放我們的自定義loader
  • webpack/plugins存放我們的自定義plugin

初始化引數階段

往往,我們在日常使用階段有兩種方式去給webpack傳遞打包引數,讓我們先來看看如何傳遞引數:

Cli命令列傳遞引數

通常,我們在使用呼叫webpack命令時,有時會傳入一定命令列引數,比如:

```shell webpack --mode=production

呼叫webpack命令執行打包 同時傳入mode為production

```

webpack.config.js傳遞引數

另一種方式,我相信就更加老生常談了。

我們在專案根目錄下使用webpack.config.js匯出一個物件進行webpack配置:

```js const path = require('path')

// 引入loader和plugin ... module.exports = { mode: 'development', entry: { main: path.resolve(__dirname, './src/entry1.js'), second: path.resolve(__dirname, './src/entry2.js'), }, devtool: false, // 基礎目錄,絕對路徑,用於從配置中解析入口點(entry point)和 載入器(loader)。 // 換而言之entry和loader的所有相對路徑都是相對於這個路徑而言的 context: process.cwd(), output: { path: path.resolve(__dirname, './build'), filename: '[name].js', }, plugins: [new PluginA(), new PluginB()], resolve: { extensions: ['.js', '.ts'], }, module: { rules: [ { test: /.js/, use: [ // 使用自己loader有三種方式 這裡僅僅是一種 path.resolve(__dirname, '../loaders/loader-1.js'), path.resolve(__dirname, '../loaders/loader-2.js'), ], }, ], }, }; ```

同時這份配置檔案也是我們需要作為例項專案example下的例項配置,接下來讓我們修改example/webpack.config.js中的內容為上述配置吧。

當然這裡的loaderplugin目前你可以不用理解,接下來我們會逐步實現這些東西並且新增到我們的打包流程中去。

實現合併引數階段

這一步,讓我們真正開始動手實現我們的webpack吧!

首先讓我們在webpack/core下新建一個index.js檔案作為核心入口檔案。

同時建立一個webpack/core下新建一個webpack.js檔案作為webpack()方法的實現檔案。

首先,我們清楚在NodeJs Api中是通過webpack()方法去得到compiler物件的。

image.png

此時讓我們按照原本的webpack介面格式來補充一下index.js中的邏輯:

  • 我們需要一個webpack方法去執行呼叫命令。
  • 同時我們引入webpack.config.js配置檔案傳入webpack方法。

js // index.js const webpack = require('./webpack'); const config = require('../example/webpack.config'); // 步驟1: 初始化引數 根據配置檔案和shell引數合成引數 const compiler = webpack(config);

嗯,看起來還不錯。接下來讓我們去實現一下webpack.js:

```js function webpack(options) { // 合併引數 得到合併後的引數 mergeOptions const mergeOptions = _mergeOptions(options); }

// 合併引數 function _mergeOptions(options) { const shellOptions = process.argv.slice(2).reduce((option, argv) => { // argv -> --mode=production const [key, value] = argv.split('='); if (key && value) { const parseKey = key.slice(2); option[parseKey] = value; } return option; }, {}); return { ...options, ...shellOptions }; }

module.export = webpack; ```

這裡我們需要額外說明的是

webpack檔案中需要匯出一個名為webpack的方法,同時接受外部傳入的配置物件。這個是我們在上述講述過的。

當然關於我們合併引數的邏輯,是將外部傳入的物件和執行shell時的傳入引數進行最終合併

Node Js中我們可以通過process.argv.slice(2)來獲得shell命令中傳入的引數,比如:

image.png

當然_mergeOptions方法就是一個簡單的合併配置引數的方法,相信對於大家來說就是小菜一碟。

恭喜大家🎉,千里之行始於足下。這一步我們已經完成了打包流程中的第一步:合併配置引數

編譯階段

在得到最終的配置引數之後,我們需要在webpack()函式中做以下幾件事情:

  • 通過引數建立compiler物件。我們看到官方案例中通過呼叫webpack(options)方法返回的是一個compiler物件。並且同時呼叫compiler.run()方法啟動的程式碼進行打包。

  • 註冊我們定義的webpack plugin外掛。

  • 根據傳入的配置物件尋找對應的打包入口檔案。

建立compiler物件

讓我們先來完成index.js中的邏輯程式碼補全:

```js // index.js const webpack = require('./webpack'); const config = require('../example/webpack.config'); // 步驟1: 初始化引數 根據配置檔案和shell引數合成引數 // 步驟2: 呼叫Webpack(options) 初始化compiler物件
// webpack()方法會返回一個compiler物件

const compiler = webpack(config);

// 呼叫run方法進行打包 compiler.run((err, stats) => { if (err) { console.log(err, 'err'); } // ... }); `` 可以看到,核心編譯實現在於webpack()方法返回的compiler.run()`方法上。

一步一步讓我們來完善這個webpack()方法:

```js // webpack.js function webpack(options) { // 合併引數 得到合併後的引數 mergeOptions const mergeOptions = _mergeOptions(options); // 建立compiler物件 const compiler = new Compiler(mergeOptions)

return compiler }

// ... ```

讓我們在webpack/core目錄下同樣新建一個compiler.js檔案,作為compiler的核心實現檔案:

```js // compiler.js // Compiler類進行核心編譯實現 class Compiler { constructor(options) { this.options = options; }

// run方法啟動編譯 // 同時run方法接受外部傳遞的callback run(callback) { } }

module.exports = Compiler ```

此時我們的Compiler類就先搭建一個基礎的骨架程式碼。

目前,我們擁有了:

  • webpack/core/index.js作為打包命令的入口檔案,這個檔案引用了我們自己實現的webpack同時引用了外部的webpack.config.js(options)。呼叫webpack(options).run()開始編譯。

  • webpack/core/webpack.js這個檔案目前處理了引數的合併以及傳入合併後的引數new Compiler(mergeOptions),同時返回建立的Compiler實力物件。

  • webpack/core/compiler,此時我們的compiler僅僅是作為一個基礎的骨架,存在一個run()啟動方法。

編寫Plugin

還記得我們在webpack.config.js中使用了兩個plugin---pluginApluginB外掛嗎。接下來讓我們來依次實現它們:

在實現Plugin前,我們需要先來完善一下compiler方法:

```js const { SyncHook } = require('tapable');

class Compiler { constructor(options) { this.options = options; // 建立plugin hooks this.hooks = { // 開始編譯時的鉤子 run: new SyncHook(), // 輸出 asset 到 output 目錄之前執行 (寫入檔案之前) emit: new SyncHook(), // 在 compilation 完成時執行 全部完成編譯執行 done: new SyncHook(), }; }

// run方法啟動編譯 // 同時run方法接受外部傳遞的callback run(callback) {} }

module.exports = Compiler; ```

這裡,我們在Compiler這個類的建構函式中建立了一個屬性hooks,它的值是三個屬性runemitdone

關於這三個屬性的值就是我們上文提到前置知識的tapableSyncHook方法,本質上你可以簡單將SyncHook()方法理解稱為一個Emitter Event類。

當我們通過new SyncHook()返回一個物件例項後,我們可以通過this.hook.run.tap('name',callback)方法為這個物件上新增事件監聽,然後在通過this.hook.run.call()執行所有tap註冊的事件。

當然webpack真實原始碼中,這裡有非常多的hook。以及分別存在同步/非同步鉤子,我們這裡更多的是為大家講解清楚流程,所以僅列舉了三個常見且簡單的同步鉤子。

此時,我們需要明白,我們可以通過Compiler類返回的例項物件上compiler.hooks.run.tap註冊鉤子。

接下來讓我們切回到webpack.js中,讓我們來填充關於外掛註冊的邏輯:

```js const Compiler = require('./compiler');

function webpack(options) { // 合併引數 const mergeOptions = _mergeOptions(options); // 建立compiler物件 const compiler = new Compiler(mergeOptions); // 載入外掛 _loadPlugin(options.plugins, compiler); return compiler; }

// 合併引數 function _mergeOptions(options) { const shellOptions = process.argv.slice(2).reduce((option, argv) => { // argv -> --mode=production const [key, value] = argv.split('='); if (key && value) { const parseKey = key.slice(2); option[parseKey] = value; } return option; }, {}); return { ...options, ...shellOptions }; }

// 載入外掛函式 function _loadPlugin(plugins, compiler) { if (plugins && Array.isArray(plugins)) { plugins.forEach((plugin) => { plugin.apply(compiler); }); } }

module.exports = webpack; ```

這裡我們在建立完成compiler物件後,呼叫了_loadPlugin方法進行註冊外掛

有接觸過webpack外掛開發的同學,或多或少可能都有了解過。任何一個webpack外掛都是一個類(當然類本質上都是funciton的語法糖),每個外掛都必須存在一個apply方法

這個apply方法會接受一個compiler物件。我們上邊做的就是依次呼叫傳入的pluginapply方法並且傳入我們的compiler物件。

這裡我請你記住上邊的流程,日常我們編寫webpack plugin時本質上就是操作compiler物件從而影響打包結果進行。

也許此時你並不是很理解這句話的含義,在我們串聯完成整個流程之後我會為大家揭曉這個答案。

接下來讓我們去編寫這些個外掛:

不瞭解外掛開發的同學可以去稍微看一下官方的介紹,其實不是很難,我個人強烈建議如果不瞭解可以先去看看再回來結合上變講的內容你一定會有所收穫的。

首先讓我們先建立檔案:

image.png

```js // plugin-a.js // 外掛A class PluginA { apply(compiler) { // 註冊同步鉤子 // 這裡的compiler物件就是我們new Compiler()建立的例項哦 compiler.hooks.run.tap('Plugin A', () => { // 呼叫 console.log('PluginA'); }); } }

module.exports = PluginA; ```

```js // plugin-b.js class PluginB { apply(compiler) { compiler.hooks.done.tap('Plugin B', () => { console.log('PluginB'); }); } }

module.exports = PluginB; ```

看到這裡我相信大部分同學都已經反應過來了,compiler.hooks.done.tap不就是我們上邊講到的通過tapable建立一個SyncHook例項然後通過tap方法註冊事件嗎?

沒錯!的確是這樣,關於webpack外掛本質上就是通過釋出訂閱的模式,通過compiler上監聽事件。然後再打包編譯過程中觸發監聽的事件從而新增一定的邏輯影響打包結果

我們在每個外掛的apply方法上通過tap在編譯準備階段(也就是呼叫webpack()函式時)進行訂閱對應的事件,當我們的編譯執行到一定階段時釋出對應的事件告訴訂閱者去執行監聽的事件,從而達到在編譯階段的不同生命週期內去觸發對應的plugin

所以這裡你應該清楚,我們在進行webpack外掛開發時,compiler物件上存放著本次打包的所有相關屬性,比如options打包的配置,以及我們會在之後講到的各種屬性。

尋找entry入口

這之後,我們的絕大多數內容都會放在compiler.js中去實現Compiler這個類實現打包的核心流程。

任何一次打包都需要入口檔案,接下來讓我們就從真正進入打包編譯階段。首當其衝的事情就是,我們需要根據入口配置檔案路徑尋找到對應入口檔案。

```js // compiler.js const { SyncHook } = require('tapable'); const { toUnixPath } = require('./utils');

class Compiler { constructor(options) { this.options = options; // 相對路徑跟路徑 Context引數 this.rootPath = this.options.context || toUnixPath(process.cwd()); // 建立plugin hooks this.hooks = { // 開始編譯時的鉤子 run: new SyncHook(), // 輸出 asset 到 output 目錄之前執行 (寫入檔案之前) emit: new SyncHook(), // 在 compilation 完成時執行 全部完成編譯執行 done: new SyncHook(), }; }

// run方法啟動編譯 // 同時run方法接受外部傳遞的callback run(callback) { // 當呼叫run方式時 觸發開始編譯的plugin this.hooks.run.call(); // 獲取入口配置物件 const entry = this.getEntry(); }

// 獲取入口檔案路徑 getEntry() { let entry = Object.create(null); const { entry: optionsEntry } = this.options; if (typeof entry === 'string') { entry['main'] = optionsEntry; } else { entry = optionsEntry; } // 將entry變成絕對路徑 Object.keys(entry).forEach((key) => { const value = entry[key]; if (!path.isAbsolute(value)) { // 轉化為絕對路徑的同時統一路徑分隔符為 / entry[key] = toUnixPath(path.join(this.rootPath, value)); } }); return entry; } }

module.exports = Compiler; ```

js // utils/index.js /** * * 統一路徑分隔符 主要是為了後續生成模組ID方便 * @param {*} path * @returns */ function toUnixPath(path) { return path.replace(/\\/g, '/'); }

這一步我們通過options.entry處理獲得入口檔案的絕對路徑。

這裡有幾個需要注意的小點:

  • this.hooks.run.call()

在我們_loadePlugins函式中對於每一個傳入的外掛在compiler例項物件中進行了訂閱,那麼當我們呼叫run方法時,等於真正開始執行編譯。這個階段相當於我們需要告訴訂閱者,釋出開始執行的訂閱。此時我們通過this.hooks.run.call()執行關於run的所有tap監聽方法,從而觸發對應的plugin邏輯。

  • this.rootPath:

在上述的外部webpack.config.js中我們配置了一個context: process.cwd(),其實真實webpack中這個context值預設也是process.cwd()

關於它的詳細解釋你可以在這裡看到Context

簡而言之,這個路徑就是我們專案啟動的目錄路徑,任何entryloader中的相對路徑都是針對於context這個引數的相對路徑。

這裡我們使用this.rootPath在建構函式中來儲存這個變數。

  • toUnixPath工具方法:

因為不同作業系統下,檔案分隔路徑是不同的。這裡我們統一使用\來替換路徑中的//來替換模組路徑。後續我們會使用模組相對於rootPath的路徑作為每一個檔案的唯一ID,所以這裡統一處理下路徑分隔符。

  • entry的處理方法:

關於entry配置,webpack中其實有很多種。我們這裡考慮了比較常見的兩種配置方式:

```js entry:'entry1.js'

// 本質上這段程式碼在webpack中會被轉化為 entry: { main:'entry1.js } ```

js entry: { 'entry1':'./entry1.js', 'entry2':'/user/wepback/example/src/entry2.js' }

這兩種方式任何方式都會經過getEntry方法最終轉化稱為{ [模組名]:[模組絕對路徑]... }的形式,關於geEntry()方法其實非常簡單,這裡我就不過於累贅這個方法的實現過程了。

這一步,我們就通過getEntry方法獲得了一個keyentryName,valueentryAbsolutePath的物件了,接來下就讓我們從入口檔案出發進行編譯流程吧。

模組編譯階段

上邊我們講述了關於編譯階段的準備工作:

  • 目錄/檔案基礎邏輯補充。
  • 通過hooks.tap註冊webpack外掛。
  • getEntry方法獲得各個入口的物件。

接下來讓我們繼續完善compiler.js

在模組編譯階段,我們需要做的事件:

  • 根據入口檔案路徑分析入口檔案,對於入口檔案進行匹配對應的loader進行處理入口檔案。
  • loader處理完成的入口檔案使用webpack進行編譯。
  • 分析入口檔案依賴,重複上邊兩個步驟編譯對應依賴。
  • 如果巢狀檔案存在依賴檔案,遞迴呼叫依賴模組進行編譯。
  • 遞迴編譯完成後,組裝一個個包含多個模組的chunk

首先,我們先來給compiler.js的建構函式中補充一下對應的邏輯:

js class Compiler { constructor(options) { this.options = options; // 建立plugin hooks this.hooks = { // 開始編譯時的鉤子 run: new SyncHook(), // 輸出 asset 到 output 目錄之前執行 (寫入檔案之前) emit: new SyncHook(), // 在 compilation 完成時執行 全部完成編譯執行 done: new SyncHook(), }; // 儲存所有入口模組物件 this.entries = new Set(); // 儲存所有依賴模組物件 this.modules = new Set(); // 所有的程式碼塊物件 this.chunks = new Set(); // 存放本次產出的檔案物件 this.assets = new Set(); // 存放本次編譯所有產出的檔名 this.files = new Set(); } // ... }

這裡我們通過給compiler建構函式中新增一些列屬性來儲存關於編譯階段生成的對應資源/模組物件。

關於entries\modules\chunks\assets\files這幾個Set物件是貫穿我們核心打包流程的屬性,它們各自用來儲存編譯階段不同的資源從而最終通過對應的屬性進行生成編譯後的檔案。

根據入口檔案路徑分析入口檔案

上邊說到我們在run方法中已經可以通過this.getEntry();獲得對應的入口物件了~

接下來就讓我們從入口檔案開始去分析入口檔案吧!

```js class Compiler { // run方法啟動編譯 // 同時run方法接受外部傳遞的callback run(callback) { // 當呼叫run方式時 觸發開始編譯的plugin this.hooks.run.call(); // 獲取入口配置物件 const entry = this.getEntry(); // 編譯入口檔案 this.buildEntryModule(entry); }

buildEntryModule(entry) { Object.keys(entry).forEach((entryName) => { const entryPath = entry[entryName]; const entryObj = this.buildModule(entryName, entryPath); this.entries.add(entryObj); }); }

// 模組編譯方法 buildModule(moduleName,modulePath) { // ... return {} } } ```

這裡我們添加了一個名為buildEntryModule方法作為入口模組編譯方法。迴圈入口物件,得到每一個入口物件的名稱和路徑。

比如如假使我們在開頭傳入entry:{ main:'./src/main.js' }的話,buildEntryModule獲得的形參entry{ main: "/src...[你的絕對路徑]" }, 此時我們buildModule方法接受的entryName為main,entryPath為入口檔案main對應的的絕對路徑。

單個入口編譯完成後,我們會在buildModule方法中返回一個物件。這個物件就是我們編譯入口檔案後的物件。

buildModule模組編譯方法

在進行程式碼編寫之前,我們先來梳理一下buildModule方法它需要做哪些事情:

  • buildModule接受兩個引數進行模組編譯,第一個為模組所屬的入口檔名稱,第二個為需要編譯的模組路徑。

  • buildModule方法要進行程式碼編譯的前提就是,通過fs模組根據入口檔案路徑讀取檔案原始碼。

  • 讀取檔案內容之後,呼叫所有匹配的loader對模組進行處理得到返回後的結果。

  • 得到loader處理後的結果後,通過babel分析loader處理後的程式碼,進行程式碼編譯。(這一步編譯主要是針對require語句,修改原始碼中require語句的路徑)。

  • 如果該入口檔案沒有依賴與任何模組(require語句),那麼返回編譯後的模組物件。

  • 如果該入口檔案存在依賴的模組,遞迴buildModule方法進行模組編譯。

讀取檔案內容

  1. 我們先呼叫fs模組讀取檔案內容。

```js const fs = require('fs'); // ... class Compiler { //... // 模組編譯方法 buildModule(moduleName, modulePath) { // 1. 讀取檔案原始程式碼 const originSourceCode = ((this.originSourceCode = fs.readFileSync(modulePath, 'utf-8')); // moduleCode為修改後的程式碼 this.moduleCode = originSourceCode; }

  // ...

} ```

呼叫loader處理匹配字尾檔案

  1. 接下來我們獲得了檔案的具體內容之後,就需要匹配對應loader對我們的原始碼進行編譯了。

實現簡單自定義loader

在進行loader編譯前,我們先來實現一下我們上方傳入的自定義loader吧。

image.png

webpack/loader目錄下新建loader-1.js,loader-2.js:

首先我們需要清楚簡單來說loader本質上就是一個函式,接受我們的原始碼作為入參同時返回處理後的結果。

關於loader的特性,更加詳細你可以在這裡看到,因為文章主要講述打包流程所以loader我們簡單的作為倒序處理。更加具體的loader/plugin開發我會在後續的文章詳細補充。

``js // loader本質上就是一個函式,接受原始內容,返回轉換後的內容。 function loader1(sourceCode) { console.log('join loader1'); return sourceCode +\n const loader1 = 'https://github.com/19Qingfeng'`; }

module.exports = loader1; ```

``js function loader2(sourceCode) { console.log('join loader2'); return sourceCode +\n const loader2 = '19Qingfeng'`; }

module.exports = loader2; ```

使用loader處理檔案

在搞清楚了loader就是一個單純的函式之後,讓我們在進行模組分析之前將內容先交給匹配的loader去處理下吧。

```js // 模組編譯方法 buildModule(moduleName, modulePath) { // 1. 讀取檔案原始程式碼 const originSourceCode = ((this.originSourceCode = fs.readFileSync(modulePath)), 'utf-8'); // moduleCode為修改後的程式碼 this.moduleCode = originSourceCode; // 2. 呼叫loader進行處理 this.handleLoader(modulePath); }

// 匹配loader處理 handleLoader(modulePath) { const matchLoaders = []; // 1. 獲取所有傳入的loader規則 const rules = this.options.module.rules; rules.forEach((loader) => { const testRule = loader.test; if (testRule.test(modulePath)) { if (loader.loader) { // 僅考慮loader { test:/.js$/g, use:['babel-loader'] }, { test:/.js$/, loader:'babel-loader' } matchLoaders.push(loader.loader); } else { matchLoaders.push(...loader.use); } } // 2. 倒序執行loader傳入原始碼 for (let i = matchLoaders.length - 1; i >= 0; i--) { // 目前我們外部僅支援傳入絕對路徑的loader模式 // require引入對應loader const loaderFn = require(matchLoaders[i]); // 通過loader同步處理我的每一次編譯的moduleCode this.moduleCode = loaderFn(this.moduleCode); } }); } ```

這裡我們通過handleLoader函式,對於傳入的檔案路徑匹配到對應字尾的loader後,依次倒序執行loader處理我們的程式碼this.moduleCode並且同步更新每次moduleCode

最終,在每一個模組編譯中this.moduleCode都會經過對應的loader處理。

webpack模組編譯階段

上一步我們經歷過loader處理了我們的入口檔案程式碼,並且得到了處理後的程式碼儲存在了this.moduleCode中。

此時,經過loader處理後我們就要進入webpack內部的編譯階段了。

這裡我們需要做的是:針對當前模組進行編譯,將當前模組所有依賴的模組(require())語句引入的路徑變為相對於跟路徑(this.rootPath)的相對路徑

總之你需要搞明白的是,我們這裡編譯的結果是期望將原始碼中的依賴模組路徑變為相對跟路徑的路徑,同時建立基礎的模組依賴關係。後續我會告訴你為什麼針對路徑進行編譯。

讓我們繼續來完善buildModule方法吧:

```js const parser = require('@babel/parser'); const traverse = require('@babel/traverse').default; const generator = require('@babel/generator').default; const t = require('@babel/types'); const tryExtensions = require('./utils/index') // ... class Compiler { // ...

 // 模組編譯方法
  buildModule(moduleName, modulePath) {
    // 1. 讀取檔案原始程式碼
    const originSourceCode =
      ((this.originSourceCode = fs.readFileSync(modulePath)), 'utf-8');
    // moduleCode為修改後的程式碼
    this.moduleCode = originSourceCode;
    //  2. 呼叫loader進行處理
    this.handleLoader(modulePath);
    // 3. 呼叫webpack 進行模組編譯 獲得最終的module物件
    const module = this.handleWebpackCompiler(moduleName, modulePath);
    // 4. 返回對應module
    return module
  }

  // 呼叫webpack進行模組編譯
  handleWebpackCompiler(moduleName, modulePath) {
    // 將當前模組相對於專案啟動根目錄計算出相對路徑 作為模組ID
    const moduleId = './' + path.posix.relative(this.rootPath, modulePath);
    // 建立模組物件
    const module = {
      id: moduleId,
      dependencies: new Set(), // 該模組所依賴模組絕對路徑地址
      name: [moduleName], // 該模組所屬的入口檔案
    };
    // 呼叫babel分析我們的程式碼
    const ast = parser.parse(this.moduleCode, {
      sourceType: 'module',
    });
    // 深度優先 遍歷語法Tree
    traverse(ast, {
      // 當遇到require語句時
      CallExpression:(nodePath) => {
        const node = nodePath.node;
        if (node.callee.name === 'require') {
          // 獲得原始碼中引入模組相對路徑
          const moduleName = node.arguments[0].value;
          // 尋找模組絕對路徑 當前模組路徑+require()對應相對路徑
          const moduleDirName = path.posix.dirname(modulePath);
          const absolutePath = tryExtensions(
            path.posix.join(moduleDirName, moduleName),
            this.options.resolve.extensions,
            moduleName,
            moduleDirName
          );
          // 生成moduleId - 針對於跟路徑的模組ID 新增進入新的依賴模組路徑
          const moduleId =
            './' + path.posix.relative(this.rootPath, absolutePath);
          // 通過babel修改原始碼中的require變成__webpack_require__語句
          node.callee = t.identifier('__webpack_require__');
          // 修改原始碼中require語句引入的模組 全部修改變為相對於跟路徑來處理
          node.arguments = [t.stringLiteral(moduleId)];
          // 為當前模組新增require語句造成的依賴(內容為相對於根路徑的模組ID)
          module.dependencies.add(moduleId);
        }
      },
    });
    // 遍歷結束根據AST生成新的程式碼
    const { code } = generator(ast);
    // 為當前模組掛載新的生成的程式碼
    module._source = code;
    // 返回當前模組物件
    return module
  }

} ```

這一步我們關於webpack編譯的階段就完成了。

需要注意的是:

  • 這裡我們使用babel相關的API針對於require語句進行了編譯,如果對於babel相關的api不太瞭解的朋友可以在前置知識中檢視我的另兩篇文章。這裡我就不在累贅了

  • 同時我們程式碼中引用了一個tryExtensions()工具方法,這個方法是針對於字尾名不全的工具方法,稍後你就可以看到這個方法的具體內容。

  • 針對於每一次檔案編譯,我們都會返回一個module物件,這個物件是重中之重。

    • id屬性,表示當前模組針對於this.rootPath的相對目錄。
    • dependencies屬性,它是一個Set內部儲存了該模組依賴的所有模組的模組ID。
    • name屬性,它表示該模組屬於哪個入口檔案。
    • _source屬性,它存放模組自身經過babel編譯後的字串程式碼。

tryExtensions方法實現

我們在上文的webpack.config.js有這麼一個配置:

image.png

熟悉webpack配置的同學可能清楚,resolve.extensions是針對於引入依賴時,在沒有書寫檔案字尾的情況下,webpack會自動幫我們按照傳入的規則為檔案新增字尾。

在清楚了原理後我們來一起看看utils/tryExtensions方法的實現:

```js

/ * * * @param {} modulePath 模組絕對路徑 * @param {} extensions 副檔名陣列 * @param {} originModulePath 原始引入模組路徑 * @param {} moduleContext 模組上下文(當前模組所在目錄) */ function tryExtensions( modulePath, extensions, originModulePath, moduleContext ) { // 優先嚐試不需要副檔名選項 extensions.unshift(''); for (let extension of extensions) { if (fs.existsSync(modulePath + extension)) { return modulePath + extension; } } // 未匹配對應檔案 throw new Error( No module, Error: Can't resolve ${originModulePath} in ${moduleContext} ); } ```

這個方法很簡單,我們通過fs.existsSync檢查傳入檔案結合extensions依次遍歷尋找對應匹配的路徑是否存在,如果找到則直接返回。如果未找到則給予用於一個友好的提示錯誤。

需要注意extensions.unshift('');是防止使用者如果已經傳入了字尾時,我們優先嚐試直接尋找,如果可以找到檔案那麼就直接返回。找不到的情況下才會依次嘗試。

遞迴處理

經過上一步處理,針對入口檔案我們呼叫buildModule可以得到這樣的返回物件。

我們先來看看執行webpack/core/index.js得到的返回結果吧。

image.png

我在buildEntryModule中列印了處理完成後的entries物件。可以看到正如我們之前所期待的:

  • id為每個模組相對於跟路徑的模組.(這裡我們配置的context:process.cwd())為webpack目錄。
  • dependencies為該模組內部依賴的模組,這裡目前還沒有新增。
  • name為該模組所屬的入口檔名稱。
  • _source為該模組編譯後的原始碼。

目前_source中的內容是基於

此時讓我們開啟src目錄為我們的兩個入口檔案新增一些依賴和內容吧:

```js // webpack/example/entry1.js const depModule = require('./module');

console.log(depModule, 'dep'); console.log('This is entry 1 !');

// webpack/example/entry2.js const depModule = require('./module');

console.log(depModule, 'dep'); console.log('This is entry 2 !');

// webpack/example/module.js const name = '19Qingfeng';

module.exports = { name, }; ```

此時讓我們重新執行webpack/core/index.js:

image.png

OK,目前為止我們針對於entry的編譯可以暫時告一段落了。

總之也就是,這一步我們通過`方法將entry進行分析編譯後得到一個物件。將這個物件新增到this.entries`中去。

接下來讓我們去處理依賴的模組吧。

其實對於依賴的模組無非也是相同的步驟:

  • 檢查入口檔案中是否存在依賴。
  • 存在依賴的話,遞迴呼叫buildModule方法編譯模組。傳入moduleName為當前模組所屬的入口檔案。modulePath為當前被依賴模組的絕對路徑。
  • 同理檢查遞迴檢查被依賴的模組內部是否仍然存在依賴,存在的話遞迴依賴進行模組編譯。這是一個深度優先的過程。
  • 將每一個編譯後的模組儲存進入this.modules中去。

接下來我們只要稍稍在handleWebpackCompiler方法中稍稍改動就可以了:

js // 呼叫webpack進行模組編譯 handleWebpackCompiler(moduleName, modulePath) { // 將當前模組相對於專案啟動根目錄計算出相對路徑 作為模組ID const moduleId = './' + path.posix.relative(this.rootPath, modulePath); // 建立模組物件 const module = { id: moduleId, dependencies: new Set(), // 該模組所依賴模組絕對路徑地址 name: [moduleName], // 該模組所屬的入口檔案 }; // 呼叫babel分析我們的程式碼 const ast = parser.parse(this.moduleCode, { sourceType: 'module', }); // 深度優先 遍歷語法Tree traverse(ast, { // 當遇到require語句時 CallExpression: (nodePath) => { const node = nodePath.node; if (node.callee.name === 'require') { // 獲得原始碼中引入模組相對路徑 const moduleName = node.arguments[0].value; // 尋找模組絕對路徑 當前模組路徑+require()對應相對路徑 const moduleDirName = path.posix.dirname(modulePath); const absolutePath = tryExtensions( path.posix.join(moduleDirName, moduleName), this.options.resolve.extensions, moduleName, moduleDirName ); // 生成moduleId - 針對於跟路徑的模組ID 新增進入新的依賴模組路徑 const moduleId = './' + path.posix.relative(this.rootPath, absolutePath); // 通過babel修改原始碼中的require變成__webpack_require__語句 node.callee = t.identifier('__webpack_require__'); // 修改原始碼中require語句引入的模組 全部修改變為相對於跟路徑來處理 node.arguments = [t.stringLiteral(moduleId)]; // 為當前模組新增require語句造成的依賴(內容為相對於根路徑的模組ID) module.dependencies.add(moduleId); } }, }); // 遍歷結束根據AST生成新的程式碼 const { code } = generator(ast); // 為當前模組掛載新的生成的程式碼 module._source = code; // 遞迴依賴深度遍歷 存在依賴模組則加入 module.dependencies.forEach((dependency) => { const depModule = this.buildModule(moduleName, dependency); // 將編譯後的任何依賴模組物件加入到modules物件中去 this.modules.add(depModule); }); // 返回當前模組物件 return module; }

這裡我們添加了這樣一段程式碼:

js // 遞迴依賴深度遍歷 存在依賴模組則加入 module.dependencies.forEach((dependency) => { const depModule = this.buildModule(moduleName, dependency); // 將編譯後的任何依賴模組物件加入到modules物件中去 this.modules.add(depModule); });

這裡我們對於依賴的模組進行了遞迴呼叫buildModule,將輸出的模組物件新增進入了this.modules中去。

此時讓我們重新執行webpack/core/index.js進行編譯,這裡我在buildEntryModule編譯結束後列印了assetsmodules:

image.png

js Set { { id: './example/src/entry1.js', dependencies: Set { './example/src/module.js' }, name: [ 'main' ], _source: 'const depModule = __webpack_require__("./example/src/module.js");\n' + '\n' + "console.log(depModule, 'dep');\n" + "console.log('This is entry 1 !');\n" + "const loader2 = '19Qingfeng';\n" + "const loader1 = 'https://github.com/19Qingfeng';" }, { id: './example/src/entry2.js', dependencies: Set { './example/src/module.js' }, name: [ 'second' ], _source: 'const depModule = __webpack_require__("./example/src/module.js");\n' + '\n' + "console.log(depModule, 'dep');\n" + "console.log('This is entry 2 !');\n" + "const loader2 = '19Qingfeng';\n" + "const loader1 = 'https://github.com/19Qingfeng';" } } entries Set { { id: './example/src/module.js', dependencies: Set {}, name: [ 'main' ], _source: "const name = '19Qingfeng';\n" + 'module.exports = {\n' + ' name\n' + '};\n' + "const loader2 = '19Qingfeng';\n" + "const loader1 = 'https://github.com/19Qingfeng';" }, { id: './example/src/module.js', dependencies: Set {}, name: [ 'second' ], _source: "const name = '19Qingfeng';\n" + 'module.exports = {\n' + ' name\n' + '};\n' + "const loader2 = '19Qingfeng';\n" + "const loader1 = 'https://github.com/19Qingfeng';" } } modules

可以看到我們已經將module.js這個依賴如願以償加入到modules中了,同時它也經過loader的處理。但是我們發現它被重複加入了兩次。

這是因為module.js這個模組被引用了兩次,它被entry1entry2都已進行了依賴,在進行遞迴編譯時我們進行了兩次buildModule相同模組。

讓我們來處理下這個問題:

js handleWebpackCompiler(moduleName, modulePath) { ... // 通過babel修改原始碼中的require變成__webpack_require__語句 node.callee = t.identifier('__webpack_require__'); // 修改原始碼中require語句引入的模組 全部修改變為相對於跟路徑來處理 node.arguments = [t.stringLiteral(moduleId)]; // 轉化為ids的陣列 好處理 const alreadyModules = Array.from(this.modules).map((i) => i.id); if (!alreadyModules.includes(moduleId)) { // 為當前模組新增require語句造成的依賴(內容為相對於根路徑的模組ID) module.dependencies.add(moduleId); } else { // 已經存在的話 雖然不進行新增進入模組編譯 但是仍要更新這個模組依賴的入口 this.modules.forEach((value) => { if (value.id === moduleId) { value.name.push(moduleName); } }); } } }, }); ... } 這裡在每一次程式碼分析的依賴轉化中,首先判斷this.module物件是否已經存在當前模組了(通過唯一的模組id路徑判斷)。

如果不存在則新增進入依賴中進行編譯,如果該模組已經存在過了就證明這個模組已經被編譯過了。所以此時我們不需要將它再次進行編譯,我們僅僅需要更新這個模組所屬的chunk,為它的name屬性添加當前所屬的chunk名稱。

重新執行,讓我們在來看看列印結果:

js Set { { id: './example/src/entry1.js', dependencies: Set { './example/src/module.js' }, name: [ 'main' ], _source: 'const depModule = __webpack_require__("./example/src/module.js");\n' + '\n' + "console.log(depModule, 'dep');\n" + "console.log('This is entry 1 !');\n" + "const loader2 = '19Qingfeng';\n" + "const loader1 = 'https://github.com/19Qingfeng';" }, { id: './example/src/entry2.js', dependencies: Set {}, name: [ 'second' ], _source: 'const depModule = __webpack_require__("./example/src/module.js");\n' + '\n' + "console.log(depModule, 'dep');\n" + "console.log('This is entry 2 !');\n" + "const loader2 = '19Qingfeng';\n" + "const loader1 = 'https://github.com/19Qingfeng';" } } entries Set { { id: './example/src/module.js', dependencies: Set {}, name: [ 'main', './module' ], _source: "const name = '19Qingfeng';\n" + 'module.exports = {\n' + ' name\n' + '};\n' + "const loader2 = '19Qingfeng';\n" + "const loader1 = 'https://github.com/19Qingfeng';" } } modules

此時針對我們的“模組編譯階段”基本已經結束了,這一步我們對於所有模組從入口檔案開始進行分析。

  • 從入口出發,讀取入口檔案內容呼叫匹配loader處理入口檔案。
  • 通過babel分析依賴,並且同時將所有依賴的路徑更換為相對於專案啟動目錄options.context的路徑。
  • 入口檔案中如果存在依賴的話,遞迴上述步驟編譯依賴模組。
  • 將每個依賴的模組編譯後的物件加入this.modules
  • 將每個入口檔案編譯後的物件加入this.entries

編譯完成階段

在上一步我們完成了模組之間的編譯,並且為moduleentry分別填充了內容。

在將所有模組遞迴編譯完成後,我們需要根據上述的依賴關係,組合最終輸出的chunk模組

讓我們來繼續改造我們的Compiler吧:

```js class Compiler {

// ...
buildEntryModule(entry) {
    Object.keys(entry).forEach((entryName) => {
      const entryPath = entry[entryName];
      // 呼叫buildModule實現真正的模組編譯邏輯
      const entryObj = this.buildModule(entryName, entryPath);
      this.entries.add(entryObj);
      // 根據當前入口檔案和模組的相互依賴關係,組裝成為一個個包含當前入口所有依賴模組的chunk
      this.buildUpChunk(entryName, entryObj);
    });
    console.log(this.chunks, 'chunks');
}

 // 根據入口檔案和依賴模組組裝chunks
  buildUpChunk(entryName, entryObj) {
    const chunk = {
      name: entryName, // 每一個入口檔案作為一個chunk
      entryModule: entryObj, // entry編譯後的物件
      modules: Array.from(this.modules).filter((i) =>
        i.name.includes(entryName)
      ), // 尋找與當前entry有關的所有module
    };
    // 將chunk新增到this.chunks中去
    this.chunks.add(chunk);
  }

  // ...

} `` 這裡,我們根據對應的入口檔案通過每一個模組(module)的name`屬性查詢對應入口的所有依賴檔案。

我們先來看看this.chunks最終會輸出什麼:

js Set { { name: 'main', entryModule: { id: './example/src/entry1.js', dependencies: [Set], name: [Array], _source: 'const depModule = __webpack_require__("./example/src/module.js");\n' + '\n' + "console.log(depModule, 'dep');\n" + "console.log('This is entry 1 !');\n" + "const loader2 = '19Qingfeng';\n" + "const loader1 = 'https://github.com/19Qingfeng';" }, modules: [ [Object] ] }, { name: 'second', entryModule: { id: './example/src/entry2.js', dependencies: Set {}, name: [Array], _source: 'const depModule = __webpack_require__("./example/src/module.js");\n' + '\n' + "console.log(depModule, 'dep');\n" + "console.log('This is entry 2 !');\n" + "const loader2 = '19Qingfeng';\n" + "const loader1 = 'https://github.com/19Qingfeng';" }, modules: [] } }

這一步,我們得到了Webpack中最終輸出的兩個chunk

它們分別擁有:

  • name:當前入口檔案的名稱
  • entryModule: 入口檔案編譯後的物件。
  • modules: 該入口檔案依賴的所有模組物件組成的陣列,其中每一個元素的格式和entryModule是一致的。

此時編譯完成我們拼裝chunk的環節就圓滿完成。

輸出檔案階段

我們先放一下上一步所有編譯完成後拼裝出來的this.chunks

分析原始打包輸出結果

這裡,我把webpack/core/index.js中做了如下修改:

```js - const webpack = require('./webpack'); + const webpack = require('webpack')

... ```

運用原本的webpack代替我們自己實現的webpack先進行一次打包。

執行webpack/core/index.js後,我們會在webpack/src/build中得到兩個檔案:main.jssecond.js,我們以其中一個main.js來看看它的內容:

```js (() => { var webpack_modules = { './example/src/module.js': (module) => { const name = '19Qingfeng';

  module.exports = {
    name,
  };

  const loader2 = '19Qingfeng';
  const loader1 = 'https://github.com/19Qingfeng';
},

}; // The module cache var webpack_module_cache = {};

// The require function function webpack_require(moduleId) { // Check if module is in cache var cachedModule = webpack_module_cache[moduleId]; if (cachedModule !== undefined) { return cachedModule.exports; } // Create a new module (and put it into the cache) var module = (webpack_module_cache[moduleId] = { // no module.id needed // no module.loaded needed exports: {}, });

// Execute the module function
__webpack_modules__[moduleId](module, module.exports, __webpack_require__);

// Return the exports of the module
return module.exports;

}

var webpack_exports = {}; // This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk. (() => { const depModule = webpack_require( /! ./module / './example/src/module.js' );

console.log(depModule, 'dep');
console.log('This is entry 1 !');

const loader2 = '19Qingfeng';
const loader1 = 'https://github.com/19Qingfeng';

})(); })();

```

這裡我手動刪除了打包生成後的多餘註釋,精簡了程式碼。

我們來稍微分析一下原始打包生成的程式碼:

webpack打包後的程式碼內部定義了一個__webpack_require__的函式代替了NodeJs內部的require方法。

同時底部的

image.png

這塊程式碼相比大家都很熟悉吧,這就是我們編譯後的入口檔案程式碼。同時頂部的程式碼是該入口檔案依賴的所有模組定義的一個物件:

image.png

這裡定義了一個__webpack__modules的物件,**物件的key為該依賴模組相對於跟路徑的相對路徑,物件的value該依賴模組編譯後的程式碼。`

輸出檔案階段

接下里在分析完webpack原始打包後的程式碼之後,上我們來繼續上一步。通過我們的this.chunks來嘗試輸出最終的效果吧。

讓我們回到Compiler上的run方法中:

```js class Compiler {

} // run方法啟動編譯 // 同時run方法接受外部傳遞的callback run(callback) { // 當呼叫run方式時 觸發開始編譯的plugin this.hooks.run.call(); // 獲取入口配置物件 const entry = this.getEntry(); // 編譯入口檔案 this.buildEntryModule(entry); // 匯出列表;之後將每個chunk轉化稱為單獨的檔案加入到輸出列表assets中 this.exportFile(callback); } ```

我們在buildEntryModule模組編譯完成之後,通過this.exportFile方法實現匯出檔案的邏輯。

讓我們來一起看看this.exportFile方法:

js // 將chunk加入輸出列表中去 exportFile(callback) { const output = this.options.output; // 根據chunks生成assets內容 this.chunks.forEach((chunk) => { const parseFileName = output.filename.replace('[name]', chunk.name); // assets中 { 'main.js': '生成的字串程式碼...' } this.assets.set(parseFileName, getSourceCode(chunk)); }); // 呼叫Plugin emit鉤子 this.hooks.emit.call(); // 先判斷目錄是否存在 存在直接fs.write 不存在則首先建立 if (!fs.existsSync(output.path)) { fs.mkdirSync(output.path); } // files中儲存所有的生成檔名 this.files = Object.keys(this.assets); // 將assets中的內容生成打包檔案 寫入檔案系統中 Object.keys(this.assets).forEach((fileName) => { const filePath = path.join(output.path, fileName); fs.writeFileSync(filePath, this.assets[fileName]); }); // 結束之後觸發鉤子 this.hooks.done.call(); callback(null, { toJson: () => { return { entries: this.entries, modules: this.modules, files: this.files, chunks: this.chunks, assets: this.assets, }; }, }); }

exportFile做了如下幾件事:

  • 首先獲取配置引數的輸出配置,迭代我們的this.chunks,將output.filename中的[name]替換稱為對應的入口檔名稱。同時根據chunks的內容為this.assets中新增需要打包生成的檔名和檔案內容。

  • 將檔案寫入磁碟前呼叫pluginemit鉤子函式。

  • 判斷output.path資料夾是否存在,如果不存在,則通過fs新建這個資料夾。

  • 將本次打包生成的所有檔名(this.assetskey值組成的陣列)存放進入files中去。

  • 迴圈this.assets,將檔案依次寫入對應的磁碟中去。

  • 所有打包流程結束,觸發webpack外掛的done鉤子。

  • 同時為NodeJs Webpack APi呼應,呼叫run方法中外部傳入的callback傳入兩個引數。

總的來說,this.assets做的事情也比較簡單,就是通過分析chunks得到assets然後輸出對應的程式碼到磁碟中。

仔細看過上邊程式碼,你會發現。this.assets這個Map中每一個元素的value是通過呼叫getSourceCode(chunk)方法來生成模組對應的程式碼的。

那麼getSourceCode這個方法是如何根據chunk來生成我們最終編譯後的程式碼呢?讓我們一起來看看吧!

getSourceCode方法

首先我們來簡單明確一下這個方法的職責,我們需要getSourceCode方法接受傳入的chunk物件。從而返回該chunk的原始碼。

廢話不多說,其實這裡我用了一個比較偷懶的辦法,但是完全不妨礙你理解Webpack流程,上邊我們分析過原本webpack打包後的程式碼僅僅只有入口檔案和模組依賴是每次打包不同的地方,關於require方法之類都是相通的

把握每次的不同點,我們直接先來看看它的實現方式:

```js // webpack/utils/index.js

...

/ * * * @param {} chunk * name屬性入口檔名稱 * entryModule入口檔案module物件 * modules 依賴模組路徑 / function getSourceCode(chunk) { const { name, entryModule, modules } = chunk; return (() => { var __webpack_modules__ = { ${modules .map((module) => { return '${module.id}': (module) => { ${module._source} } `; }) .join(',')} }; // The module cache var webpack_module_cache = {};

// The require function
function __webpack_require__(moduleId) {
  // Check if module is in cache
  var cachedModule = __webpack_module_cache__[moduleId];
  if (cachedModule !== undefined) {
    return cachedModule.exports;
  }
  // Create a new module (and put it into the cache)
  var module = (__webpack_module_cache__[moduleId] = {
    // no module.id needed
    // no module.loaded needed
    exports: {},
  });

  // Execute the module function
  __webpack_modules__[moduleId](module, module.exports, __webpack_require__);

  // Return the exports of the module
  return module.exports;
}

var __webpack_exports__ = {};
// This entry need to be wrapped in an IIFE because it need to be isolated against other modules in the chunk.
(() => {
  ${entryModule._source}
})();

})(); `; } ... ```

這段程式碼其實非常非常簡單,遠遠沒有你想象的多難!有點返璞歸真的感覺是嗎哈哈。

getSourceCode方法中,我們通過組合而來的chunk獲得對應的:

  • name: 該入口檔案對應輸出檔案的名稱。
  • entryModule: 存放該入口檔案編譯後的物件。
  • modules:存放該入口檔案依賴的所有模組的物件。

我們通過字串拼接的方式去實現了__webpack__modules物件上的屬性,同時也在底部通過${entryModule._source}拼接出入口檔案的程式碼。

這裡我們上文提到過為什麼要將模組的require方法的路徑轉化為相對於跟路徑(context)的路徑,看到這裡我相信為什麼這麼做大家都已經瞭然於胸了。因為我們最終實現的__webpack_require__方法全都是針對於模組跟路徑的相對路徑自己實現的require方法。

同時如果不太清楚require方法是如何轉變稱為__webpack_require__方法的同學可以重新回到我們的編譯章節仔細複習熬~我們通過babelAST轉化階段將require方法呼叫變成了__webpack_require__

大功告成

至此,讓我們回到webpack/core/index.js中去。重新執行這個檔案,你會發現webpack/example目錄下會多出一個build目錄。

image.png

這一步我們就完美的實現屬於我們自己的webpack

實質上,我們對於實現一個簡單版的webpack核心我還是希望大家可以在理解它的工作流的同時徹底理解compiler這個物件。

在之後的任何關於webpack相關底層開發中,真正做到對於compiler的用法瞭然於胸。瞭解compiler上的各種屬性是如何影響到編譯打包結果的。

讓我們用一張流程圖來進行一個完美的收尾吧:

image.png

寫在最後

首先,感謝每一位可以看到這裡的同學。

這篇文章相對有一定的知識門檻並且程式碼部分居多,敬佩每一位可以讀到結尾的同學。

文章中對於實現一個簡易版的Webpack在這裡就要和大家告一段落了,這其實只是一個最基礎版本的webpack工作流。

但是正是通過這樣一個小🌰可以帶我們真正入門webpack的核心工作流,希望這篇文章對於大家理解webpack時可以起到更好的輔助作用。

其實在理解清楚基礎的工作流之後,針對於loaderplugin開發都是信手拈來的部分,文章中對於這兩部分內容的開發介紹比較膚淺,後續我會分別更新有關loaderplugin的詳細開發流程。有興趣的同學可以及時關注😄。

文章中的程式碼你可以在這裡下載,這份簡易版的webpack我也會持續在程式碼庫中完善更多工作流的邏輯處理。

同時這裡這裡的程式碼我想強調的是原始碼流程的講解,真實的webpack會比這裡複雜很多很多。這裡為了方便大家理解刻意進行了簡化,但是核心工作流是和原始碼中基本一致的。