從 Prompt 來看微前端路由劫持原理

背景

前兩天,業務方給我拋來一段代碼,略去繁雜的邏輯,簡化後的代碼如下:

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

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

在結合微前端框架 icestark 使用時,跳轉到同一微應用的其他路由,會產生異常的效果: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.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 的分析,微應用是通過下面兩種方式匹配對應頁面的。

  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 源碼並對微前端實現有興趣的朋友,千萬不要錯過:

關於本文

作者:那吒

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/qELgKz3s8jepplH5f74_lQ