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

·  阅读 1318
从 Prompt 来看微前端路由劫持原理

作者:那吒

问题

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

// 代码示例 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 - 面向大型系统的微前端解决方案

分类:
前端
分类:
前端
收藏成功!
已添加到「」, 点击更改