热更新

160 阅读5分钟

三、热更新实现原理

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

pgsql
复制代码
// node_modules/webpack-dev-server/bin/webpack-dev-server.js

// 生成webpack编译主引擎 compiler
let compiler = webpack(config);

// 启动本地服务
let server = new Server(compiler, options, log);
server.listen(options.port, options.host, (err) => {
    if (err) {throw err};
});
javascript
复制代码
// node_modules/webpack-dev-server/lib/Server.js
class Server {
    constructor() {
        this.setupApp();
        this.createServer();
    }
    
    setupApp() {
        // 依赖了express
    	this.app = new express();
    }
    
    createServer() {
        this.listeningApp = http.createServer(this.app);
    }
    listen(port, hostname, fn) {
        return this.listeningApp.listen(port, hostname, (err) => {
            // 启动express服务后,启动websocket服务
            this.createSocketServer();
        }
    }                                   
}

代码主要做了三件事:

  • 1、 启动webpack,生成compiler实例。compiler上有很多方法,比如可以启动 webpack 所有编译工作,以及监听本地文件的变化。
  • 2、使用express框架启动本地server,让浏览器可以请求本地的静态资源
  • 3、本地server启动之后,再去启动websocket服务,当本地文件发生变化,立马告知浏览器可以热更新

2. webpack监听文件变化

每次修改代码,就会触发编译。说明我们还需要监听本地代码的变化,主要是通过setupDevMiddleware方法实现的。

这个方法主要执行了webpack-dev-middleware库。这个库主要是本地文件的编译输出以及监听

那我们来看下webpack-dev-middleware源码里做了什么事:

awk
复制代码
// 1、compiler.watch
node_modules/webpack-dev-middleware/index.js
compiler.watch(options.watchOptions, (err) => {
    if (err) { /*错误处理*/ }
});

// 2、setFs方法打包
通过“memory-fs”库将打包后的文件写入内存
setFs(context, compiler); 

(1)调用了compiler.watch方法,

  • 首先对本地文件代码进行编译打包,结束后,开启对本地文件的监听,监听本地文件的变化,重新编译,编译完成之后继续监听。

  • 重新检测编译就归功于compiler.watch。监听本地文件的变化主要是通过文件的生成时间是否有变化,这里就不细讲了。

(2)执行setFs方法

  • 这个方法主要目的就是将编译后的文件打包到内存。

3. 监听webpack编译结束

javascript
复制代码
// node_modules/webpack-dev-server/lib/Server.js
// 绑定监听事件
setupHooks() {
    const {done} = compiler.hooks;
    // 监听webpack的done钩子,tapable提供的监听方法
    done.tap('webpack-dev-server', (stats) => {
        this._sendStats(this.sockets, this.getStats(stats));
        this._stats = stats;
    });
};
reasonml
复制代码
// 通过websoket给客户端发消息
_sendStats() {
    this.sockWrite(sockets, 'hash', stats.hash);
    this.sockWrite(sockets, 'ok');
}

调用setupHooks方法。这个方法是用来注册监听事件的,监听每次webpack编译完成; 当监听到一次webpack编译结束,就会调用_sendStats方法通过websocket给浏览器发送通知,okhash事件,这样浏览器就可以拿到最新的hash值了,做检查更新逻辑。

  • hash事件,更新最新一次打包后的hash值。
  • ok事件,进行热更新检查。

4. 浏览器接收到热更新的通知

awk
复制代码
// webpack-dev-server/client/index.js
var socket = require('./socket');
var onSocketMessage = {
    hash: function hash(_hash) {
        // 更新currentHash值
        status.currentHash = _hash;
    },
    ok: function ok() {
        sendMessage('Ok');
        // 进行更新检查等操作
        reloadApp(options, status);
    },
};
// 连接服务地址socketUrl,?http://localhost:8080,本地服务地址
socket(socketUrl, onSocketMessage);

function reloadApp() {
	if (hot) {
        log.info('[WDS] App hot update...');
        
        // hotEmitter其实就是EventEmitter的实例
        var hotEmitter = require('webpack/hot/emitter');
        hotEmitter.emit('webpackHotUpdate', currentHash);
    } 
}

在服务器中,我们已经可以compiler.watch监听到文件的变化了,当文件发生变化,就触发重新编译。同时compiler.hooks.done钩子监听了每次编译结束。_sendStats方法就通过websoket建立客户端和服务器的连接,并注册了 2 个监听事件。

  • hash事件,更新最新一次打包后的hash值。
  • ok事件,进行热更新检查。

热更新检查事件是调用reloadApp方法。这个方法又利用node.jsEventEmitter,发出webpackHotUpdate消息。

javascript
复制代码
// node_modules/webpack/hot/dev-server.js
var check = function check() {
    module.hot.check(true)
        .then(function(updatedModules) {
            // 容错,直接刷新页面
            if (!updatedModules) {
                window.location.reload();
                return;
            }
            
            // 热更新结束,打印信息
            if (upToDate()) {
                log("info", "[HMR] App is up to date.");
            }
    })
        .catch(function(err) {
            window.location.reload();
        });
};

var hotEmitter = require("./emitter");
hotEmitter.on("webpackHotUpdate", function(currentHash) {
    lastHash = currentHash;
    check();
});

在浏览器端,webpack监听到了webpackHotUpdate事件,并获取最新了最新的hash值,然后终于进行检查更新了。检查更新呢调用的是module.hot.check方法。这个方法是属于HotModuleReplacementPlugin中。

5. moudle.hot.check 开始热更新

  • 利用上一次保存的hash值,调用hotDownloadManifest发送xxx/hash.hot-update.jsonajax请求;
  • 请求结果获取热更新模块,以及下次热更新的Hash 标识,并进入热更新准备阶段。
abnf
复制代码
hotAvailableFilesMap = update.c; // 需要更新的文件
hotUpdateNewHash = update.h; // 更新下次热更新hash值
hotSetStatus("prepare"); // 进入热更新准备状态
  • 调用hotDownloadUpdateChunk发送xxx/hash.hot-update.js 请求,通过JSONP方式。
javascript
复制代码
function hotDownloadUpdateChunk(chunkId) {
    var script = document.createElement("script");
    script.charset = "utf-8";
    script.src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js";
    if (null) script.crossOrigin = null;
    document.head.appendChild(script);
 }

这个函数体为什么要单独拿出来,因为这里要解释下为什么使用JSONP获取最新代码?主要是因为JSONP获取的代码可以直接执行。为什么要直接执行?我们来回忆下/hash.hot-update.js的代码格式是怎么样的。

可以发现,新编译后的代码是在一个webpackHotUpdate函数体内部的。也就是要立即执行webpackHotUpdate这个方法。

再看下webpackHotUpdate这个方法。

ada
复制代码
window["webpackHotUpdate"] = function (chunkId, moreModules) {
    hotAddUpdateChunk(chunkId, moreModules);
} ;
  • hotAddUpdateChunk方法会把更新的模块moreModules赋值给全局全量hotUpdate
  • hotUpdateDownloaded方法会调用hotApply进行代码的替换。
reasonml
复制代码
function hotAddUpdateChunk(chunkId, moreModules) {
    // 更新的模块moreModules赋值给全局全量hotUpdate
    for (var moduleId in moreModules) {
        if (Object.prototype.hasOwnProperty.call(moreModules, moduleId)) {
	    hotUpdate[moduleId] = moreModules[moduleId];
        }
    }
    // 调用hotApply进行模块的替换
    hotUpdateDownloaded();
}

5. hotApply 热更新模块替换

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

①删除过期的模块,就是需要替换的模块

通过hotUpdate可以找到旧模块

cpp
复制代码
var queue = outdatedModules.slice();
while (queue.length > 0) {
    moduleId = queue.pop();
    // 从缓存中删除过期的模块
    module = installedModules[moduleId];
    // 删除过期的依赖
    delete outdatedDependencies[moduleId];
    
    // 存储了被删掉的模块id,便于更新代码
    outdatedSelfAcceptedModules.push({
        module: moduleId
    });
}

②将新的模块添加到 modules 中

inform7
复制代码
appliedUpdate[moduleId] = hotUpdate[moduleId];
for (moduleId in appliedUpdate) {
    if (Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
        modules[moduleId] = appliedUpdate[moduleId];
    }
}

③通过__webpack_require__执行相关模块的代码

abnf
复制代码
for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {
    var item = outdatedSelfAcceptedModules[i];
    moduleId = item.module;
    try {
        // 执行最新的代码
        __webpack_require__(moduleId);
    } catch (err) {
        // ...容错处理
    }
}
(function (modules) {
  // 模块缓存
  var installedModules = {};
  function __webpack_require__(moduleId) {
    // 判断是否有缓存
    if (installedModules[moduleId]) {
      return installedModules[moduleId].exports;
    }
    // 没有缓存则创建一个模块对象并将其放入缓存
    var module = installedModules[moduleId] = {
      i: moduleId,
      l: false, // 是否已加载
      exports: {}
    };
    // 执行模块函数
    modules[moduleId].call(module.exports, module, module.exports, __webpack_require__);
    // 将状态置为已加载
    module.l = true;
    // 返回模块对象
    return module.exports;
  }
  // ...
  // 加载入口文件
  return __webpack_require__(__webpack_require__.s = "./src/index.js");
})