浅析webpack热更新原理以及与vite热更新区别

之前被问到webpack和vite在热更新方面有什么区别,当时并不知道具体细节,这两天研究了一下,因为在看webpack热更新过程中有些吃力,所以自己又去理解并整理了一下,而vite热更新可以参考文中提到的另一篇文章,讲的很透彻了。

致敬:
一文了解 Webpack 热更新 (HMR) 原理
即时代码热更新,vite 热更新背后的原理

webpack热更新机制

首先要明确几个概念:

Webpack-complier

webpack 的编译器,将 JavaScript 编译成 bundle(就是最终的输出文件),编译器可以监听文件本地代码的变化,并给浏览器发送通知,这其中用到了webpack-dev-middleware中间件,主要用于处理文件的输入输出,将文件写入内存而非硬盘,加快构建速度。

HMR Runtime

Webpack 在开发模式下会注入热更新运行时到打包生成的代码中,也就说最终会在浏览器中运行。这个运行时负责和开发服务器建立连接,接收服务器发送的更新,并在客户端应用更新。主要涉及到两个文件: webpack-dev-server/client/index.js:这个文件是用于 websocket 的,因为 websocket 是双向通信,,启动的是本地服务端的websocket。那客户端也就是我们的浏览器,浏览器还没有和服务端通信的代码呢?因此我们需要把websocket客户端通信代码放到我们的代码中。 webpack/hot/dev-server.js:客户端收到更新通知后,最终去替换文件的操作就是依靠此文件。

HMR Server

Wepack-dev-server会利用express在启动一个本地服务,管理客户端的连接,同时创建socket服务器,通知浏览器文件的变化。

webpack热更新主要流程

编辑器修改文件 ------> webpack编译改动的文件 ------> 通知浏览器 ------> 浏览器获取更改后模块文件 ------> 将修改后的应用到页面中去

webpack热更新原理

1、webpack-dev-server启动本地服务,同时创建了websocket服务。

javascript 复制代码
Server.prototype.listen = function (port, hostname, fn) {
  this.listenHostname = hostname;
  // eslint-disable-next-line

  const returnValue = this.listeningApp.listen(port, hostname, (err) => {
    const sockServer = sockjs.createServer({
      // Use provided up-to-date sockjs-client
      sockjs_url: '/__webpack_dev_server__/sockjs.bundle.js',
      // Limit useless logs
      log(severity, line) {
        if (severity === 'error') {
          log(line);
        }
      }
    });

  ...
  ...
  ...

  return returnValue;
};

2、webpack-dev-server.js在启动时会注入HMR Runtime

在启动服务时候,webpack-dev-server会在webpack配置项的entry中注入两个文件:webpack/client/index.js和webpack/hot/dev-server、

javascript 复制代码
// webpack-dev-server/bin/webpack-dev-server.js
function startDevServer(webpackOptions, options) {
  addDevServerEntrypoints(webpackOptions, options);
  ...
}

//webpack-dev-server/lib/util/addDevServerEntrypoints.js
module.exports = function addDevServerEntrypoints(webpackOptions, devServerOptions, listeningApp) {
  if (devServerOptions.inline !== false) {
    ...
    
    const devClient = [`${require.resolve('../../client/')}?${domain}`];

    if (devServerOptions.hotOnly) { devClient.push('webpack/hot/only-dev-server'); } 
    else if (devServerOptions.hot) { devClient.push('webpack/hot/dev-server'); }

    ...
  }
};

3. 监听 webpack 编译结束

修改好入口配置后,又调用了setupHooks 方法,用来注册监听事件的,监听每次 webpack 时间编译完成

javascript 复制代码
 // node_modules/webpack-dev-server/lib/Server.js
// 绑定监听事件
setupHooks() {
    const { node } = compiler.hooks;
    // 监听 webpack 的 done 钩子,tapple 提供的监听方法
    done.tap('webpack-dev-server', (stats) => {
        this._sendStats(this.sockets, this.getStats(stats));
        this._stats = stats;
    })
}

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

javascript 复制代码
// 通过 websocket 给客户端发消息
_sebdStats() {
    this.sockWrite(sockets, 'hash', stats.hash);
    this.sockWrite(sockets, 'ok');
}

4. webpack 监听文件变化

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

这个方法主要执行了 webpack-dev-middleware 库。很多人分不清webpack-dev-middlewarewebpack-dev-server 的区别。其实就是因为webpack-dev-server只负责启动服务和前置准备工作,所有文件相关的操作都抽离到webpack-dev-middleware库了,主要是本地文件的编译输出 以及监听,无非就是职责的划分更清晰了

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

// 通过memory-fs" 库将打包后的文件写入内存
setFs(context, compiler);

(1)调用了 comiler.watch 方法

  • 首先对本地文件代码进行编译打包,也就是 webpack 的一系列编译流程。
  • 其次编译结束后,开启对本地文件的监听,当文件发生变化,重新编译,编译完成之后继续监听

为什么代码的改动保存会自动编译,重新打包?这一系列的重新检测编译就归功于compiler.watch这个方法了。监听本地文件的变化主要是通过文件的生成时间是否有变化。

(2)执行 setFs 方法

这个方法主要目的就是将编译后的文件打包到内存 。这就是为什么在开发的过程中,你会发现 dist 目录没有打包后的代码,因为都在内存里。存在内存的原因在于访问内存中的代码比访问文件系统中的文件更快,而且也减少了代码写入文件的开销,这一切都归功于memory-fs

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

我们已经可以监听到文件的变化了,当文件发生变化,就触发重新编译。同时还监听了每次编译结束的事件。当监听到一次webpack编译结束,_sendStats方法就通过websoket给浏览器发送通知,检查下是否需要热更新。下面重点讲的就是_sendStats方法中的okhash事件都做了什么。

在上面提到的client/index.js文件中创建的socket就是为了接受服务端更新文件的通知。

ini 复制代码
var onSocketMsg = {
  ...
  
  progress: function progress(_progress) {
    if (typeof document !== 'undefined') {
      useProgress = _progress;
    }
  },

  hash: function hash(_hash) {
    currentHash = _hash;
  },
  
  ok: function ok() {
    sendMsg('Ok');
    if (useWarningOverlay || useErrorOverlay) overlay.clear();
    if (initial) return initial = false; // eslint-disable-line no-return-assign
    reloadApp();
  },

  ...
};

浏览器在接收到type为'ok'的socket消息,会去调用reloadApp方法

php 复制代码
function reloadApp() {
  if (isUnloading || !hotReload) {
    return;
  }
  if (_hot) {
    log.info('[WDS] App hot update...');
    // eslint-disable-next-line global-require
    var hotEmitter = require('webpack/hot/emitter');
    hotEmitter.emit('webpackHotUpdate', currentHash);
    if (typeof self !== 'undefined' && self.window) {
      // broadcast update to window
      self.postMessage('webpackHotUpdate' + currentHash, '*');
    }
  } 
  
  ...
}

reloadApp方法会使用hotEmitter去触发webpackHotUpdate方法。

var hotEmitter = require('webpack/hot/emitter');这里指的注意一下,一开始我很疑惑,EventEmitter明明是Nodejs提供的一个API,为何能在浏览器中使用。因为在浏览器中使用的是browserify/events ,旨在没有Node的环境中实现Node的事件模块,例如在浏览器中。那么这里的webpackHotUpdate方法是什么呢,记得上文说的addDevServerEntrypoints方法吗,在addDevServerEntrypoints方法中引入了wbpack/hot/dev-server.js文件,在这个文件中注册了webpackHotUpdate方法:

javascript 复制代码
//webpack/hot/dev-server.js
if(module.hot) {
        ...
        
	var hotEmitter = require("./emitter");
	hotEmitter.on("webpackHotUpdate", function(currentHash) {
		lastHash = currentHash;
		if(!upToDate() && module.hot.status() === "idle") {
			log("info", "[HMR] Checking for updates on the server...");
			check();
		}
	});
	log("info", "[HMR] Waiting for update signal from WDS...");
} else {
	throw new Error("[HMR] Hot Module Replacement is disabled.");
}

可以看到在webpackHotUpdate方法中会调用check(),那么check从何而来,那就是接下来要说的HotModuleReplacementPlugin。

6. 不得不说的HotModuleReplacementPlugin

我们可以对比下:配置热更新和不配置的时候 bundle.js 的区别。直接执行 webpack 命令就可以看到生成的 bundle.js 文件,不要用 webpack-dev-server 启动

(1)没有配置的

(2)配置了HotModuleReplacementPlugin--hot的。

显而易见,moudle 新增了一个属性为 hot,再看 hotCreateModule方法这不就找到module.hot.check是哪里冒出来的。

经过对比打包后的文件,webpack_require中的moudle以及代码行数的不同。我们都可以发现HotModuleReplacementPlugin原来也是默默的塞了很多代码到bundle.js中。这和第 2 步骤很是相似哦!为什么,因为检查更新是在浏览器中操作,这些代码必须在运行时的环境。

我们也可以直接看浏览器Sources下的代码,会发现webpackplugin偷偷加的代码都在,在这里调试也很方便。

由此可知 5 最后的check() 其实就是HotModuleReplacementPlugin注入的hotCheck方法 。

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

前面可以说都是准备工作,真正热更新从此开始。 hotCheck 方法中通过hotDownloadManifest获取到了一个h变量,这个是根据文件代码内容生成的hash值,通过这一步webpack会去和上一次的hash值进行比对,如果不一样则会进行后续的热更新,否则就不会有其他操作。

而hotDownloadManifest其实使用了XHR对象请求了HMR Server服务器,返回当前最新的hash值

这里可以顺便看一下修改了文件和不修改文件保存触发热更新的不同:

改动文件的热更新在请求json后,会继续通过jsonp请求一个js文件 而文件没有改动过的则只会有个json请求

继续回到hotCheck,在获取到新的hash值之后会进入hotEnsureUpdateChunk方法,这个方法使用hotDownloadUpdateChunk以jsonp格式获取js文件,而通过jsonp目的是为了能够直接执行获取的js文件。

可以看到hot-update.js返回即是一个可执行js文件

获取到了js文件,最后一步就是hotUpdateDownloaded方法,在此方法中最终通过hotApply实现文件的替换

8. hotApply 热更新模块替换

热更新的核心逻辑就在这了!!

🔸(1)删除过期的模块,就是需要替换的模块

通过 hotUpdate 可以找到旧模块

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

🔸(2)将新的模块添加到 modules 中

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

🔸(3)通过__webpack_require__执行相关模块的代码

ini 复制代码
js
复制代码
for (i = 0; i < outdatedSelfAcceptedModules.length; i++) {
    var item = outdatedSelfAcceptedModules[i];
    moduleId = item.module;
    try {
        // 执行最新的代码
        __webpack_require__(moduleId);
    } catch (err) {
        // ...容错处理
    }
}

hotApply的确比较复杂,知道大概流程就好了,这一小节,要求你对 webpack 打包后的文件如何执行的有一些了解,大家可以自去看下。

至此wepback完成一次热更新。

vite热更新机制

关于vite热更新,"即时代码热更新,vite 热更新背后的原理"这篇文章写的已经非常完善了,可以直接参考。

两者热更新机制的区别

了解完两者的热更机制,说说我理解的两者最主要区别:

webpack:webpack由于会把文件先打包成bundle机制的原因,会把改动的文件模块打包编译完成之后通知客户端去获取文件,并且用jsonp的格式推送给客户端一个可执行文件。

vite:而vite则是会去进行模块的依赖分析,收集依赖当前模块的其他模块,清除掉依赖信息,最终告诉客户端的是修改文件的路径,最终文件的获取是依靠浏览器原生的ESM模块用过import动态导入文件。

由此也能分析出为什么vite的编译为什么会比webpack快。

相关推荐
~甲壳虫9 小时前
说说webpack中常见的Plugin?解决了什么问题?
前端·webpack·node.js
Beamon__9 小时前
element-plus按需引入报错AutoImport is not a function
webpack·element-plus
CodeToGym9 小时前
Webpack性能优化指南:从构建到部署的全方位策略
前端·webpack·性能优化
~甲壳虫9 小时前
说说webpack中常见的Loader?解决了什么问题?
前端·webpack·node.js
~甲壳虫9 小时前
说说webpack proxy工作原理?为什么能解决跨域
前端·webpack·node.js
lin-lins1 天前
模块化开发 & webpack
前端·webpack·node.js
柳鲲鹏2 天前
LINUX/CMAKE编译opencv_contrib
linux·opencv·webpack
前端李易安3 天前
webpack的常见配置
前端·webpack·node.js
魏大帅。3 天前
Webpack入门教程:从基本概念到优化技巧
前端·webpack·node.js
web_code3 天前
webpack源码快速分析
前端·webpack·源码阅读