其實在前端領域,還有很多基礎的東西有待深入去做。不為造輪子而造輪子,才是在做有意義的事情。是以,我們決定撰寫《Erda 前端之聲》系列文章,深入剖析我們在前端探索過程中的一些落地經驗,以此助力在前端之路上奮進的開發者們,能夠早日發掘屬于自己的精彩。

作者|張小俊
來源|爾達 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)進入交流群讨論!
- Erda Github 位址:https://github.com/erda-project/erda
- Erda Cloud 官網:https://www.erda.cloud/