问题
前两天,业务方给我抛来一段代码,略去繁杂的逻辑,简化后的代码如下:
// 代码示例 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 方法。这个方法做了两件事:
- 监听全局 popstate 事件
- 订阅 history 变化
这样,每当通过 history.push 或浏览器的前进后退变化路由(或触发 popstate 事件),从而动态渲染对应的页面组件。大致的流程如下图:
微前端的路由劫持逻辑
微前端框架(其运行时能力)与 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 切换同一个微应用的不同路由时,微应用没有并不会渲染出正确的页面。
当然,问题总是有解的。根据我们对 React Router DOM 的分析,微应用是通过下面两种方式匹配对应页面的。
- 通过微应用的 history 实例的 push 方法
- 触发 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 源码并对微前端实现有兴趣的朋友,千万不要错过:
常见问题FAQ
- 免费下载或者VIP会员专享资源能否直接商用?
- 本站所有资源版权均属于原作者所有,这里所提供资源均只能用于参考学习用,请勿直接商用。若由于商用引起版权纠纷,一切责任均由使用者承担。更多说明请参考 VIP介绍。
- 提示下载完但解压或打开不了?
- 找不到素材资源介绍文章里的示例图片?
- 模板不会安装或需要功能定制以及二次开发?
发表评论
还没有评论,快来抢沙发吧!