阅读 415

webpack的热更新原理你了解多少?(webpack热更新实现原理)

一、前言

Hot Module Replacement,简称HMR,无需完全刷新整个页面的同时,更新模块。好处就是优化了开发体验。

如果你配置过 Webpack,想必你也不陌生,HotModuleReplacementPlugindevServerhot:true来开启HMR功能。

二、了解下webpack的日常构建

当我们开启HMR通过devServer构建我们项目的时候,我们可以打开控制台network观察到,它会生成一个hash值41e99be0fd82c43bc67d,并且引入了main.41e99be0fd82c43bc67d.hot-update.js这个文件。1638753812(1).png

同时产生了热更新标识文件41e99be0fd82c43bc67d.update.json

1638755183(1).png

然后我们修改我们的代码重新编译,我们可以在控制台中观察到:

它会产生一个新的hash值(这个hash值是webpack自动为我们生成的),并且会产生新的js文件和一个hot-update.json热更新标识文件。

1638754860(1).png

1638754752.png

我们可以发现上一次输出的hash值会作文新生成文件文件名的前缀。依次类推我们再次修改文件成触发更新会产生新的文件,文件名以这一次的生成hash为前缀。

具体看下文件的内容。1638755183(1).png

  • h:生成的新hash值

  • c:当前热更新的是那个模块(我这里是入口文件main

那么最后,浏览器是怎么知道我们本地的代码发生改变的呢,并且引入了新的文件?让我们具体来了解下吧!

src=http___cj.aiziw.com_uploads_picagyw_20191208_5deca67ce694dagyw.jpg&refer=http___cj.aiziw.jpg

三、hot热更新实现

热更新hot是结合dev-server实现的,所以我们会从webpack-dev-server开始一步一步实现其中的原理,(^▽^)这里和大家说明文本的代码是我根据blibli上大佬视频和webpack源码实现的核心代码。

1. webpack-dev-server启动本地服务

const webpack = require('webpack');//引入webpack
const config = require('../webpack.config');//这个就是我们配置的webpack.config.js文件
const Server = require('./server/Server');

const compiler = webpack(config);//编译器对象
const server = new Server(compiler);
server.listen(9090,'localhost',()=>{
    console.log("服务在9090端口");
})复制代码

Server.js服务代码

class Server {
    constructor(compiler) {
        this.compiler = compiler;
        this.setupApp();
        this.createServer();//创建http服务器,以app作为路由
    }
    setupApp() {
        this.app = express();//本地服务还是依赖于express
    }
    createServer() {
        this.server = http.createServer(this.app);
    }
    listen(port, host, callback) {//监听服务
        this.server.listen(port, host, callback);
    }
}复制代码

这里的代码的工作内容:

  1. 引入webpack,并且通过webpack.config.js生成compiler实例(这里不了解的,大家可以先去了解下webpack的一个大致构建流程)。

  2. 依赖express框架来启动本地服务。

2.websocket

如何让浏览器知道我们的本地代码发生改变呢?源码里是依据websocket来实现的。

const socketIO = require('socket.io');
constructor(compiler) {
    this.currentHash;//当前hash
    this.clientSocketList = [];//websocket 连接列表
    this.createSocketServer();//创建socket服务器        
}
createSocketServer() {
        const io = socketIO(this.server);//websocker协议握手需要依赖http
        io.on("connection", (socket) => {//socket和客户端的链接对象
            console.log("新的客户端socket");
            this.clientSocketList.push(socket);
            socket.emit("hash", this.currentHash);//服务端主动发送hash
            socket.emit("ok");//发送 ok 字段
            socket.on("disconnect", () => {//监听断开
                let index = this.clientSocketList.indexOf(socket);
                this.clientSocketList.splice(index, 1);//断开需要从列表中删除
            })
        })
}复制代码

这里的代码的工作内容:

  1. 启动websocket服务,可以建立服务器和浏览器之间的双向通信。当监听到本地代码发生改变时,主动向浏览器发送新hash以及ok字段。并且往clientSocketList链接集合中加入当前socket,同时监听链接断开事件。

3.监听webpack编译完成

监听webpack编译结束,主要是通过挂载webpack中生命周期钩子函数done,为每个socket都发送hashok

constructor(compiler) {
    this.setupHooks();//建立钩子    
}
setupHooks() {
        let { compiler } = this;//拿到compiler对象
        //监听webpack编译完成事件
        compiler.hooks.done.tap('webpack-dev-server', (state) => {
            //state是一个描述对象,里面可以拿到hash值
            console.log('hash', state.hash);
            this.currentHash = state.hash;
            this.clientSocketList.forEach(socket => {
                socket.emit("hash", this.currentHash);
                socket.emit("ok");//给客户端发一个ok
            })
        })
}复制代码

我们可以看下done回调函数返回的state:

1638757758(1).png

4.静态资源访问中间件

在通过webpack-dev-server来运行我们的项目时,webpack会在本地启动一个服务,并且我们可以访问到我们打包输出的html页面,这里主要是通过一个中间件来完成(中间件是express中的一个概念,这里不做过多解释)。

const MemoryFs = require("memory-fs");
const mime = require('mime');

constructor(compiler) {
    this.setupDevMiddleware();//创建中间件
}
setupDevMiddleware() {
    this.middleware = this.webpackDevMiddleware();
}
webpackDevMiddleware() {//返回一个express中间件
        let { compiler } = this;
        
        // let fs = new MemoryFs();//内存文件系统实例
        this.fs = compiler.outputFileSystem = fs;

        return (staticDir) => {//静态文件跟目录,他其实就是输出目录
            return (req, res, next) => {
                let { url } = req;
                if (url === '/favicon.ico') {
                    return res.sendStatus(404);
                }
                url === '/' ? url = '/index.html' : null;
                let fs = new MemoryFs();//内存文件系统实例
                let filepath = path.join(staticDir, url);//得到访问的静态路径
                try {
                    let stateObj = this.fs.statSync(filepath);//返回此路径上的描述对象
                    if (stateObj.isFile()) {//如果是一个文件
                        let content = this.fs.readFileSync(filepath);
                        res.setHeader('Content-Type', mime.getType(filepath));//
                        res.send(content);
                    } else {
                        return res.sendStatus(404);
                    }
                } catch (err) {
                    return res.sendStatus(404);
                }
            }
        }
}复制代码

主要完成了对我们本地服务对静态文件的访问,其实就是为了能够本地能够访问我们打包后dist目录下的html文件。

5.配置路由

constructor(compiler) {
    this.routes();//配置路由
}
routes() {
        let { compiler } = this;
        let config = compiler.options;
        this.app.use(this.middleware(config.output.path));//使用我们上一步生成的静态文件中间件
}复制代码

这里主要是使用我们上一步生成的静态文件中间件,我们可以看到它的目录其实就是我们webpack.config.js文件中配置的输出目录,即dist

到此我们webpack-dev-server的功能已经完成了,我们可以启用node来跑一下,是否能够访问到页面。

u=401730814,3506625751&fm=26&fmt=auto.webp

四、打包后文件浏览器端

1.浏览器端websocket

以上我们的服务端已经完成了,当监听到webpack重新编译,服务端就发送最新的hahs值,那么客户端也应该有对应的websocket来响应。响应期间客户端代码又做了什么呢?

以下代码都会被webpack进行打包,所以会出现在html文件的引入文件中。

var currentHash;//当前hash
var lastHash;
const socket = window.io('/');
class EventEmitter {
    constructor() {
        this.events = {};
    }
    on(eventName, fn) {
        this.events[eventName] = fn;
    }
    emit(eventName, ...args) {
        this.events[eventName](...args);
    }
}
var hotEmitter = new EventEmitter();
socket.on('hash', (hash) => {//监听到服务端重新编译发送hash
    console.log(hash)
    currentHash = hash;
})
socket.on("ok", () => {//监听到服务端重新编译发送ok
    console.log("ok");
    reloadApp();
})
function reloadApp() {
     hotEmitter.emit('webpackHotUpdate');//发出webpackHotUpdate消息
}复制代码

客户端websocket注册和服务端一样注册了两个事件:

  • hash : webpack重新编辑打包后的新hash值 。

  • ok : 进行reloadApp热更新检查 。

热更新检查是调用了reloadApp方法,期间还调用了一层发布订阅模式 EventEmitter,利用EventEmitter派发一个webpackHotUpdate事件进行检查。

2.webpackHotUpdate

首先你可以对比下,配置热更新和不配置时bundle.js的区别。内存中看不到?因为webpack-dev-server中使用的是内存文件系统memory-fs,所以我们在dist目录下看不到我们打包的文件,我这里给它改成了fs-extra,这样就能看到了。

  1. 配置热更新的

1638787756(1).png

  1. 没有配置热更新的

1638787766(1).png

对比我们发现配置热更新的module中多了hot,我这里也写了一个hotCreateModule

function hotCreateModule() {//热创建模块
    let hot = {
    _acceptDependencies: {},
    accept(deps, callBack) {
       deps.forEach(dep => {
          hot._acceptDependencies[dep] = callBack;
       })
    },
    check: hotCheck
   }
   return hot;
}复制代码

现在我们知道module.hot.check方法从哪里来了吧!其实这些都是HotModuleReplacementPlugin插件的功能,它给我们打包出来的代码添了不少。你也可以直接在浏览器Sources下阅读这些代码,也方便调试。

hotEmitter.on("webpackHotUpdate", () => {
   if (!lastHash) {//如果没有  就是第一次渲染
      lastHash = currentHash;
      return;
   }
   console.log(lastHash, currentHash);
   module.hot.check();//module.hot.check方法检查
})复制代码

3.hotCheck

function hotCheck() {//热更新检查
        hotDownloadManifest().then(update => {//下载热更新标识文件
            let chunkIds = Object.keys(update.c);
            chunkIds.forEach(chunkId => {//遍历发送改变的模块
                hotDownloadUpdateChunk(chunkId);
            })
            lastHash = currentHash;
        }).catch(() => {
            window.location.reload();
        })
}
function hotDownloadManifest() {//下载更新文件
        return new Promise((resolve, reject) => {
            let xhr = new XMLHttpRequest();
            let url = `${lastHash}.hot-update.json`;//通过ajax去下载热更新标识文件 json
            xhr.open('get', url);
            xhr.responseType = "json";
            xhr.onload = function () {
                resolve(xhr.response);
            }
            xhr.send();
        })
}复制代码
  • 利用上一次保存的hash值,调用hotDownloadManifest发送xxx/hash.hot-update.jsonajax请求。

  • 保存下次热更新的hash标识。

function hotDownloadUpdateChunk(chunkId) {//将新代码通过script加载到html
        let script = document.createElement('script');
        script.src = `${chunkId}.${lastHash}.hot-update.js`;
        document.head.appendChild(script);
}复制代码
  • 这里通过jsonp的方式发送xxx/hash.hot-update.js请求,获取最新的代码

这里可以看下里面的代码长什么样:

1638839561(1).png

可以看到里面是我改动最新的代码的源代码,是可以直接执行的,内部调用了一个webpackHotUpdate函数,所以我们来看下这个方法:

window.webpackHotUpdate = function (chunkId, moreModules) {//chunk名字,更新代码的模块
    hotAddUpdateChunk(chunkId, moreModules)
}
let hotUpdate = {};
function hotAddUpdateChunk(chunkId, moreModules) {
    for (let moduleId in moreModules) {
         modules[moduleId] = hotUpdate[moduleId] = moreModules[moduleId];//更新新模块
    }
    hotApply();
}复制代码

webpack在打包后会将代码整合成一个个module的形式

1638839561(1).png

上面代码就是将图中eval(第6行)代码给替换了,替换成我们最新的可执行代码。

u=3359535103,3965852938&fm=26&fmt=auto.webp

4.hotApply

热更新的核心逻辑就在hotApply方法了。

function hotApply() {
        for (let moduleId in hotUpdate) {
            let oldModule = installedModules[moduleId];//从缓存中找到老模块
            let { parents, children } = oldModule;//获取老模块的父模块数组和子模块数组
            let module = installedModules[moduleId] = {//模块缓存替换 
                i: moduleId,
                l: false,
                exports: {},
                parents: parents,
                children: children,
                hot: hotCreateModule()
            }
            modules[moduleId].call(module.exports, module, module.exports, hostCreateRequire(moduleId));//执行模块
            module.l = true;//该模块已经执行
            oldModule.parents.forEach(parentModule => {//遍历所有的父亲模块执行回调
                let cb = parentModule.hot._acceptDependencies[moduleId];
                cb && cb();
            })
        }
}


作者:zxl樑樑
链接:https://juejin.cn/post/7038767740059926565


文章分类
代码人生
文章标签
版权声明:本站是系统测试站点,无实际运营。本文内容由互联网用户自发贡献,该文观点仅代表作者本人。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如发现本站有涉嫌抄袭侵权/违法违规的内容, 请发送邮件至 XXXXXXo@163.com 举报,一经查实,本站将立刻删除。
相关推荐