1. 程式人生 > 其它 >淺談:前端路由原理解析及實踐

淺談:前端路由原理解析及實踐

其實在前端領域,還有很多基礎的東西有待深入去做。不為造輪子而造輪子,才是在做有意義的事情。所以,我們決定撰寫《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)進入交流群討論