前言
接上一篇 React-Router v6 完全解读指南 - react-router 篇,我们讲完了react-router v6
的核心库react-router
。这篇文章将是本系列的最后一篇,我们将基于react-router
库,扩展讲一讲官方是如何基于不同平台提供导航功能的。
react-router-dom
react-router-dom
是基于react-router
,专门用于在 web 端使用的路由库。其本身主要基于浏览器历史的 api 进行了扩展,我们最常使用的两个路由:BrowserRouter
和HashRouter
,都是在这个包里封装的。
用于浏览器的 Router - BrowserRouter & HashRouter
这两个Router
的用户使用率是最高的,分别结合了history
库的createBrowserHistory
与createHashHistory
创建的导航对象。
下面是源码解析:
import React from 'react';
import type { BrowserHistory, HashHistory, History } from "history";
import { createBrowserHistory, createHashHistory, createPath } from "history";
import { Router } from 'react-router';
export interface BrowserRouterProps {
basename?: string;
children?: React.ReactNode;
/**
* window 对象可自定义,因为不一定是当前页面的 window,如果不传在 history 库中就会使用 document.defaultView,也就是当前页面的 window
*/
window?: Window;
}
/**
* 在 Router 中传入了 history 的 createBrowserHistory 作为 navigator
*/
export function BrowserRouter({
basename,
children,
window
}: BrowserRouterProps) {
let historyRef = React.useRef<BrowserHistory>();
if (historyRef.current == null) {
historyRef.current = createBrowserHistory({ window });
}
let history = historyRef.current;
let [state, setState] = React.useState({
action: history.action,
location: history.location
});
// 简单对状态做监听,然后更新 location 与 action
React.useLayoutEffect(() => history.listen(setState), [history]);
return (
<Router
basename={basename}
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
/>
);
}
export interface HashRouterProps {
basename?: string;
children?: React.ReactNode;
window?: Window;
}
/**
* 在 Router 中传入了 history 的 createHashHistory 作为 navigator
*/
export function HashRouter({ basename, children, window }: HashRouterProps) {
let historyRef = React.useRef<HashHistory>();
if (historyRef.current == null) {
historyRef.current = createHashHistory({ window });
}
let history = historyRef.current;
let [state, setState] = React.useState({
action: history.action,
location: history.location
});
// 简单对状态做监听,然后更新 location 与 action
React.useLayoutEffect(() => history.listen(setState), [history]);
return (
<Router
basename={basename}
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
/>
);
}
复制代码
自定义导航对象的 Router - HistoryRouter
HistoryRouter
是react-router v6.2.1
中新增的,主要用于用户想自主控制history
对象的情况,比如使用history.block()
拦截路由跳转。
下面是源码解析:
export interface HistoryRouterProps {
basename?: string;
children?: React.ReactNode;
// 手动传入的 History 对象
history: History;
}
/**
* 主要用于用户提供自定义的 history 对象,否则只能引用 react-router 包中的 Router 组件手动传入
* 尽量不要使用自己的 history 对象,否则可能会与 react-router 内部版本不一致
*/
function HistoryRouter({ basename, children, history }: HistoryRouterProps) {
const [state, setState] = React.useState({
action: history.action,
location: history.location
});
React.useLayoutEffect(() => history.listen(setState), [history]);
return (
<Router
basename={basename}
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
/>
);
}
// 注意这里是 unstable,因为是 v6.2.1 新增的,后续可能还会改变
export { HistoryRouter as unstable_HistoryRouter };
复制代码
适用于 ssr 的 Router - StaticRouter
在react-router-dom/server
下导出了一个专用于ssr
环境的Router
,但不能只在服务端使用它,还需要配合其它Router
实现同构。下面是官方的例子:
服务端入口:
// server-entry.tsx
import express from "express";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import App from "./App";
let app = express();
app.get("*", (req, res) => {
// 这里是服务端,使用 StaticRouter
let html = ReactDOMServer.renderToString(
<StaticRouter location={req.url}>
<App />
</StaticRouter>
);
res.send("<!DOCTYPE html>" + html);
});
app.listen(3000);
复制代码
客户端入口:
// client-entry.tsx
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import App from "./App";
ReactDOM.hydrate(
// 客户端,使用 BrowserRouter
<BrowserRouter>
<App />
</BrowserRouter>,
document.documentElement
);
复制代码
这样,我们就完成了路由的同构,关于ssr
的详细内容请查看官方文档
下面是源码解析:
/**
* 服务器端,ssr 路由
* StaticRouter 实际上就是一个只提供参数传递,没有任何 navigator 操作的 Router
*/
import React from "react";
import { Action, Location, To, createPath, parsePath } from "history";
import { Router } from "react-router-dom";
export interface StaticRouterProps {
basename?: string;
children?: React.ReactNode;
location: Partial<Location> | string;
}
/**
* 没有任何操作,只是简单对 location 做验证,然后传入到 Router 中
* A <Router> that may not transition to any other location. This is useful
* on the server where there is no stateful UI.
*/
export function StaticRouter({
basename,
children,
location: locationProp = "/"
}: StaticRouterProps) {
// 转换为 location 对象
if (typeof locationProp === "string") {
locationProp = parsePath(locationProp);
}
// 所有 action 都是 Pop
let action = Action.Pop;
let location: Location = {
pathname: locationProp.pathname || "/",
search: locationProp.search || "",
hash: locationProp.hash || "",
state: locationProp.state || null,
key: locationProp.key || "default"
};
let staticNavigator = {
createHref(to: To) {
// createPath 格式化 path
return typeof to === "string" ? to : createPath(to);
},
push(to: To) {
throw new Error(
`You cannot use navigator.push() on the server because it is a stateless ` +
`environment. This error was probably triggered when you did a ` +
`\`navigate(${JSON.stringify(to)})\` somewhere in your app.`
);
},
replace(to: To) {
throw new Error(
`You cannot use navigator.replace() on the server because it is a stateless ` +
`environment. This error was probably triggered when you did a ` +
`\`navigate(${JSON.stringify(to)}, { replace: true })\` somewhere ` +
`in your app.`
);
},
go(delta: number) {
throw new Error(
`You cannot use navigator.go() on the server because it is a stateless ` +
`environment. This error was probably triggered when you did a ` +
`\`navigate(${delta})\` somewhere in your app.`
);
},
back() {
throw new Error(
`You cannot use navigator.back() on the server because it is a stateless ` +
`environment.`
);
},
forward() {
throw new Error(
`You cannot use navigator.forward() on the server because it is a stateless ` +
`environment.`
);
}
};
return (
<Router
basename={basename}
children={children}
location={location}
navigationType={action}
navigator={staticNavigator}
static={true}
/>
);
}
复制代码
代替 a 标签跳转链接 - Link
Link
组件在react=router-dom
中用于代替浏览器平台的原生a
标签,用户可以通过点击它实现导航跳转。该组件不同于原生a
标签,它不会刷新页面,而是使用浏览器history
api 实现页面跳转,优化用户体验。
import { useHref } from 'react-router';
import type { To } from "react-router";
export interface LinkProps
extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>,
"href"> {
// 去掉 a 标签的 href,这里让用户使用我们内部定义的 to 属性
/**
* 如果为 true,代表真实的 a 标签跳转
*/
reloadDocument?: boolean;
// 是否替换页面栈
replace?: boolean;
state?: any;
// react-router 中的 to,可以传 string 或者对象,详情见 react-router 篇
to: To;
}
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
function LinkWithRef(
{ onClick, reloadDocument, replace = false, state, target, to, ...rest },
ref
) {
// 如果 Router 提供了 basename,这里会加上 basename,详情见 react-router 篇
let href = useHref(to);
// 将用户传入的参数传入最终生成一个点击事件用户跳转页面
let internalOnClick = useLinkClickHandler(to, { replace, state, target });
function handleClick(
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
) {
// 如果用户传入了 onClick 事件,则调用用户传入的 onClick 事件,注意这里没有 return,会继续执行下面的语句
if (onClick) onClick(event);
// 这里的 defaultPrevented 是用户在外部 preventDefault 后的情况,阻止了 internalOnClick 的调用,也就是说不会执行内部定义的路由跳转了
// internalOnClick 中也有调用 preventDefault 方法,但这样是为了阻止 a 标签的默认跳转,从而实现内部路由跳转,和上面的意义是不同的。
// 同时,如果带了 reloadDocument 证明走的不是内部的 history 跳转,而是真实的 a 标签跳转
if (!event.defaultPrevented && !reloadDocument) {
internalOnClick(event);
}
}
return (
// eslint-disable-next-line jsx-a11y/anchor-has-content
<a
{...rest}
href={href}
onClick={handleClick}
ref={ref}
target={target}
/>
);
}
);
复制代码
除了调用react-router
提供的useHref
,Link
组件还通过useLinkClickHandler
生成了一个内部的跳转事件,它的源码是这样的:
import { useNavigate, useLocation, useResolvedPath } from 'react-router';
/**
* 判断用户是否有按下修辞符
* @param event
* @returns
*/
function isModifiedEvent(event: React.MouseEvent) {
return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
}
/**
* 返回拦截了默认 a 标签跳转的 click 事件函数
*/
export function useLinkClickHandler<E extends Element = HTMLAnchorElement>(
to: To,
{
target,
replace: replaceProp,
state
}: {
target?: React.HTMLAttributeAnchorTarget;
replace?: boolean;
state?: any;
} = {}
): (event: React.MouseEvent<E, MouseEvent>) => void {
// 调用 react-router 中的三个 hooks
// 导航函数
let navigate = useNavigate();
// 当前的 location
let location = useLocation();
// 格式化 path
let path = useResolvedPath(to);
return React.useCallback(
(event: React.MouseEvent<E, MouseEvent>) => {
if (
// 必须是左键单击才触发
event.button === 0 && // Ignore everything but left clicks
// 如果是 target 为 _blank 这种不受处理,只能是当前页面打开
(!target || target === "_self") && // Let browser handle "target=_blank" etc.
// 如果用户按了修辞符也不触发
!isModifiedEvent(event) // Ignore clicks with modifier keys
) {
// 阻止 a 标签默认跳转
event.preventDefault();
// 如果用户指定了 replace 或者 pathname 没有发生改变,则调用 replace
let replace =
!!replaceProp || createPath(location) === createPath(path);
navigate(to, { replace, state });
}
},
[location, navigate, path, replaceProp, state, target, to]
);
}
复制代码
可以看出,Link
组件内部其实就是阻止了a
标签的默认跳转,转而使用了上下文中的navigate
函数。
具有状态的 Link 组件 - NavLink
NavLink
组件是基于Link
组件的一层封装,它除了永远后者全部功能外,还提供了检测对应Link
标签是否处于active
状态的功能(active
状态,即当前页面的pathname
能够与Link
中传入的to
相匹配)。
export interface NavLinkProps
extends Omit<LinkProps, "className" | "style" | "children"> {
// 可以看到,下面的 className、style与 children 都可以传入函数获取到当前 Link 状态
// children 可直接传入函数
children:
| React.ReactNode
| ((props: { isActive: boolean }) => React.ReactNode);
/**
* 这里的 caseSensitive 是改变传入的 to 的 pathname 的大小写,主要用于 active 状态的计算
*/
caseSensitive?: boolean;
className?: string | ((props: { isActive: boolean }) => string);
// end 为 true 时需要当前 pathname 与 to 的 pathname 完全匹配,否则不需要完全匹配,只要当前 pathname 完全包含 toPathname 并且使用 / 做分割
// /home 可以匹配 /home/home2,但是不能匹配 /home2,也就是说必须要只有 /home,或者有 /home/ 作为前缀
end?: boolean;
style?:
| React.CSSProperties
| ((props: { isActive: boolean }) => React.CSSProperties);
}
export const NavLink = React.forwardRef<HTMLAnchorElement, NavLinkProps>(
function NavLinkWithRef(
{
"aria-current": ariaCurrentProp = "page",
caseSensitive = false,
className: classNameProp = "",
end = false,
style: styleProp,
to,
children,
...rest
},
ref
) {
let location = useLocation();
let path = useResolvedPath(to);
let locationPathname = location.pathname;
let toPathname = path.pathname;
// 改变大小写
if (!caseSensitive) {
locationPathname = locationPathname.toLowerCase();
toPathname = toPathname.toLowerCase();
}
// 是否匹配当前 NavLink 对应的 pathname
let isActive =
locationPathname === toPathname ||
(!end &&
locationPathname.startsWith(toPathname) &&
locationPathname.charAt(toPathname.length) === "/");
// 适配障碍人群
let ariaCurrent = isActive ? ariaCurrentProp : undefined;
let className: string;
if (typeof classNameProp === "function") {
className = classNameProp({ isActive });
} else {
// 如果没有传函数,会多一个 active 的 class 作为状态
// If the className prop is not a function, we use a default `active`
// class for <NavLink />s that are active. In v5 `active` was the default
// value for `activeClassName`, but we are removing that API and can still
// use the old default behavior for a cleaner upgrade path and keep the
// simple styling rules working as they currently do.
className = [classNameProp, isActive ? "active" : null]
.filter(Boolean)
.join(" ");
}
let style =
typeof styleProp === "function" ? styleProp({ isActive }) : styleProp;
return (
<Link
{...rest}
aria-current={ariaCurrent}
className={className}
ref={ref}
style={style}
to={to}
>
{typeof children === "function" ? children({ isActive }) : children}
</Link>
);
}
);
复制代码
从源码中可以看出,NavLink
内部实时判断了当前pathname
是否能与其匹配,进而向外传递isActive
值。
辅助 API
useLinkClickHandler
useLinkClickHandler(to, { target, replace, state})
用于生成点击跳转事件,在Link 源码分析
中讲过。
useSearchParams
useSearchParams(defaultInit)
返回一个[state, setState]
格式的值,用于帮助我们快速修改浏览器pathname
的search
部分(内部基于 URLSearchParams API)。
export type ParamKeyValuePair = [string, string];
// 可传入的 query 字符串格式
export type URLSearchParamsInit =
| string
| ParamKeyValuePair[]
| Record<string, string | string[]>
| URLSearchParams;
/**
* 将 init 的值(拓展出的值)转换为标准的 URLSearchParams 对象,主要拓展了对象形式参数的转换
* 使用 URLSearchParams API,你必须这样做:
*
* let searchParams = new URLSearchParams([
* ['sort', 'name'],
* ['sort', 'price']
* ]);
*
* 现在可以这样:
*
* let searchParams = createSearchParams({
* sort: ['name', 'price']
* });
*/
export function createSearchParams(
init: URLSearchParamsInit = ""
): URLSearchParams {
return new URLSearchParams(
// 前面两个都是 URLSearchParams 本身就可转换的
typeof init === "string" ||
Array.isArray(init) ||
init instanceof URLSearchParams
? init
: // init 为普通 key value 对象
Object.keys(init).reduce((memo, key) => {
let value = init[key];
return memo.concat(
Array.isArray(value) ? value.map(v => [key, v]) : [[key, value]]
);
}, [] as ParamKeyValuePair[])
);
}
export function useSearchParams(defaultInit?: URLSearchParamsInit) {
// 需要支持 URLSearchParams API
warning(
typeof URLSearchParams !== "undefined",
`You cannot use the \`useSearchParams\` hook in a browser that does not ` +
`support the URLSearchParams API. If you need to support Internet ` +
`Explorer 11, we recommend you load a polyfill such as ` +
`https://github.com/ungap/url-search-params\n\n` +
`If you're unsure how to load polyfills, we recommend you check out ` +
`https://polyfill.io/v3/ which provides some recommendations about how ` +
`to load polyfills only for users that need them, instead of for every ` +
`user.`
);
// 保存着 URLSearchParams 对象,初始化的值
let defaultSearchParamsRef = React.useRef(createSearchParams(defaultInit));
let location = useLocation();
let searchParams = React.useMemo(() => {
// 当前 location.search 对应的 params
let searchParams = createSearchParams(location.search);
// 如果当前 params 有初始化的 key,不做任何处理,否则加上没有的 key
for (let key of defaultSearchParamsRef.current.keys()) {
if (!searchParams.has(key)) {
defaultSearchParamsRef.current.getAll(key).forEach(value => {
searchParams.append(key, value);
});
}
}
return searchParams;
}, [location.search]);
let navigate = useNavigate();
// 设置 query 值,实际是调用了一遍 navigate 的方法
let setSearchParams = React.useCallback(
(
nextInit: URLSearchParamsInit,
navigateOptions?: { replace?: boolean; state?: any }
) => {
navigate("?" + createSearchParams(nextInit), navigateOptions);
},
[navigate]
);
return [searchParams, setSearchParams] as const;
}
复制代码
react-router-native
react-router-native
是基于react-router
,专门用于在react-native
端使用的路由库。
同react-router-dom
一样,该库也为react-native
端提供了专用的Router
、Components
与hooks
。
因为
react-native
的用户量会少很多,所以这里就只是简单介绍了。
NativeRouter
其实就是react-router
导出的MemoryRouter
,使用内存栈作为导航。
import type { MemoryRouterProps } from 'react-router';
import { MemoryRouter } from 'react-router';
export interface NativeRouterProps extends MemoryRouterProps {}
/**
* NativeRouter 就是 react-router 里的 MemoryRouter,使用内存做导航
*/
export function NativeRouter(props: NativeRouterProps) {
return <MemoryRouter {...props} />;
}
复制代码
嗯,好像做了什么,又好像什么都没做。
Link
import { TouchableHighlight, GestureResponderEvent, TouchableHighlightProps } from 'react-native';
import type { To } from 'react-router';
export interface LinkProps extends TouchableHighlightProps {
children?: React.ReactNode;
onPress?: (event: GestureResponderEvent) => void;
replace?: boolean;
state?: any;
to: To;
}
/**
* 基本同 react-router-dom 一样的组件封装,不过是使用的 react-native 的组件
*/
export function Link({
onPress,
replace = false,
state,
to,
...rest
}: LinkProps) {
// 和 react-router-dom 的 Link 逻辑大致相同,不过内部实现更简单一点
let internalOnPress = useLinkPressHandler(to, { replace, state });
function handlePress(event: GestureResponderEvent) {
if (onPress) onPress(event);
if (!event.defaultPrevented) {
internalOnPress(event);
}
}
return <TouchableHighlight {...rest} onPress={handlePress} />;
}
复制代码
生成点击函数的useLinkPressHandler
源码:
import { useNavigate } from 'react-router';
/**
* 返回 onPress 方法的 hook
*/
export function useLinkPressHandler(
to: To,
{
replace,
state
}: {
replace?: boolean;
state?: any;
} = {}
): (event: GestureResponderEvent) => void {
let navigate = useNavigate();
return function handlePress() {
navigate(to, { replace, state });
};
}
复制代码
辅助 API
useLinkPressHandler
useLinkPressHandler(to, { replace, state})
用于生成react-native
端的点击跳转事件,在Link 源码分析
中讲过。
useHardwareBackButton / useAndroidBackButton
useHardwareBackButton/useAndroidBackButton
用于启用对 Android 上的硬件后退按钮的支持。
const HardwareBackPressEventType = "hardwareBackPress";
/**
* 启用硬件后退按钮的支持,目前只是挂载事件监听,内部后续应该还有具体实现代码
*/
export function useHardwareBackButton() {
React.useEffect(() => {
function handleHardwardBackPress() {
return undefined;
// TODO: 实现应该类似下面这样,但是官方还没有实现
// if (history.index === 0) {
// return false; // home screen
// } else {
// history.back();
// return true;
// }
}
BackHandler.addEventListener(
HardwareBackPressEventType,
handleHardwardBackPress
);
return () => {
BackHandler.removeEventListener(
HardwareBackPressEventType,
handleHardwardBackPress
);
};
}, []);
}
// 注意导出函数名
export { useHardwareBackButton as useAndroidBackButton };
复制代码
useDeepLinking
useDeepLinking
用于启用深层链接模式,每次链接跳转时都会自动再加一层导航栈。
import { Linking } from 'react-native';
import { useNavigate } from 'react-router';
const URLEventType = "url";
/**
* 去除掉 url 中的协议内容
* @param url
* @returns
*/
function trimScheme(url: string) {
return url.replace(/^.*?:\/\//, "");
}
/**
* 启用深层链接模式,包括启动和后续传入
*/
export function useDeepLinking() {
let navigate = useNavigate();
// Get the initial URL
React.useEffect(() => {
let current = true;
Linking.getInitialURL().then(url => {
// 让初始化的链接更深入
if (current) {
if (url) navigate(trimScheme(url));
}
});
return () => {
current = false;
};
}, [navigate]);
// 监听 url 改变,改变后继续往同一个 url 跳转,让链接更深入
React.useEffect(() => {
function handleURLChange(event: { url: string }) {
navigate(trimScheme(event.url));
}
Linking.addEventListener(URLEventType, handleURLChange);
return () => {
Linking.removeEventListener(URLEventType, handleURLChange);
};
}, [navigate]);
}
复制代码
useSearchParams
用法同react-router-dom
中的useSearchParams
。
export type ParamKeyValuePair = [string, string];
export type URLSearchParamsInit =
| string
| ParamKeyValuePair[]
| Record<string, string | string[]>
| URLSearchParams;
export function createSearchParams(
init: URLSearchParamsInit = ""
): URLSearchParams {
return new URLSearchParams(
typeof init === "string" ||
Array.isArray(init) ||
init instanceof URLSearchParams
? init
: Object.keys(init).reduce((memo, key) => {
let value = init[key];
return memo.concat(
Array.isArray(value) ? value.map(v => [key, v]) : [[key, value]]
);
}, [] as ParamKeyValuePair[])
);
}
type SetURLSearchParams = (
nextInit?: URLSearchParamsInit | undefined,
navigateOpts?: NavigateOptions | undefined
) => void;
/**
* 同 react-router-dom,但是不需要判断 URLSearchParams 是否存在
*/
export function useSearchParams(
defaultInit?: URLSearchParamsInit
): [URLSearchParams, SetURLSearchParams] {
let defaultSearchParamsRef = React.useRef(createSearchParams(defaultInit));
let location = useLocation();
let searchParams = React.useMemo(() => {
// 当前 location.search 对应的 params
let searchParams = createSearchParams(location.search);
// 如果当前 params 有初始化的 key,不做任何处理,否则加上没有的 key
for (let key of defaultSearchParamsRef.current.keys()) {
if (!searchParams.has(key)) {
defaultSearchParamsRef.current.getAll(key).forEach(value => {
searchParams.append(key, value);
});
}
}
return searchParams;
}, [location.search]);
let navigate = useNavigate();
let setSearchParams: SetURLSearchParams = React.useCallback(
(nextInit, navigateOpts) => {
navigate("?" + createSearchParams(nextInit), navigateOpts);
},
[navigate]
);
return [searchParams, setSearchParams];
}
复制代码
总结
本文分别讲了react-router-dom
与react-router-native
的源码,两者的结构大体一致,只是根据不同平台而分别提供了相同 API 的不同实现方式。我们从中学习了Router
二次封装,Link
标签的跳转以及各种辅助路由导航的 API。
至此,React-Router v6 完全解读指南系列就告一段落了,不过后面可能还会有番外篇,讲一讲前端路由的发展及原理,当然也有可能就这样鸽了(。
本系列相关的代码仓库:github
本系列的其余文章: