2

前言

在web开发中,webpack的hot module replacement(HMR)功能是可以向运行状态的应用程序定向的注入并更新已经改变的modules。它的出现可以避免像LiveReload那样,任意文件的改变而刷新整个页面。

这个特性可以极大的提升开发拥有运行状态,以及资源文件普遍过多的前端应用型网站的效率。完整介绍可以看官网文档

本文是先从使用者的角度去介绍这个功能,然后从设计者的角度去分析并拆分需要实现的功能和实现的一些细节。

功能介绍

对于使用者来说,体验到这个功能需要以下的配置。

webpack.config.js:

const path = require('path');
const HtmlWebpackPlugin = require('html-webpack-plugin');
const webpack = require('webpack');

module.exports = {
    entry: {
        app: './src/index.js'
    },
    devServer: {
        contentBase: './dist',
        hot: true,
    },
    plugins: [
        new webpack.HotModuleReplacementPlugin(),
        new HtmlWebpackPlugin()
    ],
    output: {
        filename: '[name].bundle.js',
        path: path.resolve(__dirname, 'dist')
    }
};

代码: index.js 依赖print.js,使用module.hot.accept接受print.js的更新:

import './print';

if (module.hot) {
    module.hot.accept('./print', function () {
        console.log('i am updated');
    })
}


改变print.js代码:

console.log('print2')
console.log('i am change');

此时服务端向浏览器发送socket信息,浏览器收到消息后,开始下载以hash为名字的下载的json,jsonp文件,如下图:

图片描述

浏览器会下载对应的hot-update.js,并注入运行时的应用中:

webpackHotUpdate(0,{

/***/ 30:
/***/ (function(module, exports) {

console.log('print2')
console.log('i am change');




/***/ })

})

0 代表着所属的chunkid,30代表着所属的moduleid。

替换完之后,执行module.hot.accept的回调函数,如下图:

clipboard.png

简单来讲,开启了hmr功能之后,处于accepted状态的module的改动将会以jsonp的形式定向的注入到应用程序中。

一张图来表示HMR的整体流程:

clipboard.png

功能分析

提出问题

当翻开bundle.js的时候,你会发现Runtime代码多了许多以下的代码:

/******/     function hotDownloadUpdateChunk(chunkId) {
/******/            ...
/******/     }
/******/    function hotDownloadManifest(requestTimeout) {
/******/            ...
/******/     }
/******
/******/     function hotSetStatus(newStatus) {
/******/            ...
/******/     }
/******/     

打包的时候,明明只引用了4个文件,但是整个打包文件却有30个modules之多:

图片描述

/* 30 */
/***/ (function(module, exports) {

console.log('print3')
console.log('i am change');




/***/ })

到现在你可能会有以下几个疑问:

  1. hmr模式下Runtime是怎么加上HMR Runtime代码的。
  2. 业务代码并没有打包socketjs,hot代码的,浏览器为什么会有这些代码的。
  3. 浏览器是如何判断并下载如:501eaf61104488775d2e.hot-update.json,。501eaf61104488775d2e.hot-update.js文件的,并且如何将js内容替换应用程序的内容。
  4. 编译器如何监听资源文件的变化,并将改动的文件输出到Server里面供客户端下载,如501eaf61104488775d2e.hot-update.json,0.501eaf61104488775d2e.hot-update.js。
  5. 服务器如何监听编译器的生命周期,并在其编译开始,结束的时候,向浏览器发送socket信息。
  6. 浏览器替换新的module之后,如何及时清理缓存。

分析问题

  1. Runtime代码是根据MainTemplate内部实现的,有多种场景如normal,jsonp,hot模式,则可以考虑将字符串拼接改成事件。
  2. 编译开始时候,如果是hot模式,在编译器层面将socketjs,hot代码一并打包进来。
  3. 监听文件变化,webpack 封装了watchpack模块去监听如window,linux,mac系统的文件变化
  4. 编译结束后生成hash,文件变化后对比最近一次的hash。有变动则生成新的变动文件。
  5. server层监听编译器的编译开始,结束的事件,如compile,watch,done事件,触发事件后,像浏览器发送对应的websocket消息。
  6. 浏览器接受到了websocket消息后,根据hash信息,得到[hash].hot-update.json文件,从中解析到chunkId,在根据chunkId,hash信息去下载[chunkId].[hash]-update.js。
  7. 浏览器替换新的module之前,installedModules对象中删除缓存的module,在替换后,执行__webpack_require__(id),将其并入到installedModules对象中。

功能实现

以上问题,可以从三个不同的角度去解决。server,webpack,brower。

webpack-dev-server

  • 对入口entry做包装处理,如将
entry:{app:'./src/index.js'}

,转换为

entry:{app:['/Users/zhujian/Documents/workspace/webpack/webpack-demos-master/node_modules/_webpack-dev-server@2.11.2@webpack-dev-server/client/index.js?http://localhost:8082'],'webpack/hot/dev-server','./src/index.js'}

构建业务代码时,附带上socketjs,hot代码。

  • 初始化服务端sockjs,并注册connection事件,向客户端发送hot信息,开启hmr功能。

Server.js

   if (this.hot) this.sockWrite([conn], 'hot');

浏览器

hot: function hot() {
    _hot = true;
    log.info('[WDS] Hot Module Replacement enabled.');
  }
  • 监听编译器的生命周期模块。

    • socket

      • 监听compiler的compile事件,通过webSocket向客户端发送invalid 信息
      • 监听compiler的done事件,通过webSocket向客户端发送still-ok,hash以及hash内容,并将所有请求资源文件设置为可用状态
  compiler.plugin('compile', invalidPlugin);
  compiler.plugin('invalid', invalidPlugin);
  compiler.plugin('done', (stats) => {
    this._sendStats(this.sockets, stats.toJson(clientStats));
    this._stats = stats;
  });
  • 资源文件锁定

    • 监听compiler的invalid,watch-run,run事件。将所有请求资源文件设置为pending状态,直到构建结束。
    • 监听compiler的done事件,将所有请求资源文件重新设置为可用状态
    context.compiler.plugin("done", share.compilerDone);
    context.compiler.plugin("invalid", share.compilerInvalid);
    context.compiler.plugin("watch-run", share.compilerInvalid);
    context.compiler.plugin("run", share.compilerInvalid);

webpack

Template

MainTemplate增加module-obj,module-require事件

module-obj事件负责生成以下代码

/******/         var module = installedModules[moduleId] = {
/******/             i: moduleId,
/******/             l: false,
/******/             exports: {},
/******/             hot: hotCreateModule(moduleId),
/******/             parents: (hotCurrentParentsTemp = hotCurrentParents, hotCurrentParents = [], hotCurrentParentsTemp),
/******/             children: []
/******/         };
/******/

module-require事件负责生成以下代码

/******/         modules[moduleId].call(module.exports, module, module.exports, hotCreateRequire(moduleId));

Compiler

新增Watching类支持watch模式,并结合watchpack监听文件变化。

class Watching {
    ....
}

Module

新增updateHash实现

updateHash(hash) {
       this.updateHashWithSource(hash);
       this.updateHashWithMeta(hash);
       super.updateHash(hash);
   }

Chunk

新增updateHash实现

    updateHash(hash) {
        hash.update(`${this.id} `);
        hash.update(this.ids ? this.ids.join(",") : "");
        hash.update(`${this.name || ""} `);
        this._modules.forEach(m => m.updateHash(hash));
    }

Compilation

增加createHash方法,默认调用md5计算compilation hash。调用依赖树module,chunk的updateHash方法。

createHash() {
    ....
}

Parser

  • 增加对ifStatement的statement类的解析支持

如:

if(module.hot){}

编译后

if(true){}

MultiEntryPlugin

  • 增加MultiEntryDependency,MultiModule,MultiModuleFactory。将数组的entry对象,打包为以下的资源文件。
entry:{app:['/Users/zhujian/Documents/workspace/webpack/webpack-demos-master/node_modules/_webpack-dev-server@2.11.2@webpack-dev-server/client/index.js?http://localhost:8082'],'webpack/hot/dev-server','./src/index.js'}

打包后

/* 5 */
/***/ (function(module, exports, __webpack_require__) {

// webpack-dev-server/client/index.js
__webpack_require__(6); 
//webpack/hot/dev-server
__webpack_require__(26); 
// .src/index.js
module.exports = __webpack_require__(28);

/***/ })

HotModuleReplacementPlugin

  • 监听module-require,require-extensions,hash,bootstrap,current-hash,module-obj等事件生成HMR Runtime 代码
  • 监听record事件,存储最近一次的compilation hash。
compilation.plugin("record", function(compilation, records) {
                if(records.hash === this.hash) return;
                records.hash = compilation.hash;
                records.moduleHashs = {};
                this.modules.forEach(module => {
                    const identifier = module.identifier();
                    const hash = require("crypto").createHash("md5");
                    module.updateHash(hash);
                    records.moduleHashs[identifier] = hash.digest("hex");
                });
                records.chunkHashs = {};
                this.chunks.forEach(chunk => {
                    records.chunkHashs[chunk.id] = chunk.hash;
                });
                records.chunkModuleIds = {};
                this.chunks.forEach(chunk => {
                    records.chunkModuleIds[chunk.id] = chunk.mapModules(m => m.id);
                });
            });
  • 监听additional-chunk-assets 事件,对比record的最近一次hash,判断变化之后。生成以[hash].hot-update.json,[chunkId].[hash].hot-update.js为名称的assets对象。
compilation.plugin("additional-chunk-assets", function() {
                ....
                this.assets[filename] = source;
            });

Brower

  • 初始化runtime,将所有附加的模块代码统一增加parents,children等属性。并提供check,以及apply方法去管理hmr的生命周期。

    • check,发送http请求请求并更新manifest,请求成功之后,会将待更新的chunk hash与当前chunk hash做比较。多个chunk,则会等待相应的chunk 完成下载之后,将状态转回ready状态,表示更新已准备并可用。
    • apply,当应用状态为ready时,将所有待更新模块置为无效(清除客户端缓存),更新中调用新模块(更新缓存),更新完成之后,应用程序切回idle状态。
  • 初始化websocket,与server端建立长链接,并注册事件。如ok,invalid,hot,hash等事件。
  • 初始化hot 代码,注册事件对比新老hash,不相等则调用check方法开启模块更新功能。
module.hot.check(true).then(function(updatedModules) {
    ....
})

代码实现

本人的简易版webpack实现simple-webpack

(完)


朱建
400 声望70 粉丝

有空的时候给自己设定一个小目标。