精讀《React Router v6》

黃子毅發表於2020-03-30

1 引言

React Router v6 alpha 版本釋出了,本週通過 A Sneak Peek at React Router v6 這篇文章分析一下帶來的改變。

2 概述

更名為

一個不痛不癢的改動,使 API 命名更加規範。

// v5
import { BrowserRouter, Switch, Route } from "react-router-dom";

function App() {
  return (
    <BrowserRouter>
      <Switch>
        <Route exact path="/">
          <Home />
        </Route>
        <Route path="/profile">
          <Profile />
        </Route>
      </Switch>
    </BrowserRouter>
  );
}
複製程式碼

在 React Router v6 版本里,直接使用 Routes 替代 Switch

// v6
import { BrowserRouter, Routes, Route } from "react-router-dom";

function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="profile/*" element={<Profile />} />
      </Routes>
    </BrowserRouter>
  );
}
複製程式碼

升級

在 v5 版本立,想要給元件傳引數是不太直觀的,需要利用 RenderProps 的方式透傳 routeProps

import Profile from './Profile';

// v5
<Route path=":userId" component={Profile} />
<Route
  path=":userId"
  render={routeProps => (
    <Profile {...routeProps} animate={true} />
  )}
/>

// v6
<Route path=":userId" element={<Profile />} />
<Route path=":userId" element={<Profile animate={true} />} />
複製程式碼

而在 v6 版本中,rendercomponent 方案合併成了 element 方案,可以輕鬆傳遞 props 且不需要透傳 roteProps 引數。

更方便的巢狀路由

在 v5 版本中,巢狀路由需要通過 useRouteMatch 拿到 match,並通過 match.path 的拼接實現子路由:

// v5
import {
  BrowserRouter,
  Switch,
  Route,
  Link,
  useRouteMatch
} from "react-router-dom";

function App() {
  return (
    <BrowserRouter>
      <Switch>
        <Route exact path="/" component={Home} />
        <Route path="/profile" component={Profile} />
      </Switch>
    </BrowserRouter>
  );
}

function Profile() {
  let match = useRouteMatch();

  return (
    <div>
      <nav>
        <Link to={`${match.url}/me`}>My Profile</Link>
      </nav>

      <Switch>
        <Route path={`${match.path}/me`}>
          <MyProfile />
        </Route>
        <Route path={`${match.path}/:id`}>
          <OthersProfile />
        </Route>
      </Switch>
    </div>
  );
}
複製程式碼

在 v6 版本中省去了 useRouteMatch 這一步,支援直接用 path 表示相對路徑:

// v6
import { BrowserRouter, Routes, Route, Link, Outlet } from "react-router-dom";

// Approach #1
function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="profile/*" element={<Profile />} />
      </Routes>
    </BrowserRouter>
  );
}

function Profile() {
  return (
    <div>
      <nav>
        <Link to="me">My Profile</Link>
      </nav>

      <Routes>
        <Route path="me" element={<MyProfile />} />
        <Route path=":id" element={<OthersProfile />} />
      </Routes>
    </div>
  );
}

// Approach #2
// You can also define all
// <Route> in a single place
function App() {
  return (
    <BrowserRouter>
      <Routes>
        <Route path="/" element={<Home />} />
        <Route path="profile" element={<Profile />}>
          <Route path=":id" element={<MyProfile />} />
          <Route path="me" element={<OthersProfile />} />
        </Route>
      </Routes>
    </BrowserRouter>
  );
}

function Profile() {
  return (
    <div>
      <nav>
        <Link to="me">My Profile</Link>
      </nav>

      <Outlet />
    </div>
  );
}
複製程式碼

注意 Outlet 是渲染子路由的 Element。

useNavigate 替代 useHistory

在 v5 版本中,主動跳轉路由可以通過 useHistory 進行 history.push 等操作:

// v5
import { useHistory } from "react-router-dom";

function MyButton() {
  let history = useHistory();
  function handleClick() {
    history.push("/home");
  }
  return <button onClick={handleClick}>Submit</button>;
}
複製程式碼

而在 v6 版本中,可以通過 useNavigate 直接實現這個常用操作:

// v6
import { useNavigate } from "react-router-dom";

function MyButton() {
  let navigate = useNavigate();
  function handleClick() {
    navigate("/home");
  }
  return <button onClick={handleClick}>Submit</button>;
}
複製程式碼

react-router 內部對 history 進行了封裝,如果需要 history.replace,可以通過 { replace: true } 引數指定:

// v5
history.push("/home");
history.replace("/home");

// v6
navigate("/home");
navigate("/home", { replace: true });
複製程式碼

更小的體積 8kb

由於程式碼幾乎重構,v6 版本的程式碼壓縮後體積從 20kb 縮小到 8kb。

3 精讀

react-router v6 原始碼中有一段比較核心的理念,筆者拿出來與大家分享,對一些框架開發是大有裨益的。我們看 useRoutes 這段程式碼節選:

export function useRoutes(routes, basename = "", caseSensitive = false) {
  let {
    params: parentParams,
    pathname: parentPathname,
    route: parentRoute
  } = React.useContext(RouteContext);

  if (warnAboutMissingTrailingSplatAt) {
    // ...
  }

  basename = basename ? joinPaths([parentPathname, basename]) : parentPathname;

  let navigate = useNavigate();
  let location = useLocation();
  let matches = React.useMemo(
    () => matchRoutes(routes, location, basename, caseSensitive),
    [routes, location, basename, caseSensitive]
  );

  // ...

  // Otherwise render an element.
  let element = matches.reduceRight((outlet, { params, pathname, route }) => {
    return (
      <RouteContext.Provider
        children={route.element}
        value={{
          outlet,
          params: readOnly({ ...parentParams, ...params }),
          pathname: joinPaths([basename, pathname]),
          route
        }}
      />
    );
  }, null);

  return element;
}
複製程式碼

可以看到,利用 React.Context,v6 版本在每個路由元素渲染時都包裹了一層 RouteContext

拿更方便的路由巢狀來說:

在 v6 版本中省去了 useRouteMatch 這一步,支援直接用 path 表示相對路徑。

這就是利用這個方案做到的,因為給每一層路由檔案包裹了 Context,所以在每一層都可以拿到上一層的 path,因此在拼接路由時可以完全由框架內部實現,而不需要使用者在呼叫時預先拼接好。

再以 useNavigate 舉例,有人覺得 navigate 這個封裝僅停留在形式層,但其實在功能上也有封裝,比如如果傳入但是一個相對路徑,會根據當前路由進行切換,下面是 useNavigate 程式碼節選:

export function useNavigate() {
  let { history, pending } = React.useContext(LocationContext);
  let { pathname } = React.useContext(RouteContext);

  let navigate = React.useCallback(
    (to, { replace, state } = {}) => {
      if (typeof to === "number") {
        history.go(to);
      } else {
        let relativeTo = resolveLocation(to, pathname);

        let method = !!replace || pending ? "replace" : "push";
        history[method](relativeTo, state);
      }
    },
    [history, pending, pathname]
  );

  return navigate;
}
複製程式碼

可以看到,利用 RouteContext 拿到當前的 pathname,並根據 resolveLocationtopathname 進行路徑拼接,而 pathname 就是通過 RouteContext.Provider 提供的。

巧用多層 Context Provider

很多時候我們利用 Context 停留在一個 Provider,多個 useContext 的層面上,這是 Context 最基礎的用法,但相信讀完 React Router v6 這篇文章,我們可以挖掘出 Context 更多的用法:多層 Context Provider。

雖然說 Context Provider 存在多層會採取最近覆蓋的原則,但這不僅僅是一條規避錯誤的功能,我們可以利用這個功能實現 React Router v6 這樣的改良。

為了更仔細說明這個特性,這裡再舉一個具體的例子:比如實現搭建渲染引擎時,每個元件都有一個 id,但這個 id 並不透出在元件的 props 上:

const Input = () => {
  // Input 元件在畫布中會自動生成一個 id,但這個 id 元件無法通過 props 拿到
};
複製程式碼

此時如果我們允許 Input 元件內部再建立一個子元素,又希望這個子元素的 id 是由 Input 推匯出來的,我們可能需要使用者這麼做:

const Input = ({ id }) => {
  return <ComponentLoader id={id + "1"} />;
};
複製程式碼

這樣做有兩個問題:

  1. 將 id 暴露給 Input 元件,違背了之前設計的簡潔性。
  2. 元件需要對 id 進行拼裝,很麻煩。

這裡遇到的問題和 React Router 遇到的一樣,我們可以將程式碼簡化成下面這樣,但功能不變嗎?

const Input = () => {
  return <ComponentLoader id="1" />;
};
複製程式碼

答案是可以做到,我們可以利用 Context 實現這種方案。關鍵點就在於,渲染 Input 但元件容器需要包裹一個 Provider:

const ComponentLoader = ({ id, element }) => {
  <Context.Provider value={{ id }}>{element}</Context.Provider>;
};
複製程式碼

那麼對於內部的元件來說,在不同層級下呼叫 useContext 拿到的 id 是不同的,這正是我們想要的效果:

const ComponentLoader = ({id,element}) => {
  const { id: parentId } = useContext(Context)

  <Context.Provider value={{ id: parentId + id }}>
    {element}
  </Context.Provider>
}
複製程式碼

這樣我們在 Input 內部呼叫的 <ComponentLoader id="1" /> 實際上拼接的實際 id 是 01,而這完全拋到了外部引擎層處理,使用者無需手動拼接。

4 總結

React Router v6 完全利用 Hooks 重構後,不僅程式碼量精簡了很多,還變得更好用了,等發正式版的時候可以快速升級一波。

另外從 React Router v6 做的這些優化中,我們從原始碼中挖掘到了關於 Context 更巧妙的用法,希望這個方法可以幫助你運用到其他更復雜的專案設計中。

討論地址是:精讀《React Router v6》 · Issue #241 · dt-fe/weekly

如果你想參與討論,請 點選這裡,每週都有新的主題,週末或週一釋出。前端精讀 - 幫你篩選靠譜的內容。

關注 前端精讀微信公眾號

精讀《React Router v6》

版權宣告:自由轉載-非商用-非衍生-保持署名(創意共享 3.0 許可證

相關文章