了不起的 Webpack HMR 学习指南(含源码分析) 下

简介: 了不起的 Webpack HMR 学习指南(含源码分析) 下

四、HMR 完整原理和源码分析

通过上一节内容,我们大概知道 HMR 简单工作流程,那么或许你现在可能还有很多疑惑:文件更新是什么通知 HMR Plugin?HMR Plugin 怎么发送更新到 HMR Runtime?等等问题。

那么接下来我们开始详细结合源码分析整个 HMR 模块热更新流程,首先还是先看流程图,可以先不了解图中方法名称(红色字体黄色背景色部分):

Webpack HMR.png

上图展示了从我们修改代码,到模块热更新完成的一个 HMR 完整工作流程,图中已用红色阿拉伯数字符号将流程标识出来。

要了解上面工作原理,我们先理解图中这几个名称概念:

  • Webpack-dev-server :一个服务器插件,相当于 express 服务器,启动一个 Web 服务,只适用于开发环境;
  • Webpack-dev-middleware :一个 Webpack-dev-server 的中间件,作用简单总结为:通过watch mode,监听资源的变更,然后自动打包。
  • Webpack-hot-middleware :结合 Webpack-dev-middleware 使用的中间件,它可以实现浏览器的无刷新更新,也就是 HMR;

下面一起学习 HMR 整个工作原理吧:

1.监控代码变化,重新编译打包

首先根据 devServer 配置,使用 npm start 将启动 Webpack-dev-server 启动本地服务器并进入 Webpack 的 watch 模式,然后初始化 Webpack-dev-middleware ,在 Webpack-dev-middleware 中通过调用 startWatch() 方法对文件系统进行 watch:

// webpack-dev-server\bin\webpack-dev-server.js
// 1.启动本地服务器 Line 386
server = new Server(compiler, options);
// webpack-dev-server\lib\Server.js
// 2.初始化 Webpack-dev-middleware Line 109
this.middleware = webpackDevMiddleware(compiler, Object.assign({}, options, wdmOptions));
// webpack-dev-middleware\lib\Shared.js
// 3.开始 watch 文件系统 Line 171
startWatch: function() {
 //...
 // start watching
 if(!options.lazy) {
  var watching = compiler.watch(options.watchOptions, share.handleCompilerCallback);
  context.watching = watching;
 }
 //...
}
share.startWatch();
// ...

startWatch() 方法执行后,便进入 watch 模式,若发现文件中代码发生修改,则根据配置文件对模块重新编译打包

2.保存编译结果

Webpack 与 Webpack-dev-middleware 交互,Webpack-dev-middleware 调用 Webpack 的 API 对代码变化进行监控,并通知 Webpack 将重新编译的代码通过 JavaScript 对象保存在内存中

我们会发现,在 output.path 指定的 dist 目录并没有保存编译结果的文件,这是为什么?

其实, Webpack 将编译结果保存在内存中,因为访问内存中的代码比访问文件系统中的文件快,这样可以减少代码写入文件的开销。

Webpack 能将代码保存到内存中,需要归功于 Webpack-dev-middleware 的 memory-fs 依赖库,它将原本 outputFileSystem  替换成了 MemoryFileSystem  的实例,便实现代码输出到内存中。其中部分源码如下:

// webpack-dev-middleware\lib\Shared.js Line 108
// store our files in memory
var fs;
var isMemoryFs = !compiler.compilers && 
    compiler.outputFileSystem instanceof MemoryFileSystem;
if(isMemoryFs) {
 fs = compiler.outputFileSystem;
} else {
 fs = compiler.outputFileSystem = new MemoryFileSystem();
}
context.fs = fs;

上述代码先判断 fileSystem 是否是 MemoryFileSystem 的实例,若不是,则用 MemoryFileSystem 的实例替换 compiler 之前的 outputFileSystem。这样 bundle.js 文件代码就作为一个简单 JavaScript 对象保存在内存中,当浏览器请求 bundle.js 文件时,devServer 就直接去内存中找到上面保存的 JavaScript 对象并返回给浏览器端。

3.监控文件变化,刷新浏览器

Webpack-dev-server 开始监控文件变化,与第 1 步不同的是,这里并不是监控代码变化重新编译打包。

当我们在配置文件中配置了 devServer.watchContentBasetrue ,Webpack-dev-server 会监听配置文件夹中静态文件的变化,发生变化时,通知浏览器端对应用进行浏览器刷新,这与 HMR 不一样。

// webpack-dev-server\lib\Server.js
// 1. 读取参数 Line 385
if (options.watchContentBase) { defaultFeatures.push('watchContentBase'); }
// 2. 定义 _watch 方法 Line 697
Server.prototype._watch = function (watchPath) {
 // ...
  const watcher = chokidar.watch(watchPath, options).on('change', () => {
    this.sockWrite(this.sockets, 'content-changed');
  });
  this.contentBaseWatchers.push(watcher);
};
// 3. 执行 _watch() 监听文件变化 Line 339
watchContentBase: () => {
    if (/^(https?:)?\/\//.test(contentBase) || typeof contentBase === 'number') {
        throw new Error('Watching remote files is not supported.');
    } else if (Array.isArray(contentBase)) {
        contentBase.forEach((item) => {
            this._watch(item);
        });
    } else {
        this._watch(contentBase);
    }
}

4.建立 WS,同步编译阶段状态

这一步都是 Webpack-dev-server 中处理,主要通过 sockjs(Webpack-dev-server 的依赖),在 Webpack-dev-server 的浏览器端(Client)和服务器端(Webpack-dev-middleware)之间建立 WebSocket 长连接

然后将 Webpack 编译打包的各个阶段状态信息同步到浏览器端。其中有两个重要步骤:

  • 发送状态

Webpack-dev-server 通过 Webpack API 监听 compile 的 done 事件,当 compile 完成后,Webpack-dev-server 通过 _sendStats 方法将编译后新模块的 hash 值用 socket 发送给浏览器端。

  • 保存状态

浏览器端将_sendStats 发送过来的 hash 保存下来,它将会用到后模块热更新

// webpack-dev-server\lib\Server.js
// 1. 定义 _sendStats 方法 Line 685
// send stats to a socket or multiple sockets
Server.prototype._sendStats = function (sockets, stats, force) {
  //...
  this.sockWrite(sockets, 'hash', stats.hash);
};
// 2. 监听 done 事件 Line 86
compiler.plugin('done', (stats) => {
   // 将最新打包文件的 hash 值(stats.hash)作为参数传入 _sendStats()
    this._sendStats(this.sockets, stats.toJson(clientStats));
    this._stats = stats;
});
// webpack-dev-server\client\index.js
// 3. 保存 hash 值 Line 74
var onSocketMsg = {
  // ...
  hash: function hash(_hash) {
    currentHash = _hash;
  },
  // ...
}
socket(socketUrl, onSocketMsg);

5.浏览器端发布消息

hash 消息发送完成后,socket 还会发送一条 ok 的消息告知 Webpack-dev-server,由于客户端(Client)并不请求热更新代码,也不执行热更新模块操作,因此通过 emit 一个 "webpackHotUpdate" 消息,将工作转交回 Webpack。

// webpack-dev-server\client\index.js
// 1. 处理 ok 消息 Line 135
var onSocketMsg = {
  // ...
  ok: function ok() {
      sendMsg('Ok');
      if (useWarningOverlay || useErrorOverlay) overlay.clear();
      if (initial) return initial = false; // eslint-disable-line no-return-assign
      reloadApp();
  },
  // ...
}
// 2. 处理刷新 APP Line 218
function reloadApp() {
  // ...
  if (_hot) {
    // 动态加载 emitter
    var hotEmitter = require('webpack/hot/emitter');
    hotEmitter.emit('webpackHotUpdate', currentHash);
    if (typeof self !== 'undefined' && self.window) {
      // broadcast update to window
      self.postMessage('webpackHotUpdate' + currentHash, '*');
    }
  }
  // ...
}

6.传递 hash 到 HMR

Webpack/hot/dev-server 监听浏览器端 webpackHotUpdate 消息,将新模块 hash 值传到客户端 HMR 核心中枢的 HotModuleReplacement.runtime ,并调用 check 方法检测更新,判断是浏览器刷新还是模块热更新。 如果是浏览器刷新的话,则没有后续步骤咯~~

// webpack\hot\dev-server.js
// 1.监听 webpackHotUpdate Line 42
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();
    }
});
var check = function check() {
    module.hot.check(true).then(function(updatedModules) {
        if(!updatedModules) {
            // ...
      window.location.reload();// 浏览器刷新
            return;
        }
        if(!upToDate()) {
            check();
        }
    }).catch(function(err) { /*...*/});
};
// webpack\lib\HotModuleReplacement.runtime.js
// 3.调用 HotModuleReplacement.runtime 定义的 check 方法 Line 167
function hotCheck(apply) {
    if(hotStatus !== "idle") throw new Error("check() is only allowed in idle status");
    hotApplyOnUpdate = apply;
    hotSetStatus("check");
    return hotDownloadManifest(hotRequestTimeout).then(function(update) {
    //...
    });
}

7.检测是否存在更新

当 HotModuleReplacement.runtime 调用 check 方法时,会调用 JsonpMainTemplate.runtime 中的 hotDownloadUpdateChunk (获取最新模块代码)和 hotDownloadManifest (获取是否有更新文件)两个方法,这两个方法的源码,在下一步展开。

// webpack\lib\HotModuleReplacement.runtime.js
// 1.调用 HotModuleReplacement.runtime 定义 hotDownloadUpdateChunk 方法 Line 171
function hotCheck(apply) {
    if(hotStatus !== "idle") throw new Error("check() is only allowed in idle status");
    hotApplyOnUpdate = apply;
    hotSetStatus("check");
    return hotDownloadManifest(hotRequestTimeout).then(function(update) {
    //...
        {
          // hotEnsureUpdateChunk 方法中会调用 hotDownloadUpdateChunk
          hotEnsureUpdateChunk(chunkId);
        }
    });
}

其中 hotEnsureUpdateChunk 方法中会调用 hotDownloadUpdateChunk

// webpack\lib\HotModuleReplacement.runtime.js Line 215
 function hotEnsureUpdateChunk(chunkId) {
  if(!hotAvailableFilesMap[chunkId]) {
   hotWaitingFilesMap[chunkId] = true;
  } else {
   hotRequestedFilesMap[chunkId] = true;
   hotWaitingFiles++;
   hotDownloadUpdateChunk(chunkId);
  }
 }

8.请求更新最新文件列表

在调用 check 方法时,会先调用 JsonpMainTemplate.runtime 中的 hotDownloadManifest 方法, 通过向服务端发起 AJAX 请求获取是否有更新文件,如果有的话将 mainfest 返回给浏览器端。 这边涉及一些原生 XMLHttpRequest,就不全部贴出了~

// webpack\lib\JsonpMainTemplate.runtime.js
// hotDownloadManifest 定义 Line 22
function hotDownloadManifest(requestTimeout) {
    return new Promise(function(resolve, reject) {
        try {
            var request = new XMLHttpRequest();
            var requestPath = $require$.p + $hotMainFilename$;
            request.open("GET", requestPath, true);
            request.timeout = requestTimeout;
            request.send(null);
        } catch(err) {
            return reject(err);
        }
        request.onreadystatechange = function() {
            // ...
        };
    });
}

9.请求更新最新模块代码

hotDownloadManifest 方法中,还会执行  hotDownloadUpdateChunk 方法,通过 JSONP 请求最新的模块代码,并将代码返回给 HMR runtime 。

然后 HMR runtime 会将新代码进一步处理,判断是浏览器刷新还是模块热更新

// webpack\lib\JsonpMainTemplate.runtime.js
// hotDownloadManifest 定义 Line 12
function hotDownloadUpdateChunk(chunkId) {
  // 创建 script 标签,发起 JSONP 请求
    var head = document.getElementsByTagName("head")[0];
    var script = document.createElement("script");
    script.type = "text/javascript";
    script.charset = "utf-8";
    script.src = $require$.p + $hotChunkFilename$;
    $crossOriginLoading$;
    head.appendChild(script);
}

10.更新模块和依赖引用

这一步是整个模块热更新(HMR)的核心步骤,通过 HMR runtime 的 hotApply 方法,移除过期模块和代码,并添加新的模块和代码实现热更新。

hotApply 方法可以看出,模块热替换主要分三个阶段:

  1. 找出过期模块 outdatedModules 和过期依赖 outdatedDependencies
// webpack\lib\HotModuleReplacement.runtime.js
// 找出 outdatedModules 和 outdatedDependencies Line 342
function hotApply() { 
  // ...
  var outdatedDependencies = {};
  var outdatedModules = [];
  function getAffectedStuff(updateModuleId) {
    var outdatedModules = [updateModuleId];
    var outdatedDependencies = {};
    // ...
    return {
        type: "accepted",
        moduleId: updateModuleId,
        outdatedModules: outdatedModules,
        outdatedDependencies: outdatedDependencies
    };
 };
  function addAllToSet(a, b) {
      for (var i = 0; i < b.length; i++) {
          var item = b[i];
          if (a.indexOf(item) < 0)
              a.push(item);
      }
  }
  for(var id in hotUpdate) {
      if(Object.prototype.hasOwnProperty.call(hotUpdate, id)) {
          // ... 省略多余代码
          if(hotUpdate[id]) {
              result = getAffectedStuff(moduleId);
          }
          if(doApply) {
              for(moduleId in result.outdatedDependencies) {
                 // 添加到 outdatedDependencies
                  addAllToSet(outdatedDependencies[moduleId], result.outdatedDependencies[moduleId]);
              }
          }
          if(doDispose) {
              // 添加到 outdatedModules
              addAllToSet(outdatedModules, [result.moduleId]);
              appliedUpdate[moduleId] = warnUnexpectedRequire;
          }
      }
  }
}
  1. 从缓存中删除过期模块、依赖和所有子元素的引用;
// webpack\lib\HotModuleReplacement.runtime.js
// 从缓存中删除过期模块、依赖和所有子元素的引用 Line 442
function hotApply() {
   // ...
    var idx;
    var queue = outdatedModules.slice();
    while(queue.length > 0) {
        moduleId = queue.pop();
        module = installedModules[moduleId];
        // ...
        // 移除缓存中的模块
        delete installedModules[moduleId];
        // 移除过期依赖中不需要使用的处理方法
        delete outdatedDependencies[moduleId];
        // 移除所有子元素的引用
        for(j = 0; j < module.children.length; j++) {
            var child = installedModules[module.children[j]];
            if(!child) continue;
            idx = child.parents.indexOf(moduleId);
            if(idx >= 0) {
                child.parents.splice(idx, 1);
            }
        }
    } 
  // 从模块子组件中删除过时的依赖项
  var dependency;
  var moduleOutdatedDependencies;
  for(moduleId in outdatedDependencies) {
   if(Object.prototype.hasOwnProperty.call(outdatedDependencies, moduleId)) {
    module = installedModules[moduleId];
    if(module) {
     moduleOutdatedDependencies = outdatedDependencies[moduleId];
     for(j = 0; j < moduleOutdatedDependencies.length; j++) {
      dependency = moduleOutdatedDependencies[j];
      idx = module.children.indexOf(dependency);
      if(idx >= 0) module.children.splice(idx, 1);
     }
    }
   }
  }
}
  1. 将新模块代码添加到 modules 中,当下次调用 __webpack_require__  (webpack 重写的 require  方法)方法的时候,就是获取到了新的模块代码了。
// webpack\lib\HotModuleReplacement.runtime.js
// 将新模块代码添加到 modules 中 Line 501
function hotApply() {
   // ...
    for(moduleId in appliedUpdate) {
        if(Object.prototype.hasOwnProperty.call(appliedUpdate, moduleId)) {
            modules[moduleId] = appliedUpdate[moduleId];
        }
    }
}

hotApply 方法执行之后,新代码已经替换旧代码,但是我们业务代码并不知道这些变化,因此需要通过 accept事件通知应用层使用新的模块进行“局部刷新”,我们在业务中是这么使用:

if (module.hot) {
  module.hot.accept('./library.js', function() {
    // 使用更新过的 library 模块执行某些操作...
  })
}

11.热更新错误处理

在热更新过程中,hotApply 过程中可能出现 abort 或者 fail 错误,则热更新退回到刷新浏览器(Browser Reload),整个模块热更新完成。

// webpack\hot\dev-server.js Line 13
module.hot.check(true).then(function (updatedModules) {
    if (!updatedModules) {
        return window.location.reload();
    }
    // ...
}).catch(function (err) {
    var status = module.hot.status();
    if (["abort", "fail"].indexOf(status) >= 0) {
        window.location.reload();
    }
});

五、总结

本文和大家主要分享 Webpack 的 HMR 使用和实现原理及源码分析,在源码分析中,通过一张“Webpack HMR 工作原理解析”图让大家对 HMR 整个工作流程有所了解,HMR 本身源码内容较多,许多细节之处本文没有完整写出,需要各位读者自己慢慢阅读和理解源码。

参考文章

1.官方文档《Hot Module Replacement》

2.《Webpack HMR 原理解析》

3.《webpack HMR》

4.《配置 dev-server》

目录
相关文章
|
4月前
|
JSON JavaScript 前端开发
Webpack【Webpack图片处理、Webpack中proxy代理 、自动清理dist、Webpack优化、JavaScript中的代码检查】(三)-全面详解(学习总结---从入门到深化)(下)
Webpack【Webpack图片处理、Webpack中proxy代理 、自动清理dist、Webpack优化、JavaScript中的代码检查】(三)-全面详解(学习总结---从入门到深化)
51 2
|
7月前
|
JSON 前端开发 JavaScript
前端AJAX入门到实战,学习前端框架前必会的(ajax+node.js+webpack+git)(一)
前端AJAX入门到实战,学习前端框架前必会的(ajax+node.js+webpack+git)(一)
523 0
|
3月前
|
Web App开发 JSON 前端开发
Webpack【搭建Webpack环境、Webpack增加配置文件、Webpack中使用Loader、Webpack分离CSS文件 】(一)-全面详解(学习总结---从入门到深化)
Webpack【搭建Webpack环境、Webpack增加配置文件、Webpack中使用Loader、Webpack分离CSS文件 】(一)-全面详解(学习总结---从入门到深化)
53 0
|
3月前
|
存储 前端开发 JavaScript
Webpack【Webpack中模式(Mode)、Webpack中使用DevServer、Webpack中devtool增强调试过程】(二)-全面详解(学习总结---从入门到深化)
Webpack【Webpack中模式(Mode)、Webpack中使用DevServer、Webpack中devtool增强调试过程】(二)-全面详解(学习总结---从入门到深化)
50 0
|
4月前
|
JSON 前端开发 JavaScript
Webpack【搭建Webpack环境、Webpack增加配置文件、Webpack中使用Loader、Webpack分离CSS文件 】(一)-全面详解(学习总结---从入门到深化)(上)
Webpack【搭建Webpack环境、Webpack增加配置文件、Webpack中使用Loader、Webpack分离CSS文件 】(一)-全面详解(学习总结---从入门到深化)
56 0
|
3月前
|
JSON 前端开发 JavaScript
Webpack【Webpack图片处理、Webpack中proxy代理 、自动清理dist、Webpack优化、JavaScript中的代码检查】(三)-全面详解(学习总结---从入门到深化)
Webpack【Webpack图片处理、Webpack中proxy代理 、自动清理dist、Webpack优化、JavaScript中的代码检查】(三)-全面详解(学习总结---从入门到深化)
39 0
Webpack【Webpack图片处理、Webpack中proxy代理 、自动清理dist、Webpack优化、JavaScript中的代码检查】(三)-全面详解(学习总结---从入门到深化)
|
4月前
|
前端开发 JavaScript
Webpack【Webpack图片处理、Webpack中proxy代理 、自动清理dist、Webpack优化、JavaScript中的代码检查】(三)-全面详解(学习总结---从入门到深化)(上)
Webpack【Webpack图片处理、Webpack中proxy代理 、自动清理dist、Webpack优化、JavaScript中的代码检查】(三)-全面详解(学习总结---从入门到深化)
38 2
|
4月前
|
Web App开发 前端开发 JavaScript
Webpack【搭建Webpack环境、Webpack增加配置文件、Webpack中使用Loader、Webpack分离CSS文件 】(一)-全面详解(学习总结---从入门到深化)(下)
Webpack【搭建Webpack环境、Webpack增加配置文件、Webpack中使用Loader、Webpack分离CSS文件 】(一)-全面详解(学习总结---从入门到深化)
28 0
|
4月前
|
存储 前端开发 JavaScript
Webpack【Webpack中模式(Mode)、Webpack中使用DevServer、Webpack中devtool增强调试过程】(二)-全面详解(学习总结---从入门到深化)(下)
Webpack【Webpack中模式(Mode)、Webpack中使用DevServer、Webpack中devtool增强调试过程】(二)-全面详解(学习总结---从入门到深化)
19 0
|
4月前
|
测试技术 开发工具 开发者
Webpack【Webpack中模式(Mode)、Webpack中使用DevServer、Webpack中devtool增强调试过程】(二)-全面详解(学习总结---从入门到深化)(上)
Webpack【Webpack中模式(Mode)、Webpack中使用DevServer、Webpack中devtool增强调试过程】(二)-全面详解(学习总结---从入门到深化)
30 0