因为想系统地了解下 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
就行了
app.tsimport { 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
简单理解的话,router
就是一个状态机,提供了一些可以改变状态的方法,上层可以监听状态的变化来做 reaction。来看下比较常用的方法:
createRouter
方法签名如下:
router.tsexport 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;
}
router.initialize
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;
}
router.subscribe
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));
}
router.navigate
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
。
router.fetch
fetch
是提供给上层使用的 API,不会改变 history
,会让 state.fetchers
发生改变,使用方可以监听 state.fetchers
的变化,来拿到 fetch
的结果。
react-router
react-router
分为三个部分:components
, context
和 hooks
。
components
可以用来构建route
树context
可以存储router.state
,并在router.state
变化时触发rerender
hooks
可以从context
中拿对应的 value,也可以直接调用router
的方法
components.RouterProvider
在一开始讲 RR 的使用时,就有提到 RouterProvider
,它是这么用的:
ReactDOM.createRoot(document.getElementById("root")).render(
<RouterProvider router={router} />
);
RouterProvider
做了两件事:
- 通过
useSyncExternalStoreShim
与router.state
建立连接 - 嵌套 4 个
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>
);
}
components.Routes
除了通过 createBrowserRoutes
来创建 routes
外,也可以通过 Routes
组件来定义 routes
,就像这样:
app.tsxexport 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 都不会有影响。
hooks
react-router
的 hooks
除了 useRoutes
外,其他的大多是从 Context
里去取对应的 Value,这里就不展开讲了。
context
react-router
的 context
主要有 4 个:
- LocationContext
- NavigationContext
- DataRouterContext // 把 Router 作为 value
- DataRouterStateContext // 把 Router.state 作为 value
每个 context
对应 router
的不同部分, 当 router.state
变化时,这些 context
的 value 就会被更新,hooks
就能拿到最新的 value。
react-router-dom
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 有更多的了解。水平有限,如有错误欢迎指出和讨论。