首页>>前端>>Vue->深入理解Webpack的HMR机制(详解)

深入理解Webpack的HMR机制(详解)

时间:2023-11-29 本站 点击:1

前言

在我刚学习 vue 的时候,接触vue-cli的时候,在开发是被其提供的热加载功能深深的吸引住了。很好奇它是如何将我们所修改的代码通过不刷新方式,来更新 web 应用,与此同时还能保持应用的一些状态,这在我们平时日常开发之中是非常有用。后来再知道提供浙一功能的是 Webpack 的一个插件模块 hotModuleReplace 所提供的能力,本文就带大家一起来探究一下它是如何实现的。

抛出疑问

对于我们不熟悉或者压根没有接触的东西,通过抛出疑问,自己解决这些疑问可以很好帮我们去提升自己的能力,这是很好成长的机会。

通过 webpack 打包之后的文件存放在哪里?在没有 writeToDisk: true 这个配置项时。

打包器在工作时是如何和 client 进行交互的?

新生成的模块是如何作用于浏览器应用的。

一开始的时候我们抛出的问题的颗粒度比较粗,没有关系,随着我们一步步的探索,我们又会遇到一些细节的问题,把这细节问题梳理清楚了,从而对整个问题就有了更深的理解了。

HMR 流程图解

上图描述了一个完整HMR流程,我们来看看它是如何运转的。

webpack 的监听模式下,在我们所编写的文件保存之后,会触发其重新编译,完成之后的bundle保存在内存之中。在编译打包时,webpackProgressPlugin这个插件将编译进度通过 websocket 推送到浏览器端。

webpack-dev-serverwebpack之间主要是由 webpack-dev-middleware来进行交互的,这里它主要做了两件事:一是,通过 webpack-dev-middleware 调用webpack生成的compiler的相关api,告诉其将打包之后的文件是写入内存之后,还是写到硬盘上,依据writeToDisk配置项。二是,webpack-dev-middleware调用返回的是一个express标准的中间件,向app上注册路由,拦截 HTTP 收到的请求,根据请求路径响应对应的文件内容;

这一步,浏览器主要是通过 sockjs 这个库与服务器端的 websocket服务建立长连接通信,将 webpack编译打包的各个阶段推送浏览器端,依此会有不同的操作,最为核心的就是服务端推送新hash时,浏览器端会依据这个hash来进行后续的热更新逻辑。

当服务端推送的消息typeok时,webpack-dev-server/client会执行reloadApp方法,主要是为了触发webpackHotUpdate事件,在webpack/hot/dev-server依据相关的配置和传递的信息决定是否要刷新页面还是要热更新,这两者联系的纽带是webpack/hot/emitter,依赖的是nodeevents模块,在浏览器也能生效,说明其是和平台无关的。

webpack/hot/dev-server中会通过调用module.hot.check方法映射到HotModuleReplacement.runtime中的hotCheck方法,这些方法是通过 HotModuleReplacementPlugin注入到bendle之中。

hotCheck 然后会调用JsonpMainTemplate.runtime中的hotDownloadManifesthotDownloadUpdateChunk方法。

hotDownloadManifest携带上推送的 hash发起ajax请求,获取最新的文件列表。

hotDownloadUpdateChunk 依据文件列表发起jsonp请求相应的最新的代码块,继续后续代码更新的逻辑。

代码更新时,会进行新旧模块的对比,决定是否要更新。如果要更新,将梳理清楚模块之间的依赖,然后更新模块以及相关的依赖;如果更新失败了,就会通过刷新的方式来进行兜底操作。

运行Demo

下面我们通过一个简单的例子来说明。来说明一些其中的一些细节问题,以大家最为熟悉的 vue为例。

我们来初始化一个基本vue项目,相关目录以及核心配置如下:

Demo├─main.js├─package.json├─public│└─index.html├─test.vue├─webpack.config.js

webpack.config.js 的配置如下:

{devtool:'source-map',mode:'development',entry:path.resolve(__dirname,'./main.js'),output:{path:path.resolve(__dirname,'dist'),filename:'bundle.js'},devServer:process.env.NODE_ENV=='production'?{}:{contentBase:path.join(__dirname,'dist'),hot:true,writeToDisk:true,},module:{rules:[{test:/\.vue$/,use:['vue-loader']},]},plugins:[newVueLoaderPlugin(),newHtmlWebpackPlugin({template:'./public/index.html'})]}

package.json 的配置如下:

"scripts":{"dev":"rm-rf./dist&&NODE_ENV=developmentwebpack-dev-server--configwebpack.config.js--open--inline--progress"}

test.vue中的文件大家看心情来就好,main.js就是正常的vue启动入口,这里我就不多赘述了。

当我们修改的test.vue时,

第一步:webpack将打包之后的文件放入内存之中

webpack-dev-middleware 通过调用 webpackcompilerapi将打包之后的文件放到内存之中。

核心代码如下:

//webpack-dev-middleware/index.jsif(!options.lazy){context.watching=compiler.watch(options.watchOptions,(err)=>{//...})}

在配置了 writeToDisk 这个配置项时,打包之后的文件会写入到相应的文件夹中。但是在没有这个配置的时候,那么文件有存放在哪里呢?这里我们提到webpack-dev-middleware中的一个依赖memory-fs,看这个名字我们大概就能猜到他是干什么的了。在 webpackcompiler.outputFileSystem会被挂在上memory-fs的实例。

核心代码如下:

//webpack-dev-middleware/lib/fs.jsconstisMemoryFs=!isConfiguredFs&&!compiler.compilers&&compiler.outputFileSysteminstanceofMemoryFileSystem;if(isConfiguredFs){const{fs}=context.options;compiler.outputFileSystem=fs;fileSystem=fs;}elseif(isMemoryFs){fileSystem=compiler.outputFileSystem;}else{fileSystem=newMemoryFileSystem();compiler.outputFileSystem=fileSystem;}

如果当前 compiler.outputFileSystem 不是 MemoryFileSystem,那就用 MemoryFileSystem 替换,这样打包的文件就存放在 MemoryFileSystem 所构建的文件系统之中,加快了访问读取的速度了。

第二步:devServer在文件编译通知浏览器端

webpackcompiler会添加ProgressPlugin插件,统计编译打包的进度,然后通过sockjs与服务端 websocket 通信,将打包的各个状态推送到客户端。此时,还需要知道打包完成的状态,在 compiler.hooks 中监听了 done 事件,然后通过调用 _sendStats 将打包之后的 stats.hash推送浏览器端。

核心代码如下:

//webpack-dev-server/lib/Server.jsnewwebpack.ProgressPlugin((percent,msg,addInfo)=>{//...使用ProgressPlugin插件this.sockWrite(this.sockets,'progress-update',{percent,msg});//...}).apply(this.compiler);setupHooks(){//...constaddHooks=(compiler)=>{const{compile,invalid,done}=compiler.hooks;//...//添加done事件监听done.tap('webpack-dev-server',(stats)=>{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){//...//推送新的hash到浏览器端this.sockWrite(sockets,'hash',stats.hash);//...}

第三步:webpack-dev-server/client 接受和处理服务端推送的信息

在一开始梳理这逻辑的时候,我有点好奇 webpack-dev-server/client 这部分的处理代码是如何打到 bundle之中的?其实在 webpack-dev-server 启动的时候,回去更新 webpackcompiler, 然后通过 addEntries 这个插件将这部分的代码注入到客户端之中。

核心代码如下:

//webpack-dev-server/lib/Server.jsupdateCompiler(this.compiler,this.options)//webpack-dev-server/lib/utils/updateCompiler.jsaddEntries(webpackConfig,options);compilers.forEach((compiler)=>{//...constprovidePlugin=newwebpack.ProvidePlugin({__webpack_dev_server_client__:getSocketClientPath(options),});providePlugin.apply(compiler);});

webpack-dev-server/client在处理 websocket消息会依据不同的 type 来处理消息。当收到 typehash时记录下传递过来的hash值,当前消息的typeok时,会调用 reloadApp。如下图所示:

reloadApp 这个函数中,主要是通过webpack/hot/emitter触发webpackHotUpdate事件,并将最新的 hash作为参数传递过去,如果没有配置hot参数,则会在宏任务中来进行页面刷新的操作。

核心代码如下:

//webpack-dev-server/client-src/default/index.jsconstonSocketMessage={hash(hash){status.currentHash=hash;},ok(){reloadApp(options,status);}}functionreloadApp({hotReload,hot,liveReload},{isUnloading,currentHash}){if(hot){consthotEmitter=require('webpack/hot/emitter');hotEmitter.emit('webpackHotUpdate',currentHash);}elseif(liveReload){//...self.setInterval(()=>{rootWindow.location.reload()})}}

第四步:webpack接受到最新的hash验证以及更新模块

在上一步当中我们派发了 webpackHotUpdate 事件,要看在哪里消费了这个事件。在 webpack/hot/dev-server中消费的回调函数中主要是调用了 module.hot.check 方法,

module.hot这个对象是通过HotModuleReplacementPlugin插件将HotModuleReplacement.runtime之中的代码注入。

核心代码如下:

//webpack/hot/dev-server.jshotEmitter.on("webpackHotUpdate",function(currentHash){//...module.hot.check(true).then(()=>{//...})});//webpack/lib/HotModuleReplacementPlugin.jsconstTemplate=require("./Template")consthotInitCode=Template.getFunctionContent(require("./HotModuleReplacement.runtime"))mainTemplate.hooks.moduleObj.tap("HotModuleReplacementPlugin",(source,chunk,hash,varModuleId)=>{returnTemplate.asString([`${source},`,`hot:hotCreateModule(${varModuleId}),`,//将hmr.runtime中的api注入到module之中"parents:(hotCurrentParentsTemp=hotCurrentParents,hotCurrentParents=[],hotCurrentParentsTemp),","children:[]"]);});//webpack/lib/HotModuleReplacement.runtime.jsfunctionhotCreateModule(moduleId){varhot={check:hotCheck,//otherapi};returnhot;}

hotCheck方法会调用 JsonpMainTemplate.runtime中的两个方法 hotDownloadManifesthotDownloadUpdateChunk,前者通过上一步传入的hash发起ajax请求服务端获取文件更新列表,如图所示;后者依据文件列表发起jsonp请求相应的最新的代码块,继续后续代码更新的逻辑。

hotDownloadManifest方法获取更新文件列表

hotDownloadUpdateChunk获取到更新的新模块代码。

::: tip 在这里我一直有一个疑问,并且我自己想了一段时间也没有想通用的问题,就是为什么要通过上述的这种方式来更新。从技术来说,当服务端推送消息typeok时,可以吧更新之后的代码块也一并推送过来,但是为什么没有这么做呢?看了其他的解释是,模块解耦,dev-server/client 只负责消息的传递,HotModuleReplacement来负责代码模块的的更新,这样复合单一设计的原则,而且 webpack 在不使用 dev-server的情况下,可以配合 webpack-hot-middleware 来实现热更新功能,webpack-hot-middleware使用的协议并不是websocket而是SSE协议,这种协议在flutter-devtools也有使用到,可以很好的把模块的功能进行解耦。 :::

第五步:HotModuleReplacement.runtime 更新模块

在上述 jsonp 请求中我们看到其调用了 webpackHotUpdate 这个方法,通过打包出来的 bundle.js可以看到它又调用了JsonpMainTemplate.runtime中的webpackHotUpdateCallback,这其中主要是调用了 HotModuleReplacement.runtimehotAddUpdateChunk,后面一系列的调用就不一一详述了,我们来看一下最为核心的方法hotApply。由于这个方法实现的代码较多,我们可以追踪hotStatus的状态变化来梳理更新的逻辑。

"idle" => "check",这个阶段 module.hot.check 方法。

"check" => "prepare",这个阶段通过上述的 hotDownloadManifest获取到了最新的文件列表。

"prepare" => "ready",这个阶段,将新的模块存贮到了 hotUpdate 之中。

"ready" => "dispose",这个阶段经历了两个循环,在第一个循环中,先通过 getAffectedStuff 方法找到当前这个新模块所有相关的模块outdatedModules和依赖outdatedDependencies,result返回值如下,如果没有找到的话,将 result.type 表示为 disposed。正常返回 result.typeaccepted

letresult={"type":"accepted","moduleId":"./lib/vue-loader/index.js?!./test.vue?vue&type=script&lang=js&","outdatedModules":["./lib/vue-loader/index.js?!./test.vue?vue&type=script&lang=js&","./test.vue?vue&type=script&lang=js&","./test.vue"],"outdatedDependencies":{}}

在第二个循环中,找出outdatedModules中,并且已经被使用的模块中 hot._selfAcceptedtrue的模块,这些模块通过都自己调用的 这个方法,对于 vue组件来说,这部分的代码是在vue-loader中注入的,核心代码如下:

{devtool:'source-map',mode:'development',entry:path.resolve(__dirname,'./main.js'),output:{path:path.resolve(__dirname,'dist'),filename:'bundle.js'},devServer:process.env.NODE_ENV=='production'?{}:{contentBase:path.join(__dirname,'dist'),hot:true,writeToDisk:true,},module:{rules:[{test:/\.vue$/,use:['vue-loader']},]},plugins:[newVueLoaderPlugin(),newHtmlWebpackPlugin({template:'./public/index.html'})]}0

"dispose" => "apply",在这个阶段,遍历outdatedModules,从installedModules已经安装的模块中删除过期模块以及被该模块引用的子模块,还要删除删除依赖,核心代码如下:

{devtool:'source-map',mode:'development',entry:path.resolve(__dirname,'./main.js'),output:{path:path.resolve(__dirname,'dist'),filename:'bundle.js'},devServer:process.env.NODE_ENV=='production'?{}:{contentBase:path.join(__dirname,'dist'),hot:true,writeToDisk:true,},module:{rules:[{test:/\.vue$/,use:['vue-loader']},]},plugins:[newVueLoaderPlugin(),newHtmlWebpackPlugin({template:'./public/index.html'})]}1

"apply" => "fail" or "idle",在这个阶段主要是更新模块,先将可被更新的模块appliedUpdate挂载到全局的modules上,对于 outdatedSelfAcceptedModules中的模块,会调用 $require$(moduleId),这里的$require$就是打包出来的 __webpack_require__函数,主要是用来加载模块的。

第六步:对 vue 组件是如何自定更新的

test.vue这个模块在被vue-loader解析时,会通过vue-loader/codegen/hotReload.js中的genHotReloadCode方法添加如下代码,其中实现不刷新页面来更新vue组件的核心逻辑在vue2都是由vue-hot-reload-api这个库提供的。

{devtool:'source-map',mode:'development',entry:path.resolve(__dirname,'./main.js'),output:{path:path.resolve(__dirname,'dist'),filename:'bundle.js'},devServer:process.env.NODE_ENV=='production'?{}:{contentBase:path.join(__dirname,'dist'),hot:true,writeToDisk:true,},module:{rules:[{test:/\.vue$/,use:['vue-loader']},]},plugins:[newVueLoaderPlugin(),newHtmlWebpackPlugin({template:'./public/index.html'})]}2

这里 vue-hot-reload-api 为了支持更热新,它的核心实现逻辑是,在组件的生命周期的钩子函数中,插入保存组件上下文的对象的函数,初次渲染是保存当前的组件上下文;当第二次热更新的,先替换组件的 options, 然后调用组件的 $forceUpdate方法来重新渲染。

总结

本文大概了解 webpack hmr整个工作的流程,还有很多的细节没有捕捉到位,比如 webpack-dev-server/client 代码的插入,hotapply里面的弯弯绕绕,以及整个webpack的模块机制的实现,欢迎小伙伴积极探讨。

本文参考了 Webpack HMR 原理解析,感谢大佬的付出,觉得这样的行文思路非常值得借鉴。

参考文献

Webpack HMR 原理解析

Hot Module Replacement

作者:Jayconscious


本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。
如若转载,请注明出处:/Vue/801.html