最新公告
  • 欢迎您光临起源地模板网,本站秉承服务宗旨 履行“站长”责任,销售只是起点 服务永无止境!立即加入钻石VIP
  • 从 Prompt 来看微前端路由劫持原理

    正文概述 掘金(那吒)   2021-07-31   584

    问题

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

    // 代码示例 1
    import { Prompt, Link } from 'react-router-dom';
    
    export const App = () => {
      return (
        <>
          <Prompt message="跳转到另一个同微应用路由" />
          <Link to="/detail">跳转到 detail </Link>
        </>
      )
    }
    

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

    从 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 事件),从而动态渲染对应的页面组件。大致的流程如下图:

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

    微前端的路由劫持逻辑

    微前端框架(其运行时能力)与 React Router DOM 类似,本质是通过劫持 window.history 的 pushState 和 replaceState 方法,以及监听 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 切换同一个微应用的不同路由时,微应用没有并不会渲染出正确的页面。

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

    当然,问题总是有解的。根据我们对 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 组件完全依赖 prompt 的内容来判断是否展示 confirm 弹框。由上一节的分析,由于 icestark 重复执行了一次路由的执行逻辑,那么罪魁祸首是不是就是 “它” ? ​ 果然,当 icestark 移除 callCapruteEventListeners (看代码示例 4)代码之后,Prompt 弹框恢复正常了。 ​

    如何解决

    原因可算找到了。那接下来,我们怎么解决这个问题呢? ​ 进一步分析 Prompt 的实现,我们发现 Prompt 组件在卸载后会调用 history.block 返回的函数(参看代码示例 5)清除 prompt 的内容。

    那是不是因为在 Prompt 组件还未卸载,callCapruteEventListeners 就已经执行了。验证的方式很简单,只需要在 callCapruteEventListeners 执行的位置和 Prompt 卸载的位置执行断点即可。结果和我们设想的一致。 ​ 最终的解决方案,我们通过异步调用 callCapruteEventListeners,保证其在 Prompt 组件卸载之后执行即可 。

    总结

    在解决这个问题的过程中,我们通过先剖析 React Router DOM 和 icestark 如何劫持路由,以及当时在设计时的考虑, 来帮助大家了解微前端的一些核心运行原理。 ​ 最后,想了解 icestark 源码并对微前端实现有兴趣的朋友,千万不要错过: ​


    起源地下载网 » 从 Prompt 来看微前端路由劫持原理

    常见问题FAQ

    免费下载或者VIP会员专享资源能否直接商用?
    本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
    提示下载完但解压或打开不了?
    最常见的情况是下载不完整: 可对比下载完压缩包的与网盘上的容量,若小于网盘提示的容量则是这个原因。这是浏览器下载的bug,建议用百度网盘软件或迅雷下载。若排除这种情况,可在对应资源底部留言,或 联络我们.。
    找不到素材资源介绍文章里的示例图片?
    对于PPT,KEY,Mockups,APP,网页模版等类型的素材,文章内用于介绍的图片通常并不包含在对应可供下载素材包内。这些相关商业图片需另外购买,且本站不负责(也没有办法)找到出处。 同样地一些字体文件也是这种情况,但部分素材会在素材包内有一份字体下载链接清单。
    模板不会安装或需要功能定制以及二次开发?
    请QQ联系我们

    发表评论

    还没有评论,快来抢沙发吧!

    如需帝国cms功能定制以及二次开发请联系我们

    联系作者

    请选择支付方式

    ×
    迅虎支付宝
    迅虎微信
    支付宝当面付
    余额支付
    ×
    微信扫码支付 0 元