Webpack源碼解讀:理清編譯主流程

前言

webpack的熟練使用已成爲當代前端工程師必備的生存技能。毋庸置疑,webpack已成爲前端構建工具的佼佼者,網絡上關於如何使用webpack的技術文檔層出不窮。但鮮有能將webpack的構建流程講清楚的。本文嘗試從解讀源碼以及斷點調試的方式,來探究 webpack 是如何一步步的構建資源的。css

截至本文發表前,webpack的最新版本爲webpack 5.0.0-beta.1,即本文的源碼來自於最新的webpack v5前端

特別說明,本文所列源碼均通過精簡加工,若是要看具體代碼你能夠根據我標識的源碼文件名訪問webpack官方庫查看。本文精簡部分:node

  • 刪除了模塊引入,即 const xxx = require('XXX');
  • 異常兜底代碼,雖然異常處理也很重要,但本文主要分析webpack正常工做的主流程,若是異常處理不可忽視,我會特別說明;

如何調試webpack

我一向認爲學習源碼並非硬着頭皮去一行行的閱讀代碼,對於一個成熟的開源項目,一定是存在不少錯綜複雜的分支走向。試着一步步的調試代碼來跟蹤程序運行路徑,是快速瞭解一個項目基本架構的最快方式。webpack

VS Code編輯器中完善的Debugger功能是調試Node程序最好利器。git

  1. 首先,爲了學習webpack源碼,你必須先從webpack庫clone一份源碼到本地:
git clone https://github.com/webpack/webpack.git
複製代碼
  1. 安裝項目依賴;VS Code打開本地webpack倉庫
npm install
cd webpack/
code .
複製代碼
  1. 爲了避免污染項目根目錄,在根目錄下新建debug文件夾,用於存放調試代碼,debug文件夾結構以下:
debug-|
      |--dist    // 打包後輸出文件
      |--src
         |--index.js   // 源代碼入口文件
      |--package.json  // debug時須要安裝一些loader和plugin
      |--start.js      // debug啓動文件
      |--webpack.config.js  // webpack配置文件
複製代碼

詳細debug代碼以下:github

//***** debug/src/index.js *****
import is from 'object.is'  // 這裏引入一個小而美的第三方庫,以此觀察webpack如何處理第三方包
console.log('很高興認識你,webpack')
console.log(is(1,1))


//***** debug/start.js *****
const webpack = require('../lib/index.js')  // 直接使用源碼中的webpack函數
const config = require('./webpack.config')
const compiler = webpack(config)
compiler.run((err, stats)=>{
    if(err){
        console.error(err)
    }else{
        console.log(stats)
    }
})


//***** debug/webpack.config.js *****
const path = require('path')
module.exports = {
    context: __dirname,
    mode: 'development',
    devtool: 'source-map',
    entry: './src/index.js',
    output: {
        path: path.join(__dirname, './dist'),
    },
    module: {
        rules: [
            {
                test: /\.js$/,
                use: ['babel-loader'],
                exclude: /node_modules/,
            }
        ]
    }
}
複製代碼
  1. 在VS Code的Debug欄添加調試 配置:
{
    "configurations": [
        {
            "type": "node",
            "request": "launch",
            "name": "啓動webpack調試程序",
            "program": "${workspaceFolder}/debug/start.js"
        }
    ]
}
複製代碼

配置完成後,試着點擊一下 ► (啓動) 看看調試程序是否正常運行(若是成功,在debug/dist中會打包出一個main.js文件)。web

若是你有時間,我但願你能親手完成一次webpack調試流程,我相信你會有收穫的。探索欲是人類的天性。shell

接下來,經過斷點調試,來一步步剖析webpack是如何工做的吧。npm

源碼解讀

webpack啓動方式

webpack有兩種啓動方式:json

  1. 經過webpack-cli腳手架來啓動,便可以在Terminal終端直接運行;
webpack ./debug/index.js --config ./debug/webpack.config.js
複製代碼

這種方式是最爲經常使用也是最快捷的方式,開箱即用。

  1. 經過require('webpack')引入包的方式執行;

其實第一種方式最終仍是會用require的方式來啓動webpack,用興趣的能夠查看./bin/webpack.js文件。

webpack編譯的起點

一切從const compiler = webpack(config)開始。

webpack函數源碼(./lib/webpack.js):

const webpack = (options, callback) => {
    let compiler = createCompiler(options)
    // 若是傳入callback函數,則自啓動
    if(callback){
        compiler.run((err, states) => {
            compiler.close((err2)=>{
                callbacl(err || err2, states)
            })
        })
    }
    return compiler
}
複製代碼

webpack函數執行後返回compiler對象,在webpack中存在兩個很是重要的核心對象,分別爲compilercompilation,它們在整個編譯過程當中被普遍使用。

  • Compiler類(./lib/Compiler.js):webpack的主要引擎,在compiler對象記錄了完整的webpack環境信息,在webpack從啓動到結束,compiler只會生成一次。你能夠在compiler對象上讀取到webpack config信息,outputPath等;
  • Compilation類(./lib/Compilation.js):表明了一次單一的版本構建和生成資源。compilation編譯做業能夠屢次執行,好比webpack工做在watch模式下,每次監測到源文件發生變化時,都會從新實例化一個compilation對象。一個compilation對象表現了當前的模塊資源、編譯生成資源、變化的文件、以及被跟蹤依賴的狀態信息。

二者的區別?
compiler表明的是不變的webpack環境; compilation表明的是一次編譯做業,每一次的編譯均可能不一樣;

舉個栗子🌰:
compiler就像一條手機生產流水線,通上電後它就能夠開始工做,等待生產手機的指令; compliation就像是生產一部手機,生產的過程基本一致,但生產的手機多是小米手機也多是魅族手機。物料不一樣,產出也不一樣。

Compiler類在函數createCompiler中實例化(./lib/index.js):

const createCompiler = options => {
    const compiler = new Compiler(options.context)
    // 註冊全部的自定義插件
    if(Array.isArray(options.plugin)){
        for(const plugin of options.plugins){
            if(typeof plugin === 'function'){
                plugin.call(compiler, compiler)
            }else{
                plugin.apply(compiler)
            }
        }
    }
    compiler.hooks.environment.call()
    compiler.hooks.afterEnvironment.call()
    compiler.options = new WebpackOptionsApply().process(options, compiler)  // process中註冊全部webpack內置的插件
    return compiler
}
複製代碼

Compiler類實例化後,若是webpack函數接收了回調callback,則直接執行compiler.run()方法,那麼webpack自動開啓編譯之旅。若是未指定callback回調,須要用戶本身調用run方法來啓動編譯。

從上面源碼中,能夠得出一些信息:

  • compiler由Compiler實例化,裏面的屬性和方法後面一節會提到,其中最重要的是compiler.run()方法;

  • 遍歷webpack config中的plugins數組,這裏我加粗了plugins數組,因此配置plugins時不要配成對象了。(事實上,在webpack函數中會對options作object schema的校驗)。

  • plugin:若是 plugin 是函數,直接調用它;若是 plugin 是其餘類型(主要是object類型),執行plugin對象的apply方法。apply函數簽名:(compiler) => {}

    webpack很是嚴格的要求咱們plugins數組元素必須是函數,或者一個有apply字段的對象且apply是函數,緣由就在於此。

    {
    plugins: [ new HtmlWebpackPlugin() ]
    }
    複製代碼
  • 調用鉤子:compiler.hooks.environment.call() 以及 compiler.hooks.afterEnvironment.call()是源碼閱讀至此咱們最早遇到的鉤子調用,在以後的閱讀中,你會遇到更多的鉤子註冊與調用。要理解webpack鉤子的應用,須要先了解Tapable,這是編寫插件的基礎。

    關於Tapable,我會」另案處理「它的。

  • process(options):在 webpack config中,除了plugins還有其餘不少的字段呢,那麼process(options)的做用就是一個個的處理這些字段。

至此,咱們瞭解了webpack在初始化階段作了哪些準備工做。當點燃導火索compiler.run()時,纔是webpack真正強大的時候。」兵馬未動,糧草先行「,在此以前,須要先看看new WebpackOptionsApply().process(options, compiler)作了哪些準備工做,它爲後面編譯階段提供了重要的後勤保衛。

process(options, compiler)

WebpackOptionsApply類的工做就是對webpack options進行初始化。 打開源碼文件lib/WebpackOptionsApply.js,你會發現前五十行都是各類webpack內置的Plugin的引入,那麼能夠猜測process方法應該是各類各樣的new SomePlugin().apply()的操做,事實就是如此。

精簡源碼(lib/WebpackOptionsApply.js):

class WebpackOptionsApply extends OptionsApply {
    constructor() {
        super();
    }
    process(options, compiler){
    // 當傳入的配置信息知足要求,處理與配置項相關的邏輯
        if(options.target) {
            new OnePlugin().apply(compiler)
        }
        if(options.devtool) {
            new AnotherPlugin().apply(compiler)
        }
        if ...
		
        new JavascriptModulesPlugin().apply(compiler);
        new JsonModulesPlugin().apply(compiler);
        new ...
		
        compiler.hooks.afterResolvers.call(compiler);
    }
}
複製代碼

源碼中...省略號省略了不少類似的操做,process函數很長,有接近500行左右的代碼,主要作了兩件事:

  1. new不少的Plugin,而且apply它們。

    在上一小節中,咱們知道webpack插件其實就是一個提供apply方法的類,它在合適的時候會被webpack實例化並執行apply方法。而apply方法接收了 compiler 對象,方便在hooks上監聽消息。 同時在process函數中實例化的各個Plugin都是webpack本身維護的,所以你會發現webpack項目根目錄下有不少的以Plugin結尾的文件。而用戶自定義的插件在以前就已經註冊完成了。 不一樣插件有本身不一樣的使命,它們的職責是鉤住compiler.hooks上的一個消息,一旦某個消息被觸發,註冊在消息上的回調根據hook類型依次調用。所謂「鉤住」的三個方式:tap tapAsync tapPromise,你須要知道Tapable的工做原理哦。

  2. 根據options.xxx的配置項,作初始化工做,而大多數初始化工做仍是在幹上面👆的事情

這一小結總結一下:process函數執行完,webpack將全部它關心的hook消息都註冊完成,等待後續編譯過程當中挨個觸發。

執行process方法裝填好彈藥,等待大戰即發。

compiler.run()

先貼上源碼吧(./lib/Compiler.js):

class Compiler {
    constructor(context){
    // 全部鉤子都是由`Tapable`提供的,不一樣鉤子類型在觸發時,調用時序也不一樣
    this.hooks = {
            beforeRun: new AsyncSeriesHook(["compiler"]),
            run: new AsyncSeriesHook(["compiler"]),
            done: new AsyncSeriesHook(["stats"]),
            // ...
        }
    }
  
    // ...
	
    run(callback){
        const onCompiled = (err, compilation) => {
            if(err) return
            const stats = new Stats(compilation);
            this.hooks.done.callAsync(stats, err => {
                if(err) return
                callback(err, stats)
                this.hooks.afterDone.call(stats)
            })
        }
        this.hooks.beforeRun.callAsync(this, err => {
            if(err) return
            this.hooks.run.callAsync(this, err => {
                if(err) return
                this.compile(onCompiled)
            })
        })
    }
}
複製代碼

通讀一遍run函數過程,你會發現它鉤住了編譯過程的一些階段,並在相應階段去調用已經提早註冊好的鉤子函數(this.hooks.xxxx.call(this)),效果與React中生命週期函數是同樣的。在run函數中出現的鉤子有:beforeRun --> run --> done --> afterDone。第三方插件能夠鉤住不一樣的生命週期,接收compiler對象,處理不一樣邏輯。

run函數鉤住了webpack編譯的前期和後期的階段,那麼中期最爲關鍵的代碼編譯過程就交給了this.compile()來完成了。在this.comille()中,另外一個主角compilation粉墨登場了。

compiler.compile()

compiler.compile函數是模塊編譯的主戰場,話很少說,先貼上精簡後僞代碼:

compile(callback){
    const params = this.newCompilationParams()  // 初始化模塊工廠對象
    this.hooks.beforeCompile.callAsync(params, err => {
        this.hooks.compile.call(params)
        // compilation記錄本次編譯做業的環境信息 
        const compilation = new Compilation(this)
        this.hooks.make.callAsync(compilation, err => {
            compilation.finish(err => {
                compilation.seal(err=>{
                    this.hooks.afterCompile.callAsync(compilation, err => {
                        return callback(null, compilation)
                    })
                })
            })
        })
    })
}
複製代碼

compile函數和run同樣,觸發了一系列的鉤子函數,在compile函數中出現的鉤子有:beforeCompile --> compile --> make --> afterCompile

其中make就是咱們關心的編譯過程。但在這裏它僅是一個鉤子觸發,顯然真正的編譯執行是註冊在這個鉤子的回調上面。

webpack由於有Tapable的加持,代碼編寫很是靈活,node中流行的callback回調機制(說的就是回調地獄),webpack使用的爐火純青,若是用斷點調試,可能不太容易捕捉到。這裏我使用搜索關鍵詞的方法反向查找make鉤子是在哪裏註冊的。

經過搜索關鍵詞hooks.make.tapAsync咱們發如今lib/EntryPlugin.js中找到了它的身影。

依靠搜索關鍵詞,會列出較多幹擾項,聰明的你就須要識別出哪一個選項纔是最接近實際狀況的。

此時,咱們要倒查一下這個EntryPlugin是在何時被調用的,繼續關鍵詞new EntryPlugin搜索,在lib/EntryOptionPlugin.js中找到了它,並且其中你發現了熟悉的「東西」:

if(typeof entry === "string" || Array.isArray(entry)){
	applyEntryPlugins(entry, "main")
}else if (typeof entry === "object") {
  for (const name of Object.keys(entry)) {
    applyEntryPlugins(entry[name], name);
  }
} else if (typeof entry === "function") {
  new DynamicEntryPlugin(context, entry).apply(compiler);
}
複製代碼

還記得在webpack.config.js中,entry字段是怎麼配置的嗎?此時你會明白entry是字符串或數組時,打包出來的資源統一叫main.js這個名字了。

咱們的回溯尚未結束,繼續搜索關鍵詞new EntryOptionPlugin,Oops,搜索到的文件就是lib/WebpackOptionsApply.js。如此一切都明瞭了,make鉤子在process函數中就已經註冊好了,就等着你來調用。

回到lib/EntryPlugin.js看看compiler.hooks.make.tapAsync都幹了啥。其實就是運行compiliation.addEntry方法,繼續探索compiliation.addEntry

addEntry(context, entry, name, callback) {
    this.hooks.addEntry.call(entry, name);
    // entryDependencies中的每一項都表明了一個入口,打包輸出就會有多個文件
    let entriesArray = this.entryDependencies.get(name)
	entriesArray.push(entry)
    this.addModuleChain(context, entry, (err, module) => {
        this.hooks.succeedEntry.call(entry, name, module);
        return callback(null, module);
    })
}
複製代碼

addEntry的做用是將模塊的入口信息傳遞給模塊鏈中,即addModuleChain,隨後繼續調用compiliation.factorizeModule,這些調用最後會將entry的入口信息」翻譯「成一個模塊(嚴格上說,模塊是NormalModule實例化後的對象)。讀這段源碼的時候,有點難理解,因爲node回調地獄的陷進,我一度覺得entry的處理應該是同步,後來發現process.nextTick的使用使得不少回調都是異步調用的。建議在這裏多斷點,多調試,以理解彎彎繞的異步回調。

這裏我列出相關函數的調用順序:this.addEntry --> this.addModuleChain --> this.handleModuleCreation --> this.addModule --> this.buildModule --> this._buildModule --> module.build(this指代compiliation)`。

最終會走到NormalModule對象(./lib/NormalModule.js)中,執行build方法。

normalModule.build方法中會先調用自身doBuild方法:

const { runLoaders } = require("loader-runner");
doBuild(options, compilation, resolver, fs, callback){
    // runLoaders從包'loader-runner'引入的方法
    runLoaders({
        resource: this.resource,  // 這裏的resource多是js文件,多是css文件,多是img文件
        loaders: this.loaders,
    }, (err, result) => {
        const source = result[0];
        const sourceMap = result.length >= 1 ? result[1] : null;
        const extraInfo = result.length >= 2 ? result[2] : null;
        // ...
    })
}
複製代碼

其實doBuild就是選用合適的loader去加載resource,目的是爲了將這份resource轉換爲JS模塊(緣由是webpack只識別JS模塊)。最後返回加載後的源文件source,以便接下來繼續處理。

webpack對處理標準的JS模塊很在行,但處理其餘類型文件(css, scss, json, jpg)等就無能爲力了,此時它就須要loader的幫助。loader的做用就是轉換源代碼爲JS模塊,這樣webpack就能夠正確識別了。 loader的做用就像是Linux中信息流管道,它接收源碼字符串流,加工一下,而後返回加工後的源碼字符串交給下一個loader繼續處理。 loader的基本範式:(code, sourceMap, meta) => string

通過了doBuild後,任何的模塊都轉換成標準JS模塊。

能夠試着在js代碼中引入css代碼,觀察一下轉換出的標準JS模塊的數據結構。

接下來就是編譯標準JS代碼了。在傳入doBuild的回調函數中這樣處理source

const result = this.parser.parse(source)
複製代碼

而這裏的this.parser其實就是JavascriptParser的實例對象,最終JavascriptParser會調用第三方包acorn提供的parse方法對JS源代碼進行語法解析。

parse(code, options){
    // 調用第三方插件`acorn`解析JS模塊
    let ast = acorn.parse(code)
    // 省略部分代碼
    if (this.hooks.program.call(ast, comments) === undefined) {
        this.detectStrictMode(ast.body)
        this.prewalkStatements(ast.body)
        this.blockPrewalkStatements(ast.body)
        // 這裏webpack會遍歷一次ast.body,其中會收集這個模塊的全部依賴項,最後寫入到`module.dependencies`中
        this.walkStatements(ast.body)
    }
}
複製代碼

有個線上小工具 AST explorer 能夠在線將JS代碼轉換爲語法樹AST,將解析器選擇爲acorn便可。將調試代碼./debug/src/index.js使用acron解析一下語法,獲得以下的數據結構:

可能你會有些疑惑,一般咱們會使用一些相似於babel-loader等 loader 預處理源文件,那麼webpack 在這裏的parse具體做用是什麼呢?parse的最大做用就是收集模塊依賴關係,好比調試代碼中出現的import {is} from 'object-is'const xxx = require('XXX')的模塊引入語句,webpack會記錄下這些依賴項,記錄在module.dependencies數組中。

compilation.seal()

至此,從入口文件開始,webpack收集完整了該模塊的信息和依賴項,接下來就是如何進一步打包封裝模塊了。

在執行compilation.seal(./lib/Compliation)以前,你能夠打個斷點,查看此時compilation.modules的狀況。此時compilation.modules有三個子模塊,分別爲./src/index.js node_modules/object.is/index.js 以及 node_modules/object.is/is.is

compilation.seal的步驟比較多,先封閉模塊,生成資源,這些資源保存在compilation.assets, compilation.chunks

你會在多數第三方webpack插件中看到compilation.assetscompilation.chunks 的身影。

而後調用compilation.createChunkAssets方法把全部依賴項經過對應的模板 render 出一個拼接好的字符串:

createChunkAssets(callback){
    asyncLib.forEach(
        this.chunks,
        (chunk, callback) => {
            // manifest是數組結構,每一個manifest元素都提供了 `render` 方法,提供後續的源碼字符串生成服務。至於render方法什麼時候初始化的,在`./lib/MainTemplate.js`中
            let manifest = this.getRenderManifest()
            asyncLib.forEach(
                manifest,
                (fileManifest, callback) => {
                    ...
                    source = fileManifest.render()
                    this.emitAsset(file, source, assetInfo)
                },
                callback
            )
        },
        callback
    )
}
複製代碼

能夠在createChunkAssets方法體中的this.emitAsset(file, source, assetInfo)代碼行打上斷點,觀察此時source中的數據結構。在source._source字段已經初見打包後源碼雛形:

值得一提的是,createChunkAssets執行過程當中,會優先讀取cache中是否已經有了相同hash的資源,若是有,則直接返回內容,不然纔會繼續執行模塊生成的邏輯,並存入cache中。

compiler.hooks.emit.callAsync()

在seal執行後,關於模塊全部信息以及打包後源碼信息都存在內存中,是時候將它們輸出爲文件了。接下來就是一連串的callback回調,最後咱們到達了compiler.emitAssets方法體中。在compiler.emitAssets中會先調用this.hooks.emit生命週期,以後根據webpack config文件的output配置的path屬性,將文件輸出到指定的文件夾。至此,你就能夠在./debug/dist中查看到調試代碼打包後的文件了。

this.hooks.emit.callAsync(compilation, () => {
    outputPath = compilation.getPath(this.outputPath, {})
    mkdirp(this.outputFileSystem, outputPath, emitFiles)
 })
複製代碼

總結

很是感謝你閱讀到最後,本文篇幅較長,簡單總結一下 webpack 編譯模塊的基本流程:

  1. 調用webpack函數接收config配置信息,並初始化compiler,在此期間會apply全部 webpack 內置的插件;
  2. 調用compiler.run進入模塊編譯階段;
  3. 每一次新的編譯都會實例化一個compilation對象,記錄本次編譯的基本信息;
  4. 進入make階段,即觸發compilation.hooks.make鉤子,從entry爲入口: a. 調用合適的loader對模塊源碼預處理,轉換爲標準的JS模塊; b. 調用第三方插件acorn對標準JS模塊進行分析,收集模塊依賴項。同時也會繼續遞歸每一個依賴項,收集依賴項的依賴項信息,不斷遞歸下去;最終會獲得一顆依賴樹🌲;
  5. 最後調用compilation.seal render 模塊,整合各個依賴項,最後輸出一個或多個chunk;

如下爲簡單的時序圖:

以上過程並不能徹底歸納webpack的所有流程,隨着webpack.config配置愈來愈複雜,webpack會衍生更多的流程去應對不一樣的狀況。

webpack複雜嗎?很複雜,TabableNode回調讓整個流程存在多種多樣的走向,也由於它的插件系統,讓 webpack 高度可配置。 webpack容易嗎?也容易,它只作了一件事,編譯打包JS模塊,並把這件事作到極致完美。

最後

碼字不易,若是:

  • 這篇文章對你有用,請不要吝嗇你的小手爲我點贊;
  • 有不懂或者不正確的地方,請評論,我會積極回覆或勘誤;
  • 指望與我一同持續學習前端技術知識,請關注我吧;
  • 轉載請註明出處;

您的支持與關注,是我持續創做的最大動力!

相關文章
相關標籤/搜索