通過 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 方法更新 action 和 location 信息,然後觸發組件的更新和重新渲染。

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() 的實現主要是從 NavigationContext、RouteContext 以及 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 /> 組件(導航鏈接)用於導航欄,例如管理後臺的頂部菜單,或者是左側的菜單。

其內部主要是對 className 和 style 兩個屬性做了注入,如果傳遞的是函數,則會注入 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