v6使用hooks+ts对进行了重新的实现,相比与v5,打包之后的体积有所减少 性能有所提升。
注:由于我们在项目中大多使用的BrowserRouter路由, 所以我们在本文只关注了history库中的BrowserHistory以及react-router-dom中的BrowserRouter的实现。
react-router-dom的实现结构
history库:
- 重新抽象、定义window.History、window.Location等对象
- 提供 createBrowserHistory等三种类型的history ,封装增强window.history,history对象作为三种主要路由的导航器使用。
react-router: 实现了路由的核心功能
- 提供Router、Routes、Route等核心组件
- 提供根据路由配置以及当前location 生成或者重渲染组件树的能力
- 提供useLocation、useNavigate等hooks
react-router-dom : 基于 react-router ,加入了在浏览器运行环境下的一些功能
- 提供BrowserRouter、HashRouter、Link等的定义
- 提供 useSearchParams等hooks
History库
使用 React 开发稍微复杂一点的应用,React Router 几乎是路由管理的唯一选择。history 库是 实现React Router 的核心依赖库。
history库所做的事情
- 借鉴HTML 5 history对象的理念,再次基础上进行扩展。
- 提供了3种类型的history:browserHistory,hashHistory,memoryHistory,并保持统一的api。 对应生成不同的router。 老浏览器的history: 主要通过hash来实现,对应
createHashHistory
高版本浏览器: 通过html5里面的history,对应createBrowserHistory
node环境下: 主要存储在memeory里面,对应createMemoryHistory
- 支持发布/订阅功能,当history发生改变的时候,可以自动触发订阅的函数。 history.listen()
- 提供跳转拦截、跳转确认和basename等功能。
原生的window.history对象
在浏览器进行前进/后退操作的时候,实际上就是调用history对象的对应方法(forward/back),取出对应的state,从而进行页面的切换。
除了操作url,history对象还提供2个不用通过操作url也能更新内部state的方法,分别是pushState
和replaceState
。还能将额外的数据存到state中,然后在onpopstate
事件中再通过event.state
取出来。 了解更多history对象
History库与原生window.history的对比
History库是对原生history的重新实现,功能更强大。
let history: BrowserHistory = {
get action() {
return action; // Pop、Push、Replace
},
get location() { // Location对象 封装了pathname、search、hash、state、key等属性
return location;
},
createHref, // 将history内部定义的Path对象转换为原始的 url
push, // 导航到新的路由,并记录在history中
replace, // 替换掉当前记录在history中的路由信息
go, // 前进或后退n个记录
back() {
go(-1);
},
forward() {
go(1);
},
listen(listener) { // 订阅history变更事件
return listeners.push(listener);
},
block(blocker) {} //跳转前让用户确定是否要跳转
};
return history;
抽象化的Path与Location
Link组件中的to支持传入string类型 也支持传入此处定义的Path类型的对象。
对于一次路由的跳转,history进行了两层抽象:
- 基于url创建的Path对象,把url解析为path、query、hash。
export type Pathname = string;
export type Search = string;
export type Hash = string;
export type To = string | Partial<Path>;
// 一次跳转 url 对应的对象
export interface Path {
pathname: Pathname;
search: Search;
hash: Hash;
}
下面是history提供的url与Path对象相互转换的方法:
/**
* pathname + search + hash 创建完整 url
*/
export function createPath({
pathname = '/',
search = '',
hash = ''
}: Partial<Path>) {
if (search && search !== '?')
pathname += search.charAt(0) === '?' ? search : '?' + search;
if (hash && hash !== '#')
pathname += hash.charAt(0) === '#' ? hash : '#' + hash;
return pathname;
}
/**
* 解析 url,将其转换为 Path 对象
*/
export function parsePath(path: string): Partial<Path> {
let parsedPath: Partial<Path> = {};
if (path) {
let hashIndex = path.indexOf('#');
if (hashIndex >= 0) {
parsedPath.hash = path.substr(hashIndex);
path = path.substr(0, hashIndex);
}
let searchIndex = path.indexOf('?');
if (searchIndex >= 0) {
parsedPath.search = path.substr(searchIndex);
path = path.substr(0, searchIndex);
}
if (path) {
parsedPath.pathname = path;
}
}
return parsedPath;
}
- 抽象形成的Location对象 每次导航相关联的state的信息 以及 这次导航唯一对应的key
// 唯一字符串,与每次跳转的 location 匹配
export type Key = string;
// 路由跳转抽象化的导航对象
export interface Location extends Path {
// 与当前 location 关联的 state 值,可以是任意手动传入的值
state: unknown;
// 当前 location 的唯一 key,一般都是自动生成 Math.random().toString(36).substr(2, 8);
key: Key;
}
History对象
定义createBrowserHistory,return一个BrowserHistory的实例对象history:
一个基础的history包含:
- 两个属性
action
和location
- 一个方法:
createHref
: 用户将 history 内部定义的 path 对象转换为原始的 url
- 五个路由跳转方法:
push()
replace()
go()
back()
forward()
- 两个路由监听方法: 监听路由跳转的钩子(类似后置守卫)
listen()
阻止路由跳转的钩子(如果想正常跳转必须要取消监听,可以封装成类似前置钩子的功能)block()
关于
block
监听的阻止路由跳转这里的阻止跳转并不是阻止新的路由的推入,而是监听到新路由跳转后马上返回到前一个路由,也就是强制执行了
history.go(index - nextIndex)
,index
是原来的路由在路由栈的索引,nextIndex
是跳转路由在路由栈的索引。
// 开始监听路由跳转
let unlisten = history.listen(({ action, location }) => {
// The current location changed.
});
// 取消监听
unlisten();
// 开始阻止路由跳转
let unblock = history.block(({ action, location, retry }) => {
// retry 方法可以让我们重新进入被阻止跳转的路由// 取消监听,如果想要 retry 生效,必须要在先取消掉所有 block 监听,否则 retry 后依然会被拦截然后进入 block 监听中unblock();
retry();
});
react-router-dom
BrowserRouter
BrowserRouter 主要干了这几件事:
- 调用createBrowserHistory创建history单例,
- 维护一个当前location、action的状态,并向history对象挂载监听;变化时,重新渲染
- 把history对象与location、action传给react-router.js提供的Router
// react-router-dom index.tsx
export function BrowserRouter({
basename, // 当前项目中使用的BrowserRouter的路由统一的前缀
children,
window,
}: BrowserRouterProps) {
let historyRef = React.useRef<BrowserHistory>();
if (historyRef.current == null) {
// 创建history单例
historyRef.current = createBrowserHistory({ window });
}
let history = historyRef.current;
let [state, setState] = React.useState({
action: history.action,
location: history.location,
});
// 向history对象挂载监听, 变化时,重渲染Router
React.useLayoutEffect(() => history.listen(setState), [history]);
// 用Router组件传入一些参数且包裹着children返回出去
return (
<Router
basename={basename} // basename传入
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
/>
);
}
目前项目中发现可以改变的地方
BrowserRouter中传入basename指定当前项目的全局统一前缀 不需要在每一个路由里面都写。
<BrowserRouter>
<Entry />
</BrowserRouter>
const routes: ExtendRouteProps[] = [
{
path: '/veen/journal',
title: '日志管理',
component: Journal,
showMenu: true,
},
{
path: '/veen/dashboard',
title: '服务概览',
component: Dashboard,
showMenu: true,
},
}
history.push('/veen/journal')
react-router
实现了URL与UI界面的同步。其中在react-router中,URL 对应 location对象,而UI是由react element来决定的,这样就转变成location
与 element
之间的同步问题。
Router
- 接收到BrowerRouter传过来的location、navigate、basename并进行处理
- 提供 NavigationContext,使
useNavigate()
等hooks能轻易获取到navigator(history单例),调用.push .replace 等方法
- 提供 LocationContext,使
useLocation()
等hooks能轻易获取到location状态及更新。
// packages\react-router\lib\components.tsx
export function Router(){
// normalizePathname用于对basename格式化,如normalizePathname('//asd/')=>'/asd'
let basename = normalizePathname(basenameProp);
let navigationContext = React.useMemo(
);
// 如果locationProp为字符串则把他转为对象(包含pathname,search,state,key,hash)
if (typeof locationProp === "string") {
locationProp = parsePath(locationProp);
}
// 此 location 与 开始传入的location 的区别在于pathname做了去掉basename的处理
let location = React.useMemo(() => {
// trailingPathname为当前location.pathname中截掉basename的那部分,如
// stripBasename('/prefix/a', '/prefix') => '/a'
// 如果basename为'/',则不对pathname处理直接返回原值
let trailingPathname = stripBasename(pathname, basename);
return {
pathname: trailingPathname,
search,
hash,
state,
key,
};
}, [basename, pathname, search, hash, state, key]);
// 最后返回被NavigationContext和LocationContext包裹着的children出去
return (
<NavigationContext.Provider value={navigationContext}>
<LocationContext.Provider
children={children}
value={{ location, navigationType }}
/>
</NavigationContext.Provider>
);
}
useLocation()
useLocation是react-router内组件获取、监听location更新的核心hook,也是export给用户使用的常用工具。它很简单,直接通过全局单例LocationContext获取状态。
此处location中的pathname就是经过去掉basename之后的。
// packages\react-router\lib\hooks.tsx
export function useLocation(): Location {
return React.useContext(LocationContext).location;
}
Route
<Routes>
包在<BrowerRouter>
中,<Route>
包在<Routes>
中,并且<Routes>
的jsx子树中的节点只能是<Route>
。这是因为Routes会遍历它的children,解析Route树jsx得到一份路由配置(所以 <Route>
只是个语法糖,源码中Route只有定义类型,并无实际内容)。
Route 组件内部没有进行任何操作,仅仅只是定义 props,我们就是为了使用它的 props。
Routes
Routes用于解析传入Route的props:
- 本质上还是用
useRoutes
这个hook
实现的路由的定义
- 使用了
createRoutesFromChildren
这个方法将children
转换为了useRoutes
的配置参数,从而得到最后的路由元素。
export function Routes({
children,
location,
}: RoutesProps): React.ReactElement | null {
return useRoutes(createRoutesFromChildren(children), location);
}
function createRoutesFromChildren(children: React.ReactNode): RouteObject[]
interface RouteObject {
caseSensitive?: boolean; // 匹配时是否区分大小写 默认为 false
children?: RouteObject[]; // 子路由
element?: React.ReactNode; // 要渲染的元素
index?: boolean; // 是否是索引路由
path?: string;
}
react-router
在路由定义时同时提供了两种方式:命令式与声明式,而这两者本质上都是调用的同一种路由 useRoutes() 生成的方法。
// 命令式
<Routes>
<Route path="/" element={<App />}>
<Route index element={<Home />} />
<Route path="teams" element={<Teams />}>
<Route path=":teamId" element={<Team />} />
<Route path="new" element={<NewTeamForm />} />
<Route index element={<LeagueStandings />} />
</Route>
</Route>
</Routes>
Route
可以被看作一个挂载用户传入参数的对象,它不会在页面中渲染,而是会被**Routes
**接受并解析,我们也不能单独使用它。
Routes
与Route
强绑定,有Routes
则必定要传入且只能传入Route
。
useRoutes()
******
useRoutes
是整个react-router v6
**的核心所在,内部包含了大量的解析与匹配逻辑,主要的渲染逻辑都在这个hook中。
declare function useRoutes(
routes: RouteObject[],
location?: Partial<Location> | string;
): React.ReactElement | null;
-
路由匹配阶段 matchRoutes()
// packages\react-router\lib\hooks.tsx
export function useRoutes(){
let matches = matchRoutes(routes, { pathname: remainingPathname });
}
看一个例子,猜测下路由会如何匹配:路由匹配
matchRoutes
,帮我们解决上面例子中路由规则冲突的问题。通过一系列的匹配算法来检测哪一个路由规则最契合给定的location
。如果有匹配的,则返回类型为RouteMatch[]
的数组。
matchRoutes的核心步骤:
- flattenRoutes( ) :用于把所有路由规则,包括children里的子路由规则全部铺平成一个一维数组。并且给每个路由打上分数,分数代表路由的质量。
- rankRouteBranches():根据branch的score和routeMeta中的childrenIndex进行排行。
- 遍历一维数组,通过matchRouteBranch获取第一个匹配pathname的路由规则作为matches
export function matchRoutes(
): RouteMatch[] | null {
let branches = flattenRoutes(routes);
rankRouteBranches(branches);
let matches = null;
for (let i = 0; matches == null && i < branches.length; ++i) {
// branches 中有一个匹配到了就终止循环,或者全都没有匹配到
matches = matchRouteBranch(branches[i], pathname);
}
return matches;
}
flattenRoutes( )
经过flattenRoutes( )处理后返回的branches:
[
{ path: "master1", element: <M1/> },
{
path: "master2",
element: <M2/>,
children: [
{ index : true, element: <M2-1/> },
{ path : "branch2", element: <M2-2/> },
]
},
{
path: "master3",
element: <M3/>,
children: [
{ path: "branch1", element: <M3-1> },
{ path: ":arg", element: <M3-2> },
]
}
]
function flattenRoutes(): RouteBranch[] {
// 递归遍历routes
routes.forEach((route, index) => {
let meta: RouteMeta = {
relativePath: route.path || "",
caseSensitive: route.caseSensitive === true,
childrenIndex: index,
route,
};
// 合并父路由数据,生成新的路由数据列表
let routesMeta = parentsMeta.concat(meta);
if (route.children && route.children.length > 0) {
flattenRoutes(route.children, branches, routesMeta, path);
}
// 解析出一条路由分支时,计算该branch的score
branches.push({ path, score: computeScore(path, route.index), routesMeta });
});
return branches;
}
interface RouteBranch {
path: string;
score: number; // 排序
routesMeta: RouteMeta[]; // branch的嵌套路由信息
}
interface RouteMeta {
relativePath: string;
caseSensitive: boolean;
childrenIndex: number;
route: RouteObject;
}
flattenRoutes中score的计算规则:
- 对传入的path以'/'进行切分,initialScore为path.length
- 如果路径片段中中含一个或多个*,都在初始分数上减2分
- 如果路径为index:score+=2
- 如果路径片段为常量: score+=10
- 如果路径片段为空字符串:score+=1
- 如果路径片段是 :arg之类的匹配: score+=3
rankRouteBranches()
- 先score的大小进行排序 score大的在前
- 如果两个route的score相等,则按照传入的顺序 childrenIndex进行排序 同级的
matchRouteBranch()
- 遍历已经排好序的branch 找到匹配的matches并返回
- 从传入的
branch
中我们拿到了routesMeta
这个包含了所有路由层级的数组,又开始了一层层的路由匹配,然后把每一次匹配上的完整路径与参数都推入matches
数组中,最后返回。
for (let i = 0; matches == null && i < branches.length; ++i) {
// branches 中有一个匹配到了就终止循环,或者全都没有匹配到
matches = matchRouteBranch(branches[i], pathname);
}
-
路由渲染阶段
useRoutes
在内部是调用_renderMatches
来实现渲染的,将之前的matches数组渲染为React元素。
// packages\react-router\lib\hooks.tsx
export function useRoutes() {
return _renderMatches(
matches &&
matches.map((match) =>
Object.assign({}, match, {
params: Object.assign({}, parentParams, match.params),
pathname: joinPaths([parentPathnameBase, match.pathname]),
pathnameBase:
match.pathnameBase === "/"
? parentPathnameBase
: joinPaths([parentPathnameBase, match.pathnameBase]),
})
),
parentMatches
);
}
_renderMatches的实现:
其实就是渲染 RouteContext.Provider 组件(包括多个嵌套的 Provider)
/**
* 其实就是渲染 RouteContext.Provider 组件(包括多个嵌套的 Provider)
*/
function _renderMatches(): React.ReactElement | null {
// 生成 outlet 组件,注意这里是从后往前 reduce,所以索引在前的 match 是最外层,也就是父路由对应的 match 是最外层
/**
* 可以看到 outlet 是通过不断递归生成的组件,最外层的 outlet 递归层数最多,包含有所有的内层组件,
* 所以我们在外层使用的 <Outlet /> 是包含有所有子组件的聚合组件
* */
return matches.reduceRight((outlet, match, index) => {
return (
<RouteContext.Provider
// 如果有 element 就渲染 element,如果没有填写 element,则默认是 <Outlet />,继续渲染内嵌的 <Route />
children={
match.route.element !== undefined ? match.route.element : <Outlet />
}
// 代表当前 RouteContext 匹配到的值,matches 并不是全局状态一致的,会根据层级不同展示不同的值,最后一个层级是完全的 matches,这也是之前提到过的不要在外部使用 RouteContext 的原因
value={{
outlet,
matches: parentMatches.concat(matches.slice(0, index + 1))
}}
/>
);
// 最内层的 outlet 为 null,也就是最后的子路由
}, null as React.ReactElement | null);
}
useRoute主要做了两件事 :
- 根据当前的路由
location
,从传入的routes
中找出所有匹配的路由对象,放到数组matches
里
- 用
renderMatches
把matches
渲染成一个React
元素,期间会把macthes
从尾到头遍历用RouteContext
包裹起来,如下所示:
如果路由规则对象没有定义element
属性,则RouteContext.Provider
的children
会指向其自身的outlet
,如下所示。
Outlet & useOutlet()
当定义的路由信息包含子路由的时候,使用到了Outlet。
export function Outlet(props: OutletProps): React.ReactElement | null {
return useOutlet(props.context);
}
Outlet的实现是直接调用useOutlet的结果。
<Outlet/>
就是把当前RouteContext
的outlet
值渲染出来。
export function useOutlet(context?: unknown): React.ReactElement | null {
let outlet = React.useContext(RouteContext).outlet;
// 可以看到,当 context 有值时才使用 OutletContext.Provider,如果没有值会继续沿用父路由的 OutletContext.Provider 中的值if (outlet) {
return (
<OutletContext.Provider value={context}>{outlet}</OutletContext.Provider>
);
}
return outlet;
}
Navigate & useNavigate()
v6 版本的 useNavigate() 与 v5 版本的useHistory只是暴露出来的方法有区别,其实本质都是context获取初始的NavigationContext.Provider中的值。
- v5将 整个history对象暴露出来 v6只是对history对象的push、replace、go、goBack、goForward方法进行封装并返回一个方法。
// useNavigate()的使用
const navigate = useNavigate()
navigate('/user'); // push
navigate('/login',{replace: true}) // replace
navigate(1) // 前进
navigate(-1) // 后退1
navigate(-2) // 后退2
navigate
自身只是用于无刷新地改变路由。但因为在BrowserRouter
中有这部分逻辑:
export function BrowserRouter({
basename,
children,
window,
}: BrowserRouterProps) {
// ...省略其他代码 React.useLayoutEffect(() => history.listen(setState), [history]);
// ...省略其他代码 }
history.go
、 hsitory.push
、 hsitory.replace
在执行后都会触发执行history.listen
中注册的函数,而setState
的执行会让BrowserRouter
及其children
更新,从而让页面响应式变化。