likes
comments
collection
share

探究webpack代码热更新原理

作者站长头像
站长
· 阅读数 27

一、前备知识

1.HMR - Hot Module Replacement 热模块替换 发生代码改动时,保持当前页面状态的同时,局部更新修改模块

2.http协议的特点 只能由客户端主动发起请求。如果服务器处理某些数据需要时间比较久或者链路过长,真正的接口处理结果只能由前端多次轮询获取,轮询非常耗费资源,需要保持http连接的同时间断性发送请求

3.websocket协议的特点 全双工通信。建立在TCP协议之上,保证数据的可靠性,建立链接前也存在握手阶段,与http协议有良好的兼容性。数据格式轻量,可以发送文本或者二级制数据,不存在同源策略,协议标志符是ws 这里推荐一下阮一峰老师的文章,介绍websocket基础用法的,websocket基础

二、原理猜测

1.需要在node层启动两个服务端,一个使用http协议负责静态资源路由,一个使用websocket协议负责服务端下发更新消息通知

2.服务端需要具备监听文件更新的能力,每次开发人员更改文件代码后触发webpack文件监听,然后重新编译。然后识别出更改的模块Id,通过websocket服务端下发到浏览器

3.客户端需要有自动搭建websocket客户端的能力,接收到服务端返下发的更新通知以及新模块Id后,再通过http协议主动拉取新的模块代码,并用新的模块代码替换旧的模块,替换之后再执行新的模块代码

三、初探-从表面现象开始

首次运行npm run dev,控制台输出本次编译构建的hash值作为标记,还有对应的bundle.js文件。每次更改代码重新触发编译之后,控制台都会输出对应编译过程的标志hash值,bundle文件,以及带有上个hash值标志的json和hot-update.js文件。然后客户端会收到服务端下发的hash和ok值,然后根据hash值进行JSONP的请求到服务端获取更改后的模块代码并且更新到缓存并执行新的代码块

这里不难看出,热更新的核心原理是利用了http + websocket,websocket用来服务端下发代码更新通知,而真正获取代码都是通过http协议,利用了websocket全双工通信+轻量特点,但是由于无同源策略,出于安全的考虑具体到数据代码获取都是通过http协议。类似数据加密过程是SM2 + SM4结合的方式,充分发挥双方的优点

四、深究-透过现象看本质

  1. 从webpack配置开始 配置代码热更新,需要在webpack.dev.js中配置devServer: {hot: true},

在package.json中添加scripts,scripts: {"dev": "webpack-dev-server --config webpack.dev.js --open"}

所以下一步的重点是webpack-dev-server指令会运行的入口文件

  1. 找到webpack-dev-server主入口文件 .bin/webpack-dev-server === webpack-dev-server/bin/webpack-dev-server.js 提取原文件中的关键代码
// webpack-dev-server/bin/webpack-dev-server.js文件中的代码

const Server = require('../lib/Server');
let server;

// 处理webpack-dev-server的配置参数,并开启devServer 
processOptions(config, argv, (config, options) => { 
    startDevServer(config, options); 
}); 

function startDevServer(config, options) { 
    let compiler = webpack(config); 
    // 这里的server是全局变量 
    server = new Server(compiler, options, log); 
    if (options.socket) { 
        server.listen(options.socket, options.host, (err) => {}) 
    } else { 
        server.listen(options.port, options.host, (err) => {}) 
    } 
}
  1. 深入核心,了解如何通过compiler初始化服务器server对象,并且调用listen方法
// webpack-dev-server/lib/Server.js 

class Server { 
    constructor(compiler, options = {}, _log) { 
        // 保存webpack实例 
        this.compiler = compiler; 
        
        // 保存用户的配置参数 
        this.options = options; 
        
        this.heartbeatInterval = 30000; 
        // socketServer参数 
        this.socketServerImplementation = getSocketServerImplementation(this.options); 
        this.sockets = []; 
        
        // 设置文件监听的目录范围 
        this.contentBaseWatchers = []; 
        
        // 开启代码热更新的必备参数 
        this.hot = this.options.hot || this.options.hotOnly; 
        
        // 文件监听配置 
        this.watchOptions = options.watchOptions || {}; 
        
        this.setupHooks(); 
        this.setupApp(); 
        this.setupDevMiddleware(); 
        this.createServer(); 
    } 
    
    // 使用文件编译结束的钩子 
    setupHooks() { 
        const addHooks = (compiler) => { 
            done.tap('webpack-dev-server', (stats) => { 
                // 服务端编译结束通过websocket告知客户端,以及传递当前的hash值和ok 
                this._sendStats(this.sockets, this.getStats(stats)); 
                this._stats = stats; 
            }) 
        } 
        if (this.compiler.compilers) { 
            this.compiler.compilers.forEach(addHooks); 
        } else { 
            addHooks(this.compiler); 
        } 
    } 
    _sendStats(sockets, stats, force) { 
        this.sockWrite(sockets, 'hash', stats.hash); 
        this.sockWrite(sockets, 'ok'); 
    } 
    
    // 利用express初始化一个服务器,用于静态资源的路由 
    setupApp() { 
        this.app = new express(); 
    } 
    
    // 配置express搭建后的服务器,确认使用的协议 
    createServer() { 
        // 如果使用的协议是https 
        if (this.options.https) { 
            const isHttp2 = this.options.http2 !== false; 
            if (semver.gte(process.version, '10.0.0') || !isHttp2) { 
                this.listeningApp = https.createServer(this.options.https, this.app); 
            } else { 
                    this.listeningApp = require('spdy').createServer(this.options.https, this.app); 
            } 
        } else { 
             this.listeningApp = http.createServer(this.app); 
        } 
    } 
         
    // 配置文件监听 
    setupDevMiddleware() { 
        // middleware for serving webpack bundle 
        this.middleware = webpackDevMiddleware( this.compiler, Object.assign({}, this.options, { logLevel: this.log.options.level }) ) 
    }
    
    // 创建websocket服务器,用于下发模块更新的通知到客户端 
    createSocketServer() { 
        const SocketServerImplementation = this.socketServerImplementation; 
        this.socketServer = new SocketServerImplementation(this); 
        this.socketServer.onConnection((connection, headers) => {}) 
    } 
    
    // 监听对应的端口开启静态资源路由,同时部署另一个websocket服务器 
    listen(port, hostname, fn) { 
        return this.listeningApp.listen(port, hostname, (err) => { 
            this.createSocketServer(); 
        } 
    } 
} 

// 添加两个打包入口模块,利用webpack将相关代码注入到bundle.js中,用于客户端开启websokct以及处理热模块替换 
Server.addDevServerEntrypoints = require('./utils/addEntries'); 

module.exports = Server;

到这里webpack的HMR在node层做的处理基本完成了,这部分同样是让服务端拥有静态资源路由以及主动下发代码更新通知到客户端的能力,下面看一下如何实现客户端接收websocket通知后主动拉取更新后的服务端代码,并且替换执行新的模块代码

webpack客户端的代码肯定不会让开发人员自己去实现,不然就会出现千奇百怪的问题。这部分代码处理被黑盒处理,隐藏在了Server.addDevServerEntrypoints方法内,悄悄得在webpack带包过程中添加entry注入这部分代码处理

  1. 巧妙地划分客户端能力到两个模块中
// webpack-dev-server/utils/addEntries.js 
function addEntries(config, options, server) { 
    const domain = createDomain(options, app); 
    const sockHost = options.sockHost ? `&sockHost=${options.sockHost}` : ''; 
    const sockPath = options.sockPath ? `&sockPath=${options.sockPath}` : ''; 
    const sockPort = options.sockPort ? `&sockPort=${options.sockPort}` : ''; 
    
    // 引入搭建websocket客户端代码块module 
    const clientEntry = `${require.resolve( '../../client/' )}?${domain}${sockHost}${sockPath}${sockPort}`; 
    
    // 处理客户端从服务端获取新模块并且替换执行的代码块module 
    let hotEntry; 
    if (options.hotOnly) { 
        hotEntry = require.resolve('webpack/hot/only-dev-server'); 
    } else if (options.hot) { 
        hotEntry = require.resolve('webpack/hot/dev-server'); } 
    } 
    
module.exports = addEntries;

从这里可以看出来,客户端需要的两个能力被划分到了两个代码模块中,一个是搭建websocket客户端,一个是处理客户端的代码模块更新和替换

  1. 搭建websocket客户端
// webpack-dev-server/client/index.js 
var socket = require('./socket'); 
var sendMessage = require('./utils/sendMessage'); 
var createSocketUrl = require('./utils/createSocketUrl'); 
var reloadApp = require('./utils/reloadApp'); 
var socketUrl = createSocketUrl(__resourceQuery); 

var onSocketMessage = { 
    // 接收websocket服务端返回的最新hash值 
    hash: function hash(_hash) { 
        status.currentHash = _hash; 
    } 
    ok: function ok() { 
        sendMessage('Ok'); 
        reloadApp(options, status); 
    } 
} 

socket(socketUrl, onSocketMessage);

当客户端收到服务端返回的ok消息推送时,会调用reloadApp,这里看一下具体是怎么处理的

// webpack-dev-server/client/utils/reloadApp.js 
function reloadApp(_ref, _ref2) { 
    if (hot) { 
        log.info('[WDS] App hot update...'); 
        var hotEmitter = require('webpack/hot/emitter'); 
        hotEmitter.emit('webpackHotUpdate', currentHash); 
        
        // 如果当前宿主是浏览器环境,则触发webpackHotUpdate消息推送 
        if (typeof self !== 'undefined' && self.window) { 
            self.postMessage("webpackHotUpdate".concat(currentHash), '*'); 
        } 
    } 
} 

module.exports = reloadApp;

所以调用this.postMessage("webpackHotUpdate".concat(currentHash), '*')

有发送就会有接收,找一下对应的回调处理,而处理这部分的代码被划分到了hot模块中,根据hash获取新的代码模块并进行替换执行

  1. 处理客户端的代码模块更新和替换
// webpack/hot/dev-server.js 

if (module.hot) { 
    var lastHash; 
    var check = function check() { 
        module.hot 
            .check(true) 
                .then(function(updatedModules) { 
                    // 容错,如果不存在待更新的模块,直接刷新页面 
                    if (!updatedModules) { 
                        log("warning", "[HMR] Cannot find update. Need to do a full reload!"); log( "warning", "[HMR] (Probably because of restarting the webpack-dev-server)" ); 
                        window.location.reload(); 
                        return; 
                    } 
                } .catch(function(err) { 
                    window.location.reload(); 
                } 
            } 
            
    hotEmitter.on("webpackHotUpdate", function(currentHash) { 
        lastHash = currentHash; 
        if (!upToDate() && module.hot.status() === "idle") { 
            log("info", "[HMR] Checking for updates on the server..."); 
            
            // 调用check方法拉取更新后的模块代码并进行处理 
            check(); 
        } 
    }); 
}

这里的module.hot.check方法,其实是另一位隐藏的大佬进行的方法注入

HotModuleReplacementPlugin

由于涉及到另一个插件的解析,放到后面去扩展。感兴趣的读者可以去webpack/lib/hotModuleReplacement.js阅读源码。

这里重点介绍针对module.hot.check都注入了怎样的代码

// webpack/lib/web/JsonpMainTemplate.runtime.js

function hotCreateModule(moduleId) { 
    var hot = { check: hotCheck 
}

function hotCheck(apply) { 
    hotSetStatus("check"); 
    return hotDownloadManifest(hotRequestTimeout).then(function(update) { 
        hotAvailableFilesMap = update.c; 
        hotUpdateNewHash = update.h; 
        hotSetStatus("prepare");
    }) 
} 
    
function hotDownloadManifest(requestTimeout) { 
    requestTimeout = requestTimeout || 10000; 
    return new Promise(function(resolve, reject) { 
        var request = new XMLHttpRequest(); 
        var requestPath = __webpack_require__.p + "" + hotCurrentHash + ".hot-update.json"; 
        request.open("GET", requestPath, true); 
        request.timeout = requestTimeout; 
        request.send(null); 
        
        request.onreadystatechange = function() { 
            var update = JSON.parse(request.responseText); 
            resolve(update); 
        } 
    } 
}

这里之所以使用JSONP的方式获取新的模块代码,是因为JSONP获取的代码可以直接执行,而hash.hot-update.js代码里有个webpackHotUpdate函数调用,最后重点看一下这个函数是如何处理代码模块替换和执行的

// webpack/lib/HotModuleReplacement.runtime.js 
window["webpackHotUpdate"] = function (chunkId, moreModules) { 
    hotAddUpdateChunk(chunkId, moreModules); 
}; 

function hotAddUpdateChunk(chunkId, moreModules) { 
    // 更新的模块moreModules赋值给全局全量hotUpdate 
    for (var moduleId in moreModules) { 
        if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) { 
            hotUpdate[moduleId] = moreModules[moduleId]; 
        } 
    } 
    
    // 调用hotApply进行模块的替换 
    hotUpdateDownloaded(); 
} 

function hotUpdateDownloaded() { 
    if (hotApplyOnUpdate) { 
        Promise.resolve() 
            .then(function() { 
                return hotApply(hotApplyOnUpdate); 
            }) 
            .then( function(result) { 
                deferred.resolve(result); 
            }, 
        function(err) { 
            deferred.reject(err); 
        }) 
    } 
} 

function hotApply(options) { 
    options = options || {}; 
    return hotApplyInternal(options); 
} 

function hotApplyInternal(options) { 
    var queue = outdatedModules.slice(); 
    while (queue.length > 0) { 
        moduleId = queue.pop(); 
        module = installedModules[moduleId]; 
        
        // 从缓存中删除过期的模块 
        delete installedModules[moduleId]; 
        
        // 删除过期的依赖 
        delete outdatedDependencies[moduleId]; 
        
        // 存储了被删掉的模块id,便于更新代码 
        outdatedSelfAcceptedModules.push({ module: moduleId }); 
    } 
    
    // 插入新的模块 
    appliedUpdate[moduleId] = hotUpdate[moduleId]; 
    for (moduleId in appliedUpdate) { 
        if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) { 
            modules[moduleId] = appliedUpdate[moduleId]; 
        } 
    } 
    
    // 执行最新的代码模块 
    for (i = 0; i < outdatedSelfAcceptedModules.length; i++) { 
        var item = outdatedSelfAcceptedModules[i]; 
        moduleId = item.module; 
        try { 
            // __webpack_require__(moduleId); 
            $require$(moduleId); 
        } catch (err) { 
            // ...容错处理 
        } 
    } 
}

hotApply的确比较复杂,知道大概流程就好了

五、整理总结

最后用一个流程图整理一下关键的节点

探究webpack代码热更新原理