webpack模块热更新原理

avatar
前端工程师 @公众号:ELab团队

什么是模块热更新?

模块热替换(hot module replacement 或 HMR)是 webpack 提供的最有用的功能之一。它允许在运行时更新所有类型的模块,而无需完全刷新。

下面我们运行一个例子来更直观的感受什么是模块热更新。

7fkj4-q8g9z.gif

视频中,我修改了字体颜色,页面会立即更新,但输入框中的内容依然保留着。HMR就是帮助我们实现了这样一个效果,不然我们在每次修改代码时,还需要手动刷新页面,且页面的内容不会保留。模块热更新的好处显而易见,它可以帮助我们节省开发时间,提升开发体验。

细心的同学可能会发现,webpack自动进行重新编译同时又多生成了两个文件。

  • HMR 是怎样实现自动编译的?
  • 模块内容的变更浏览器又是如何感知的?
  • 以及新产生的两个文件又是干嘛的?
  • 局部更新又是如何做到的?

下面让我们带着这些疑问,一起来探索模块热更新的原理。

模块热更新的配置

在学习原理前,我们需要对模块热更新的配置有一个清晰的认识。因为平时的工作中很少需要我们自己手动去配置,所以会导致我们忽略一些细节的问题。现在我们来回顾一下配置流程,这样更有助于对源码的理解。

第一步:安装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()



//添加如下内容

+ 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(),

    ],

}

现在你可能会有一些疑问,为什么平时修改代码的时候不用监听module.hot.accept也能实现热更新?那是因为我们使用的 loader 已经在幕后帮我们实现了。

webpack-dev-server 提供了实时重加载的功能,但是不能局部刷新。必须配合后两步的配置才能实现局部刷新,这两步的背后其实是借助了HotModuleReplacementPlugin

可以说HMR是webpack-dev-serverHotModuleReplacementPlugin 共同的功劳。

热更新原理

下面就正式进入我们今天的主题。先来介绍第一位主角:webpack-dev-server。

Webpack-dev-server

通过node_modules/webpack-dev-server下的package.json文件,根据 bin 的值可以找到命令实际运行的文件。./node_modules/webpack-dev-server/bin/webpack-dev-server.js

下面我们就顺着入口文件,来看一看webpack-dev-server都做了哪些事。为了减少篇幅,提高阅读质量,以下示例均为简易版的实现,感兴趣的可以参照源码一起来看。

1、开启本地服务

首先通过webpack创建了一个compiler实例,然后通过创建自定义server实例,开启了一个本地服务。

// 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 不仅是创建了一个http服务,它还基于http服务创建了一个websocket服务,同时监听浏览器的接入,当浏览器成功接入时向它发送hash值,从而实现服务端和浏览器间的双向通信。

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

class Server {

    constructor() {

        this.setupApp();

        this.createServer();

    }

    //创建http应用

    setupApp() {

        this.app = express();

    }

    //创建http服务

    createServer() {

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

    }

    //监听端口号 

    listen(port, host, callback) {

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

        this.createSocketServer();

    }

    //基于http服务创建websocket服务,并注册监听事件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、监听编译完成

仅仅在建立websocket连接时,服务端向浏览器发送hash和拉取代码的通知还不够,我们还希望当代码改变时,浏览器也可以接到这样的通知。于是,在开启服务前,还需要对编译完成事件进行监听。

//监听编译完成,当编译完成后通过websocket向浏览器发送广播

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、监听文件修改

要想在代码修改的时候,触发重新编译,那么就需要对代码的变动进行监听。这一步,源码是通过webpackDevMiddleware库实现的。库中使用了compiler.watch对文件的修改进行了监听,并且通过memory-fs实现了将编译的产物存放到内存中,这也是为什么我们在dist目录下看不到变化的内容,放到内存的好处就是为了更快的读写从而提高开发效率。

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

const MemoryFs = require('memory-fs')

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

let fs = new MemoryFs();

this.fs = compiler.outputFileSystem = fs;

4、向浏览器中插入客户端代码

前面提到要想实现浏览器和本地服务的通信,那么就需要浏览器接入到本地开启的websocket服务,然而浏览器本身并不具备这样的能力,这就需要我们自己提供这样的客户端代码将它运行在浏览器。因此自定Server在开启http服务之前,就调用了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 连接,当服务端发送hash广播时就保存hash,当服务端发送ok广播时就调用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()继续调用module.hot.check(),当然第一次加载页面时是不会被调用的。至于这里为啥会分成两个文件,个人理解是为了解藕,每个模块负责不同的分工。

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

现在,我们来看一下今天的第二位主角HotModuleReplacementPlugin 在main.js都悄悄插了哪些代码,从而实现的热更新。

1、为模块添加hot属性

前面提到过,当代码发生改动时,服务端会向浏览器发送ok消息,浏览器会执行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()就是调用hotCheck,此时浏览器会向服务端获取两个补丁文件。

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发生了改变

  • main.d04feccfa446b174bc10.hot-update.js

告知浏览器,main 代码块中的/src/title.js模块变更的内容

首先是通过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 拼接文件名进而获取文件内容。

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、模块内容替换

当hot-update.js文件加载好后,就会执行window.webpackHotUpdate,进而调用了hotApply。hotApply根据模块ID找到旧模块然后将它删除,然后执行父模块中注册的accept回调,从而实现模块内容的局部更新。

    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()

            })

        }

    }

总结

模块热更新原理总结:

image.png 在执行npm run dev 后,首先会通过updateCompiler方法去修改compiler的entry,将两个文件的代码一起打包到main.js,这两个文件一个是用来与服务端进行通信的,一个是用来调用module.hot.check的。接着通过compiler.hooks.done.tap来监听编译完成,通过compiler.watch 监听代码的改动,通过createSocketServer()开启http服务和websocekt服务。

当用户访问http://localhost:8080时,浏览器会与服务端建立websocket连接。随后服务端向浏览器发送hash 和 ok ,用来通知浏览器当前最新编译版本的hash值和告诉浏览器拉取代码。同时服务端,会根据路由,将内存中的文件返回,此时浏览器保存hash,页面内容出现。

当修改本地代码时,会触发重新编译,此时webpackDevMiddleWare会将编译的产物保存到内存中,这得益于内置模块memory-fs的功劳。同时HotModuleReplacementPlugin 会生成两个补丁包,这两个补丁包一个是用来告诉浏览器哪个chunk变更了,一个是用来告诉浏览器变更模块及内容。当重新编译完成,浏览器会保存当前hash,然后通上一次的hash 值拼接出要请求的描述文件路径,再根据描述文件返回的内容,拼接出要另一个要请求的补丁包文件。请求成功就开始执行webpckHotUdate了,会继续调用 hotApply,实质就是执行了我们当初在配置模块热更新第二步中的回调事件,从而实现了页面内容的局部刷新。

参考文档:

模块热替换 | webpack 中文文档

轻松理解webpack热更新原理 - 掘金