webpack 热更新原理探究

1,604 阅读9分钟

HMR 介绍

模块热替换(HMR - hot module replacement)功能会在应用程序运行过程中,替换、添加或删除 模块,而无需重新加载整个页面。

版本

  1. webpack-cli 4.6 提供了一些命令来使 webpack 的工作变得更简单。
  2. webpack 5.35.0 是一个用于JavaScript 应用程序的静态模块打包工具。
  3. webpack-dev-server 4.0.0-beta.2 提供一个基本的 web server
  4. webpack-dev-middleware 4.1.0 express风格的中间件;把打包后端文件放到内存里;延迟请求;

总体流程

  1. 执行webapck serve
  2. 加入引导流程主要是 webpack 不支持执行这个命令,需要由 webpack-cli 来执行,所以这个阶段会引入 webpack-cli 来执行 serve 命令。
  3. 构造命令,webpack-cli 一开始也不支持 serve,但是可以通过扩展的方式来支持这个命令。
  4. 执行命令,执行命令时,会创建一个http server和 socket server 并 建立浏览器和 server 的连接。
  5. 在浏览器和server建立的连接基础上,进行HMR工作流程。

未命名文件(5).jpg

引导

因为 webpack 不支持执行这个命令,需要由webpack-cli 来执行,所以一开始就会校验webpack-cli是否已经安装了,没安装的话会有相应提示。这个阶段执行完成以后,会生成一个cli到实例,然后转到下个阶段执行。

【流程图】

yindao.png

构造命令

进到这个阶段首先说明一个问题:为什么会需要构造命令?原因是webpack-cli并没有内置serve命令到处理逻辑,而是将这个命令单独发布成一个package。他和 webpack plugin 一样是一个具有 apply 方法的 JavaScript 对象。

【webpack-cli package.json 截图】

cli_pkg.png

【serveCommand】

serveCommand.png

结合流程图和部分代码截图看一下具体流程:

  1. 开始执行。第一个阶段命令转移到webpack-cli 执行后,会首先调用 cli 实例的 run 方法。
  2. 查询serve信息,校验依赖,require引入,实例化命令package。 webpack-cli 内部维护了一个命令描述列表,run 执行的时候,会从这个列表里查找是否存在,根据命令的描述信息校验命令的依赖是否完整,询问安装。最后require引入实例化。

【命令描述列表】

[
	{
		name: 'serve [entries...]', // 名称以及参数信息    
		alias: ['server', 's'], // 别名    
		pkg: '@webpack-cli/serve', // 依赖
	}
]
  1. serve 实例 的 apply 方法会被 cli 调用,方法参数是cli实例。

  2. 构造命令。apply 方法 会调用 cli 的 makeCommand 方法,参数别为 serve 命令的描述信息(类似2中的结构),命令的参数信息,命令的处理函数。

    // 新增命令
    const command = this.program.command(commandOptions.name, {
        noHelp: commandOptions.noHelp,
        hidden: commandOptions.hidden,
        isDefault: commandOptions.isDefault,
    });
    
  3. 为命令设置参数信息。命令构建完成以后,会给 serve 设置命令的参数信息 比如 -host, -port等。 这参数的获取来源有两个,分别是 ‘webpack-dev-server/bin/cli-flags’ 和 cli.getBuiltInOptions。

    if (options) {
        // ....
        options.forEach((optionForCommand) => {
            this.makeOption(command, optionForCommand);
        });
    }
    
  4. 命令构造完成,再次解析命令参数信息,开始执行命令。

这个阶段主要通过 commander 实现的,常用的vue-clicreate-react-app, 有兴趣可以看下。

【流程图】

构建命令.jpg

【简化代码】【serveCommand】【webpack-cli】

build_code.png

执行命令

【流程图】

命令执行.jpg

createCompiler,通过调用 webpack 得到 compiler 对象

Compiler 模块是 webpack 的主要引擎,它通过 CLI 传递的所有选项, 或者 Node API,创建出一个 compilation 实例。用来注册和调用插件。 大多数面向用户的插件会首先在 Compiler 上注册。

【webpack-cli createCompiler】

async createCompiler(options, callback) {
    // node 环境变量
    this.applyNodeEnv(options);
    // 获取config
    let config = await this.resolveConfig(options);
    // options 合并到 config上
    config = await this.applyOptions(config, options);
    // options 添加 cli-plugin
    config = await this.applyCLIPlugin(config, options);
    return this.webpack(config.options,callback)
}
  1. 获取config

    1. 解析配置文件 webpack.config (对象,函数,数组,promise)

    2. 命令行上的config 信息

    命令行接口(Command Line Interface)参数的优先级,高于配置文件参数。

  2. 添加默认插件

    1. cli.progress → ProgressPlugin 自定义编译期间进度报告方法。
    2. cli.hot → HotModuleReplacementPlugin 热更新
    3. cli.prefetch → PrefetchPlugin
    4. cli.analyze → BundleAnalyzerPlugin 生成代码分析报告
  3. 调用webpack,得到complier对象

实例化server

  1. 初始化服务 new devServer()

  2. 更新complier

    1. 打包入口 entry 添加 webpack/hot/dev-server 和 webpack-dev-server/client

    2. 自动加载模块 socket client (providePlugin),

      【webpack-dev-server 】

      // 引入模块 WebsocketClient
      const providePlugin = new webpack.ProvidePlugin({
        __webpack_dev_server_client__: getSocketClientPath(options),
      });
      
      providePlugin.apply(compiler);
      
    3. 添加 HotModuleReplacementPlugin插件(如果插件列表么有的话)

  3. 选择一个 web-socket 服务器的实现方式

    1. ws
    2. sockjs
    3. 自己实现
  4. client.progress 根据配置(progress)添加webpack 进度插件ProgressPlugin,使用socket 将进度信息发送到浏览器,在浏览器中以百分比显示编译进度。

  5. setupHooks 与 complier 建立链接,编译完成时将编译结果发送到前端。

    【webpack-dev-server】

    compile.tap('webpack-dev-server', invalidPlugin);
    invalid.tap('webpack-dev-server', invalidPlugin);
    done.tap('webpack-dev-server', (stats) => {
      this.sendStats(this.sockets, this.getStats(stats));
      this.stats = stats;
    });
    
  6. 初始化 express server

    this.app = new express();
    
  7. 根据 配置 devMiddleware 初始化 webpackDevMiddleware,他的作用是以 watch 模式 启动 webpack,监听的资源一旦发生变更,便会自动编译;在编译期间,将请求延迟到最新的编译结果完成之后;webpack 编译后的资源会存储在内存中,当用户请求资源时,直接于内存中查找对应资源。

    1. 初始化一个context ,包含了当前的编译状态和请求的回调。

      【webpackDevMiddleware】

      const context = {
          state: false, // 编译的状态
          stats: null,  
          callbacks: [], // 请求callbackFn
          options,
          compiler,
          watching: null,
       };
      
    2. setupHooks 与complier 建立链接

      【webpackDevMiddleware 】

      /*
      在 done 生命周期上注册 done 方法,该方法主要是 report 编译的信息以及执行 context.callbacks 回调函数
      在 invalid、run、watchRun 等生命周期上注册 invalid 方法,该方法主要是修改编译的状态信息
      */
      
      context.compiler.hooks.watchRun.tap('webpack-dev-middleware', invalid);
      context.compiler.hooks.invalid.tap('webpack-dev-middleware', invalid);
      (context.compiler.webpack
        ? context.compiler.hooks.afterDone
        : context.compiler.hooks.done
      ).tap('webpack-dev-middleware', done);
      
    3. setupOutputFileSystem 作用是使用 memory-fs 对象替换掉 compiler 的文件系统对象,让 webpack 编译后的文件输出到内存中。(memfs

    4. compiler.watch 调用 compiler 的 watch 方法,之后 webpack 便会监听文件变更,一旦检测到文件变更,就会重新执行编译。

    5. 返回 middleware 中间件,根据当前的编译状态返回结果,如果没有在编译期间则直接返回,在编译期间,context.state 会被设置为 false,用户发起请求时,并不会直接返回对应的文件内容,而是会将回调函数 添加至 context.callbacks 中,编译完成后循环调用 context.callbacks。

  8. 设置路由

  9. WatchFiles,根据配置 watchFiles 监听文件变化

  10. 在http服务上设置中间件

    1. 压缩中间件 compress → compression ,将静态资源经过压缩后返回
    2. onBeforeSetupMiddleware读取配置中设置的中间件列表,执行所有其他中间件之前执行。
    3. headers 为所有响应添加 headers
    app.all('*', setContentHeaders(req, res, next) {
      if (this.options.headers) {
        for (const name in this.options.headers) {
          res.setHeader(name, this.options.headers[name]);
        }
      }
      next();
    })
    
    1. webpackDevMiddleware → webpack-dev-middleware
    2. proxy → http-proxy-middleware → http-proxy,服务代理
    3. staticexpress.static 配置项允许配置从目录提供静态文件的选项
    4. historyApiFallbackconnect-history-api-fallback
    5. staticServeIndex → serveIndex 中间件会在查看没有 index.html 文件的目录时生成目录列表。
    6. staticWatch → static.watch 监听文件更新 文件的监听通过 chokidar 包实现。
    7. magicHtml
    8. onAfterSetupMiddleware 读取配置中设置的中间件列表,提供服务器内部在所有其他中间件之后执行。
  11. http.createServer

启动服务,初始化client与server的连接

  1. 调用 server.listen() 启动http服务

  2. 寻找可用的端口 findPort portfinder + p-retry 默认重试3次

  3. createScketServer 启动socket 服务

  4. 打来浏览器 open

  5. 首次获取的打包后的静态文件除了自己代码之外还包括了socket client以及一些热更新的代码,利用这些代码可以建立和服务端的socket链接。

    【dev-server】

    listen(port, hostname, fn) {
      return (
        findPort(port || this.options.port)
          // eslint-disable-next-line no-shadow
          .then((port) => {
            this.port = port;
            return this.server.listen(port, this.hostname, (error) => {
              if (this.options.hot || this.options.liveReload) {
                this.createSocketServer();
              }
              // ....
    
              this.showStatus();
              // ...
            });
          })
          .catch((error) => {
            if (fn) {
              fn.call(this.server, error);
            }
          })
      );
    }
    

HMR 工作流程

【热更新流程】

hrm.jpg

  1. 当文件系统中某一个文件发生修改,webpack 监听到文件变化,根据配置文件对模块重新编译打包,并将打包后的代码保存在内存中。然后通过webpack-dev-server/client/index.js 在浏览器端和服务端之间建立的 websocket 长连接,将 webpack 编译打包的状态信息告知浏览器端。发送本次编译的hash 值到前端

    【webpack-dev-server】

    sendStats(sockets, stats, force) {
    	// ......
      this.sockWrite(sockets, 'hash', stats.hash);
    	// ......
      this.sockWrite(sockets, 'ok');
    }
    

Untitled.png

  1. webpack-dev-server/client 在浏览器端接受服务端发过来的消息,

    【webpack-dev-server/client/index】

    // socket client 初始化,保存的状态和配置
    const status = {
      isUnloading: false,
      currentHash: '',
    };
    // 默认的配置
    const defaultOptions = {
      hot: false,
      hotReload: true,
      liveReload: false,
      initial: true,
      useWarningOverlay: false,
      useErrorOverlay: false,
      useProgress: false,
    };
    
    // 接受消息
    const onSocketMessage = {
    	// .....
    	hash(hash) {
        status.currentHash = hash;
      },
    	ok() {
        sendMessage('Ok');
    		// ....
        reloadApp(options, status);
      },
    	// ....
    }
    
    // reloadApp
    const hotEmitter = require('webpack/hot/emitter');
    hotEmitter.emit('webpackHotUpdate', currentHash);
    
  2. 浏览器端通过 webpack/hot/dev-server.js 接收到最新的编译信息,从而触发 module.hot.check 方法来开始热更新检查。

    【webpack/hot/dev-server.js】

    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();
    	}
    });
    
  3. 执行 webpack/lib/hmr/HotModuleReplacement.runtime.js 中的 hotCheck 方法,通过以 fetch 方式请求 hot-update.json 文件,然后执行 loadScript 以 jsonp 的方式加载 hot-update.js 文件。(manifest

    【webpack/lib/hmr/HotModuleReplacement.runtime.js 】

    function hotCheck(applyOnUpdate) {
    		// ....
    	return $hmrDownloadManifest$().then(function (update) { // 下载hot-update.json
    		return Promise.all(
    			Object.keys($hmrDownloadUpdateHandlers$).reduce(function (
    				promises,
    				key
    			) {
    				$hmrDownloadUpdateHandlers$[key]( // loadScript
    					update.c,
    					update.r,
    					update.m,
    					promises,
    					currentUpdateApplyHandlers,
    					updatedModules
    				);
    				return promises;
    			},
    			[])
    		).then(function () {
    			return waitForBlockingPromises(function () {
    				if (applyOnUpdate) {
    					return internalApply(applyOnUpdate);
    				} else {
    					return updatedModules;
    				}
    			});
    		});
    	});
    }
    

Untitled1.png

```js
{"c":["main"],"r":[],"m":[]} => {chunkIds, removedChunks, removedModules,}
```

5. loadScript,调用loadUpdateChunk发送hash.hot-update.js 请求,通过JSONP方式。

[【loadScript】](https://github.com/webpack/webpack/blob/e05935f8969d873a20762767d348256e5ad8fb46/lib/web/JsonpChunkLoadingRuntimeModule.js#L275)

```js
var waitingUpdateResolves = {};
function loadUpdateChunk(chunkId) {
    return new Promise((resolve,reject)=>{
        waitingUpdateResolves[chunkId] = resolve;
        var url = __webpack_require__.p + __webpack_require__.hu(chunkId);
				// .....
        var loadingEnded = (event)=>{
            // 加载完成。。。
        }
        ;
        __webpack_require__.l(url, loadingEnded);
    });
}
```

```js
// https://github.com/webpack/webpack/blob/e05935f8969d873a20762767d348256e5ad8fb46/lib/runtime/LoadScriptRuntimeModule.js
__webpack_require__.l = (url,done,key,chunkId)=>{
    // ... 
    var script, needAttach;
    // ...
		if (!script) {
        needAttach = true;
        script = document.createElement('script');
        script.charset = 'utf-8'
        script.timeout = 120;
        // ...
        script.setAttribute("data-webpack", dataWebpackPrefix + key);
        script.src = url;
    }
    var onScriptComplete = (prev,event)=>{
        // ...
    }
    // ...
    script.onerror = onScriptComplete.bind(null, script.onerror);
    script.onload = onScriptComplete.bind(null, script.onload);
    needAttach && document.head.appendChild(script);
}
```

![Untitled.png](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/757a10eb303f4696a47d9935eedaf83a~tplv-k3u1fbpfcp-watermark.image)

```js
// https://github.com/webpack/webpack/blob/e05935f8969d873a20762767d348256e5ad8fb46/lib/web/JsonpChunkLoadingRuntimeModule.js#L305
// hotUpdateGlobal -> https://webpack.docschina.org/configuration/output/#outputhotupdateglobal
self["webpackHotUpdatewebpack_demo"] = (chunkId,moreModules,runtime)=>{
    for (var moduleId in moreModules) {
				// __webpack_require__.o === Object.prototype.hasOwnProperty
        if (__webpack_require__.o(moreModules, moduleId)) {
            currentUpdate[moduleId] = moreModules[moduleId]; // 收集更新的模块
            if (currentUpdatedModulesList)
                currentUpdatedModulesList.push(moduleId);
        }
    }
    if (runtime) currentUpdateRuntime.push(runtime);
    if (waitingUpdateResolves[chunkId]) {
        waitingUpdateResolves[chunkId](); // js 加载完成了
        waitingUpdateResolves[chunkId] = undefined;
    }
}
```

6. internalApply 热更新模块替换 1. 删除过期的模块,就是需要替换的模块 1. 删除chunk 2. 删除过时的module 3. 删除过时的依赖 Untitled2.png

2. 更新代码,执行accept 回调,更新失败时会刷新页面
    1. 插入新的代码
    2. 执行accept 回调
    3. 自更新模块的更新
    ![Untitled4.png](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/e7129755799c462d98179c92b9fb20d6~tplv-k3u1fbpfcp-watermark.image)