淺談:前端路由原理解析及實踐
作者|張小俊
來源|爾達 Erda 公眾號
導讀:其實在前端領域,還有很多基礎的東西有待深入去做。不為造輪子而造輪子,才是在做有意義的事情。所以,我們決定撰寫《Erda 前端之聲》系列文章,深入剖析我們在前端探索過程中的一些落地經驗,以此助力在前端之路上奮進的開發者們,能夠早日發掘屬於自己的精彩。
系列文章推薦:
- 《靈魂拷問:我們該如何寫一個適合自己的狀態管理庫?》
- 《淺談:前端路由原理解析及實踐》(本文)
前言
大家好,這裡是 Erda 技術團隊。作為 Erda 專案的前端,Erda-UI 專案從最初開發到現在開源,業務複雜度在不斷遞增,專案的程式碼檔案已經近 2000,專案內部的路由配置已經超過 500 個。本文會先簡單介紹一下前端路由原理,以及 React-Router 的基礎使用,接著會主要分享 Erda-UI 專案在路由上實踐的一些拓展功能。
背景
在單頁面應用(SPA)已經非常成熟的當下,路由也成了前端專案的主要配置,我們使用路由來管理專案頁面的組成結構,各大前端框架也都有各自成熟的路由解決方案(React: React-Router、Vue: Vue-Router)。而在複雜的業務系統中,往往存在很多跟路由相關的其他邏輯,比如許可權、麵包屑等。我們希望這部分邏輯能整合到路由的配置當中,這樣能有效的減輕開發和維護的負擔。Erda-UI 專案使用 React 框架,所以下面的內容都基於 React-Router。
路由原理
路由的基本原理,就是在不重新整理瀏覽器的情況下修改瀏覽器連結,同時監聽連結的變化並找到匹配的元件渲染。滿足這兩個條件即可實現。
路由的實現通常有以下兩種形式:
- hash ( /#path )
- history ( /path )
hash 在瀏覽器中預設是作為錨點來使用的,在 hash 模式中,url 裡始終會有 #,沒有傳統 url 寫法那麼美觀,所以在不考慮相容性的情況下使用 history 的模式是更好的選擇。
hash
hash 模式下,url 中 # 後面的部分只是一個客戶端狀態,當這部分變化時,瀏覽器本身就不會重新整理,天生具備第一個條件(即在不重新整理瀏覽器的情況下修改瀏覽器連結),同時通過監聽 hashChange 事件或註冊 onhashchange 回撥函式來監聽 url 中 hash 值的變化。
window.addEventListener('hashchange', hashChangeHandler);
// or window.onhashchange = hashChangeHandler;
history
history 模式,是利用了 HTML5 中 history 的 API,history.pushState 和 history.replaceState 這兩個方法,可以在不重新整理頁面的情況下,操作瀏覽器的歷史記錄,前者為新增一條記錄,後者為替換最後一條記錄。同時通過監聽 popState 事件或註冊 onpopstate 回撥函式來監聽 url 的變化。
window.addEventListener('popState', locationChangeHandler);
// or window.onpopstate = locationChangeHandler;
但是這裡有一點需要注意,history.pushState 和 history.replaceState 是不會自動觸發 popState 的。只有在做出瀏覽器動作時,才會觸發該事件,比如使用者點選瀏覽器的回退按鈕。通常路由庫裡會封裝一個監聽方法,不管是呼叫 history.pushState、history.replaceState,還是使用者觸發瀏覽器動作導致的路由變化,都能夠觸發監聽函式。以 react-router-dom 中的 listen(部分為虛擬碼)為例:
function setState(nextState) {
_extends(history, nextState);
history.length = history.entries.length;
// 將路由變化使用 state 管理,在變化時,通知所有監聽者
transitionManager.notifyListeners(history.location, history.action);
}
// 封裝 push、replace 等方法
function push(path, state) {
// ...
globalHistory.pushState({
key: key,
state: state
}, null, href);
// ...
setState({ // 手動觸發監聽
action: action,
location: location
})
}
// popState 事件監聽,監聽事件同時 setState,通知 transitionManager 中的 listeners;
function handlePopState(location){
// ...
setState(location)
// ...
}
// 封裝 listen。
function listen(listener) {
var unlisten = transitionManager.appendListener(listener);
window.addEventListener('popState', handlePopState); // 監聽瀏覽器事件。
// ...
}
React-Router 路由基礎
為了方便展開下面的內容探討,本章節先簡單介紹一下 React-Router 相關基礎。
基礎庫
React-Router 相關的庫主要有以下幾個:
- react-router 核心庫
- react-router-dom 基於 DOM 的路由實現,內部包含 react-router 的實現,使用時無需再引 react-router
- react-router-native 基於 React Native 的路由實現
- react-router-redux 路由和 Redux 的整合,不再維護
- react-router-config 用於配置靜態路由
react-router-dom
對應了路由的兩種實現方式,react-router-dom 庫也提供了兩個路由元件:BrowserRouter、HashRouter。
- Route : 路由單元,配置一個 path 以及對應的渲染元件,其中 exact 表示精確匹配
- Switch: 控制渲染第一個匹配的路由元件
- Link: 連結元件,相當於 標籤
- Redirect: 重定向元件
使用
路由基本的使用如下:
import { BrowserRouter, Link, Route, Switch, Redirect } from 'react-router-dom'
function App(){
return (
<BrowserRouter>
<Link to="/home">home</Link>
<Link to="/about">About</Link>
<Switch>
<Route path="/home" exact component={Home} />
<Route path="/about" exact component={About} />
<Redirect to="/not-found" component={NotFound} />
</Switch>
</BrowserRouter>
)
}
除此之外,還可以巢狀使用,即在元件內部再配置路由。在路由過多的情況下,可以通過這種方式將 Router 拆分,這讓 Router 更具有一般元件的特性,可以隨意巢狀。而元件中可以得到一個 math 的 props 來獲取上級路由的相關資訊。
import { BrowserRouter, Link, Route, Switch, Redirect } from 'react-router-dom'
function App(){
return (
<BrowserRouter>
<Link to="/home">home</Link>
<Link to="/settings">Settings</Link>
<Switch>
<Route path="/home" exact component={Home} />
<Route path="/settings" exact component={Settings} />
</Switch>
</BrowserRouter>
)
}
const Setting = (props) => {
const matchPath = props.match.path;
return (
<div>
<Link to={`${matchPath}/a`}>a</Link>
<Link to={`${matchPath}/b`}>b</Link>
<Switch>
<Route path={`${matchPath}/a`} component={AComp} />
<Route path={`${matchPath}/b`} component={BComp} />
</Switch>
</div>
)
}
然而,專案中的路由除了數量比較多外,通常還會有一些需要集中處理的邏輯,分散的路由配置方式顯然不太適合,而 react-router-config 為我們提供了方便的靜態路由配置,其本質就是將一份 config 轉換為 Route 元件,而在元件渲染的方法 render 中,則可以根據業務情況來做一些統一的處理。
function renderRoutes(routes, extraProps, switchProps) {
// ...
return routes ? React.createElement(reactRouter.Switch, switchProps, routes.map(function (route, i) {
return React.createElement(reactRouter.Route, {
key: route.key || i,
path: route.path,
exact: route.exact,
strict: route.strict,
render: function render(props) {
return route.render ? route.render(_extends({}, props, {}, extraProps, {
route: route
})) : React.createElement(route.component, _extends({}, props, extraProps, {
route: route
}));
}
});
})) : null;
}
Erda-UI 專案路由實踐
路由配置
const routers = {
path: ':orgName',
mark: 'org',
breadcrumbName: '{orgName}'
routes: [
{
path: 'workBench',
breadcrumbName: 'DevOps平臺',
mark: 'workBench',
routes: [
{
path: 'projects/:projectId',
breadcrumbName: '',
mark: 'project',
AuthContainer: ProjectAuth,
routes: [
{
path: 'apps',
pageTitle: '應用列表',
getComp: cb => cb(import('/xx/xx')),
routes: [
{
path: 'apps/:appId',
mark: 'application',
breadcrumbName: '應用',
AuthContainer: AppAuth,
}
]
},
]
}
],
},
]
}
由上我們可以看到,在配置中除了 path 之外,其他的欄位似乎都和 React-Router 沒什麼太大關係,這些欄位也正是我們實現跟路由相關邏輯的配置,下面我們會一一介紹。
路由狀態管理:routeInfoStore
為了拓展路由相關功能,我們首先需要有一個路由物件為我們提供資料支援,之所以需要這個物件,是因為單個的路由資訊不足以實現其他相關邏輯,我們需要更多路由資訊,比如路由層級上的鏈路記錄,前後路由的狀態對比等。
我們使用一個 routeInfoStore 物件來管理路由相關的資料和狀態。這個物件可以在元件之間共享路由狀態(類似 Redux 中 store)。
我們通過在 browserHistory.listen 中監聽並呼叫 routeInfoStore 中處理路由變化的方法($_updateRouteInfo)來更新路由資料和狀態。
browserHistory.listen((loc) => {
// 監聽路由變化觸發 routerStore 的更新,類似 Redux 中 dispatch;
// 此處使用釋出訂閱模式 來實現觸發呼叫事件
emit('@routeChange', routerStore.reducers.$_updateRouteInfo(loc));
});
// routeStore 中的資料
const initRouteInfo: IRouteInfo = {
routes: [], // 當前路由所經過的層級,若路由在子模組,則改子模組所有的父模組也會被記錄在內
params: {}, // 當前 url 中路徑裡的所有變數
query: {}, // 當前 url 中 search(?後面)的引數
currentRoute: {}, // 當前匹配上的路由配置
routeMarks: [], // 標記了 mark 的路由層級
isIn: () => false, // 擴充套件方法:用於判斷是否在當前路由內
isMatch: () => false,// 擴充套件方法:用於判斷是否匹配當前路由
isEntering: () => false,// 擴充套件方法:用於判斷是否正在進入當前路由
isLeaving: () => false,// 擴充套件方法:用於判斷是否離開當前路由
prevRouteInfo: {}, // 上一次路由的資訊
};
路由監聽擴充套件:mark
通常我們需要監聽路由在進入或離開某個範圍內,自動進行的一些前置初始化操作,比如進模組 A,首先要獲取模組 A 的許可權,或者模組 A 的一些基礎資訊。離開模組 A 時,需要去清空相關的資訊。為了做到這些監聽和初始化,我們需要兩個條件:
- 標記範圍的欄位。
- 在路由變化的時候,判斷路由是否離開或進入相應的範圍。
我們在路由配置中添加了 mark 欄位,用於標記當前路由的範圍,類似路由範圍的 id,需要保證全域性唯一。而上文有說到 routeInfoStore 中,routeMarks 中會記錄路由鏈路層級的 mark 集合,prevRouteInfo 會記錄上一次路由資訊。藉此,我們可以在 routerInfoStore 裡新增一些路由範圍判斷的函式 isIn、isEntering、isLeaving、isMatch。
isIn($mark) => boolean
表示當前路由是否在某個範圍內。傳入一個 mark 值,通過 routeInfoStore 中 routeMarks 中是否包含來判斷:
// routeMarks 內記錄了路由經過的所有 mark 標記,通過判斷 mark 是否被包含
isIn: (mark: string) => routeMarks.includes(mark),
isEntering($mark) => boolean
表示當前路由正在進入某個範圍,區別於 isIn, 這是一個正在進行時的判斷,表示上一次路由並不在該範圍,而當前這次在該範圍內。
//通過判斷 mark 被包含,同時上一次的路由不被包含,判斷是正在進入當前 mark。
isEntering: (mark: string) => routeMarks.includes(mark) && !prevRouteInfo.routeMarks.includes(mark),
isLeaving($mark) => boolean
跟 isEntering 相反,isLeaving 表示上一次路由在範圍內,而下一次路由離開範圍,即正在離開。
//通過判斷 mark 不被包含,同時上一次的路由被包含,判斷是正在離開當前 mark。
isLeaving: (mark: string) => !routeMarks.includes(mark) && prevRouteInfo.routeMarks.includes(mark),
isMatch($pattern) => boolean
傳入一個正則,判斷路由是否匹配正則,一般用於對當前路由的直接判斷:
//通過正則判斷
isMatch: (pattern: string) => !!pathToRegexp(pattern, []).exec(pathname),
註冊監聽
我們提供了一個監聽的方法,可以在專案啟動時,由各個模組註冊自己的路由監聽函式,而監聽函式中,則可以方便使用以上方法判斷路由的範圍。
// 路由監聽註冊
export const listenRoute = (cb: Function) => {
// getState 返回routeInfoStore 物件,其中包含了以上的判斷方法
cb(routeInfoStore.getState(s => s));
// 路由變化時,呼叫監聽方法
on('@routeChange', cb);
};
// 模組 A 註冊
listenRoute((_routeInfo) => {
const { isEntering, isLeaving } = _routeInfo;
if(isEntering('markA')){
// 初始化模組 A
}
if(isLeaving('markA')) {
// 清除模組 A 資訊
}
})
路由拆分:toMark
當路由數量過大,一份路由資料巢狀可能很深,因此必然需要支援路由配置的拆分。
我們提供了路由註冊的方法 registerRouter,不同模組可以只註冊自己的路由,然後通過 toMark 欄位來建立路由之間的所屬關聯,toMark 的值是另一個路由的標記 mark 值。在 registerRouter 內部,將所有路由整合成一份完整的配置。
// 註冊 org 路由
registerRouter({
path: ':orgName',
mark: 'org',
breadcrumbName: '{orgName}'
});
// 註冊 workBench 路由
registerRouter({
path: 'workBench',
breadcrumbName: 'DevOps平臺',
mark: 'workBench',
toMark: 'org', // 配置 workBench 路由屬於 org 的子路由
});
// 註冊 project 路由
registerRouter({
path: 'projects/:projectId',
breadcrumbName: '',
mark: 'project',
toMark: 'workBench', // 配置 project 路由屬於 workBench 的子路由
AuthContainer: ProjectAuth,
routes: [
{
path: 'apps',
pageTitle: '應用列表',
getComp: cb => cb(import('/xx/xx')),
},
]
});
// 註冊 application 路由
registerRouter({
path: 'apps/:appId',
mark: 'application',
toMark: 'project', // 配置 application 路由屬於 project 的子路由
breadcrumbName: '應用',
AuthContainer: AppAuth,
})
路由元件非同步載入:getComp
我們使用 getComp 的方式給單個路由配置元件,getComp 是一個非同步方法引入一個元件,然後我們通過一個非同步載入的高階元件來實現路由元件的載入。
// 重寫 render
map(router, route => {
return {
...route,
render: (props) => asyncComponent(()=>route.getComp());
}
})
// 非同步元件
export const asyncComponent = (getComponent: Function) => {
return class AsyncComponent extends React.Component {
static Component: any = null;
state = { Component: AsyncComponent.Component };
componentDidMount() {
if (!this.state.Component) {
getComponent().then((Component: any) => {
AsyncComponent.Component = Component;
this.setState({ Component });
});
}
}
render() {
const { Component } = this.state;
if (Component) { // 當元件載入完成後,渲染
return <Component {...this.props} />;
}
return null;
}
};
};
麵包屑:breadcrumbName
Erda-UI 的業務中,路由的配置是一個樹形結構,進入子模組路由則一定經過了父模組路由,通過對路由資料的解析,我們能得到從根路由到當前路由所經過的層級鏈路,而路由層級鏈路剛好映射了麵包屑的層級。
我們通過在路由配置中新增 breadcrumbName 欄位,並在 routeInfoStore 的 routes 儲存路由的層級鏈路資料。因此麵包屑的資料可以直接通過 routers 中得到。
map(routes, route => {
return {
name: route.breadcrumbName,
path: route.path,
}
})
在配置中, breadcrumbName 可以是文字,也可以是字串模板 {temp} 。這裡是利用了另一份 store 的資料來管理所有字串模板對應的資料,渲染的時候,通過匹配 key 值獲取相應的展示文字。
路由鑑權: AuthContainer
在專案中,路由是否能訪問,往往需要對應一些條件判斷(使用者許可權、模組是否開放等)。不同路由的鑑權條件可能不一樣,而且鑑權失敗的提示也可能需要個性化,或者可能存在鑑權不通過後頁面需要重定向等場景。這些都需要路由上的鑑權能個性化。就如 react-router-config 中的一樣,我們可以通過調整 Route 元件的 render 函式來達到這個目的。
我們通過在路由上配置 AuthContainer 元件來給路由做許可權攔截,大致過程分兩步:
- 提供一個鑑權元件 AuthComp,內部封裝鑑權相關邏輯及提示。
- 在渲染路由前,獲取這個鑑權元件 AuthComp,並重寫 render。
// AuthComp
const AuthComp = (props) => {
const { children } = props;
const [auth, setAuth] = React.useState(undefined);
useMount(()=>{
doSomeAuthCheck().then(()=>{
setAuth(true)
})
})
if( auth === undefined ){
return <div>載入中</div>
}
return auth ? children : <div>您無權訪問,請聯絡管理員...</div>
}
// 重寫 render
map(router, route => {
return {
...route,
render: (props) => {
const AuthComp = route.AuthContainer;
const Comp = route.components;
return (
<AuthComp {...props} route={route}> // 新增路由鑑權攔截
{Comp ? <Comp {...props} /> : Comp }
</AuthComp>
)
}
}
})
總結及後續思考
Erda-UI 專案中,我們通過以上的一些配置擴充套件,來集中管理所有的路由。這種方式可以簡單高效的維護路由本身以及擴充套件關聯業務邏輯。除此之外還可以做一些更靈活的事情,比如通過分析整個路由結構,生成視覺化的路由樹,支援路由的動態調整等等。經過漫長的業務演進和內容完善,我們驗證了這種方式帶來的好處。
同時我們也在不斷思考還可以改進的地方,比如:
- 在有鏈路層級的模組之間,路由的監聽如何做到非同步串聯?
如:模組 A 包含模組 B,在模組 A 中註冊監聽初始化方法 initA,在模組 B 中註冊 initB,如何控制 initB 在 initA 完成之後執行(若 initB 中需要使用到 initA 返回的結果時,則需要嚴格控制執行順序)。
結語
本文中的內容都是很常見的一些場景,為了貼合業務的需要,Erda 專案也在不斷更新迭代。我們也會時刻保持對社群的關注以及對自身業務發展的分析,將這一塊做到更好,也歡迎大家新增小助手微信(Erda202106)進入交流群討論!