码云笔记前端博客
Home > JavaScript > webpack源码解析六之HMR热更新原理

webpack源码解析六之HMR热更新原理

2019-10-02 分类:JavaScript 作者:码云 阅读(87)

本文共计9149个字,预计阅读时长需要23分钟。

style-loader 支持热更新

回到我们之前写的 style-loader,当时为了简单并没有支持热更新,这里我们为他加上热更新功能。

因为 style-loader 其实会处理 css-loader 传过来的 locals,也就是 css modules 的class映射。那么根据有没有启用 css modules,其实 style-loader 的热更新会分为两种情况。

第一种情况,启用了 css modules 如果我们已经在项目中配好了 HMR的代码,那么style-loader不作任何修改,就能默认支持热更新。为什么呢? 因为在webpack中,如果一个模块没有处理热更新的事件,那么会自动冒泡到他的父元素,直到被处理或者最终刷新浏览器。那么如果 style-loader 没有处理热更新的事件,会自动冒泡上去。以一个 React 项目为例,最终会冒泡到 react-hot-loader,他会重新渲染 Root 组件,然后我们的 import styles from './styles.css' 就会被重新执行一遍,所以CSS样式就被更新了。 当然,因为我们只负责插入 style 标签,而没有删除它,所以 style 标签会越来越多。为了保证性能,我们还是需要增加一行删除旧 style 标签的代码:

1
2
3
if (module.hot) {
  module.hot.dispose(/**此处删除我们的旧style标签**/)
}

需要说明的是,在启用 css modules 的时候,style-loader 不处理热更新事件并不是投机取巧,而是必须不能处理。为什么呢? 因为如果 style-loader 自己处理了,比如把 style 标签的内容更新下,那么热更新就到此停止,父元素不会被更新,而且其实父元素依赖的 className 的列表已经变了,这样会导致样式出现错误。所以必须不能处理。 而如果没有启用 css modules,则父组件不会有直接依赖,这个时候只要 style-loader 自己更新下样式就好了。

第二种情况,如果没有启用 css modules 其实不作任何修改也可以!道理和上面的一样。然而,由于不作任何修改会冒泡到React根节点上,而其实只需要把 style 更新一下就好了,完全不用 React 组件做任何修改。所以在没有启用 css modules时,我们的 style-loader 就自己处理热更新,只需要在热更新的时候,把style的内容更新一下就好。

我们加上完整的热更新的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
/**
 * style loader will insert css into DOM
 */

module.exports.pitch = function (request) {
  var result = [
    'var content=require(' + loaderUtils.stringifyRequest(this, '!!' + request) + ');',
    'var style = require(' + loaderUtils.stringifyRequest(this, '!' + path.join(__dirname, "add-style.js")) + ')(content);',
    'if (module.hot) {',
    '  if (content.locals) {', // 未启用 css modules, 则可以直接更新 style内容即可。如果启用了,因为还需要父组件更新,所以这里就不作处理,直接冒泡到父组件处理(style-loader被父组件重新调用了一次)
        '        module.hot.accept(' + loaderUtils.stringifyRequest(this, '!!' + request) + ', function() {',
    '     console.log("update new style")',
    '     style.innerHTML = require(' + loaderUtils.stringifyRequest(this, '!!' + request) + ');',
    '   })',
    '  }',
    '  module.hot.dispose(function () { console.log(style);style.remove() })',  // 无论如何,当dispose的时候记得把之前创建的 style 标签删除掉
    '}',
    'if(content.locals) module.exports = content.locals'
  ]
  return result.join(';')
}

事实上,这也就是官方的 style-loader 的做法:只有在启用 cssmodules 的时候才处理热更新,否则只负责删除,而把更新代码交给父组件做。

HMR的原理

我们将分别从服务端(也就是nodejs)和客户端(也就是浏览器)两部分讲 HMR 热更新的原理。

我这里先画出一张流程图:
webpack源码解析六之HMR热更新原理
暂时看不懂没关系,下面我们详细讲解

HMR 在server端的实现

之前我们讲到 webpack 是从 webpack/bin/webpack 开始启动的,但是如果我们使用了 webpack-dev-server,那么就是从 webpack-dev-server/bin/webpack-dev-server.js 启动的。
HMR 在server端的实现
如上图所示,webpack-dev-server 包含了三部分:

  • webpack, 负责编译代码
  • webpack-dev-server,主要提供了 in-memory 内存文件系统,他会把webpack的outputFileSystem 替换成一个 inMemoryFileSystem,并且拦截全部的浏览器请求,从这个文件系统中把结果取出来返回。
  • express,作为服务器

先来总结下 webpack 在server端进行热更新的流程

初始化阶段:
1. webpack-dev-server 初始化的时候

  • var compiler = webpack(options) // 创建 webpack 实例
  • 监听 compiler 也就是 webpack 的 done 事件
  • 创建 express 实例
  • 创建 WebpackDevMiddleware 实例
  • 设置 express router,WebpackDevServer 会作为 express的一个中间件拦截所有请求

2. WebpackDevMiddleware 在初始化的时候

  • 创建 一个 MemoryFileSystem 实例,替换掉 webpack.outputFileSystem,这样 webpack 编译出的文件其实都是存在内存中,而不是磁盘上
  • 把对编译后的文件的请求,都重定向到上面创建的 MemoryFileSystem 中

热更新阶段:
1. webpack 监听文件变化,并完成编译

2. webpack-dev-server 监听 done 事件,并通过 websocket 向客户端发送消息

3. 客户端经过处理后,请求新的JS模块代码

4. WebpackDevServer 从 MemoryFileSystem 中取出代码,并返回

下面我们来看看代码:

初始化阶段,webpack-dev-server.js 会创建一个 webpack 实例:

1
2
3
4
5
6
7
8
9
10
let compiler;
  try {
    compiler = webpack(webpackOptions);
  } catch (e) {
  }
  let server;
  try {
    server = new Server(compiler, options);
  } catch (e) {
  }

接着,webpack-dev-server/lib/Server.js 会创建 express 和 WebpackDevMiddleware:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
function Server(compiler, options) {
  compiler.plugin('done', (stats) => {
    this._sendStats(this.sockets, stats.toJson(clientStats)); // 当完成编译的时候,就通过 websocket 发送给客户端一个消息(一个 `hash` 和 一个`ok`)
    this._stats = stats;
  });

  // Init express server
  const app = this.app = new express(); // eslint-disable-line

  // middleware for serving webpack bundle
  this.middleware = webpackDevMiddleware(compiler, options);
}

// 后面会有一行
app.use(this.middleware); // middleware会拦截所有请求,如果发现对应的请求是要请求 `dist` 中的文件,则会进行处理。

在热更新阶段,首先会触发这几行代码:

1
2
3
4
compiler.plugin('done', (stats) => {
    this._sendStats(this.sockets, stats.toJson(clientStats));
    this._stats = stats;
  });

_sendStats 会通过 websocket 给客户端发送两条消息。 客户端收到消息后,会去请求一个 json 配置文件,然后根据配置请求新的JS模块代码。这些请求都会被 WebpackDevMiddleware 拦截:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
function webpackDevMiddleware(req, res, next) {
        function goNext() {
            if(!context.options.serverSideRender) return next();
            return new Promise(function(resolve) {
                shared.ready(function() {
                    res.locals.webpackStats = context.webpackStats;
                    resolve(next());
                }, req);
            });
        }

        if(req.method !== "GET") {
            return goNext();
        }
        // 如果发现这个请求是 publicPath 中的文件内容,那么就从 fs 中取出内容并返回
        // 如果不是,那么 next,交给 `webpack-dev-server` 进行处理
        var filename = getFilenameFromUrl(context.options.publicPath, context.compiler, req.url);
        if(filename === false) return goNext();

        return new Promise(function(resolve) {
            shared.handleRequest(filename, processRequest, req);
            function processRequest() {
                // ...
                // server content
                var content = context.fs.readFileSync(filename);
                // ....
                if(res.send) res.send(content);
                else res.end(content);
                resolve();
            }
        });
    }

HMR 在浏览器中的工作流程

webpack 在 启用HMR之后,会在server端(nodejs端)监听文件改动,并且一旦发生变动就把新的代码编译后发送到浏览器。浏览器中也会有HMR相关的代码,会通过socket和server保持通信,获取新代码并进行热替换。

这里我们先不看webpack在nodejs端是如何编译的,而是先看看在浏览器中是如何工作的。
HMR 在浏览器中的工作流程
HMR 工作流程:

1. client 和 server 建立一个 websocket 通信

2. 当有文件发生变动的时候,webpack编译文件,并通过 websocket 向client发送一条更新消息

3. client 根据收到的hash值,通过ajax获取一个 manifest 描述文件

4. client 根据manifest 获取新的JS模块的代码

5. 当取到新的JS代码之后,会更新 modules tree,(installedModules)

6. 调用之前通过 module.hot.accept 注册好的回调,可能是loader提供的,也可能是你自己写的

这里以 用 webpack-dev-server 为例,当我们启用了 HMR 后,他会把我们的入口文件包一层,加上两个依赖:

1
2
3
4
5
6
7
8
/***/ 0:
// 这个模块是新加的,我们的入口就是 index,而这里加了一个模块,引用了 index,并且额外加了两行 require
/***/ (function(module, exports, __webpack_require__) {
__webpack_require__("./node_modules/webpack-dev-server/client/index.js?http://localhost:8080");
__webpack_require__("./node_modules/webpack/hot/dev-server.js");
module.exports = __webpack_require__("./src/index.js");
/***/ })
/******/ })

其中:

  • client/index.js 主要负责建立socket 通信,并在收到消息后调用对应的方法。
  • dev-server.js 会调用 module.hot.check 方法,最终真正去做代码更新的,是在 webpack/lib/HotModuleReplacement.runtime.js 文件中。顺便说下,你在console中看到的HMR log,几乎都是在 dev-server.js 中输出的,有兴趣可以看下这个文件的源码。 我们的代码启用了 HMR 之后会多出9000行代码,很大一部分就是由于引入了 webpack 中 HMR runtime相关的代码导致的。

我们先从这个文件开始看:

1. 初始化的时候,client.js 会启动一个 socket 和 webpack-dev-server 建立连接,然后等待 hash 和 ok 消息。

2. 当有文件内容改动的时候,首先会收到 webpack-dev-server 发来的 hash 消息,得到新的 哈希值并保存起来。

3. 然后会立刻接收到 ok 消息,表示现在可以加载最新的代码了,于是进入 reloadApp 方法。

4. reloadApp -> check()

5. check => hotDownloadManifest, 这里会下载一个本次热更新的manifest文件,url就是用上面存的 hash 拼接出来的,大概这样:8b52a72952cca784407e.hot-update.json,结果大概长这样:{"h":"8b52a72952cca784407e","c":{"0":true}}。这里仔细观察会发现,每一次取到的manifest中的hash 都是上一次 hash 消息的值,这样应该是为了保证顺序。

6. hotDownloadManifest 下载完配置文件后,可以看到其中有一个 h ,这个hash就是我们等会要取编译后的新代码的地址,在 hotEnsureUpdateChunk 方法中最终会通过 jsonp的方式把新的代码加载进来。

7. 加载到新的模块代码后,会有一系列的对 依赖树 比如 installedModules 的更新操作。

8. 最终,在 hotApply 中会执行我们的 module.hot.accept 注册的回调函数

上面说的这些JS代码,都是被webpack打包在我们的 bundle.js 头部的代码。都是在浏览器中执行的。

我们来看一下代码:

首先,我们的bundle文件会被加入 webpack-dev-server/client.js,他会创建一个 socket 和 devserver 连接,监听事件,主要代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const onSocketMsg = {
  hash: function msgHash(hash) { // 在 `hash` 事件触发的时候,把 `hash` 记下来
    currentHash = hash;
  },
  ok: function msgOk() { // `ok` 事件触发的时候,表示server已经便已完成最新代码,
    sendMsg('Ok');
    if (useWarningOverlay || useErrorOverlay) overlay.clear();
    if (initial) return initial = false; // eslint-disable-line no-return-assign
    reloadApp();
  }
};
// 省略
function reloadApp() {
  if (hot) {
    log.info('[WDS] App hot update...');
    // eslint-disable-next-line global-require
    const hotEmitter = require('webpack/hot/emitter');
    hotEmitter.emit('webpackHotUpdate', currentHash) // 触发这个事件的时候,会触发 `dev-server.js` 中的 check 方法
  }
// 省略
}

dev-server.js 中的check方法,最终会进入到这里:

1
2
3
4
5
6
7
8
9
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) {
                // 取到了 manifest后,就可以通过jsonp 加载最新的模块的JS代码了  
                 hotEnsureUpdateChunk(chunkId);
         });
     }

加载JS的代码如下:

1
2
3
4
5
6
7
8
9
10
function hotDownloadUpdateChunk(chunkId) { // eslint-disable-line no-unused-vars
        // 通过jsonp的方式加载
         var head = document.getElementsByTagName("head")[0];
         var script = document.createElement("script");
         script.type = "text/javascript";
         script.charset = "utf-8";
         script.src = __webpack_require__.p + "" + chunkId + "." + hotCurrentHash + ".hot-update.js";
         ;
         head.appendChild(script);
     }

加载到的代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
webpackHotUpdate(0,{

/***/ "./build/css-loader/index.js?modules!./src/style.css":
/***/ (function(module, exports, __webpack_require__) {

exports = module.exports = __webpack_require__("./build/css-loader/css-base.js")();
;exports.i(__webpack_require__("./src/global.css"));exports.push([module.i, "@import './global.css';\n\nh1 {\n  color: blue;\n}\n\n._input_css_12__avatar {\n  width: 100px;\n  height: 100px;\n  background-image: url('" + __webpack_require__("./src/avatar.jpeg") + "');\n  background-size: cover;\n}\n\n._input_css_12__avatar2 {\n  width: 100px;\n  height: 100px;\n  background-image: url('" + __webpack_require__("./src/m.png") + "');\n  background-size: cover;\n}\n", ""]);;exports.locals ={"avatar":"_input_css_12__avatar","avatar2":"_input_css_12__avatar2"}

/***/ })

})
//# sourceMappingURL=0.8b52a72952cca784407e.hot-update.js.map

到此为止,我们就已经得到了新模块的JS代码了,下面要做的就是调用对应的 accept 回调,这也是在 hotApply 方法的后面部分做的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
for(moduleId in outdatedDependencies) {
             if(Object.prototype.hasOwnProperty.call(outdatedDependencies, moduleId)) {
                 module = installedModules[moduleId];
                 if(module) {
                     moduleOutdatedDependencies = outdatedDependencies[moduleId];
                     var callbacks = [];
                     for(i = 0; i < moduleOutdatedDependencies.length; i++) {
                         dependency = moduleOutdatedDependencies[i];
                         cb = module.hot._acceptedDependencies[dependency]; // 先去到所有对这个模块注册的 accept 回调
                         if(cb) {
                             if(callbacks.indexOf(cb) >= 0) continue;
                             callbacks.push(cb);
                         }
                     }
                     for(i = 0; i < callbacks.length; i++) {
                         cb = callbacks[i];
                         try {
                             cb(moduleOutdatedDependencies); // 挨个调用一遍
                         } // ...
                     }
                 }
             }
         }

「除特别注明外,本站所有文章均为码云笔记原创,转载请保留出处!」

赞(10) 打赏

觉得文章有用就打赏一下文章作者

支付宝
微信
10

觉得文章有用就打赏一下文章作者

支付宝
微信

上一篇:

下一篇:

你可能感兴趣

共有 0 条评论 - webpack源码解析六之HMR热更新原理

博客简介

码云笔记 mybj123.com,一个专注Web前端开发技术的博客,主要记录和总结博主在前端开发工作中常用的实战技能及前端资源分享,分享各种科普知识和实用优秀的代码,以及分享些热门的互联网资讯和福利!码云笔记有你更精彩!
更多博客详情请看关于博客

精彩评论

站点统计

  • 文章总数: 458 篇
  • 分类数目: 13 个
  • 独立页面: 8 个
  • 评论总数: 215 条
  • 链接总数: 14 个
  • 标签总数: 1011 个
  • 建站时间: 495 天
  • 访问总量: 8647979 次
  • 最近更新: 2019年10月21日