webpack模塊熱更新原理

以下文章來源于ELab團隊 ,作者ELab.tanyueying

什么是模塊熱更新?
模塊熱替換(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允許在運行時更新所有類型的模塊,而無需完全刷新。

下面我們運行一個例子來更直觀的感受什么是模塊熱更新。

視頻中,我修改了字體顏色,頁面會立即更新,但輸入框中的內(nèi)容依然保留著。HMR就是幫助我們實現(xiàn)了這樣一個效果,不然我們在每次修改代碼時,還需要手動刷新頁面,且頁面的內(nèi)容不會保留。模塊熱更新的好處顯而易見,它可以幫助我們節(jié)省開發(fā)時間,提升開發(fā)體驗。

細(xì)心的同學(xué)可能會發(fā)現(xiàn),webpack自動進行重新編譯同時又多生成了兩個文件。


HMR 是怎樣實現(xiàn)自動編譯的?
模塊內(nèi)容的變更瀏覽器又是如何感知的?
以及新產(chǎn)生的兩個文件又是干嘛的?
局部更新又是如何做到的?
下面讓我們帶著這些疑問,一起來探索模塊熱更新的原理。

模塊熱更新的配置
在學(xué)習(xí)原理前,我們需要對模塊熱更新的配置有一個清晰的認(rèn)識。因為平時的工作中很少需要我們自己手動去配置,所以會導(dǎo)致我們忽略一些細(xì)節(jié)的問題?,F(xiàn)在我們來回顧一下配置流程,這樣更有助于對源碼的理解。

第一步:安裝webpack-dev-server

npm install --save-dev. webpack-dev-server
第二步:在父模塊中注冊module.hot.accept事件

//src/index.js

let div = document.createElement('div');

document.body.appendChild(div);

let input = document.createElement('input');

document.body.appendChild(input);

let render = () => {

    let title = require('./title.js')

    div.innerHTML = title;

}

render()

//添加如下內(nèi)容

+ if (module.hot) {

+     module.hot.accept(['./title.js'], render)

+ }
// 子模塊 src/title.js

module.exports = 'Hello webpack'
第三步:在webpack.config.js中配置hot:true

const path = require('path');

const HtmlWebpackPlugin = require('html-webpack-plugin')

module.exports = {

    mode: 'development',

    devtool: 'source-map',

    entry: './src/index.js',

    output: {

        filename: 'main.js',

        path: path.resolve(__dirname, 'dist')

    },

 +   devServer: {

 +       hot: true

 +   },

    plugins: [

        new HtmlWebpackPlugin(),

    ],

}
現(xiàn)在你可能會有一些疑問,為什么平時修改代碼的時候不用監(jiān)聽module.hot.accept也能實現(xiàn)熱更新?那是因為我們使用的 loader 已經(jīng)在幕后幫我們實現(xiàn)了。

webpack-dev-server 提供了實時重加載的功能,但是不能局部刷新。必須配合后兩步的配置才能實現(xiàn)局部刷新,這兩步的背后其實是借助了HotModuleReplacementPlugin。

可以說HMR是webpack-dev-server和HotModuleReplacementPlugin 共同的功勞。

熱更新原理
下面就正式進入我們今天的主題。先來介紹第一位主角:webpack-dev-server。

Webpack-dev-server
通過node_modules/webpack-dev-server下的package.json文件,根據(jù) bin 的值可以找到命令實際運行的文件。./node_modules/webpack-dev-server/bin/webpack-dev-server.js


下面我們就順著入口文件,來看一看webpack-dev-server都做了哪些事。為了減少篇幅,提高閱讀質(zhì)量,以下示例均為簡易版的實現(xiàn),感興趣的可以參照源碼一起來看。

1、開啟本地服務(wù)
首先通過webpack創(chuàng)建了一個compiler實例,然后通過創(chuàng)建自定義server實例,開啟了一個本地服務(wù)。

// node_modules/webpack-dev-server/bin/webpack-dev-server.js

const webpack = require('webpack');

const config = require('../../webpack.config');

const Server = require('../lib/Server')

const compiler = webpack(config);

const server = new Server(compiler);

server.listen(8080, 'localhost', () => {})
這個自定義Server 不僅是創(chuàng)建了一個http服務(wù),它還基于http服務(wù)創(chuàng)建了一個websocket服務(wù),同時監(jiān)聽瀏覽器的接入,當(dāng)瀏覽器成功接入時向它發(fā)送hash值,從而實現(xiàn)服務(wù)端和瀏覽器間的雙向通信。

// node_modules/webpack-dev-server/lib/Server.js

class Server {

    constructor() {

        this.setupApp();

        this.createServer();

    }

    //創(chuàng)建http應(yīng)用

    setupApp() {

        this.app = express();

    }

    //創(chuàng)建http服務(wù)

    createServer() {

        this.server = http.createServer(this.app);

    }

    //監(jiān)聽端口號

    listen(port, host, callback) {

        this.server.listen(port, host, callback)

        this.createSocketServer();

    }

    //基于http服務(wù)創(chuàng)建websocket服務(wù),并注冊監(jiān)聽事件connection

    createSocketServer() {

        const io = socketIO(this.server);

        io.on('connection', (socket) => {

            this.clientSocketList.push(socket);

            socket.emit('hash', this.currentHash);

            socket.emit('ok');

            socket.on('disconnect', () => {

                let index = this.clientSocketList.indexOf(socket);

                this.clientSocketList.splice(index, 1)

            })

        })

    }

}



module.exports = Server;





2、監(jiān)聽編譯完成
僅僅在建立websocket連接時,服務(wù)端向瀏覽器發(fā)送hash和拉取代碼的通知還不夠,我們還希望當(dāng)代碼改變時,瀏覽器也可以接到這樣的通知。于是,在開啟服務(wù)前,還需要對編譯完成事件進行監(jiān)聽。

//監(jiān)聽編譯完成,當(dāng)編譯完成后通過websocket向瀏覽器發(fā)送廣播

setupHooks() {

    let { compiler } = this;

    compiler.hooks.done.tap('webpack-dev-server', (stats) => {

        this.currentHash = stats.hash;

        this.clientSocketList.forEach((socket) => {

            socket.emit('hash', this.currentHash);

            socket.emit('ok');

        })

    })

}
3、監(jiān)聽文件修改
要想在代碼修改的時候,觸發(fā)重新編譯,那么就需要對代碼的變動進行監(jiān)聽。這一步,源碼是通過webpackDevMiddleware庫實現(xiàn)的。庫中使用了compiler.watch對文件的修改進行了監(jiān)聽,并且通過memory-fs實現(xiàn)了將編譯的產(chǎn)物存放到內(nèi)存中,這也是為什么我們在dist目錄下看不到變化的內(nèi)容,放到內(nèi)存的好處就是為了更快的讀寫從而提高開發(fā)效率。

// node_modules/webpack-dev-middleware/index.js

const MemoryFs = require('memory-fs')

compiler.watch({}, () => {})

let fs = new MemoryFs();

this.fs = compiler.outputFileSystem = fs;
4、向瀏覽器中插入客戶端代碼
前面提到要想實現(xiàn)瀏覽器和本地服務(wù)的通信,那么就需要瀏覽器接入到本地開啟的websocket服務(wù),然而瀏覽器本身并不具備這樣的能力,這就需要我們自己提供這樣的客戶端代碼將它運行在瀏覽器。因此自定Server在開啟http服務(wù)之前,就調(diào)用了updateCompiler()方法,它修改了webpack配置中的entry,使得插入的兩個文件的代碼可以一同被打包到 main.js 中,運行在瀏覽器。

//node_modules/webpack-dev-server/lib/utils/updateCompiler.js

const path = require('path');

function updateCompiler(compiler) {

    compiler.options.entry = {

        main: [

            path.resolve(__dirname, '../../client/index.js'),

            path.resolve(__dirname, '../../../webpack/hot/dev-server.js'),

            config.entry,

        ]

    }

}

module.exports = updateCompiler
node_modules /webpack-dev-server/client/index.js

這段代碼會放在瀏覽器作為客戶端代碼,它用來建立 websocket 連接,當(dāng)服務(wù)端發(fā)送hash廣播時就保存hash,當(dāng)服務(wù)端發(fā)送ok廣播時就調(diào)用reloadApp()。

let currentHash;

let hotEmitter = new EventEmitter();

const socket = window.io('/');

socket.on('hash', (hash) => {

    currentHash = hash;

})

socket.on('ok', () => {

    reloadApp();

})


function reloadApp() {

    hotEmitter.emit('webpackHotUpdate', currentHash)

}
webpack/hot/dev-server.js

reloadApp()繼續(xù)調(diào)用module.hot.check(),當(dāng)然第一次加載頁面時是不會被調(diào)用的。至于這里為啥會分成兩個文件,個人理解是為了解藕,每個模塊負(fù)責(zé)不同的分工。

let lastHash;

hotEmitter.on('webpackHotUpdate', (currentHash) => {

    if (!lastHash) {

        lastHash = currentHash;

        return;

    }

    module.hot.check();

})
module.hot.check()是哪來的?答案是HotModuleReplacementPlugin。我們可以在瀏覽器的sources下看到,main.js被插入很多代碼,這些代碼就是被HotModuleReplacementPlugin 插入進來的。


它不僅在main.js中插入了代碼,前面提到過的編譯后生成的兩個補丁包也是它生成的 。

HotModuleReplacementPlugin
現(xiàn)在,我們來看一下今天的第二位主角HotModuleReplacementPlugin 在main.js都悄悄插了哪些代碼,從而實現(xiàn)的熱更新。

1、為模塊添加hot屬性
前面提到過,當(dāng)代碼發(fā)生改動時,服務(wù)端會向瀏覽器發(fā)送ok消息,瀏覽器會執(zhí)行module.hot.check進行模塊熱檢查。check方法就是來源于這里了。

function hotCreateModule() {

    let hot = {

        _acceptedDependencies: {},

        accept(deps, callback) {

            deps.forEach(dep => hot._acceptedDependencies[dep] = callback);

        },

        check: hotCheck

    }

    return hot

}
2、請求補丁文件
module.hot.check()就是調(diào)用hotCheck,此時瀏覽器會向服務(wù)端獲取兩個補丁文件。

function hotCheck() {

    hotDownloadManifest().then(update => {

        //{"h":"eb861ba9f6408c42f1fd","c":{"main":true}}

        let chunkIds = Object.keys(update.c) //['main']

        chunkIds.forEach(chunkId => {

            hotDownloadUpdateChunk(chunkId)

        })

        lastHash = currentHash;

    }).catch(() => {

        window.location.reload();

    })

}





先看一眼這兩個文件長什么樣

d04feccfa446b174bc10.hot-update.json
告知瀏覽器新的hash值,并且是哪個chunk發(fā)生了改變


main.d04feccfa446b174bc10.hot-update.js
告知瀏覽器,main 代碼塊中的/src/title.js模塊變更的內(nèi)容


首先是通過XMLHttpRequest的方式,利用上一次保存的hash值請求hot-update.json文件。這個描述文件的作用就是提供了修改的文件所在的chunkId。

    function hotDownloadManifest() {

        return new Promise(function (resolve, reject) {

            let xhr = new XMLHttpRequest();

            let url = `${lastHash}.hot-update.json`

            xhr.open('get', url);

            xhr.responseType = 'json'

            xhr.onload = function () {

                resolve(xhr.response)

            }

            xhr.send()

        })

    }
然后通過JSONP的方式,利用hot-update.json返回的chunkId 及 上一次保存的hash 拼接文件名進而獲取文件內(nèi)容。

function hotDownloadUpdateChunk(chunkId) {

    let script = document.createElement('script');

    script.src = `${chunkId}.${lastHash}.hot-update.js`;

    document.head.appendChild(script);

}

window.webpackHotUpdate = function (chunkId, moreModules) {

    hotAddUpdateChunk(chunkId, moreModules);

}
3、模塊內(nèi)容替換
當(dāng)hot-update.js文件加載好后,就會執(zhí)行window.webpackHotUpdate,進而調(diào)用了hotApply。hotApply根據(jù)模塊ID找到舊模塊然后將它刪除,然后執(zhí)行父模塊中注冊的accept回調(diào),從而實現(xiàn)模塊內(nèi)容的局部更新。

    window.webpackHotUpdate = function (chunkId, moreModules) {

        hotAddUpdateChunk(chunkId, moreModules);

    }

    let hotUpdate = {}

    function hotAddUpdateChunk(chunkId, moreModules) {

        for (let moduleId in moreModules) {

            modules[moduleId] = hotUpdate[moduleId] = moreModules[moduleId];

        }

        hotApply();

    }

    function hotApply() {

        for (let moduleId in hotUpdate) {

            let oldModule = installedModules[moduleId]

            delete installedModules[moduleId]

            oldModule.parents.forEach((parentModule) => {

                let cb = parentModule.hot._acceptedDependencies[moduleId]

                cb && cb()

            })

        }

    }
總結(jié)
模塊熱更新原理總結(jié):


在執(zhí)行npm run dev 后,首先會通過updateCompiler方法去修改compiler的entry,將兩個文件的代碼一起打包到main.js,這兩個文件一個是用來與服務(wù)端進行通信的,一個是用來調(diào)用module.hot.check的。接著通過compiler.hooks.done.tap來監(jiān)聽編譯完成,通過compiler.watch 監(jiān)聽代碼的改動,通過createSocketServer()開啟http服務(wù)和websocekt服務(wù)。

當(dāng)用戶訪問http://localhost:8080時,瀏覽器會與服務(wù)端建立websocket連接。隨后服務(wù)端向瀏覽器發(fā)送hash 和 ok ,用來通知瀏覽器當(dāng)前最新編譯版本的hash值和告訴瀏覽器拉取代碼。同時服務(wù)端,會根據(jù)路由,將內(nèi)存中的文件返回,此時瀏覽器保存hash,頁面內(nèi)容出現(xiàn)。

當(dāng)修改本地代碼時,會觸發(fā)重新編譯,此時webpackDevMiddleWare會將編譯的產(chǎn)物保存到內(nèi)存中,這得益于內(nèi)置模塊memory-fs的功勞。同時HotModuleReplacementPlugin 會生成兩個補丁包,這兩個補丁包一個是用來告訴瀏覽器哪個chunk變更了,一個是用來告訴瀏覽器變更模塊及內(nèi)容。當(dāng)重新編譯完成,瀏覽器會保存當(dāng)前hash,然后通上一次的hash 值拼接出要請求的描述文件路徑,再根據(jù)描述文件返回的內(nèi)容,拼接出要另一個要請求的補丁包文件。請求成功就開始執(zhí)行webpckHotUdate了,會繼續(xù)調(diào)用 hotApply,實質(zhì)就是執(zhí)行了我們當(dāng)初在配置模塊熱更新第二步中的回調(diào)事件,從而實現(xiàn)了頁面內(nèi)容的局部刷新。

參考文檔:
模塊熱替換 | webpack 中文文檔[1]

輕松理解webpack熱更新原理 - 掘金[2]

參考資料

[1] 模塊熱替換 | webpack 中文文檔: https://webpack.docschina.org/guides/hot-module-replacement/

[2] 輕松理解webpack熱更新原理 - 掘金:https://juejin.cn/post/6844904008432222215

作者:ELab.tanyueying



歡迎關(guān)注微信公眾號 :前端晚間課

更多文章,收錄于小程序-互聯(lián)網(wǎng)小兵