通過 React Router V6 源碼,掌握前端路由

在 React 前端項目中,涉及到前端路由,想必大家都用過了 react-router-dom[1] 這個包,因爲常用,所以有必要弄清楚其中的實現細節,對前端路由會有一個更深入的認識,另外也有助於提升工作效率。

此文不贅述使用方法,相關內容可以參考 tutorial 官方的指導手冊 [2]。

客戶端裏的路由模式

相較於 “服務端路由” 每次從服務端獲取 CSS、JS、HTML 資源,客戶端路由即是在客戶端內自行控制,與服務端解耦,頁面數據異步獲取,瀏覽器無刷新切換頁面,能爲用戶提供更快的頁面切換體驗,同時也爲前端 SPA 應用發展提供了基礎。

在瀏覽器 Web 環境裏有 “Hash” 和 “History” 兩種客戶端路由模式。

Hash 模式

Hash 模式點擊會跳轉定位到指定 DOM 位置,同時觸發 hashchange 事件,支持在瀏覽器中操作前進後退,其本質還是在同一文檔中操作,Server 端無感知前端路由變化。

Hash 值在 window.location.hash 中存儲,因此 Hash 變化時,同時可看到瀏覽器的 window.location.pathname 不變。

History 模式

Window 對象中提供了 history 實例,同時可以通過 history 暴露的 API 操作路由歷史堆棧。

也就是說,我們可以通過控制 history 對象來控制頁面的路由跳轉,瀏覽器不會刷新,但瀏覽器裏的 URL 會變更,SEO 更友好。

History 的 API 具體用法可參閱:History API - MDN[3]

React Router v6 的架構設計

react-router-dom 是一個封裝瀏覽器客戶端路由方案的優質工具模塊,基於 React 的應用開發者,可藉助其快速開發實現 “客戶端路由”,同時提升用戶體驗。

react-router-dom 作爲一款優秀的前端模塊,更新到了 V6 版本,全面擁抱 React hooks 功能設計,通過閱讀其源碼,瞭解其設計思想,相信可以給大家在 路由設計Hooks 實踐上帶來一些收穫。

文件結構

在項目管理上採用了基於 Yarn 的 Monorepo 方案:

項目設計

react-router-dom 是瀏覽器環境中的橋接層,react-router-native 則是 Hybrid 開發的橋接層,其核心實現都在 react-router 模塊中,層層遞進。

此外,react-router-dom-v5-compat 是用於 react-router-dom v5 版本兼容遷移到 v6 版本的處理方案,但個人更建議是直接使用 / 切換到 v6 版本,直接衝 🚀!

因此項目設計可以簡單分爲兩層:

架構設計

因爲我們常用 History 模式的前端路由,也就是 BrowserRouter,與此同時,可以理解爲 HashRouter 只是調用的 Browser API 不一樣,因此下面僅分析了 BrowserRouter 模式下的架構和設計。

react-router-dom@6.4 版本開始支持數據 API,即根據路規則預先獲取網絡數據,數據預加載和路由做了綁定。

雖然該功能是可選,但個人感覺大部分業務應該還是會自行在頁面內控制,或者採用自有的一套靈活的預加載方案,目前無法定量評估方案好壞,因此,我們閱讀的源碼版本爲 react-router-dom@6.3.0

react-router-dom 整體的功能架構設計如下圖:

雖然還有 StaticRouter、MemoryRouter、NativeRouter,但是掌握了 BrowserRouter,其它的應當也很容易理解。

核心實現 & 組件

react-router-dom 的實踐案例

要使用 react-router-dom,如下例舉了一個簡單的實踐案例。

頂層組件使用 BrowserRouter 包裹:

藉助 useRoutes Hooks 快速創建路由組件,不再像之前那些寫大量的組件,這裏直接做了官方的封裝和 “路由配置” 的定義:

BrowserRouter

BrowserRouter 確定了是 Web 運行環境,然後利用工具方法 createBrowserHistory 創建了對 Window.history API 的自定義封裝實例。

同時向自定義 history 實例上註冊監聽器,當路由發生變化時,會回調執行 setState 方法更新 actionlocation 信息,然後觸發組件的更新和重新渲染。

Router

Router 是一個提供 Location 和 Navigation 的 Context 組件,不會參與實際的 DOM 渲染,只是存儲相關路由的規格化數據。

useRoutes

以前我們總要寫大段的配置,以及自行編寫路由組件,各個業務甚至都定義了自己的路由配置(樹狀結構),這種通用化的代碼實際是可以做統一封裝。

useRoutes 功能上等同於 <Routes>,但它使用 JS 對象而不是 <Route> 元素來定義路由,useRoutes 的返回值是可用於呈現路由樹的有效 React 元素,或因無匹配路由返回 null

路由配置

因此 react-router-dom 參考相關 issue 定義了 RouteObject 類型:

/**
 * A route object represents a logical route, with (optionally) its child
 * routes organized in a tree-like structure.
 */
export interface RouteObject {
  caseSensitive?: boolean; // 大小寫敏感
  children?: RouteObject[]; // 嵌套路由
  element?: React.ReactNode; // 組件 or 頁面
  index?: boolean; // 是否作爲 outlet 的默認索引/渲染
  path?: string; // 匹配路徑
}

路由 Context

export interface RouteMatch<ParamKey extends string = string> {
  /** URL 上的 query 參數 Key => value */
  params: Params<ParamKey>;
  pathname: string;
  pathnameBase: string;
  /** 用於匹配的路由對象 */
  route: RouteObject;
}

interface RouteContextObject {
  outlet: React.ReactElement | null;
  matches: RouteMatch[];
}

// 路由 Context
export const RouteContext = React.createContext<RouteContextObject>({
  outlet: null,
  matches: [],
});

路由匹配

藉助 React Hooks 定義了 useRoutes 方法,功能上等同於 <Routes> 組件,useRoutes 能夠依據 “路由配置對象” 和當前路由做匹配,然後按匹配規則渲染對應的“組件”。

該 hooks 文件位置:packages/react-router/lib/hooks.tsx

其中 matchRoutes() 函數返回一個對象數組,每個匹配的路由對應一個對象,是 React Router 的 核心算法 函數,不難理解。

渲染

_renderMatches() 函數將 matchRoutes() 的結果渲染爲 React 元素:

這個函數爲每個匹配組路由組(嵌套路由)建立 RouteContext,children 即爲需要渲染的 React 元素。

其中比較巧妙的設計是利用 reduceRight() 方法,從右往左開始遍歷,也就是從子到父的嵌套路由順序,將前一項的 React 節點作爲下一個 React 節點的 outlet

其中 outlet 是一個非常核心的概念,其用於嵌套路由場景,outlet 的渲染實現方式可參考下文中的 useOutlet() Hooks。

舉例

一個嵌套路由配置如圖:

在 HomePage 組件中使用了 <Outlet /> 組件,useRoutes 的執行過程如下:

第一階段:獲取 pathname

第二階段:獲取匹配的路由 & 組件

第三階段:渲染

其他常用 Hooks

useLocation

這個 Hooks 比較簡單,從 LocationContext 中獲取 location 對象:

因此可以通過該 Hooks 感知 location 的變化。

useNavigate

useNavigate() Hooks 會返回如下兩種函數調用方式:

interface NavigateOptions {
  replace?: boolean;
  state?: any;
}

interface NavigateFunction {
  (to: To, options?: NavigateOptions): void;
  (delta: number): void;
}

function useNavigate(): NavigateFunction

第一種是跳轉指定路由,第二個參數可以設置 replace(是否使用 history.replace) 和 state(狀態數據);

第二種是如果第一個參數是數字,等同於 window.history.go()[4] 方法。

useNavigate() 的實現主要是從 NavigationContextRouteContext 以及 LocationContext 中獲取相關路由數據、Location 和 navigator 實例,然後根據不同的入參調用相應的執行跳轉邏輯。

useParams

useParams Hooks 從當前 URL 返回與 <Route path> 匹配的動態參數的鍵 / 值對對象。子路由繼承父路由的所有參數。

也就是說從 path 路徑中按照規則獲取對應的 Key/Value

useOutlet

該 Hooks 通過 RouteContext 獲取當前路由下的 outlet,如果存在則返回由 OutletContext 包裹的子路由 React 組件。

其他常用組件

類比網頁中的 <a href="xxx" /> 標籤。

其實現如下:

有個疑惑是,不知道 reloadDocument 這個參數的實際作用,顧名思義的角度就是是否重載文檔。

但是從 <Link /> 組件內 handleClick() 方法的實現上看,其似乎只是一個是否調用默認 click 事件的開關,不過實際生產的時候,倒是沒怎麼用到。

<NavLink /> 組件(導航鏈接)用於導航欄,例如管理後臺的頂部菜單,或者是左側的菜單。

其內部主要是對 classNamestyle 兩個屬性做了注入,如果傳遞的是函數,則會注入 isActive 變量,用於確定當前路由是否激活。當匹配到的路由激活時,默認是 className 會拼接 active 類名。

<Navigate /> 組件功能是 “路由跳轉”,可以理解爲,當渲染該組件時,則立即跳轉到指定路由。

其內部實現依賴 useNavigate() Hooks,換句話說,這個組件只是跳轉事件的一個 JSX 封裝形式。

Outlet

<Outlet /> 組件用於嵌套路由場景,在父路由元素(組件)中使用 <Outlet /> 來顯式表明它們的子路由元素的渲染位置,在子路由匹配時顯示嵌套 UI。

父路由使用精準匹配的情況下,但子路由沒有顯式聲明索引的話(RouteObject.index),將不會渲染任何內容。

/**
 * Renders the child route's element, if there is one.
 *
 * @see https://reactrouter.com/docs/en/v6/api#outlet
 */
export function Outlet(props: OutletProps): React.ReactElement | null {
  return useOutlet(props.context);
}

<Outlet /> 組件的實現是基於 useOutlet() Hooks。

Routes

Routes 組件內的實現還是使用了 useRoutes() Hooks,因此在生產實踐中還是推薦大家用 “配置化路由” 方式,來實現渲染路由組件,能提升路由的可維護性。

總結

React Router 目前更新到了 6.6.x 版本,其中的數據預加載和路由綁定方案,確實也是一個不錯的方案,但在實際生產過程中,想要快速實現 “大一統” 也確實會遇到各種問題,因此大家還是需要辯證看待,按需取捨。

此外 @remix-run/router 這個模塊是對 History 和 Navigator 的封裝,部分實現細節也是值得借鑑。

閱讀源碼可能確實比較枯燥,但是如果能夠潛心閱讀,仔細推敲每一個讓你疑惑的問題點,並學習其精妙的設計與實現,相信能夠對我們的編碼技能有一定的促進作用。

參考資料

[1]

react-router-dom: https://www.npmjs.com/package/react-router-dom

[2]

tutorial 官方的指導手冊: https://reactrouter.com/en/main/start/tutorial

[3]

History API - MDN: https://developer.mozilla.org/zh-CN/docs/Web/API/History_API

[4]

window.history.go(): https://developer.mozilla.org/en-US/docs/Web/API/History/go

[5]

React Router Docs: https://reactrouter.com/en/main

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