前置知识
- HTML5 history API 以及 popstate 事件
- URL hash 以及 hashchange 事件
- 前端路由模式:hash 模式、history
- React 相关: 如 Context Api, hooks 等
- 理解 router 与 react 组件设计与匹配
SPA 需要的路由模式
单页面对路由提出了这样的需求:URL 地址发生变化,但是不会刷新页面。hash 是 SPA 应用一种可行的方式。在 HTML5 推出 history api 之后:
history.pushState
history.replaceState
也有了类似于 hashchange 效果,但是也不完全一致。
SPA 应用 hash 模式与 history 模式的区别
- hash 本质通过 hashchange 事件来达到路由切换的效果,但是 hashchange 不会刷新浏览器当前页面,实现路由切换,可能需要 HTML history API 协同。
- HTML history API 本身具有刷新浏览器的能力,与 hash 有本质区别。
社区做出的努力:history
history 包是 react-router 的底层包,但是它也适用于其他的 javascript 运行环境,下面我们一 html 环境为示例简单使用:
- type="module"
- 从cdn中加载 history
- 在全局环境中找到 HistoryLibrary
index.html 主页面,使用 hash 模式作为示例,使用定时器进行定时跳转
<!DOCTYPE html>
<html lang="en">
<head>
<script src="https://cdn.bootcdn.net/ajax/libs/history/5.3.0/history.development.js"></script>
<title>Document</title>
</head>
<body>
<div>index: page</div>
<script type="module">
const history = HistoryLibrary.createHashHistory()
setTimeout(() => {
history.push('/about.html');
}, 1000);
</script>
</body>
</html>
createHashHistory 表现
用同样的方法定义 about.html, 使用服务打开页面发现 createHashHistory() 创建的 history url 发生了变化,http://127.0.0.1:5500/#/about.html
, 但是 about.html 并没有加载,刷新浏览器页面,同样不会加载新的页面。
createHistoryHistory 表现
使用 createHistoryHistory 创建 history 发生了变化:跳转到 about.html 路由,但是没有加载 about.html。刷新当前页面,页面内容变成了 about.html。 这一点与 hash 有很大的区别。如果我们需要在新的页面刷新页面,可能用 history 模式更加好。
history api 分析
- action 触发的类型:POP/PUSH/REPLACE
- createBrowserHistory 创建history模式
- createHashHistory 创建hash模式
- createMemoryHistory 创建缓存模式
- createPath 创建 path 路径
- parsePath 解析 path 路径
location 对象
- hash hash值
- key 特定 key
- pathname 路径
- search 查询参数
- state 状态对象
history 对象
- "action" 路由动作类型
- "location" 地址对象
- "createHref" href 创建函数
- "push" 栈方法
- "replace" 替换方法
- "go" 栈跳转方法
- "back" 栈返回方法
- "forward" 栈前进
- "listen" 监听路由方法
- "block" 阻塞方法
- index (memory 模式)
history 提供多种路由模式,api 上基本一致,页面表现上也基本一致。 history 作为 react-router 基础 api 提供者。
react-router-dom
react-router-dom 是在 react-router 基础上进行封装,而 router 是与平台无关
react-router-dom 组件: BrowserRouter
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,
});
React.useLayoutEffect(() => history.listen(setState), [history]);
return (
<Router
basename={basename}
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
/>
);
}
- basename 基础路径, 默认值是 "/"
- children 需要包含 Routes 和 Route
- location 即 history 对象
接下来,进入 react-router 查看 Router 的实现
export function Router({
basename: basenameProp = "/",
children = null,
location: locationProp,
navigationType = NavigationType.Pop,
navigator,
static: staticProp = false,
}: RouterProps): React.ReactElement | null {
// ...
let basename = normalizePathname(basenameProp);
let navigationContext = React.useMemo(
() => ({ basename, navigator, static: staticProp }),
[basename, navigator, staticProp]
);
if (typeof locationProp === "string") {
locationProp = parsePath(locationProp);
}
// ...
let location = React.useMemo(() => {
let trailingPathname = stripBasename(pathname, basename);
if (trailingPathname == null) {
return null;
}
return {
pathname: trailingPathname,
search,
hash,
state,
key,
};
}, [basename, pathname, search, hash, state, key]);
// ...
if (location == null) {
return null;
}
return (
<NavigationContext.Provider value={navigationContext}>
<LocationContext.Provider
children={children}
value={{ location, navigationType }}
/>
</NavigationContext.Provider>
);
}
数据通过 context api 进行渗透,子组件通过 context 相关的钩子函数访问 value 属性中的数据。
所以: 所有路由组件 Routes、Route 必须要包裹在顶层提供了 context api 的数据的组件内部作为 children 才有意义,才能正确使用。
Routes 组件
Routes 不在需要进行二次正对平台封装,因为,Routes 本来就是一个 Route 的管理器组件。它调用 useRoutes 来匹配路由:
export function Routes({
children,
location,
}: RoutesProps): React.ReactElement | null {
return useRoutes(createRoutesFromChildren(children), location);
}
createRoutesFromChildren 函数也是一种重要的函数,将 Routes 有 html 形式,转换成对象形式进行描述 Route 对象:
export function createRoutesFromChildren(
children: React.ReactNode
): RouteObject[] {
let routes: RouteObject[] = [];
React.Children.forEach(children, (element) => {
if (!React.isValidElement(element)) {
// Ignore non-elements. This allows people to more easily inline
// conditionals in their route config.
return;
}
if (element.type === React.Fragment) {
// Transparently support React.Fragment and its children.
routes.push.apply(
routes,
createRoutesFromChildren(element.props.children)
);
return;
}
// ...
let 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;
}
先看匹配结果返回的组件
组件还是 Context.Provider 组件
function _renderMatches(
matches: RouteMatch[] | null,
parentMatches: RouteMatch[] = []
): React.ReactElement | null {
if (matches == null) return null;
return matches.reduceRight((outlet, match, index) => {
return (
<RouteContext.Provider
children={
match.route.element !== undefined ? match.route.element : outlet
}
value={{
outlet,
matches: parentMatches.concat(matches.slice(0, index + 1)),
}}
/>
);
}, null as React.ReactElement | null);
}
react-router 核心: 匹配
根据当前的浏览器的路由进行匹配
匹配函数
匹配函数接收 routes, 进过 flattenRoutes 将router 转换成 branches
export function matchRoutes(
routes: RouteObject[],
locationArg: Partial<Location> | string,
basename = "/"
): RouteMatch[] | null {
let location =
typeof locationArg === "string" ? parsePath(locationArg) : locationArg;
let pathname = stripBasename(location.pathname || "/", basename);
if (pathname == null) {
return null;
}
let branches = flattenRoutes(routes);
rankRouteBranches(branches);
let matches = null;
for (let i = 0; matches == null && i < branches.length; ++i) {
matches = matchRouteBranch(branches[i], pathname);
}
return matches;
}
branches 是 flattenRoutes 会将 routes 全部 <Route />
中 数据提取出来,组成一个数组。然后遍历 brances 得到匹配结果
for (let i = 0; matches == null && i < branches.length; ++i) {
matches = matchRouteBranch(branches[i], pathname);
}
这个记过就是当前路由所有需要的 Route 的内容。
渲染匹配
在获取到匹配对象之后,我们就可以渲染组件了 _renderMatches
方法就是用于渲染匹配组件
function _renderMatches(
matches: RouteMatch[] | null,
parentMatches: RouteMatch[] = []
): React.ReactElement | null {
if (matches == null) return null;
return matches.reduceRight((outlet, match, index) => {
return (
<RouteContext.Provider
children={
match.route.element !== undefined ? match.route.element : outlet
}
value={{
outlet,
matches: parentMatches.concat(matches.slice(0, index + 1)),
}}
/>
);
}, null as React.ReactElement | null);
}
很容易看出,match 中 element 元素被 RouteContext.Provider 渲染到其 children,然后将 outlet/matches 等数据,传递下去。
react-router 跳转的方式
- Link 组件
- useNavigate 钩子函数
Link
Link 是 react-router-dom 中的组件。
本质是一个 a 标签,点击之后跳转到对应的路由,浏览器路由,其次触发 onClick 事件,导航到对应的地址,本质是调用钩子函数。
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
function LinkWithRef(
{ onClick, reloadDocument, replace = false, state, target, to, ...rest },
ref
) {
let href = useHref(to);
let internalOnClick = useLinkClickHandler(to, { replace, state, target });
function handleClick(
event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
) {
if (onClick) onClick(event);
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}
/>
);
}
);
点击之后,会触发如何函数
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 {
let navigate = useNavigate();
let location = useLocation();
let path = useResolvedPath(to);
return React.useCallback(
(event: React.MouseEvent<E, MouseEvent>) => {
if (
event.button === 0 && // Ignore everything but left clicks
(!target || target === "_self") && // Let browser handle "target=_blank" etc.
!isModifiedEvent(event) // Ignore clicks with modifier keys
) {
event.preventDefault();
let replace =
!!replaceProp || createPath(location) === createPath(path);
navigate(to, { replace, state });
}
},
[location, navigate, path, replaceProp, state, target, to]
);
}
本质还是调用了 useNavigate 来进行路由切换
useNavigate
useNavigate 是 react-router 中的 react-钩子函数:
export function useNavigate(): NavigateFunction {
//...
let { basename, navigator } = React.useContext(NavigationContext);
let { matches } = React.useContext(RouteContext);
let { pathname: locationPathname } = useLocation();
let routePathnamesJson = JSON.stringify(
matches.map((match) => match.pathnameBase)
);
let activeRef = React.useRef(false);
React.useEffect(() => {
activeRef.current = true;
});
let navigate: NavigateFunction = React.useCallback(
(to: To | number, options: NavigateOptions = {}) => {
//...
if (!activeRef.current) return;
if (typeof to === "number") {
navigator.go(to);
return;
}
let path = resolveTo(
to,
JSON.parse(routePathnamesJson),
locationPathname
);
if (basename !== "/") {
path.pathname = joinPaths([basename, path.pathname]);
}
(!!options.replace ? navigator.replace : navigator.push)(
path,
options.state
);
},
[basename, navigator, routePathnamesJson, locationPathname]
);
return navigate;
}
useNavigate 返回的是一个函数, 最后是闭包 navigate 来调用 push/replace 方法来确定如何条状。navigate 的来源还是保存在 context 中
let { basename, navigator } = React.useContext(NavigationContext);
NavigationContext 保存的contxt 要回溯到 Router 组件中
navigator 是 Router 组件的 props。
let navigationContext = React.useMemo(
() => ({ basename, navigator, static: staticProp }),
[basename, navigator, staticProp]
);
Router 本身是底层组件,BrowserRouter、HashRouter、MemoryRouter 都是基于它
<Router
basename={basename}
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
/>
得知 navigator 即 history, 所以具有 push 等 history 底层包的方法。函数式编程,组合大于继承。
Outout
类似于 Vue 中 router-view, 也就是路由组件要显示的地方
export function Outlet(props: OutletProps): React.ReactElement | null {
return useOutlet(props.context);
}
调用了 useOutlet 钩子函数,来看源码
export function useOutlet(context?: unknown): React.ReactElement | null {
let outlet = React.useContext(RouteContext).outlet;
if (outlet) {
return (
<OutletContext.Provider value={context}>{outlet}</OutletContext.Provider>
);
}
return outlet;
}
获取了 RouteContext 的上下文,获取到了 outlet 属性, 如果存在 outlet, 渲染 OutletContext.Provider 组件 children。 否在直接渲染 outlet。
这样从定义 HistoryRouter 到编写 Routes 和 Route, 到 Outlet, 两种导航方式的跳转。就得到了一个较为完整的前端路由方案。
钩子函数
location、history 对象上有很多的有用信息数据,react-router 提供了很多的钩子函数来满足,业务需求(其实这些钩子函数很大一部分已经在分析流程的时候,已经用到了)
- useHref
- useInRouterContext
- useLocation
- useMatch
- useNavigate
- useNavigationType
- useOutlet
- useParams
- useResolvedPath
- useRoutes
- useOutletContext
useNavigate 编程式导航
const navigator = useNavigate()
navigator({url, {})
导航数据
let location = useLocation(); // location 对象
let urlParams = useParams(); // params 参数
let [urlSearchParams] = useSearchParams(); //查询对象
路由概念
- 路由元素 Route
- 父路由、子路由
- index 路由,呈现在一组路由的子路由中,用 index 属性标记
- 布局路由,存在于父组件中,但是没有 path 属性
- 嵌套路由,在现有的路由里面,递归一套路由