因为想系统地了解下 Remix 的实现,于是就顺藤摸瓜看了下 React Router (v6.4,下面简称 RR) 的工作原理。
我们都知道 React 是一个 UI Library,按照 React 规范书写的代码可以被渲染到某一个 Dom 节点上,并进行交互,所以用 React 来写单页应用(SPA)很方便。但网站通常会有多个页面,页面之间有公共部分(如 header,sidebar),也可以通过链接跳转。如果不借助第三方类库,最直接的方式就是为多个页面建立入口文件,然后在页面内再引入 React,就像这样:
但这样实在是不够便捷,有了 RR 可以省去很多重复性的步骤(新建文件、定义 root element 等),让页面加载更快(无页面刷新、并行请求 URL 等),写起来方便,功能还很强大。对于后端来说无论怎样的请求路径,都返回同样的 JS 代码就行了(假设没有 split code),这些 JS 会自己去识别 URL,完成页面布局,加载正确的组件等等。
先来看看 React Router 的使用:
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
errorElement: <ErrorPage />,
},
{
path: "contacts/:contactId",
element: <Contact />,
},
]);
ReactDOM.createRoot(document.getElementById("root")).render(
<RouterProvider router={router} />
);
只需要两个步骤:
Router
Router
作为参数传给 RouteProvider
route
的定义也很直观:
{
path: "/", // 匹配怎样的 URL
element: <Root />, // 匹配到了后,渲染哪个 Component
errorElement: <ErrorPage />, // 如果出错展示哪个 Component
}
假如有两个 URL:/dashboard/settings
和 /dashboard/analytics
,可以这样定义:
const router = createBrowserRouter([
{
path: "/dashboard/settings",
element: <Settings />
},
{
path: "/dashboard/analytics",
element: <Analytics />
},
]);
但这样的话,每个 element
对应的 Component
就要自己去处理组件复用,也无法体现组件之间的关系,当 URL 多起来后,分散的 route
也不容易维护(如果维护过 CSS 应该就有体会,想象每一个 route 都是一条 CSS 规则)。
RR 支持嵌套 route,就像这样:
const router = createBrowserRouter([
{
path: "/",
element: <Root />,
children: [
{
path: "dashboard",
element: <Dashboard />,
children: [
{ path: "settings", element: <Settings /> },
{ path: "analytics", element: <Analytics /> },
]
},
]
},
]);
当访问 /dashboard/settings
URL 时,React Router 就能根据配置,找到所需的 Components
来构建最终的页面。
拿到了 Root
, Dashboard
, Setting
这三个组件后,如何把它们组成一个页面呢?这就要使用 Outlet
了,可以把它理解为一个 Slot
,也就是为 children
准备好的一块地方。children
的内容会渲染在 parent 的 Outlet
区域内。
除了能布局组件,React Router 还可以用来管理数据,就像这样:
<Route
path="/"
loader={async ({ request }) => {
const res = await fetch("/api/user.json", {
signal: request.signal,
});
const user = await res.json();
return user;
}}
element={<Root />}
>
初看起来好像没什么,甚至会觉得有点管得太多了,组件内部完全可以在 useEffect
里自己去加载数据并展示,但结合上面的路由配置,RR 可以拿到每个组件对应的 loader
。有了全局视角,就可以做很多事了,比如可以并行请求这些 loader
(通过 Promise.all
),中断这些组件的请求(切换到了另一个 URL),并在合适的时机再重新请求等等。
使用时,只需要 useLoaderData
就行了
import { useLoaderData } from "react-router-dom";
import { getContacts } from "../contacts";
export async function loader() {
const contacts = await getContacts();
return { contacts };
}
export default function Root() {
const { contacts } = useLoaderData();
return (
//...
);
}
除了这些,RR 还支持 Ranked Routes(每个路由会有一个分数,分高者优先匹配)、Redirects、Data Mutation、Optimistic UI(假定请求会成功,先用本地数据更新 UI)等等,功能很齐全。
了解了 RR 的使用后,再来看看 RR 内部是如何工作的。可以分为三个部分:
- router // 平台无关,实现核心逻辑,可以简单理解为一个状态机
- react-router // 适配 react,包括 components, context, hooks
- react-router-dom // web 端的实现,如 Link、Form 等
这三个部分的主要组成部分如下:
简单理解的话,router
就是一个状态机,提供了一些可以改变状态的方法,上层可以监听状态的变化来做 reaction。来看下比较常用的方法:
方法签名如下:
export interface RouterInit {
basename?: string;
routes: AgnosticRouteObject[];
history: History;
hydrationData?: HydrationState;
}
export function createRouter(init: RouterInit): Router {
//...
}
使用方构建好 RouterInit
后作为参数传入,就能拿到一个 Router
实例。RouterInit
里的 routes
是一个 AgnosticRouteObject
数组,它的定义如下:
export interface AgnosticRouteObject {
caseSensitive?: boolean;
children?: AgnosticRouteObject[];
index?: boolean;
path?: string;
id?: string;
loader?: LoaderFunction;
action?: ActionFunction;
hasErrorBoundary?: boolean;
shouldRevalidate?: ShouldRevalidateFunction;
handle?: any;
}
基本上 route
相关的信息都有了,除了 element
,这个就交给上层去定义和使用了,因为不同的实现,element 都会不一样。 createRouter
方法内部把 init
的内容重新组织后作为变量保存起来,供 Router
相关的方法使用,同时也会初始化其他的内部变量。
export function createRouter(init: RouterInit): Router {
let dataRoutes = convertRoutesToDataRoutes(init.routes);
let initialMatches = matchRoutes(
dataRoutes,
init.history.location,
init.basename
);
//... other variable initiation
router = {
get basename() { return init.basename; },
get state() { return state; },
get routes() { return dataRoutes; },
initialize,
subscribe,
navigate,
fetch,
//..
};
return router;
}
initialize
方法做了两件事:
history
的变化startNavigation
)function initialize() {
// 监听 history 的变化,然后执行 navigate 的跳转逻辑
// 这里监听的是 history 的 `popstate` 事件,对应用户点击浏览器的前进、后退按钮行为
// 其他的 url 改变行为都是内部行为(比如执行 `router.navigate(path)`),不需要监听
unlistenHistory = init.history.listen(
({ action: historyAction, location }) =>
startNavigation(historyAction, location)
);
if (!state.initialized) {
startNavigation(HistoryAction.Pop, state.location);
}
return router;
}
subscribe
方法可以注册一个 listener,该 listener 在 state
改变后会被通知到。
function subscribe(fn: RouterSubscriber) {
subscribers.add(fn);
return () => subscribers.delete(fn);
}
function updateState(newState: Partial<RouterState>): void {
state = {
...state,
...newState,
};
subscribers.forEach((subscriber) => subscriber(state));
}
navigate
方法可以让当前的路由状态发生改变,同时修改 history
。
上图只是描述了大致的流程,navigate
方法处理的细节很多,比如 matchRoutes
会将树状的 routes 扁平化(flatten
)、给每个 route
打分,并按照分数倒序排列。
假设原先的 routes 是这样的:
{
path:"/",
children:
[
{ path: "about" },
{ path: "posts",
children:
[{ path:"all" }]
}
]
}
扁平化后会变成这样:(score 得分为示意)
[
{ path: "/posts/all", score: 100, routesMeta: [rootMeta, postsMeta, postsAllMeta]},
{ path: "/posts", score: 80, routesMeta: [rootMeta, postsMeta]},
{ path: "/about", score: 80, routesMeta: [rootMeta, aboutMeta]},
{ path: "/", score: 50, routesMeta: [rootMeta]},
]
这样对 routes
进行遍历,找到的第一条就是最匹配目标 path 的那个 route
。
fetch
是提供给上层使用的 API,不会改变 history
,会让 state.fetchers
发生改变,使用方可以监听 state.fetchers
的变化,来拿到 fetch
的结果。
react-router
分为三个部分:components
, context
和 hooks
。
components
可以用来构建 route
树context
可以存储 router.state
,并在 router.state
变化时触发 rerender
hooks
可以从 context
中拿对应的 value,也可以直接调用 router
的方法在一开始讲 RR 的使用时,就有提到 RouterProvider
,它是这么用的:
ReactDOM.createRoot(document.getElementById("root")).render(
<RouterProvider router={router} />
);
RouterProvider
做了两件事:
useSyncExternalStoreShim
与 router.state
建立连接context.Provider
export function RouterProvider({
fallbackElement,
router,
}: RouterProviderProps): React.ReactElement {
// 当 `router.state` 变化时会收到通知,并拿到最新的 state
// 参见:https://reactjs.org/docs/hooks-reference.html#usesyncexternalstore
let state: RouterState = useSyncExternalStoreShim(
router.subscribe,
() => router.state,
() => router.state
);
//...
return (
// 将 `router` 的不同部分放到对应的 context provider value 中
<DataRouterContext.Provider
value={{ router, navigator, ... }}
>
<DataRouterStateContext.Provider value={state}>
// 这里展开来的话,也是两个嵌套的 Provider
<Router
basename={router.basename}
location={router.state.location}
navigationType={router.state.historyAction}
navigator={navigator}
>
{router.state.initialized ? <Routes /> : fallbackElement}
</Router>
</DataRouterStateContext.Provider>
</DataRouterContext.Provider>
);
}
除了通过 createBrowserRoutes
来创建 routes
外,也可以通过 Routes
组件来定义 routes
,就像这样:
export default function App() {
return (
<div>
<p> Hello, world! </p>
<Routes>
<Route path="/" element={<Layout />}>
<Route index element={<Home />} />
<Route path="about" element={<About />} />
<Route path="dashboard" element={<Dashboard />} />
<Route path="*" element={<NoMatch />} />
</Route>
</Routes>
</div>
);
}
Routes
内部主要用到了 useRoutes
这个 hook
export function useRoutes(
routes: RouteObject[],
locationArg?: Partial<Location> | string
): React.ReactElement | null {
//...
// 1.
let matches = matchRoutes(routes, { pathname: remainingPathname });
// 2.
let renderedMatches = _renderMatches(
matches,
parentMatches,
dataRouterStateContext || undefined
);
// 3.
if (locationArg) {
return (
<LocationContext.Provider
value={{
location: {...},
navigationType: NavigationType.Pop,
}}
>
{renderedMatches}
</LocationContext.Provider>
);
}
return renderedMatches;
}
matchRoutes
用的是 router
里的实现,用来找到匹配 pathname
的 routes
_renderMatches
方法得到 renderedMatches
,这是 matchRoutes
方法最重要的一块LocationContext.Provider
如果有必要的话,这样就能通过 useLocation
来拿 location
了来看一下 _renderMatches
里面大概是怎样的:
export function _renderMatches(
matches: RouteMatch[] | null,
parentMatches: RouteMatch[] = [],
dataRouterState?: RemixRouter["state"]
): React.ReactElement | null {
//...
// 1.
return matches.reduceRight((outlet, match, index) => {
// 去掉了 error 相关的处理逻辑
let getChildren = () => (
<RenderedRoute
match={match}
// 2.
routeContext={{
outlet,
matches: parentMatches.concat(renderedMatches.slice(0, index + 1)),
}}
>
{error
? errorElement
: match.route.element !== undefined
? match.route.element
: outlet}
</RenderedRoute>
);
return getChildren();
}, null as React.ReactElement | null);
}
reduceRight
时,没有反应过来,为什么要从右往左 reduce,想明白后觉得甚为精妙。还记得之前将 routeObjects
扁平化么,每个数组元素都有一个 routesMeta
数组,里面放的就是 AgnosticRouteObject
,更重要的是,这些 Objects 的顺序也是有讲究的,最左边是根,最右边是叶子。[
{ path: "/posts/all", score: 100, routesMeta: [rootMeta, postsMeta, postsAllMeta]},
{ path: "/posts", score: 80, routesMeta: [rootMeta, postsMeta]},
...
]
matches.reduceRight((outlet, match, index) => {})
就是从最右边的叶子开始作为 outlet
传递给 parent
,如果 parent
组件有 Outlet
占位组件,就用传入的 outlet
组件填充。这样依次从右往左遍历(以上面的代码为例,就是先把 postsAllMeta
作为 outlet
传给 postsMeta
,再把 postsMeta
作为 outlet
传给 rootMeta
)。
outlet
作为 context value 包在 match.route.element
外,因为 React Context 获取 Context Value 是就近匹配原则,也就是向上找到的第一个符合的 Context Provider。这样外面有再多的匹配的 Context Provider 都不会有影响。react-router
的 hooks
除了 useRoutes
外,其他的大多是从 Context
里去取对应的 Value,这里就不展开讲了。
react-router
的 context
主要有 4 个:
- LocationContext
- NavigationContext
- DataRouterContext // 把 Router 作为 value
- DataRouterStateContext // 把 Router.state 作为 value
每个 context
对应 router
的不同部分, 当 router.state
变化时,这些 context
的 value 就会被更新,hooks
就能拿到最新的 value。
react-router-dom
按照浏览器的特性又进一步封装了 hooks
和 components
。先来看一下 createBrowserRouter
export function createBrowserRouter(
routes: RouteObject[],
opts?: {
basename?: string;
hydrationData?: HydrationState;
window?: Window;
}
): RemixRouter {
return createRouter({
basename: opts?.basename,
history: createBrowserHistory({ window: opts?.window }),
hydrationData: opts?.hydrationData || window?.__staticRouterHydrationData,
routes: enhanceManualRouteObjects(routes),
}).initialize();
}
createRouter
和 createBrowserHistory
都由 router
提供,routes
(RouteObject[]
) 则是在 react-router
中定义。
再来看看 Link
组件,用 Link
组件构造的链接,点击后可以不刷新页面(指定 reloadDocument
后,可以回退到正常的链接模式)
export const Link = React.forwardRef(
function LinkWithRef(
{ onClick, to, ... },
ref
) {
let href = useHref(to, { relative });
let internalOnClick = useLinkClickHandler(to, {...});
function handleClick(event) {
if (onClick) onClick(event);
if (!event.defaultPrevented) {
internalOnClick(event);
}
}
return (
<a
href={href}
onClick={reloadDocument ? onClick : handleClick}
ref={ref}
...
/>
);
}
);
可以看到 Link
内部是一个 a
链接,同时绑定了 handleClick
,默认用 useLinkClickHandler
来处理
export function useLinkClickHandler(to: To, {...}) (event) => void {
let navigate = useNavigate();
let location = useLocation();
let path = useResolvedPath(to, { relative });
return React.useCallback(
(event) => {
if (shouldProcessLinkClick(event, target)) {
event.preventDefault();
navigate(to, { replace, state, preventScrollReset, relative });
}
},
[ location, navigate, path, ... ]
);
}
方法内部用了 router.navigate
方法来完成跳转。Form
组件也类似,先禁用默认行为(event.preventDefault()
)再用 router.fetch
来提交数据。
React Router 功能很强大,使用也很广泛,这篇文章也只是大致剖析了下 RR 的内部实现,更细致的内容(如错误处理、请求管控、状态变更时机等)还是要啃源码才行,希望这篇文章能够让你对 RR 有更多的了解。水平有限,如有错误欢迎指出和讨论。
👍