通過 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 組件。
其他常用組件
Link
類比網頁中的 <a href="xxx" />
標籤。
其實現如下:
有個疑惑是,不知道 reloadDocument
這個參數的實際作用,顧名思義的角度就是是否重載文檔。
但是從 <Link />
組件內 handleClick()
方法的實現上看,其似乎只是一個是否調用默認 click
事件的開關,不過實際生產的時候,倒是沒怎麼用到。
NavLink
<NavLink />
組件(導航鏈接)用於導航欄,例如管理後臺的頂部菜單,或者是左側的菜單。
其內部主要是對 className
和 style
兩個屬性做了注入,如果傳遞的是函數,則會注入 isActive
變量,用於確定當前路由是否激活。當匹配到的路由激活時,默認是 className
會拼接 active
類名。
Navigate
<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