React-router 從 0 到 1

引子

本文會討論 react 生態下的常用路由庫,React-router 的版本迭代與源碼架構,並嘗試探討路由思維的變化與未來。

什麼是路由?

路由是一種向用戶顯示不同頁面的能力。 這意味着用戶可以通過輸入 URL 或單擊頁面元素在 WEB 應用的不同部分之間切換。

版本

爲了探究 react-router 設計思維,從 v3 開始有這幾個版本:

讓我們逐個參與討論。

react-router3:靜態路由

靜態路由的設計如下圖所示:

React.render((
  <Router>
    <Route path="/" component={Wrap}>
      <Route path="a" component={App} />
      <Route path="b" component={Button} />
    </Route>
  </Router>
), document.body)

特點:

v3 靜態路由的設計對前端工程師來說,相對更易接受,因爲前端工程師很多都接觸過類似的路由配置設計,比如 express、rails 等框架。

雖然細節各有不同,但是思路大致相同——將 path 靜態映射爲渲染模塊。

react-router4:動態路由

雖然 v3 以一種質樸無華的方式完成了基本的路由工作,但 react-router 的幾個核心成員感覺現有的實現嚴重受 ReactAPI 的制約,並且實現方式也不夠優雅。

於是,經過了激烈的思考與討論,他們大膽地在 v4 中做出了比較激進的更迭。

React-router4 不再提倡靜態路由的集中化架構,取之的是路由存在於佈局和 UI 之間:

const App = () =(
    <BrowserRouter>
      <div>
      <Route path="/a" component={A}/>
      </div>
    </BrowserRouter>
);

const A  = ({ match }) =(
    <div>
     <span>A</span>
      <Route
     path={match.url + '/b'}
     component={B}
      />
    </div>
);

const B = () => <div>B</div>;

我們來看以上代碼的邏輯

  1. 一開始在 App 組件裏,只有一個路由/a

  2. 用戶跳轉訪問/a時,渲染A組件,瀏覽器上出現字母 A,然後子路由/b被定義

  3. 用戶跳轉訪問/a/b時,渲染B組件,瀏覽器上出現字母 B

我們可以看到,在 v4 中:

這被稱之爲「動態路由」。

動態路由

傳統靜態路是在程序渲染前就定義好。

而動態意味着路由功能在應用渲染時才動態生成,這需要把路由看成普通的 React 組件,傳遞 props 來正常使用,藉助它來控制組件的展現。這樣,沒有了靜態配置的路由規則,取而代之的是程序在運行渲染過程中動態控制的展現。

動態路由將帶來很大的好處。比如代碼分割,也就是 react 常說的code splitting,由於不需要在渲染前決定結果,動態路由可以滿足代碼塊的按需加載,這對於大型在線應用非常有幫助。

但是,畢竟路由對一個應用的架構來說非常重要,這麼大的改變顯得過於激進,這會改變以前開發者比較習慣的一些模式,由於這次的更新過於激進,遭到了開發者們的一些負面反饋:

這就要討論到動態路由的缺點了:

由於 React-router 團隊保證 v3 會持續維護,所以當時很多開發者沒有選擇升級。

react-router5:沿用

原本只是計劃發佈 React Router 4.4 版本,但由於不小心誤用了^字符,將依賴錯誤地寫成 "react-router": "^4.3.1",導致報錯。於是最後團隊決定撤銷 4.4 版本,直接改爲發佈 React Router v5。

react-router5 延續了動態路由的模式,但是提供了更加直觀的寫法:

export default function App() {
  return (
    <Router>
      <Switch>
        <Route path="/about">
          <About />
        </Route>
        <Route path="/topics">
          <Topics />
        </Route>
        <Route path="/">
          <Home />
        </Route>
      </Switch>
    </Router>
  );
}

以上的寫法,/about顯示<About>組件,/topics顯示<Topic>組件,根路由顯示<Home>組件。

同時,v5 還允許你將路由配置作爲一個 config 的 json 數據,寫在組件外引入。

<Route>將作爲父組件用於匹配路由,同時還有一系列輔助組件,比如<Switch>可以限制子元素進行單一的路由匹配。當然,這也會帶來一定的

@reach/router:簡潔

Reach-Router 是 ReactRouter 成員 Ryan Florence 開發的一套基於 react 的路由控件。

那麼已經有比較成熟的 ReactRouter 了, 爲什麼要” 再” 做一套 Router 呢?

優點:小而簡

react-router6:終極方案

2021 年 11 月,react-router 6.0.0 正式版發佈:

v6 的設計可以說很大程度參照了 @reach/router,API 和 @reach/router v1.3 非常相似。因此,官方也宣稱 v6 可以被看做 @reach/router 的 v2。

總體來說,v6 更像是一個以前版本的完善和整合,相對路徑與嵌套分散的選擇方式,讓大家能夠按個人喜好去構建路由。

源碼

探討完設計哲學與版本更迭,我們正式進入從 0 到 1 的源碼學習。

本文對源碼的探討,就是以 v6 爲基礎(中間存在各種簡化)。

我們先從 V6 的簡易的實例開始:

import { render } from "react-dom";
import {
  BrowserRouter,
  Routes,
  Route,
  Link,
} from "react-router-dom";
import App from "./App";
import Expenses from "./routes/expenses";
import Invoices from "./routes/invoices";

const rootElement = document.getElementById("root");
render(
  <BrowserRouter>
    <Routes>
      <Route path="/" element={<App />}>
        <Route path="expenses" element={<Expenses />} />
        <Route path="invoices" element={<Invoices />} />
      </Route>
    </Routes>
    <Link to="/invoices">Check Invoices</Link>
  </BrowserRouter>,
  rootElement
);

React-router 的結構主要分爲四個模塊:

讓我們分別對各部分的源碼進行拆分與討論。

history

每個<Router>都會創建一個history對象,它記錄了當前以及歷史的路由位置。

react-router 使用了history庫作爲路由歷史狀態的管理模塊:

history這個庫可以讓你在 JavaScript 運行的任何地方都能輕鬆地管理回話歷史,history對象抽象化了各個環境中的差異,並提供了最簡單易用的的 API 來給你管理歷史堆棧、導航,並保持會話之間的持久化狀態。——React Training 文檔

這部分值得關注的源碼:

  1. 工廠函數createBrowserHistory

    它們代碼差別很小,不同的router只有parsePath的入參不同。還有其它的差別,比如hashHistory增加了hashchange事件的監聽等

    由於篇幅所限,這裏我們只討論createBrowserHistory

  2. history.push,用於基本的切換路由

    go/replace/forward/back也類似,不過pushhistory棧變化的基礎

  3. history.listen

    添加路由監聽器,每當路由切換可以收到最新的actionlocation,從而做出不同的判斷,BrowserRouter中就是通過history.listen(setState)來監聽路由的變化,從而管理所有的路由

  4. history.block

    添加阻塞器,會阻塞push等行爲和瀏覽器的前進後退,阻止離開當前頁面。且只要判斷有 blockers,那麼同時會阻止瀏覽器刷新、關閉等默認行爲。且只要有blocker,會阻止上面listener的監聽

createBrowserHistory

我們先看工廠函數:

工廠函數的用途是創建一個history對象,後面的listenunlisten都是掛載在這個 API 的返回對象上面的。

export function createBrowserHistory(
  options: BrowserHistoryOptions = {}
): BrowserHistory {
 // -----------------------------第一部分--------------------------------
 const [index, location] = getIndexAndLocation();

 function getIndexAndLocation()[number, Location] {
   const { pathname, search, hash } = window.location;
   const state = window.history.state || {};
   return [
     state.idx,
     readOnly<Location>({
       pathname,
       search,
       hash,
       state: state.usr || null,
       key: state.key || 'default'
     })
   ];
 }

 if (index == null) {
   index = 0;
   window.history.replaceState({ ...window.history.state, idx: index }'');
 }

 function handlePop() {
   const [nextIndex, nextLocation] = getIndexAndLocation();
   const delta = index - nextIndex;
   go(delta)
 }

 window.addEventListener('popstate', handlePop);

  // ----------------------------第二部分-------------------------------

  const listeners = createEvents<Listener>();
 const blockers = createEvents<Blocker>();
 
 function createEvents<F extends Function>(): Events<F> {
   let handlers: F[] = [];
 
   return {
     get length() {
       return handlers.length;
     },
     push(fn: F) {
       handlers.push(fn);
       return function() {
         handlers = handlers.filter(handler => handler !== fn);
       };
     },
     call(arg) {
       handlers.forEach(fn => fn && fn(arg));
     }
   };
 }
 
 listeners.call({ action, location });
 blockers.call({ action, location, retry });
  
  // ----------------------------第三部分------------------------------—

 const history: BrowserHistory = {
   get action() {
     return action;
   },
   get location() {
     return location;
   },
   createHref,
   push, // 重點
   replace,
   go(delta: number) {
     window.history.go(delta);
   },
   back() {
     go(-1);
   },
   forward() {
     go(1);
   },
   listen(listener) { // 重點
     return listeners.push(listener);
   },
   block(blocker) {  // 重點
     const unblock = blockers.push(blocker);
     if (blockers.length === 1) {
       window.addEventListener('beforeunload', promptBeforeUnload);
     }
     return function() {
       unblock();
       if (!blockers.length) {
         window.removeEventListener('beforeunload', promptBeforeUnload);
       }
     };
   }
 };
  return history
}

我們可以將源碼分爲三部分:

history.push

replacepush非常相似,區別在於replace將歷史堆棧中當前location替換爲新的,被替換的將不再存在,所以我們着重關注push

function push(to: To, state?: State) {
  const nextAction = Action.Push;
  const nextLocation = getNextLocation(to, state);

 function getNextLocation(to: To, state: State = null): Location {
   return readOnly<Location>({
     ...location,
     ...(typeof to === 'string' ? parsePath(to) : to),
     state,
     key: createKey()
   });
 }

  function retry() {
    push(to, state);
  }

  if (allowTx(nextAction, nextLocation, retry)) { // blockers的限制

    const [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);

  function getHistoryStateAndUrl(
    nextLocation: Location,
    index: number
  )[HistoryState, string] {
    return [
      {
        usr: nextLocation.state,
        key: nextLocation.key,
        idx: index
      },
      createHref(nextLocation)
    ];
  }
  
  window.history.pushState(historyState, '', url);

    try {
      globalHistory.pushState(historyState, '', url);
    } catch (error) {
      window.location.assign(url);
    } // 用try-catch的原因是因爲ios限制了100次pushState的調用,catch後只能選擇刷新頁面

    applyTx(nextAction); // 調用listeners
  }
}

history.listener

const history: HashHistory = {
  // ...
  listen(listener) {
    return listeners.push(listener);
  },
  // ...
}

function applyTx(nextAction: Action) {
  const [index, location] = getIndexAndLocation();
  listeners.call({ action: nextAction, location });
}

function push(to: To, state?: State) { // replace
  // ...
  if (allowTx(nextAction, nextLocation, retry)) {
    // ...
    applyTx(nextAction);
  }
}

function handlePop() {
  if (blockedPopTx) {
    // ...
  } else {
    // ...
    if (blockers.length) {
    // ...
    } else {
      applyTx(nextAction);
    }
  }
}

function allowTx(action: Action, location: Location, retry: () => void): boolean {
  return (
    !blockers.length || (blockers.call({ action, location, retry })false)
  );
}

history.listen是一個標準的發佈訂閱模式,可以往history中添加listener,返回一個取消監聽的可調用方法

history.block

const history: BrowserHistory = {
  // ...
  block(blocker) {
    const unblock = blockers.push(blocker);

    if (blockers.length === 1) {
      window.addEventListener('beforeunload', promptBeforeUnload);
    }

    return function() {
      unblock();
      if (!blockers.length) {
        window.removeEventListener('beforeunload', promptBeforeUnload);
      }
    };
  }
};

blockers 與listeners類似,區別在於:

Router

應用頂層使用,爲後代的Route提供Context的數據傳遞。

Router有很多種,區別在於路由在 url 上面存在的方式:

還有MemoryRouter(在內存中保存)、NativeRouter(在ReactNative中使用)等,他們使用的history狀態機也不一樣。

BrowserRouter

篇幅所限,這裏我們主要討論最通用的BrowserRouter

作爲應用的最外層的容器組件,BrowserRouter源碼如下:

export function BrowserRouter({
  basename,
  children,
  window
}) {
  const history = useRef<BrowserHistory>();
  if (historyRef.current == null) {
    historyRef.current = createBrowserHistory({ window });
  }

  const history = historyRef.current;
  const [state, setState] = useState({
    action: history.action,
    location: history.location
  });

  useLayoutEffect(() ={
    history.listen(setState)
  }[history]);

  return (
    <Router
      basename={basename}
      children={children}
      action={state.action}
      location={state.location}
      navigator={history}
    />
  );
}

可以看到是,是一個構建了history<Router>組件的封裝

我們來看Router

export function Router({
  action = Action.Pop,
  basename: basenameProp = "/",
  children = null,
  location: locationProp,
  navigator,
  static: staticProp = false
}: RouterProps): React.ReactElement | null {

  // ...

  return (
    <NavigationContext.Provider value={navigationContext}>
      <LocationContext.Provider
        children={children}
        value={{ action, location }}
      />
    </NavigationContext.Provider>
  );
}

const { basename, navigator } = React.useContext(NavigationContext);
const { location } = React.useContext(LocationContext);

export function useLocation(): Location {
  return React.useContext(LocationContext).location;
}

Router最後返回了兩個Context.Provider,中間就是針對於location的處理

Route「路由端口」

我們直接看RoutesRoute的源碼:

export function Routes({
  children,
  location
}: RoutesProps): React.ReactElement | null {
  return useRoutes(createRoutesFromChildren(children), location);
}

export function Route(
  _props: PathRouteProps | LayoutRouteProps | IndexRouteProps
): React.ReactElement | null {

  invariant(
    false,
    `A <Route> is only ever to be used as the child of <Routes> element, ` +
      `never rendered directly. Please wrap your <Route> in a <Routes>.`
  );
}

可以發現

我們只需要着重研究createRoutesFromChildrenuseRoutes:

export function createRoutesFromChildren(
  children: React.ReactNode
): RouteObject[] {
  const routes: RouteObject[] = [];
  React.Children.forEach(children, element ={
    if (!React.isValidElement(element)) return;

    if (element.type === React.Fragment) {
      routes.push.apply(
        routes,
        createRoutesFromChildren(element.props.children)
      );
      return;
    }

    const route: RouteObject = {
      caseSensitive: element.props.caseSensitive,
      element: element.props.element,
      index: element.props.index,
      path: element.props.path
    };

    if (element.props.children) {
      route.children = createRoutesFromChildren(element.props.children);
    }

    routes.push(route);
  });
  return routes;
}

我們看到,createRoutesFromChildren作用如下:

export function useRoutes(
  routes: RouteObject[],
): React.ReactElement | null {
 // --------------------------------第一段------------------------------------
  
  const { matches: parentMatches } = React.useContext(RouteContext);
  const routeMatch = parentMatches[parentMatches.length - 1];
  const parentParams = routeMatch ? routeMatch.params : {};
  const parentPathnameBase = routeMatch ? routeMatch.pathnameBase : "/";
  
  // --------------------------------第二段------------------------------------
 
  let location = useLocation();
  const pathname = location.pathname || "/";
  const remainingPathname =
    parentPathnameBase === "/"
      ? pathname
      : pathname.slice(parentPathnameBase.length) || "/";

  const matches = matchRoutes(routes, { pathname: remainingPathname });
  
  // --------------------------------第三段-------------------------------------
 
  return _renderMatches(
    matches &&
      matches.map(match =>
        Object.assign({}, match, {
          params: Object.assign({}, parentParams, match.params),
          pathname: joinPaths([parentPathnameBase, match.pathname]),
          pathnameBase: joinPaths([parentPathnameBase, match.pathnameBase])
        })
      ),
    parentMatches
  );
}

useRoutes參數的routes嵌套數組就是createRoutesFromChildren返回的路由配置,通過路由配置匹配到對應的 route 元素進行渲染:

Link、Switch 等「導航」

我們來看看源碼:

export default function Link({
  to,
  ...rest
}) {
  return (
    <RouterContext.Consumer>
      {context ={
        const { history } = context;
        const props = {
          ...rest,
          href: to,
          onClick: event ={
            event.preventDefault();
            history.push(to);
          }
        };

        return <a {...props} />;
      }}
    </RouterContext.Consumer>
  );
}

我們看到,<Link>只是渲染了一個沒有默認行爲的a標籤,其跳轉行爲由context傳入的history.push實現。

未來:Remix

remix 是由 react-router 原班人馬打造,並獲得三百萬美元融資的 ts 全棧明星開發框架,筆者認爲 remix 作爲一個全新的全棧的解決方案值得關注,其路由功能非常靈活高效。

“我們經常將 Remix 描述爲 "React Router 的編譯器",因爲有關 Remix 的所有內容都利用了嵌套路由。”

官網對 remix 的介紹如下:

remix 可以幹掉骨架屏等加載狀態,所有資源都可預加載,而且管理後臺,對於數據的加載、嵌套數據或者組件的路由、併發加載優化做得很好,並且異常的處理已經可以精確到局部級別:

remix 告別瀑布式的方式來獲取數據,數據獲取在服務端並行獲取,生成完整 HTML 文檔,類似 React 併發特性:

相比之下,Next.js 更像是一個靜態網站生成器。Gatsby 相比下則門檻過高,需要一定的 GraphQL 基礎。

同時,客戶端與服務端能有一致的開發體驗,客戶端代碼與服務端代碼寫在一個文件裏,無縫進行數據交互,同時基於 TypeScript,類型定義可以跨客戶端與服務端共用,路由也可以同步,實現一個組件化、路由爲首的全棧模型。

結尾

我們看到,隨着 Web 技術思維的變革,最早的漸進式應用正在走向越來越強的一體化,大前端、泛前端的思維性質越來越濃厚。

而服務端技術則通過雲技術,走向了 SaaS,容器化這樣更靈活、成本更低的道路上,旨在爲應用端提供更便捷的開發。

在未來的 Web3 浪潮下,由於公鏈的存在,「胖協議 + 瘦應用」會是大勢所趨,越來越敏捷和低成本的開發會更爲重要。

路由作爲前後端,交互最緊密的橋樑,會是一個關鍵的變革區域,或許有天我們可以看到,Web 技術通過路由,實現了真正的前後端的統一,走向了人人都可開發的大全棧未來。

本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源https://mp.weixin.qq.com/s/Qd5aBRc72AV69nlUXONWng