Webpack DevServer & HMR 小析

2,479 阅读10分钟

现代化的前端开发体验中,代码变更后浏览器在维持当前页面状态的同时自动完成代码的更新,这早已成为众多开发工具链中的标配,今天我们讨论的主题就是 Webpack 的 DevServer & HMR 的使用及实现原理。

基本使用

先看下面的例子:

// src/index.css
#app > div {
  color:red;
  font-size: 20px;
}

// src/app.js
export function setup(initValue = null) {
  let appElement = document.getElementById('app');
  let nameInputElement = document.createElement('input');
  nameInputElement.type = 'text';
  nameInputElement.placeholder = '请输入姓名';
  appElement.appendChild(nameInputElement);

  let nameDisplayElement = document.createElement('div');
  nameDisplayElement.innerHTML = '姓名:';
  appElement.appendChild(nameDisplayElement);

  nameInputElement.addEventListener('keyup', (event) => {
    nameDisplayElement.innerHTML = `姓名:${event.target.value}`;
  });

  if (initValue) {
    nameInputElement.value = initValue;
    nameDisplayElement.innerHTML = `姓名:${initValue}`; 
  }
}

// src/index.js
import './index.css';
import { setup } from './app';

setup();

上述代码片段中,我们创建了一个文本输入框和一个实时显示输入框内容的 div,效果如下所示:

basic.gif

接下来我们对 Webpack 启用 DevServer & HMR 的两种方式进行简单介绍。

直接配置

由于 webpack-cli 内置了 webpack-dev-server,因此我们直接为 Webpack 配置设置 devServer 属性即可启用 DevServer & HMR:

// webpack.config.js
module.exports = {
  // 其它配置信息……
  entry: './src/index.js',
  devServer: {
    static: './dist',
    port: 3000,
    hot: true,
  },
};

上述代码片段中,我们为 devServer 设置了 static(静态资源根路径)、port(服务端口号)、hot(是否开启 HMR),然后运行 npx webpack serve --open,等待浏览器打开后,试着更新 src/index.csssrc/app.js 并保存,会发现浏览器自动更新了代码,效果如下所示:

css.gif

no_hot.gif

Middleware

通过直接配置我们可以轻松启用 DevServer & HMR 功能,由于 webpack-cli 使用了 webpack-dev-server,webpack-dev-server 使用了 webpack-dev-middlewarewebpack-hot-middleware,因此本小节我们直接使用这两个 middleware 来启用 DevServer & HMR:

首先执行以下命令安装相关依赖:

yarn add webpack-dev-middleware webpack-hot-middleware express --dev 
# or npm install --save-dev webpack-dev-middleware webpack-hot-middleware express

创建 ./server.js 并输入以下内容:

const express = require('express');

const webpack = require('webpack');
const webpackDevMiddleware = require('webpack-dev-middleware');
const webpackHotMiddleware = require('webpack-hot-middleware');

const app = express();
const config = require('./webpack.config.js');
const compiler = webpack(config);
app.use(webpackDevMiddleware(compiler));
app.use(webpackHotMiddleware(compiler));

app.listen(config.devServer.port, function () {
  console.log(`Project is running at: http://localhost:${config.devServer.port}\n`);
});

上述代码片段中,我们使用 express 来实现本地服务器,首先实例化 express,然后加载 webpack.config.js 配置以完成 compiler 的实例化,接着将 webpack-dev-middlewarewebpack-hot-middleware 注入到 express middleware 中,最后监听 Webpack 配置中的开发服务端口启动服务。

然后修改 webpack.config.js

// webpack.config.js
const webpack = require('webpack');

module.exports = {
  // 其它配置信息……
  entry: [
    'webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000',
    './src/index.js',
  ],
  devServer: {
    static: './dist',
    port: 3000,
  },
  plugins: [
    new webpack.HotModuleReplacementPlugin(),
  ],
};

将上述代码片段与上一节的 webpack.config.js 内容进行对比,可以发现多了以下几项配置:

  • entry 中添加 webpack-hot-middleware/client?path=/__webpack_hmr&timeout=20000
  • 删除 devServer 中的 hot 属性(此时该属性多余,故此删除);
  • plugins 中添加 webpack.HotModuleReplacementPlugin

上面的一切都处理完毕后,运行 node ./server.js,然后访问 http://localhost:3000,试着更新 src/index.csssrc/app.js 并保存,会发现效果与上一节中的一模一样。

局部刷新

如果运行上文中的任何一个例子,大家会发现这样一个事实,即更新 JavaScript 代码时,浏览器选择了重新刷新,而不像更新 CSS 时保持了页面的状态。这是因为 CSS 的更新是无状态的,即只需要把相关内容给替换掉即可,但 JavaScript 的更新涉及到各种各样的状态的维护,Webpack HMR 模块不知道如何处理这些状态,因此只能采取最保守的操作(即刷新页面)来完成代码的更新。如果想要实现 JavaScript 的局部刷新,需要我们手动进行状态重置。针对于本文的例子,我们可以在 src/index.js 中添加以下内容:

if (module.hot) {
  module.hot.accept('./app.js', function() {
    console.log('Accepting the updated from ./app.js');

    let initValue = null;
    let appElement = document.getElementById('app');
    while (appElement.firstChild) {
      let child = appElement.lastChild;
      if (child.nodeName.toLocaleLowerCase() === 'input') {
        initValue = child.value;
      }
      appElement.removeChild(child);
    }
    setup(initValue);
  });
}

上述代码片段中,我们首先判断接口 module.hot 是否可用(由 HotModuleReplacementPlugin 暴露),如果可用,我们则通过 module.hot.accept 方法来监听 ./app.js 模块的更新,在其回调中,我们首先通过 initValue 来缓存当前输入框的值,接着移除 id 为 app 的所有子节点,最后调用 setup 函数重新创建相关节点。

以上文中任何一种方式再次运行该示例,接着在输入框内输入一些内容后修改 ./src/app.js 并保存,此刻页面以局部刷新的形式完成了 JavaScript 代码更新并维护了页面状态,效果如下所示:

hot.gif

当然,除了使用 module.hot 外,也可使用 import.meta.webpackHot(只能在 strict ESM 中使用),相关详情参见 webpack.docschina.org/api/hot-mod…,此处不再阐述。

原理分析

上文我们介绍了 Webpack DevServer & HMR 的基本用法,本节我们对其依赖的 webpack-dev-middlewarewebpack-hot-middlewareHotModuleReplacementPlugin 的实现原理进行简单的介绍。

webpack-dev-middleware

webpack-dev-middleware 的主要职责是监听模块的变化,并且以最新的模块内容响应相关请求,其核心代码如下所示(为便于理解,代码已经过最大程度的简化):

const path = require('path');
const memfs = require('memfs');
const mime = require("mime-types");

function getPaths(context) {
  const { stats } = context;
  return (stats.stats ? stats.stats : [stats]).map(({ compilation }) => ({
    outputPath: compilation.getPath(compilation.outputOptions.path),
    publicPath: compilation.getPath(compilation.outputOptions.publicPath),
  }));
}

function getFilenameFromRequest(context, req) {
  const paths = getPaths(context);
  const baseUrl = `${req.protocol}://${req.get('host')}`;
  const url = new URL(req.url, baseUrl);

  for (const { publicPath, outputPath } of paths) {
    const publicPathUrl = new URL(publicPath, baseUrl);
    if (url.pathname && url.pathname.startsWith(publicPathUrl.pathname)) {
      let filename = outputPath;
      const pathname = url.pathname.substr(publicPathUrl.pathname.length);
      if (pathname) {
        filename = path.join(outputPath, pathname);
      }
      let fileStats;
      try {
        fileStats = context.outputFileSystem.statSync(filename);
      } catch (_) {
        continue;
      }
      if (fileStats.isFile()) {
        return filename;
      }
      if (fileStats.isDirectory()) {
        filename = path.join(filename, 'index.html');
        try {
          fileStats = context.outputFileSystem.statSync(filename);
        } catch (_) {
          continue;
        }
        if (fileStats.isFile()) {
          return filename;
        }
      }
    }
  }
  return undefined;
}

function ready(context, req, callback) {
  if (context.isReady) {
    callback(context.stats);
    return;
  }

  const name = req && req.url || callback.name;
  context.logger.info(`Wait until bundle finished${name ? `: ${name}` : ""}`);
  context.callbacks.push(callback);
}

function main(compiler) {
  /**
   * 上下文环境设置
   */
  const context = {
    isReady: false,
    stats: null,
    callbacks: [],
    outputFileSystem: null,
    logger: compiler.getInfrastructureLogger('webpack-dev-middleware'),
  };

  /**
   * 内存文件系统设置
   */
  const memeoryFileSystem = memfs.createFsFromVolume(new memfs.Volume());
  memeoryFileSystem.join = path.join.bind(path);
  context.outputFileSystem = memeoryFileSystem;
  compiler.outputFileSystem = memeoryFileSystem;

  /**
   * compiler hook 设置
   */
  function invalid() {
    if (context.isReady) {
      context.logger.info('Compilation starting...');
    }
    context.isReady = false;
    context.stats = undefined;
  }
  compiler.hooks.watchRun.tap('webpack-dev-middleware', invalid);
  compiler.hooks.invalid.tap('webpack-dev-middleware', invalid);
  compiler.hooks.done.tap('webpack-dev-middleware', (stats) => {
    context.stats = stats;
    context.isReady = true;
    process.nextTick(() => {
      if (!context.isReady) {
        return;
      }
      context.logger.info('Compilation finished');
      const callbacks = context.callbacks;
      context.callbacks = [];
      callbacks.forEach(callback => callback(context.stats))
    });
  });

  /**
   * 开启监听
   */
  const watchOptions = compiler.options.watchOptions || {};
  compiler.watch(watchOptions, (error) => {
    if (error) {
      context.logger.error(error);
    }
  });

  /**
   * Express middleware
   */
  return async function(req, res, next) {
    const method = req.method;
    if (['GET', 'HEAD'].indexOf(method) === -1) {
      await next();
      return;
    }

    ready(context, req, async () => {
      const filename = getFilenameFromRequest(context, req);
      if (!filename) {
        await next();
        return;
      }
      const contentType = mime.contentType(path.extname(filename));
      if (contentType) {
        res.setHeader('Content-Type', contentType);
      }
      res.send(context.outputFileSystem.readFileSync(filename));
    });
  };
};

module.exports = main;

main 函数中,我们主要做了以下几件事情:

  • 定义变量 context 设置上下文环境;

  • 在开发环境下,一般使用内存来存储打包后的资源,故此这里我们通过 memfs 模块创建内存文件系统 memeoryFileSystem,并将其赋值给 compiler.outputFileSystem,以此来实现 Webpack 将打包后的资源存储到内存中的目的;

  • 接着监听 compilerwatchRuninvaliddone 钩子,其中:

    • watchRuninvalid 的回调中,重置 context.isReadycontext.stats
    • done 的回调中,首先设置 context.isReadycontext.stats 的值,然后调用 process.nextTick 以延迟触发 context 回调,这里之所以延迟触发是因为如果资源此时发生了变化,那么 context 回调中得到的资源可能是无效的,故在下一个任务中触发 context 回调以避免资源无效的情况发生。
  • 设置完 compiler 的钩子函数监听后,通过调用 compiler.watch 方法以监听模式开启 Webpack 打包流程;

  • 最后按照 express 自定义 middleware 的格式要求返回对打包资源请求处理的中间件。

在 express middleware 的具体实现中:

  • 如果不是 GETHEAD 请求,那么直接调用 next 方法将请求交给其它中间件进行处理,否则进入下一步;

  • 调用 ready 函数,并在回调中通过调用 getFilenameFromRequest 函数来获取请求资源的路径(filename),接着设置 Content-Type 头部,并将文件内容发送给请求方。这其中:

    • ready 函数中,如果 context.isReady 的值为 true,则直接调用回调,否则将其添加到 context.callbacks 中以便在 compiler.done 钩子的回调中触发;
    • getFilenameFromRequest 函数中,我们首先计算出请求资源的路径,如果该路径存在且为文件,直接返回;如果该路径存在且为目录,则匹配该路径下的 index.html 是否存在,存在即返回该路径下 index.html 的路径,否则返回 undefined

本小节我们对 webpack-dev-middleware 的核心实现进行了简要分析,总结一下共有以下几个流程:

  • 将 Webpack 的文件系统设置为内存文件系统;
  • 监听 watchRuninvaliddone 钩子;
  • 以监听模式运行 Webpack 的打包流程;
  • 通过 express middleware 拦截资源请求并对其进行响应。

webpack-hot-middleware

webpack-hot-middleware 的主要职责是监听模块的变化,然后将发生变化的模块推送给客户端,客户端对模块进行替换。不同于 webpack-dev-middlewarewebpack-hot-middleware 分服务端与客户端两部分,下面我们便就对其核心实现进行解析(为便于理解,代码已经过最大程度的简化)。

服务端

function createEventStream() {
  let clientId = 0;
  let clients = {};
  function everyClient(callback) {
    Object.keys(clients).forEach(id => callback(clients[id]));
  }

  return {
    handler: (req, res) => {
      const headers = {
        'Access-Control-Allow-Origin': '*',
        'Content-Type': 'text/event-stream;charset=utf-8',
        'Cache-Control': 'no-cache, no-transform',
        // While behind nginx, event stream should not be buffered:
        // http://nginx.org/docs/http/ngx_http_proxy_module.html#proxy_buffering
        'X-Accel-Buffering': 'no',
      };
      const isHttp1 = !(parseInt(req.httpVersion) >= 2);
      if (isHttp1) {
        req.socket.setKeepAlive(true);
        Object.assign(headers, {
          'Connection': 'keep-alive',
        });
      }
      res.writeHead(200, headers);
      res.write('\n');
      const id = clientId++;
      clients[id] = res;
      req.on('close', function () {
        if (!res.finished) {
          res.end()
        };
        delete clients[id];
      });
    },
    publish: (payload) => {
      everyClient(client => {
        client.write('data: ' + JSON.stringify(payload) + '\n\n');
      });
    },
  };
};

function publishStats(action, context) {
  const stats = context.stats.toJson({
    all: false,
    cached: true,
    children: true,
    modules: true,
    timings: true,
    hash: true,
  });
  [stats.children && stats.children.length ? stats.children: [stats]].forEach(() => {
    context.logger.info(`Webpack built ${stats.hash} in ${stats.time} ms`);
    context.eventStream.publish({
      action,
      time: stats.time,
      hash: stats.hash,
      warnings: stats.warnings || [],
      errors: stats.errors || [],
      modules: stats.modules.reduce((result, moduleItem) => ({
        ...result,
        [moduleItem.id]: moduleItem.name,
      }), {}),
    });
  });
}

function main(compiler) {
  /**
   * 上下文环境设置
   */
  const context = {
    stats: null,
    path: '/__webpack_hmr',
    eventStream: createEventStream(),
    logger: compiler.getInfrastructureLogger('webpack-hot-middleware'),
  };

  /**
   * compiler hook 设置
   */
  compiler.hooks.invalid.tap('webpack-hot-middleware', () => {
    context.stats = null;
    context.logger.info('Webpack building...');
    context.eventStream.publish({ action: 'building' });
  });
  compiler.hooks.done.tap('webpack-hot-middleware', (stats) => {
    context.stats = stats;
    publishStats('built', context);
  });

  /**
   * Express middleware
   */
  return function(req, res, next) {
    const url = new URL(req.url, `${req.protocol}://${req.get('host')}`);
    if (url.pathname !== context.path) {
      return next();
    }
    context.eventStream.handler(req, res);
    if (context.stats) {
      publishStats('sync', context);
    }
  }
}

module.exports = main;

main 函数中,我们主要做了以下几件事情:

  • 定义变量 context 设置上下文环境,注意查看 createEventStream 函数的实现,这里使用了 EventSource 进行服务端推送;

  • 接着监听 compilerinvaliddone 钩子,其中:

    • invalid 的回调中,重置 context.stats 并给客户端推送 building 事件;
    • done 的回调中,设置 context.stats 的值并给客户端推送 built 事件。
  • express middleware 的实现中:

    • 如果请求的 pathname 不为 /__webpack_hmr,那么直接调用 next 方法将请求交给其它中间件进行处理,否则进入下一步;
    • 通过 context.eventStream.handler 调用将当前请求转换为 EventSource 长链接以便与客户端保持长久通信;
    • 接着判断是否设置了 context.stats 的值,满足则给客户端推送 sync 事件。

客户端

通过分析服务端的实现可知,服务端需要推送事件给客户端,客户端自然需要监听相关事件并进行处理,以下是客户端的核心逻辑:

let lastHash;
function upToDate(hash) {
  if (hash) { 
    lastHash = hash;
  }
  return lastHash == __webpack_hash__;
}

function applyCallback(err) {
  if (err) {
    console.warn(`[HMR] Update check failed: ${err.stack || err.message}`);
    return;
  }
  if (!upToDate()) {
    checkServer();
  }
}

const applyOptions = {
  ignoreUnaccepted: true,
  ignoreDeclined: true,
  ignoreErrored: true,
  onUnaccepted: (data) => {
    console.warn(`Ignored an update to unaccepted module ${data.chain.join(' -> ')}`);
  },
  onDeclined: (data) => {
    console.warn(`Ignored an update to declined module ${data.chain.join(' -> ')}`);
  },
  onErrored: (data) => {
    console.error(data.error);
    console.warn(`Ignored an error while updating module ${data.moduleId} (${data.type})`);
  },
};
function checkServer() {
  const checkCallback = (err)  => {
    if (err) {
      console.warn(`[HMR] Update check failed: ${err.stack || err.message}`);
      return;
    }
    module.hot.apply(applyOptions, applyCallback)
      .then(_ => applyCallback(null))
      .catch(applyCallback);
  };
  module.hot.check(false, checkCallback)
    .then(_ => checkCallback(null))
    .catch(checkCallback);
}

function parseMessage(message) {
  switch (message.action) {
    case 'building':
      console.log('[HMR] bundle rebuilding');
      break;
    case 'built':
      console.log(`[HMR] bundle ${message.hash} rebuilt in ${message.time} ms`);
    case 'sync':
      if (!upToDate(message.hash) && module.hot.status() === 'idle') {
        console.log('[HMR] Checking for updates on the server...');
        checkServer();
      }
      break;
    default:
      console.error(`[HMR] unknown message action:${message.action}`);
      break;
  }
}

function connect() {
  const source = new window.EventSource('/__webpack_hmr');
  source.onopen = () => {
    console.log('[HMR] connected');
  };
  source.onerror = () => {
    source.close();
  };
  source.onmessage = (event) => {
    try {
      parseMessage(JSON.parse(event.data));
    } catch (error) {
      console.warn('Invalid HMR message: ' + event.data + '\n' + error);
    }
  };
}

connect();
  • connect 函数中,我们主要使用 EventSource 与服务端对路径为 /__webpack_hmr 的请求建立长链接,然后在 onmessage 的回调中调用 parseMessage 函数对服务端发送的信息进行处理;
  • parseMessage 函数中,如果消息类型为 sync,消息中 hash 与当前最新的 hash 不一致且 module.hot.status 的返回值为 idle 时,调用 checkServer 函数;
  • checkServer 函数中,我们通过调用 module.hot.check 并在其回调中调用 module.hot.apply 来完成模块的更新。

小结

本小节我们对 webpack-hot-middleware 的核心实现进行了简要分析,由于模块更新需要服务端与客户端的配合才能完成,因此它的实现分为服务端与客户端两部分:

  • 在服务端中,使用 express middleware 拦截路径为 /__webpack_hmr 的请求,将其转换为长链接,以便 invaliddone 钩子触发后能够为客户端推送相关事件;
  • 在客户端中,使用 EventSource 与服务端建立长链接,并监听 onmessage 事件,在得到消息后对消息进行解析以完成模块更新操作。

HotModuleReplacementPlugin

如前所述,在 webpack-hot-middleware 的客户端代码中,我们调用了 module.hot 中的相关方法,这些方法由 HotModuleReplacementPlugin 注入,本节我们便对其实现进行简要分析。

查看 HotModuleReplacementPlugin 的实现,在 apply 方法中, HotModuleReplacementPlugin 通过监听 compiler.hooks.compilation 钩子来完成支撑模块动态替换的逻辑设置。

依赖设置

if (compilation.compiler !== compiler) return;

// 添加 module.hot.accept 接口依赖
compilation.dependencyFactories.set(
  ModuleHotAcceptDependency,
  normalModuleFactory
);
compilation.dependencyTemplates.set(
  ModuleHotAcceptDependency,
  new ModuleHotAcceptDependency.Template()
);
// 此处省略其它 module.hot.* 接口依赖设置代码

// 添加 import.meta.webpackHot.accept 接口依赖
compilation.dependencyFactories.set(
  ImportMetaHotAcceptDependency,
  normalModuleFactory
);
compilation.dependencyTemplates.set(
  ImportMetaHotAcceptDependency,
  new ImportMetaHotAcceptDependency.Template()
);
// 此处省略其它 import.meta.webpackHot.* 接口依赖设置代码

上述代码片段中:

  • 首先判断当前 compilation 所属的 compiler 是否与 apply 方法的 compiler 实参相等,如不相等,直接返回,否则继续执行(因为该插件不应该影响子 compilation 的执行);
  • 然后通过 compilation.dependencyTemplates.set 调用分别设置 module.hot.*import.meta.webpackHot.* 接口依赖(用于在 seal 阶段生成相关代码);

compilation.hooks.record

通过监听 compilation.hooks.record 钩子来更新 records 中的一些属性:

let hotIndex = 0;
const fullHashChunkModuleHashes = {};
const chunkModuleHashes = {};

compilation.hooks.record.tap(
  "HotModuleReplacementPlugin",
  (compilation, records) => {
    if (records.hash === compilation.hash) return;

    const chunkGraph = compilation.chunkGraph;
    records.hash = compilation.hash;
    records.hotIndex = hotIndex;
    records.fullHashChunkModuleHashes = fullHashChunkModuleHashes;
    records.chunkModuleHashes = chunkModuleHashes;
    records.chunkHashes = {};
    records.chunkRuntime = {};
    for (const chunk of compilation.chunks) {
      records.chunkHashes[chunk.id] = chunk.hash;
      records.chunkRuntime[chunk.id] = getRuntimeKey(chunk.runtime);
    }
    records.chunkModuleIds = {};
    for (const chunk of compilation.chunks) {
      records.chunkModuleIds[chunk.id] = Array.from(
        chunkGraph.getOrderedChunkModulesIterable(
          chunk,
          compareModulesById(chunkGraph)
        ),
        m => chunkGraph.getModuleId(m)
      );
    }
  }
);

compilation.hooks.fullHash

通过监听 compilation.hooks.fullHash 钩子(runtime 被添加之后触发)来计算哪些 module 发生了变化并将其存储到 updatedModules 中:

const updatedModules = new TupleSet();
const fullHashModules = new TupleSet();
const nonCodeGeneratedModules = new TupleSet();

compilation.hooks.fullHash.tap("HotModuleReplacementPlugin", hash => {
  const chunkGraph = compilation.chunkGraph;
  const records = compilation.records;
  for (const chunk of compilation.chunks) {
    const getModuleHash = module => {
      if (compilation.codeGenerationResults.has(module, chunk.runtime)) {
        return compilation.codeGenerationResults.getHash(
          module,
          chunk.runtime
        );
      } else {
        nonCodeGeneratedModules.add(module, chunk.runtime);
        return chunkGraph.getModuleHash(module, chunk.runtime);
      }
    };
    const fullHashModulesInThisChunk = chunkGraph.getChunkFullHashModulesSet(chunk);
    // 设置 fullHashModules 的值
    const modules = chunkGraph.getChunkModulesIterable(chunk);
    if (modules !== undefined) {
      if (records.chunkModuleHashes) {
        if (fullHashModulesInThisChunk !== undefined) {
          for (const module of modules) {
            const key = `${chunk.id}|${module.identifier()}`;
            const hash = getModuleHash(module);
            if (fullHashModulesInThisChunk.has(module)) {
              if (records.fullHashChunkModuleHashes[key] !== hash) {
                updatedModules.add(module, chunk);
              }
              fullHashChunkModuleHashes[key] = hash;
            } else {
              if (records.chunkModuleHashes[key] !== hash) {
                updatedModules.add(module, chunk);
              }
              chunkModuleHashes[key] = hash;
            }
          }
        } else {
          // 设置 chunkModuleHashes 的值
        }
      } else {
        // 设置 fullHashChunkModuleHashes 及 chunkModuleHashes 的值
      }
    }
  }

  hotIndex = records.hotIndex || 0;
  if (updatedModules.size > 0) hotIndex++;

  hash.update(`${hotIndex}`);
});

钩子 compilation.hooks.fullHash 中存在许多干扰逻辑,它们的目的是为了计算 fullHashModulesfullHashChunkModuleHasheschunkModuleHashes 等变量的值,把这些干扰代码去掉后,回调的核心逻辑便是对比 chunkmodulehash 值是否发生了变化,如果发生了变化,就将相关 modulechunk 添加到 updatedModules 中去。

compilation.hooks.processAssets

通过监听 compilation.hooks.processAssets 钩子来生成 [hash].hot-update.js[hash].hot-update.json 文件:

compilation.hooks.processAssets.tap(
  {
    name: "HotModuleReplacementPlugin",
    stage: Compilation.PROCESS_ASSETS_STAGE_ADDITIONAL
  },
  () => {
    const chunkGraph = compilation.chunkGraph;
    const records = compilation.records;
    if (records.hash === compilation.hash) return;
    if (!records.chunkModuleHashes || !records.chunkHashes || !records.chunkModuleIds) {
      return;
    }
  
    // 对比 chunk 中 module 的 hash 是否发生了变化,如果发生了变化,将相关 module 及 chunk 添加到 updatedModules 中;
    // 更新 chunkModuleHashes 的值。

    const hotUpdateMainContentByRuntime = new Map();
    let allOldRuntime;
    // 通过遍历 records.chunkRuntime 的 key 收集已过时的 runtime,并将其存储到 allOldRuntime 中;
    // 通过 forEachRuntime 调用遍历 allOldRuntime,并设置 hotUpdateMainContentByRuntime 的值。
    if (hotUpdateMainContentByRuntime.size === 0) return;
 
    const allModules = new Map(); // 所有 module 列表(用于后续验证哪些 module 被完全删除)
    // 遍历 compilation.modules 设置 allModules 的值。

    const completelyRemovedModules = new Set();
    for (const key of Object.keys(records.chunkHashes)) {
      const oldRuntime = keyToRuntime(records.chunkRuntime[key]);
      const remainingModules = [];
      // 遍历 records.chunkModuleIds[key] 的值来设置 remainingModules 和 completelyRemovedModules 的值。
      let chunkId;
      let newModules;
      let newRuntimeModules;
      let newFullHashModules;
      let newDependentHashModules;
      let newRuntime;
      let removedFromRuntime;
      const currentChunk = find(
        compilation.chunks,
        chunk => `${chunk.id}` === key
      );
      if (currentChunk) {
        // 设置 newRuntime 的值。
        if (newRuntime === undefined) continue;
        // 根据 updatedModules 设置 newModules、newRuntimeModules、newFullHashModules、newDependentHashModules 的值;
        // 根据 oldRuntime、newRuntime 设置 removedFromRuntime 的值。
      } else {
        // 由于此时 chunk 已经被删除,将 removedFromRuntime、newRuntime 的值均设置为 oldRuntime
      }
      if (removedFromRuntime) {
        // 根据 remainingModules 及 newRuntime 更新 hotUpdateMainContentByRuntime
      }

      // 生成 [hash].hot-update.js 文件
      if ((newModules && newModules.length > 0) || (newRuntimeModules && newRuntimeModules.length > 0)) {
        const hotUpdateChunk = new HotUpdateChunk();
        // 检查是否开启了 Webpack 4 API 的向后兼容
        if (backCompat)
          ChunkGraph.setChunkGraphForChunk(hotUpdateChunk, chunkGraph);
        hotUpdateChunk.id = chunkId;
        hotUpdateChunk.runtime = newRuntime;
        if (currentChunk) {                                                             
          for (const group of currentChunk.groupsIterable)
            hotUpdateChunk.addGroup(group);
        }
        chunkGraph.attachModules(hotUpdateChunk, newModules || []);
        chunkGraph.attachRuntimeModules(
          hotUpdateChunk,
          newRuntimeModules || []
        );
        if (newFullHashModules) {
          chunkGraph.attachFullHashModules(
            hotUpdateChunk,
            newFullHashModules
          );
        }
        if (newDependentHashModules) {
          chunkGraph.attachDependentHashModules(
            hotUpdateChunk,
            newDependentHashModules
          );
        }
        const renderManifest = compilation.getRenderManifest({
          chunk: hotUpdateChunk,
          hash: records.hash,
          fullHash: records.hash,
          outputOptions: compilation.outputOptions,
          moduleTemplates: compilation.moduleTemplates,
          dependencyTemplates: compilation.dependencyTemplates,
          codeGenerationResults: compilation.codeGenerationResults,
          runtimeTemplate: compilation.runtimeTemplate,
          moduleGraph: compilation.moduleGraph,
          chunkGraph
        });
        for (const entry of renderManifest) {
          let filename;
          let assetInfo;
          if ("filename" in entry) {
            filename = entry.filename;
            assetInfo = entry.info;
          } else {
            ({ path: filename, info: assetInfo } =
              compilation.getPathWithInfo(
                entry.filenameTemplate,
                entry.pathOptions
              ));
          }
          const source = entry.render();
          compilation.additionalChunkAssets.push(filename);
          compilation.emitAsset(filename, source, {
            hotModuleReplacement: true,
            ...assetInfo
          });
          if (currentChunk) {
            currentChunk.files.add(filename);
            compilation.hooks.chunkAsset.call(currentChunk, filename);
          }
        }
        forEachRuntime(newRuntime, runtime => {
          hotUpdateMainContentByRuntime
            .get(runtime)
            .updatedChunkIds.add(chunkId);
        });
      }
    }
    const completelyRemovedModulesArray = Array.from(
      completelyRemovedModules
    );
    const hotUpdateMainContentByFilename = new Map();
    // 设置 hotUpdateMainContentByFilename 的值,包括属性:
    // removedChunkIds、removedModules、updatedChunkIds 及 assetInfo。
    for (const {
      removedChunkIds,
      removedModules,
      updatedChunkIds,
      filename,
      assetInfo
    } of hotUpdateMainContentByRuntime.values()) {
        // 设置 hotUpdateMainContentByFilename 的值:
        // key 为:filename 的值;属性有:removedChunkIds、removedModules、updatedChunkIds 及 assetInfo。
    }

    // 生成 [hash].hot-update.json 文件
    for (const [
      filename,
      { removedChunkIds, removedModules, updatedChunkIds, assetInfo }
    ] of hotUpdateMainContentByFilename) {
      const hotUpdateMainJson = {
        c: Array.from(updatedChunkIds),
        r: Array.from(removedChunkIds),
        m:
          removedModules.size === 0
            ? completelyRemovedModulesArray
            : completelyRemovedModulesArray.concat(
                Array.from(removedModules, m =>
                  chunkGraph.getModuleId(m)
                )
              )
      };
      const source = new RawSource(JSON.stringify(hotUpdateMainJson));
      compilation.emitAsset(filename, source, {
        hotModuleReplacement: true,
        ...assetInfo
      });
    }
  }
);

钩子 compilation.hooks.processAssets 逻辑较多,此处已将计算 newModuleshotUpdateMainContentByFilename 等变量的代码以注释替换,查看简化版的实现可知,该钩子的主要任务就是根据 newModuleshotUpdateMainContentByFilename 等变量来生成 [hash].hot-update.js[hash].hot-update.json 文件。在介绍 webpack-hot-middleware 客户端的时候我们说过,在接收到服务端类型为 sync 的消息后,客户端会调用 module.hot.checkmodule.hot.apply,在其内部会请求这两个文件,并据此完成模块的更新操作。

compilation.hooks.additionalTreeRuntimeRequirements

通过监听 compilation.hooks.additionalTreeRuntimeRequirements 钩子,设置并实例化 HMR 需要的运行时,这样才能保证 Webpack 在打包时将相关运行时代码准入到最终生成的代码中(关于运行时的讲解,可参考笔者的:Webpack Runtime 小析):

compilation.hooks.additionalTreeRuntimeRequirements.tap(
  "HotModuleReplacementPlugin",
  (chunk, runtimeRequirements) => {
    runtimeRequirements.add(RuntimeGlobals.hmrDownloadManifest);
    runtimeRequirements.add(RuntimeGlobals.hmrDownloadUpdateHandlers);
    runtimeRequirements.add(RuntimeGlobals.interceptModuleExecution);
    runtimeRequirements.add(RuntimeGlobals.moduleCache);
    compilation.addRuntimeModule(
      chunk,
      new HotModuleReplacementRuntimeModule()
    );
  }
);

代码转换

运行上文的例子,并观察最终生成的代码,会发现我们的热更新代码由:

if (module.hot) {
  module.hot.accept('./app.js', function() {
    console.log('Accepting the updated from ./app.js');

    let initValue = null;
    let appElement = document.getElementById('app');
    while (appElement.firstChild) {
      let child = appElement.lastChild;
      if (child.nodeName.toLocaleLowerCase() === 'input') {
        initValue = child.value;
      }
      appElement.removeChild(child);
    }
    setup(initValue);
  });
}

变成了:

if (true) {
  module.hot.accept('./app.js', function() {
    console.log('Accepting the updated from ./app.js');

    let initValue = null;
    let appElement = document.getElementById('app');
    while (appElement.firstChild) {
      let child = appElement.lastChild;
      if (child.nodeName.toLocaleLowerCase() === 'input') {
        initValue = child.value;
      }
      appElement.removeChild(child);
    }
    (0, _app__WEBPACK_IMPORTED_MODULE_1__.setup)(initValue);});
  }
}

这是因为 HotModuleReplacementPlugin 通过设置 JavaScriptParser 对其进行了转换:

const applyModuleHot = parser => {
  parser.hooks.evaluateIdentifier.for("module.hot").tap(
    {
      name: "HotModuleReplacementPlugin",
      before: "NodeStuffPlugin"
    },
    expr => {
      return evaluateToIdentifier(
        "module.hot",
        "module",
        () => ["hot"],
        true
      )(expr);
    }
  );
  parser.hooks.call
    .for("module.hot.accept")
    .tap(
      "HotModuleReplacementPlugin",
      createAcceptHandler(parser, ModuleHotAcceptDependency)
    );
};

normalModuleFactory.hooks.parser
  .for("javascript/auto")
  .tap("HotModuleReplacementPlugin", parser => {
    applyModuleHot(parser);
    // 省略其它逻辑……
  });
normalModuleFactory.hooks.parser
  .for("javascript/dynamic")
  .tap("HotModuleReplacementPlugin", parser => {
    applyModuleHot(parser);
  });

上述代码中,我们通过监听钩子 normalModuleFactory.hooks.parser,并在其回调处理函数 applyModuleHot 中:

  • 通过监听钩子 parser.hooks.evaluateIdentifier 匹配 module.hot 求值表达式(这里是 if (module.hot)),然后将其转换为 true
  • 通过监听钩子 parser.hooks.call 匹配 module.hot.accept 调用,然后为其设置必要的依赖,以便在代码生成阶段通过代码生成器(JavaScriptGenerator)配合依赖模板生成最终的代码。

小结

本小节对 HotModuleReplacementPlugin 的实现进行了简单的分析,这里简单总结下主要流程:

  • 添加必要的依赖;
  • 通过 JavaScriptParser 转换我们业务代码中的模块更新代码;
  • 通过 compilation.hooks.recordcompilation.hooks.fullHash 钩子,计算并记录 module 更新前后的一系列信息;
  • 根据前面 module 更新信息在 compilation.hooks.processAssets 钩子回调中生成 [hash].hot-update.js[hash].hot-update.json 文件,以便客户端能够根据这些文件动态地更新模块。

这里需要注意的是,在 HarmonyImportDependencyParserPlugin 中,会通过调用 HotModuleReplacementPlugin.getParserHooks 方法获取与 hotAcceptCallbackhotAcceptWithoutCallback 相关的 JavaScriptParser 钩子,并分别为其设置 HarmonyAcceptImportDependencyHarmonyAcceptDependency 依赖:

// 已删除其它无关代码……
const HotModuleReplacementPlugin = require("../HotModuleReplacementPlugin");
module.exports = class HarmonyImportDependencyParserPlugin {
  apply(parser) {
    const { hotAcceptCallback, hotAcceptWithoutCallback } = HotModuleReplacementPlugin.getParserHooks(parser);
    hotAcceptCallback.tap(
      "HarmonyImportDependencyParserPlugin",
      (expr, requests) => {
        if (!HarmonyExports.isEnabled(parser.state)) {
          // This is not a harmony module, skip it
          return;
        }
        const dependencies = requests.map(request => {
          const dep = new HarmonyAcceptImportDependency(request);
          dep.loc = expr.loc;
          parser.state.module.addDependency(dep);
          return dep;
        });
        if (dependencies.length > 0) {
          const dep = new HarmonyAcceptDependency(
            expr.range,
            dependencies,
            true
          );
          dep.loc = expr.loc;
          parser.state.module.addDependency(dep);
        }
      }
    );
    hotAcceptWithoutCallback.tap(
      "HarmonyImportDependencyParserPlugin",
      (expr, requests) => {
        // 与 hotAcceptCallback 处理逻辑一样,省略……
      }
    );
  }
}

HMR 运行时

通过上文可知,HotModuleReplacementPlugin 通过 compilation.hooks.additionalTreeRuntimeRequirements 来设置 HMR 所需的运行时代码,本节我们主要对 module.hot.checkmodule.hot.apply 方法进行解析。

module.hot.check

module.hot.check 实际上调用的是 lib/hmr/HotModuleReplacement.runtime.js 中的 hotCheck 函数,其定义如下:

function hotCheck(applyOnUpdate) {
  if (currentStatus !== "idle") {
    throw new Error("check() is only allowed in idle status");
  }
  return setStatus("check")
    .then($hmrDownloadManifest$)
    .then(function (update) {
      if (!update) {
        return setStatus(applyInvalidatedModules() ? "ready" : "idle").then(
          function () {
            return null;
          }
        );
      }

      return setStatus("prepare").then(function () {
        var updatedModules = [];
        blockingPromises = [];
        currentUpdateApplyHandlers = [];
        return Promise.all(
          Object.keys($hmrDownloadUpdateHandlers$).reduce(function (
            promises,
            key
          ) {
            $hmrDownloadUpdateHandlers$[key](
              update.c,
              update.r,
              update.m,
              promises,
              currentUpdateApplyHandlers,
              updatedModules
            );
            return promises;
          },
          [])
        ).then(function () {
          return waitForBlockingPromises(function () {
            if (applyOnUpdate) {
              return internalApply(applyOnUpdate);
            } else {
              return setStatus("ready").then(function () {
                return updatedModules;
              });
            }
          });
        });
      });
    });
}

Webpack 在打包时,会替换掉上述代码片段中的几个变量:

  • $hmrDownloadManifest$ 替换为 __webpack_require__.hmrM,用于加载 [hash]-hot-update.json 文件;
  • $hmrDownloadUpdateHandlers$ 替换为 __webpack_require__.hmrC,用于加载 [hash]-hot-update.js 文件;

通过代码片段可知,hotCheck 的主要目的是加载 [hash]-hot-update.json[hash]-hot-update.js 文件,然后根据 applyOnUpdate 的值执行相应逻辑,执行流程如下:

  • 如果当前状态不为 idle,抛出异常,否则进入下一步;

  • 通过 setStatus 将当前状态设置为 check,并通过 __webpack_require__.hmrM 加载 [hash]-hot-update.json 文件;

  • 文件加载成功后,如果回调中参数 update 的值为空,根据 applyInvalidatedModules 调用的返回值设置当前状态,并在回调中返回 null,否则进入下一步;

  • 通过 setStatus 将当前状态设置为 prepare,并将 __webpack_require__.hmrC 转换成 Promise 数组以实现并行加载 [hash]-hot-update.js 文件的目的;

  • 文件加载成功后,调用 waitForBlockingPromises 以等待所有未执行完的请求,最后根据参数 applyOnUpdate 的值做以下不同处理:

    • 如果 applyOnUpdatetrue,执行 internalApply 进行依赖替换;
    • 如果 applyOnUpdatefalse,通过 setStatus 将当前状态设置为 ready,并在回调中返回 updatedModules

module.hot.apply

module.hot.apply 实际上调用的是 lib/hmr/HotModuleReplacement.runtime.js 中的 hotApply 函数,其定义如下:

function hotApply(options) {
  if (currentStatus !== "ready") {
    return Promise.resolve().then(function () {
      throw new Error("apply() is only allowed in ready status");
    });
  }
  return internalApply(options);
}

function internalApply(options) {
  options = options || {};

  applyInvalidatedModules();

  var results = currentUpdateApplyHandlers.map(function (handler) {
    return handler(options);
  });

  currentUpdateApplyHandlers = undefined;

  var errors = results
    .map(function (r) {
      return r.error;
    })
    .filter(Boolean);

  if (errors.length > 0) {
    return setStatus("abort").then(function () {
      throw errors[0];
    });
  }

  // Now in "dispose" phase
  var disposePromise = setStatus("dispose");

  results.forEach(function (result) {
    if (result.dispose) result.dispose();
  });

  // Now in "apply" phase
  var applyPromise = setStatus("apply");

  var error;
  var reportError = function (err) {
    if (!error) error = err;
  };

  var outdatedModules = [];
  results.forEach(function (result) {
    if (result.apply) {
      var modules = result.apply(reportError);
      if (modules) {
        for (var i = 0; i < modules.length; i++) {
          outdatedModules.push(modules[i]);
        }
      }
    }
  });

  return Promise.all([disposePromise, applyPromise]).then(function () {
    // handle errors in accept handlers and self accepted module load
    if (error) {
      return setStatus("fail").then(function () {
        throw error;
      });
    }

    if (queuedInvalidatedModules) {
      return internalApply(options).then(function (list) {
        outdatedModules.forEach(function (moduleId) {
          if (list.indexOf(moduleId) < 0) list.push(moduleId);
        });
        return list;
      });
    }

    return setStatus("idle").then(function () {
      return outdatedModules;
    });
  });
}

通过上述代码片段可知,在 hotApply 中,如果当前状态不为 ready,抛出异常,否则调用 internalApply 函数,函数 internalApply 执行流程如下:

  • 调用 applyInvalidatedModules 用于执行客户端在调用 module.hot.invalidate 时指定的回调函数;
  • 然后通过 currentUpdateApplyHandlers 来收集 applyHandle 的执行结果(即调用 module.hot.apply 时指定的回调函数的处理结果);
  • 通过 setStatus 将当前状态设置为 dispose,并移除废弃的模块;
  • 通过 setStatus 将当前状态设置为 apply,并更新相应的模块。

小节

本小节我们对 HMR 运行时中的 module.hot.checkmodule.hot.apply 的实现进行了简要分析,除了这两个接口外,module.hot 也提供了其它的 API(webpack.docschina.org/api/hot-mod…),出于篇幅限制,此处不再一一分析。

总结

本文全面对 Webpack DevServer & HMR 进行了分析介绍:

  • 首先我们介绍了 Webpack DevServer & HMR 的使用进行了介绍,我们即可以通过 webpack-cli 来快速启用该功能,也可以借用 webpack apiwebpack-dev-middlewarewebpack-hot-middlewareHotModuleReplacementPlugin 来自行启用;
  • 接着我们介绍了 webpack-dev-middlewarewebpack-hot-middlewareHotModuleReplacementPlugin 的实现原理,其中 webpack-hot-middleware 包含客户端及服务端两部分,而 HotModuleReplacementPlugin 除了自身复杂的逻辑外,它还需要 otModuleReplacement.runtime.jsHarmonyImportDependencyParserPlugin 的辅助支撑。

Webpack 体系过于庞大,本文仅对核心流程、方法的实现进行了简要说明,对于遗漏的部分,衷心希望能够与大家一起研究。最后祝愿大家开心 code 每一天!^_^