從 Prompt 來看微前端路由劫持原理
背景
前兩天,業務方給我拋來一段代碼,略去繁雜的邏輯,簡化後的代碼如下:
// 代碼示例 1
import { Prompt, Link } from 'react-router-dom';
export const App = () => {
return (
<>
<Prompt message="跳轉到另一個同微應用路由" />
<Link to="/detail">跳轉到 detail </Link>
</>
)
}
在結合微前端框架 icestark 使用時,跳轉到同一微應用的其他路由,會產生異常的效果:Prompt 彈窗了兩次。
面對這個錯誤,我陷入了深深地沉思。接下來,我嘗試解開這個錯誤的神祕面紗,在這個過程中,會涉及到:
-
React Router 的實現原理
-
的底層實現
-
以及微前端框架劫持路由後,面臨的困境
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 源碼並對微前端實現有興趣的朋友,千萬不要錯過:
關於本文
作者:那吒
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/qELgKz3s8jepplH5f74_lQ