react项目首次热更新自动添加iframe的问题

975 阅读6分钟

React-dev-utils主要用于 React 开发过程中的开发工具,例如创建开发服务器、热更新、错误处理等功能。

在开发时遇到了一个问题,在项目第一次热更新时,即使项目编译成功也会在document.body添加一个iframe覆盖

react-dev-utils: 9.0.3

image-20230413172415431.png

观察ifame元素查找关键字,并打好debugger。查看调用栈发现问题,便开始了研究之路

image-20230413180411121.png

实现原理

  1. react-dev-utils 注册websocket
var connection = new SockJS(
  url.format({
    protocol: window.location.protocol,
    hostname: window.location.hostname,
    port: window.location.port,
    // Hardcoded in WebpackDevServer
    pathname: '/sockjs-node',
  })
);
  1. onmessage监听消息。并根据相应消息进行对应的逻辑处理

image-20230413162053353.png

  1. 比如编译结果成功时会接收到'{"type":"ok"}'的消息并调用handleSuccess函数

    function handleSuccess() {
      clearOutdatedErrors();
    ​
      var isHotUpdate = !isFirstCompilation;
      isFirstCompilation = false;
      hasCompileErrors = false;
    ​
      // Attempt to apply hot updates or reload.
      if (isHotUpdate) {
        tryApplyUpdates(function onHotUpdateSuccess() {
          // Only dismiss it when we're sure it's a hot update.
          // Otherwise it would flicker right before the reload.
          tryDismissErrorOverlay();
        });
      }
    }
    
  2. 调用tryDismissErrorOverlay函数,底层是调用react-error-overlaydismissBuildError函数

image-20230413162412387.png

image-20230413162528164.png

  1. react-error-overlayreact-error-overlay函数

    export function dismissBuildError() {
      currentBuildError = null;
      update();
    }
    ​
    ​
    
  2. update中的内容

    function update() {
      // Loading iframe can be either sync or async depending on the browser.
      if (isLoadingIframe) {
        // Iframe is loading.
        // First render will happen soon--don't need to do anything.
        return;
      }
      if (isIframeReady) {
        // Iframe is ready.
        // Just update it.
        updateIframeContent();
        return;
      }
      // We need to schedule the first render.
      isLoadingIframe = true;
      const loadingIframe = window.document.createElement('iframe');
      applyStyles(loadingIframe, iframeStyle);
      loadingIframe.onload = function () {
        const iframeDocument = loadingIframe.contentDocument;
        if (iframeDocument != null && iframeDocument.body != null) {
          iframe = loadingIframe;
          const script =
            loadingIframe.contentWindow.document.createElement('script');
          script.type = 'text/javascript';
          script.innerHTML = iframeScript;
          iframeDocument.body.appendChild(script);
        }
      };
      const appDocument = window.document;
      appDocument.body.appendChild(loadingIframe);
    }
    

小结

到这里我们可以发现iframe中的弹窗是在这里添加的,但是当第一次编译成功时,react-error-overlay中的全局变量isLoadingIframe和isIframeReady都为false。那么当第一次热更新时就会走创建iframe的逻辑,当手动删除后,而后的更新都不会增加。这是因为其添加的全局hookwindow.__REACT_ERROR_OVERLAY_GLOBAL_HOOK__提供的方法iframeReady重置了变量,不会走创建iframe的逻辑

研究拓展

出于好奇,react-dev-utils的websocket是和谁建立的,决定研究下去

全局搜索sockjs-node。搜索出来的结果还是挺多的有53个结果。仔细一想,我们启动服务时使用了webpack-dev-server启动了一恶搞服务器。客户端建立websocket需要和服务器建立连接,便先以webpack-dev-server路径下的资源这里开始查找

image-20230413164334201.png

点击去一看,这里果然是用于创建socket

image-20230413164607624.png

但仔细看下来后发现这里的websocket也是客户端用户接收消息的,并未查看到推送消息相关的😭

image-20230413164731143.png

按照之前查找的结果,重新查找,最终在node_modules/webpack-dev-server/lib/Server.js路径下找到了

image-20230413165153329.png

整理的流程图

801681384983_.pic.jpg

webpack-dev-server注册websocket主要流程代码自我理解

  1. 使用 sockjs.createServer 创建 WebSocket 服务器实例,其中 sockjs_url 指定了客户端 JavaScript 库的 URL,该库用于创建 WebSocket 连接。其中sockjs_url指定了用于创建 WebSocket 服务的 sockjs-client 的 URL。这个 URL 是由 webpack-dev-server 通过 html-webpack-plugin 插件生成的,会注入到 HTML 模板中

    const socket = sockjs.createServer({
        // Use provided up-to-date sockjs-client
        sockjs_url: '/__webpack_dev_server__/sockjs.bundle.js',
        // Limit useless logs
        log: (severity, line) => {
          if (severity === 'error') {
            this.log.error(line);
          } else {
            this.log.debug(line);
          }
        },
      });
    
  2. 监听 WebSocket 服务器的 connection 事件。这里会检查连接的请求头是否符合要求,如果不符合则关闭连接

    socket.on('connection', (connection) => {
            if (!connection) {
              return;
            }
    ​
            if (
              !this.checkHost(connection.headers) ||
              !this.checkOrigin(connection.headers)
            ) {
              this.sockWrite([connection], 'error', 'Invalid Host/Origin header');
    ​
              connection.close();
    ​
              return;
            }
    ​
            this.sockets.push(connection);
    ​
            connection.on('close', () => {
              const idx = this.sockets.indexOf(connection);
    ​
              if (idx >= 0) {
                this.sockets.splice(idx, 1);
              }
            });
    ​
            // ...
          });
    
  3. 启用配置发送消息

    return this.listeningApp.listen(port, hostname, (err) => {
            // ... 
            if (this.hot) {
              this.sockWrite([connection], 'hot');
            }
    ​
            if (this.progress) {
              this.sockWrite([connection], 'progress', this.progress);
            }
    ​
            if (this.clientOverlay) {
              this.sockWrite([connection], 'overlay', this.clientOverlay);
            }
    ​
            if (this.clientLogLevel) {
              this.sockWrite([connection], 'log-level', this.clientLogLevel);
            }
    ​
            if (!this._stats) {
              return;
            }
    ​
            this._sendStats([connection], this._stats.toJson(STATS), true);
          });
    ​
          // ...
        });
      }
    
  4. 如果已经有了编译结果,向客户端发送编译结果

this._sendStats([connection], this._stats.toJson(STATS), true);

其中_sendStats的作用:

_sendStats方法,该方法是其内部实现的一个方法,它的作用是将编译结果通过WebSocket发送给客户端,更新客户端的界面。

_sendStats方法接受两个参数,第一个参数是发送目标,这里传入了this.sockets,也就是所有与服务器建立了WebSocket连接的客户端;第二个参数是编译结果,这里调用了stats.toJson(STATS)方法,将webpack的编译结果转换成JSON格式

接着,这个函数将编译结果保存在this._stats属性中,方便在其他地方使用。由于webpack-dev-server使用了WebSocket实现了热更新功能,所以在每次编译完成后,需要将编译结果发送到客户端,更新客户端的界面

如果没编译好,会在addHooks处理

const addHooks = (compiler) => {
  const { compile, invalid, done } = compiler.hooks;
​
  compile.tap('webpack-dev-server', invalidPlugin);
  invalid.tap('webpack-dev-server', invalidPlugin);
  done.tap('webpack-dev-server', (stats) => {
    this._sendStats(this.sockets, stats.toJson(STATS));
    this._stats = stats;
  });
};

通过调用 compile.tap 方法,为 compile 和 invalid 这两个钩子函数注册一个名为 "webpack-dev-server" 的插件(plugin),并指定执行 invalidPlugin 函数作为回调函数。这里用了 tap 方法,表示添加一个新的回调函数到钩子的回调队列中。

最后,调用 done.tap 方法,为 done 钩子函数也注册一个名为 "webpack-dev-server" 的插件,并指定一个回调函数。当编译完成后,会调用这个回调函数,将编译生成的统计信息(stats)转换成 JSON 格式,然后通过 _sendStats 方法发送给当前正在监听的所有 WebSocket 连接。同时,也将这个统计信息保存在 this._stats 变量中,以便后续使用

  1. 使用 socket.installHandlers 方法将 WebSocket 服务器安装到 HTTP 服务器中。大概查了一下这里的作用是: WebSocket 服务器的处理逻辑绑定到 HTTP 服务器中,从而将 HTTP 请求转发到 WebSocket 服务器中处理。

    socket.installHandlers(this.listeningApp, {
      prefix: this.sockPath,
    });
    
  2. 如果传入了回调函数 fn,则在 HTTP 服务器启动完成时调用该函数

    if (fn) {
      fn.call(this.listeningApp, err);
    }
    
const addHooks = (compiler) => {
  const { compile, invalid, done } = compiler.hooks;
​
  compile.tap('webpack-dev-server', invalidPlugin);
  invalid.tap('webpack-dev-server', invalidPlugin);
  done.tap('webpack-dev-server', (stats) => {
    this._sendStats(this.sockets, stats.toJson(STATS));
    this._stats = stats;
  });
};

_sendStats方法,该方法是webpack-dev-server内部实现的一个方法,它的作用是将编译结果通过WebSocket发送给客户端,更新客户端的界面

_sendStats(sockets, stats, force) {
    if (
      !force &&
      stats &&
      (!stats.errors || stats.errors.length === 0) &&
      stats.assets &&
      stats.assets.every((asset) => !asset.emitted)
    ) {
      return this.sockWrite(sockets, 'still-ok');
    }
​
    this.sockWrite(sockets, 'hash', stats.hash);
​
    if (stats.errors.length > 0) {
      this.sockWrite(sockets, 'errors', stats.errors);
    } else if (stats.warnings.length > 0) {
      this.sockWrite(sockets, 'warnings', stats.warnings);
    } else {
      this.sockWrite(sockets, 'ok');
    }
  }

总结

/sockjs-node 是由 webpack-dev-server 提供的,它是一个用于开发环境的静态资源服务器,可以为前端项目提供本地开发环境。在使用 react-dev-utilsWebpackDevServerUtils.createCompiler 方法创建编译器时,会在内部通过 webpack-dev-server 提供的 /sockjs-node 接口,与客户端通过 WebSocket 建立长连接,实现实时更新和热加载等功能。

在开发环境中,客户端文件会被打包成多个 Chunk,当其中某个 Chunk 发生变化时,webpack-dev-server 会通过 WebSocket 推送消息到客户端。具体来说,当文件发生变化时,webpack-dev-server 会发送一个 JSON 数据到客户端,该数据包含了需要重新加载的 Chunk 名称和路径等信息。客户端接收到这个 JSON 数据后,就会使用新的 Chunk 来替换旧的 Chunk,从而实现实时更新和热加载。