时隔好久, 终于进入了hmr的简要流程分析.

hmr功能介绍

使用hmr

这里写了一个例子, 用webpack-dev-server起项目. 不需要任何配置.

只要在能接受hmr的父module里调用module.accpet()来注册hmr行为就可以了.

而hmr行为在一般的框架开发中都以loader, plugin的形式被包装了.

这种包装加上webpack配置的包装, 就使hmr如果没有预期工作会让大部分人debug无力.

默认配置

虽然什么都没配置, 但其实是在normalizeOptions()里处理了默认配置, 和hmr相关的有:

  • webSocketServer: `{ type: ‘ws’, options: { path: ‘/ws’ } }
  • hot: true

(如果手动把这2个配置关了, 那么就会关闭hmr)

建立ws连接

从文件变化到页面变化, 页面和服务端是需要有信息交换的. 所以我们从ws连接的建立开始.

server端创建ws服务

Server.jscreateWebSocketServer()方法里创建了ws服务.

具体的可以顺着: createWebSocketServer => getServerTransport => require("./servers/WebsocketServer") => new WebSocket.Server(options); 看到是用了ws包来创建ws服务.

server端维护clients

在上一步的WebsocketServer类的构造方法中.

把ws服务的实例赋值给了this.implementation, 并注册了connection事件, 把连上自己的clients维护到this.clients中.

这样, server端广播的实现就是: 遍历this.webSocketServer.clients, 并调用client.send发送给浏览器了.

client建立ws连接

client端在开启hmr的时候, 被插入了许多代码.

为保证我们看流程的连贯, 会在后面章节进行解释来源.

client-src/index.js下, 调用了socket(socketURL, onSocketMessage, options.reconnect)方法建立了与server的ws连接. 分别说一下这些方法参数:

  • socketURL: 是根据配置产生的server的url.
  • socket方法: 我们可以顺着: socket => new Client(url) => WebSocketClient 看到, 最后调用的是浏览器原生的WebSocket api.
  • onSocketMessage: 是一个对象, 键值分别为ws接受到的事件名字, 和执行的脚本. 在client.onMessage时被注册.

文件更新到页面响应

ws连接完成了, 接下来继续讲: 从文件变化到页面执行业务逻辑的流程.

compiler.compile()的两种调用方式

众所周知webpack的编译工作是由compiler.compile()发起的.

而除了compiler.run()以外, 还有compiler.watch()可以触发compiler.compile().

.run()方法是手动调用, 或者在.webpack()方法有回调的时候自动调用. 是之前了解过的.

而运行webpack-devserver的时候并没有调用.run(), 而是调用了.watch()来触发.compile().

.watch()方法中, 实例化了Watching: new Watching(this, watchOptions, handler)

在构造方法中调用了this._invalidate(), 继而调用this._go(), 在这里可以看到和.run()里类似的代码模式, 调用了.compile().

server建立watcher

接下来回到Server.js, 来找一下哪里调用的compiler.watch().

setupDevMiddleware()的时候, 引入了webpack-dev-middleware.

进入这个仓库, 调用了context.compiler.watch(watchOptions, errorHandler).

于是再回到上一章节, 最后调用了.compile()进行编译.

server检测到module更新

webpack编译完会触发this._done(), 再触发this.watch().

这里用watchpack来检测文件更新, 更新后会触发this._invalidate(), 继而重复以上流程.

server重新编译

当再次调用compiler.compile(), 也还会从入口开始整个流程, 但在module.needBuild()会过滤没有变化的文件. 重新编译的文件会产生新的hash.

经过debug可以看到, 在.needBuild()过程中, 第一次编译会走到forceBuild而直接编译.

之后会对比needBuild参数的valueCacheVersionsthis.buildInfo.valueDependencies的hash值.

最后hmr的重新build是被fileSystemInfo.checkSnapshotValid返回了false而进行重新编译的.

产生menifest文件和更新内容文件

hmrplugin在compilation.hooks.processAssets钩子调用emitAsset()先后产生了新模块内容文件(js文件)和menifest文件(json文件).

可以看到在processAssets的钩子里, 获取了compilation的chunkGraph, modules, records中的信息进行了之后的处理. 输出了这2个类型的文件供之后client调用获取更新信息和更新内容.

server向client推送信息

.watch()方法的invalid钩子和.compile()完成后的done钩子都在Server.js里的setupHooks()被注册了脚本.

最后调用了3次this.sendMessage()先后向clients广播了invalid, hash, ok事件.

client收到module更新信息

让我们回到client的onSocketMessage里找到对应的事件.

hash事件更新了statuspreviousHashcurrentHash.

ok事件把status作为参数, 调用了reloadApp()方法, 从名字也可以看出这个是更新应用的核心方法.

然后通过hotEmitter.emit("webpackHotUpdate", status.currentHash)调用了webpack/hot/dev-server.js中的代码, 执行了check()方法. (注意这里是另外一个代码仓库里的)

执行更新并兜底

check()方法中, 我们来观察这段关键代码:

1
module.hot.check(true).then(function (updatedModules) {}

可以看出, module.hot.check(true)就是执行hmr业务代码的方法了.

并且在回调里打出了被更新的模块.

在回调中, 如果判断没有任何模块被更新, 那么就会刷新页面来兜底hmr.

module.hot.check()的执行流程和冒泡机制打算下次分析.

这里就说明下module变量是从哪里来的.

module变量的来源

我们去到webpack打包结果, 可以看到这个modulewebpack_require的第一个参数.

(如果对打包结果不熟悉, 可以翻一下我webpack系列前面讲打包结果的文章)

我们来看\_\_webpack_require\_\_方法, 这个方法是和没hmr的时候不一样的, 增加了一段\_\_webpack_require\_\_.i的拦截. 在拦截里添加了module变量的accept()等方法.

(这里的.i()方法的全称就是拦截器interceptor, interceptModuleExecution)

那接下来的问题是.i()方法是哪里来的, 下一章整理了一下client在hmr模式下被插入的代码.

client额外代码来源

运行时方法和全局变量

这里要介绍一个api: compilation.addRuntimeModule().

在编译结果中额外增加的以iife格式在打包结果中的runtime代码, 都是用这个api的.

继承了RuntimeModule的类自己写一个generate方法, 获取runtime的global变量, 并替换一些模板, 最后返回一个字符串就行了. (当然也可以直接返回字符串)

我在我的demo代码仓库里也写了最简的例子, 很容易看.

从入口新增的代码

另外一个来源就比较简单, 是在Server.jsaddAdditionalEntries()方法添加的2个文件入口. 如果有EntryPlugin的话就会直接调用.

增加的2个文件是: webpack-dev-server/client-src/index.jswebpack/hot/dev-server.js

总结

Server.js

为入口文件新增2个文件.

建立ws服务.

调用webpack的编译, 并检测文件变化触发增量编译.

注册done钩子, 在编译完成后向client发送消息.

hmrPlugin

从编译时候的钩子中获取信息.

编译结束根据信息emit文件.

为runtime加入module变量, 这里包含了拉取上一步emit的文件流程和执行hmr业务的代码.

流程总结

  1. client和dev-server建立ws连接.
  2. 文件变化, 重新编译, 并产生menifest文件和更新内容文件.
  3. 通知client.
  4. client请求menifest文件和更新内容文件.
  5. 用获取的新内容替换缓存内容. (webpack_requrei.cache)
  6. 根据menifest文件尝试执行hmr业务, 并尝试冒泡, 或兜底刷新.

(后几步上文没详解, 等下次分析)

简单聊我是如何debug的

说2个一开始错误的debug思路:

  • 逐行看代码(Server.js), 根据配置判断代码执行情况.

    这么做不容易抓重点, 并且会打击信心.

  • 配置了writeToDisk + static. (具体配置在上面代码仓库里有) 希望直接看到dev时候生成的代码.

    这么做会触发static文件监听而刷新页面, 不可行. 直接看浏览器network就行了, 实在需求的情况下再配置一下, 查看dist文件.

最后现在debug模式是: 根据发生的事情追溯导致行为的代码. 本文也是按照发生事情的流程进行的, 容易理解.