从 Prompt 来看微前端路由劫持原理

2,414 阅读5分钟

作者:那吒

问题

前两天,业务方给我抛来一段代码,略去繁杂的逻辑,简化后的代码如下:

// 代码示例 1
import { Prompt, Link } from 'react-router-dom';

export const App = () => {
  return (
    <>
      <Prompt message="跳转到另一个同微应用路由" />
      <Link to="/detail">跳转到 detail </Link>
    </>
  )
}

在结合微前端框架 icestark 使用时,跳转到同一微应用的其他路由,会产生异常的效果:Prompt 弹窗了两次

面对这个错误,我陷入了深深地沉思。接下来,我尝试解开这个错误的神秘面纱,在这个过程中,会涉及到: ​

  • React Router 的实现原理
  • <Prompt /> 的底层实现
  • 以及微前端框架劫持路由后,面临的困境

React Router DOM 是怎么实现单页应用路由的

我们以 BrowserHistory 为例:

// 代码示例 2
import { BrowserRouter, Route } from 'react-router-dom';

ReactDOM.render(
  <BrowserRouter>
    <Route exact path="/">
      <Home />
    </Route>
  </BrowserRouter>
)

上面的代码会初始化一个 BrowserHistory 实例,并触发 BrowserHistory 的 listen 方法。这个方法做了两件事:

  1. 监听全局 popstate 事件
  2. 订阅 history 变化

这样,每当通过 history.push 或浏览器的前进后退变化路由(或触发 popstate 事件),从而动态渲染对应的页面组件。大致的流程如下图:

微前端的路由劫持逻辑

微前端框架(其运行时能力)与 React Router DOM 类似,本质是通过劫持 window.historypushStatereplaceState 方法,以及监听 popstate 和 hashChange 事件,并根据当前 URL 动态渲染匹配成功的微应用。

以微前端框架 icestark 为例,简化逻辑如下:

// 代码示例 3
const originPush = window.history.pushState;
const originReplace = window.history.replaceState;

const urlChange = () => {
	// 根据 url 匹配相应的微应用
}

// 劫持 history 的 pushState 方法
const hajackHistory = () => {
	window.history.pushState = (...rest) => {
  	originPush.apply(window.history, [...rest]);
  	urlChange();
  }
  
  window.addEventListener('popstate', urlChange, false);
}

但这样并不能解决全部问题

微应用是有独立路由的,当框架应用和微应用不共享同一个 history 实例的情况下。当框架应用切换路由,或其他微应用切换路由后,微应用如何能感知到路由变化呢?

比如,当通过框架应用的 history.push 切换同一个微应用的不同路由时,微应用没有并不会渲染出正确的页面。

当然,问题总是有解的。根据我们对 React Router DOM 的分析,微应用是通过下面两种方式匹配对应页面的。

  1. 通过微应用的 history 实例的 push 方法
  2. 触发 popstate 事件

对于方式一,如果页面框架应用侵入到微应用内部,这里不合理的,主应用与微应用应该尽量保持独立而非耦合。 因此,icestark 在解决这个问题的过程中,是通过劫持所有对 popstate 事件的监听,并在路由变化后主动触发 所有 popstate 的监听器

// 代码示例 4
const popstateCapturedListeners = [];

const hijackEventListener = () => {
  window.addEventListener = (eventName, fn, ...rest) => {
  	if (typeof fn === 'function' && eventName === 'popstate') {
    	// 劫持 popstate 的监听器
      popstateCapturedListeners.push(fn);
    }
  }
};

// 执行捕获的 popstate 事件监听器
const callCapturedEventListeners = () => {
  if (popstateCapturedListeners.length) {
    popstateCapturedListeners.forEach(listener => {
      listener.call(this, historyEvent)
    })
  }
};

reroute()
	// 匹配到对应微应用后,触发监听器
	.then(() => {
		callCapturedEventListeners();
});

副作用

需要额外注意的是,这种方案仍存在一个副作用。也就是:当微应用内部执行 history.push 时,微应用挂载的popstate 的监听器就会重复执行一次。

目前来说,这是一个预期的行为。

进一步分析 Prompt 的实现

似乎察觉到一些端倪了,接下来我们再深入 Prompt 的实现来看一下是什么原因导致了 Prompt 的两次触发。

React Router DOM Prompt 的代码可以在这里找到

// 代码示例 5
function Prompt({ message, when = true }) {
  return (
    <RouterContext.Consumer>
      {context => {
        invariant(context, "You should not use <Prompt> outside a <Router>");

        if (!when || context.staticContext) return null;

        const method = context.history.block;

        return (
          <Lifecycle
            onMount={self => {
              self.release = method(message);
            }}
            onUpdate={(self, prevProps) => {
              if (prevProps.message !== message) {
                self.release();
                self.release = method(message);
              }
            }}
            onUnmount={self => {
              self.release();
            }}
            message={message}
          />
        );
      }}
    </RouterContext.Consumer>
  );
}

代码比较浅显,在 Prompt 组件加载的时候,调用了 history.block 方法;在卸载的时候,做了一些回收操作。继续深入 history.block 的实现:

// 代码示例 5
function block(prompt = false) {
  const unblock = transitionManager.setPrompt(prompt);

  if (!isBlocked) {
    checkDOMListeners(1);
    isBlocked = true;
  }

  return () => {
    if (isBlocked) {
      isBlocked = false;
      checkDOMListeners(-1);
    }

    return unblock();
  };
}

history.block 在这里调用了 transitionManager.setPrompt 的全局方法。这里面又是什么逻辑呢?

// 代码示例 6
function createTransitionManager() {
  let prompt = null;

  function setPrompt(nextPrompt) {
    warning(prompt == null, 'A history supports only one prompt at a time');

    prompt = nextPrompt;

    return () => {
      if (prompt === nextPrompt) prompt = null;
    };
  }

  function confirmTransitionTo(
    location,
    action,
    getUserConfirmation,
    callback
  ) {
    if (prompt != null) {
      const result =
        typeof prompt === 'function' ? prompt(location, action) : prompt;

      if (typeof result === 'string') {
        if (typeof getUserConfirmation === 'function') {
          getUserConfirmation(result, callback);
        } else {
          warning(
            false,
            'A history needs a getUserConfirmation function in order to use a prompt message'
          );

          callback(true);
        }
      } else {
        // Return false from a transition hook to cancel the transition.
        callback(result !== false);
      }
    } else {
      callback(true);
    }
  }
	...

  return {
    setPrompt,
    confirmTransitionTo,
  };
}

原来 setPrompt 方法只是简单地保存一个 prompt,当调用 history.push 或响应到 popstate 的变化时,会调用 createTransitionManager.confirmTransitionTo 判断当前是否存在 Prompt。处理逻辑如下:

通过上面的分析,Prompt 组件完全依赖 prompt 的内容来判断是否展示 confirm 弹框。由上一节的分析,由于 icestark 重复执行了一次路由的执行逻辑,那么罪魁祸首是不是就是 “它” ?

果然,当 icestark 移除 callCapruteEventListeners (看代码示例 4)代码之后,Prompt 弹框恢复正常了。

如何解决

原因可算找到了。那接下来,我们怎么解决这个问题呢?

进一步分析 Prompt 的实现,我们发现 Prompt 组件在卸载后会调用 history.block 返回的函数(参看代码示例 5)清除 prompt 的内容。

那是不是因为在 Prompt 组件还未卸载,callCapruteEventListeners 就已经执行了。验证的方式很简单,只需要在 callCapruteEventListeners 执行的位置和 Prompt 卸载的位置执行断点即可。结果和我们设想的一致。

最终的解决方案,我们通过异步调用 callCapruteEventListeners,保证其在 Prompt 组件卸载之后执行即可 。

总结

在解决这个问题的过程中,我们通过先剖析 React Router DOM 和 icestark 如何劫持路由,以及当时在设计时的考虑, 来帮助大家了解微前端的一些核心运行原理。

最后,想了解 icestark 源码并对微前端实现有兴趣的朋友,千万不要错过:

icestark - 面向大型系统的微前端解决方案