我报名参加金石计划1期挑战——瓜分10万奖池,这是我的第1篇文章。
前言
这是精读源码的第二篇文章。
React
玩家基本都接触过react-router
。截止目前为止,react-router
库已经收获了4.8w
的star
,也足以说明这个库的受欢迎程度,这篇文章就来揭秘react-router
的运行原理,本篇源码解析基于react-router
最新版本6.3
,这个版本也已全面拥抱hooks
,使用起来更加丝滑。
诞生
在正式开始之前,我们先了解下为什么会有react-router
的诞生。
大概在2016
年,单页面应用的概念被提出并迅速流行起来,因为在此之前的多页面应用(MPA
)用户想改变网页内容都需要等待浏览器的请求刷新获取新的页面,而单页面应用颠覆了这种模式,单页面应用实现了在仅加载一次资源,后续可以在不刷新浏览器的情况下动态改变页面展现内容从而让用户获得更好的体验。
单页面应用实现原理
实现单页面应用(single-page application,缩写SPA
)实现原理是,通过浏览器的API
改变浏览器的url
,然后在应用中监听浏览器url
的变化,根据不同变化渲染不同的页面,而这个过程中的重点是不能刷新页面。
url
变化分为两种模式:hash
模式和browser
模式。
hash模式
hash
模式的url
类似于: www.baidu.com/#/a/b
url
中#
后面的即为hash
值,而在改变浏览器的hash值时是不会刷新浏览器的,所以我们可以通过window.location.hash
来改变hash
值,然后通过监听浏览器事件hashchange
来获取hash
变化从而决定如何渲染页面。
缺点:
- 如果拿来做路由的话,原来的锚点功能就不能用了;
hash
的传参是基于url
的,如果要传递复杂的数据,会有体积的限制;
browser模式
在HTML5
规范出来后,浏览器有了history
对象。关键是这个history
对象上的pushState
,replaceState
方法也可以在改变浏览器url的同时不刷新浏览器。了解相关API:MDN-History
window.history.pushState(state, title, url)
// state:需要保存的数据,这个数据在触发popstate事件时,可以在event.state里获取
// title:标题,基本没用,一般传 null
// url:设定新的历史记录的 url。新的 url 与当前 url 的 origin 必须是一樣的,否则会抛出错误。url可以是绝对路径,也可以是相对路径。
//如 当前url是 https://www.baidu.com/a/,执行history.pushState(null, null, './qq/'),则变成 https://www.baidu.com/a/qq/,
//执行history.pushState(null, null, '/qq/'),则变成 https://www.baidu.com/qq/
window.history.replaceState(state, title, url)
// 与 pushState 基本相同,但她是修改当前历史记录,而 pushState 是创建新的历史记录
window.addEventListener("popstate", function() {
// 监听浏览器前进后退事件,pushState 与 replaceState 方法不会触发
});
window.history.back() // 后退
window.history.forward() // 前进
window.history.go(1) // 前进一步,-2为后退两步,window.history.lengthk可以查看当前历史堆栈中页面的数量
react-router实现
有了上面的铺垫,react-router
也就随之诞生了,react-router
就是基于上述两种模式分别做了实现。
架构
react-router
源码目前分四个包:
react-router
:react-router
的核心包,下面的三个包都基于该包;react-router-dom
:react-router
用于web
应用的包;react-router-v5-compat
:如其名,为了兼容v5
版本;react-router-native
:用于rn
项目;
除此之外,react-router
还重度依赖一个他们团队开发的包history
,该包主要用于配合宿主操作路由变化。
history
这里讲的history
是react-router
开发团队开发的history
包,不是浏览器的history
。当然,history
包也是依托浏览器的history
的API
,最终返回的就是一个包装过后的history对
象。
export interface History {
readonly action: Action; // 操作类型
readonly location: Location; // location对象,包含state,search,path等
createHref(to: To): string; // 创建路由路径的方法,兼容非string类型的路径
push(to: To, state?: any): void; // 路由跳转指定路径
replace(to: To, state?: any): void; // 路由替换当前路由
go(delta: number): void; // 根据参数前进或后退
back(): void; // 类似浏览器后退按钮
forward(): void; // 类似浏览器前进按钮
listen(listener: Listener): () => void; // push和replace添加监听事件
block(blocker: Blocker): () => void; // push和replace添加拦截事件
}
在上面讲到的两种模式,history
包分别实现了browser
模式的createBrowserHistory
和hash
模式的createHashHistory
createBrowserHistory
看一下返回的history
,很简单,有的方法就是在浏览器的history
的API
上包了一层。
history: BrowserHistory = {
get action() { // 使用get是为了只读,并且动态获取返回
return action;
},
get location() {
return location;
},
createHref,
push,
replace,
go, // 基于浏览器的API的go方法实现
back() {
go(-1);
},
forward() {
go(1);
},
listen(listener) { // 添加监听事件
return listeners.push(listener);
},
block(blocker) {...}, // 添加拦截事件
};
有没有发现,真正有点复杂度的就是push
和replace
方法了,我们接下来重点看一下push
的实现,这个可以说是history
最核心的API
。
push
function push(to: To, state?: any) {
let nextAction = Action.Push; // 将action设置为PUSH
let nextLocation = getNextLocation(to, state); // 创建一个新的location对象
function retry() { // 拦截后重新push
push(to, state);
}
if (allowTx(nextAction, nextLocation, retry)) { // 判断是否有拦截
let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1); // 基于新的location和新的index格式化
try {
globalHistory.pushState(historyState, "", url); // 借助浏览器API改变url
} catch (error) {
window.location.assign(url); // 错误捕获就基于url刷新页面
}
applyTx(nextAction); // 触发监听事件
}
}
push
方法整体看下来,思路很清晰,就是创建一个新的location
对象,没有拦截就在原来的历史记录基础再添加一条,并且触发监听事件。
replace
思路和push
基本一致,主要是把globalHistory.pushState
替换成了globalHistory.replace3State
。
除了push
和replace
之外,还有个看点,就是popstate
。
popstate
借助MDN的一句话:
调用 history.pushState()
或者 history.replaceState()
不会触发 popstate
事件。popstate
事件只会在浏览器某些行为下触发,比如点击后退按钮(或者在 JavaScript 中调用 history.back(),history.go()
方法)。即,在同一文档的两个历史记录条目之间导航会触发该事件。
在历史记录之间切换时,url
会变化,所以react-router
也要加以监听并处理:
function handlePop() {
if (blockedPopTx) {
blockers.call(blockedPopTx); // 如果有拦截,就执行拦截的函数
blockedPopTx = null;
} else {
let nextAction = Action.Pop;
let [nextIndex, nextLocation] = getIndexAndLocation();
if (blockers.length) { // 当有拦截的情况
if (nextIndex != null) {
let delta = index - nextIndex; // 通过当前下标和要跳转的下标计算出跳转步数
if (delta) {
// Revert the POP
blockedPopTx = { // 将当前有拦截的路由信息存储下来
action: nextAction,
location: nextLocation,
retry() {
go(delta * -1);
},
};
go(delta); // 当有拦截的情况再次跳转回去,就会再次触发popstate,这样就可以执行拦截函数了
}
} else {...}
} else { // 没有拦截的情况,只需要触发添加的监听事件,其余的浏览器会自行处理
applyTx(nextAction);
}
}
}
window.addEventListener('popstate', handlePop); // 添加popstate监听函数
当通过go
,back
,forward
等API
触发popstate
时,如果没有拦截器的情况下,只需要执行相关的监听函数,然后让浏览器跳转即可。但是如果有拦截器,这里的处理是,将跳转的路由信息存储下来,然后通过go
跳转回之前页面,这时又会触发popstate
,因为代码判断逻辑这次就会执行拦截器函数,而不会再次触发跳转。
这个拦截的设计可以说是很巧妙,巧妙在:
Q
: 它为什么要在触发第二次popstate
,并在第二次做拦截,第一次不行吗?
A
: 答案肯定是不行,因为popstate
是在跳转行为之后触发的,此时做拦截毫无意义。react-router
的做法是,既然你跳过去了,那我就让你再跳回来,给你一种没有跳转的假象。你说是不是秀儿🐶。
createHashHistory
react-router
的hash
模式也是使用了浏览器history
相关的API
的。和browser
模式的主要区别是,url
的path
处理会多一个'#'的处理,还有就是多了个hashchange
的监听函数。
window.addEventListener('hashchange', () => {
let [, nextLocation] = getIndexAndLocation();
// Ignore extraneous hashchange events.
if (createPath(nextLocation) !== createPath(location)) {
handlePop();
}
});
这里hashchange
的处理和popstate
的处理是一样的,都是调用的handlePo
p函数。
关系图
react-router
history
包讲完了,接下来看下react-router
是怎么借助history
完成渲染的,react-router
也有browser
模式和hash
模式两种,分别对应BrowserRouter
和HashRouter
。
来看一段,react-router v6
常规的业务写法:
<BrowserRouter>
<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>
</BrowserRouter>
我们可以根据这段代码作为切入点来了解react-router
内部的实现,首先是BrowserRouter
。
BrowserRouter
export function BrowserRouter({
basename,
children,
window,
}: BrowserRouterProps) {
let historyRef = React.useRef<BrowserHistory>();
if (historyRef.current == null) {
// 通过history包的createBrowserHistory获取包装后的history
historyRef.current = createBrowserHistory({ window });
}
let history = historyRef.current;
let [state, setState] = React.useState({
action: history.action,
location: history.location,
});
// 通过useLayoutEffect监听history的变化,并在history的listener中对action和location进行修改
React.useLayoutEffect(() => history.listen(setState), [history]);
return (
<Router
basename={basename}
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
/>
);
}
这段代码最重要的两段已经加了注释,看到这里,我们就很清楚的知道react-router
是怎么和history
包协同工作的。就是通过useLayoutEffect
监听用户对history
的操作,然后通过setState
分发出去。
再往下看上面的组件:
<NavigationContext.Provider value={navigationContext}>
<LocationContext.Provider
children={children}
value={{ location, navigationType }}
/>
</NavigationContext.Provider>
通过React.useContext
创建的两个上下文,用来存储navigationContext
和location
的信息,便于子组件获取。
navigationContext
:
{
basename,
navigator, // Pick<History, "go" | "push" | "replace" | "createHref">
static,
}
location
:
{
pathname,
search,
hash,
state,
key,
}
这就是BrowserRouter
,可以看到,主要就是创建browser history
并监听,然后用两个Provider
分别存储navigationContext
和location
的信息,方便父组件分发和子组件获取使用。接下来看内部的Routes
。
Routes
export function Routes({
children,
location,
}: RoutesProps): React.ReactElement | null {
return useRoutes(createRoutesFromChildren(children), location);
}
createRoutesFromChildren
主要是借助React.Children.forEach
的API
对子元素做一个校验和props
的序列化,如果有嵌套的子元素还会进行递归。
而useRoutes
最终返回:
<RouteContext.Provider
children={
match.route.element !== undefined ? match.route.element : outlet
}
value={{
outlet,
matches: parentMatches.concat(matches.slice(0, index + 1)),
}}
/>
useRoutes
主要是对子组件中的路由进行一个匹配,找到匹配的路由并渲染。
这里的匹配规则还是比较复杂的,因为Routes
还有Route
都是可以嵌套的,这就会让数据结构变复杂,这里做了个简单的梳理:
Routes
返回的Provider
中会存有父Routes
中的matches
和自己这层匹配的matches
,默认是/
;- 在对子路由进行匹配时,会将子路由数组进行扁平化及优先级排序处理,优先级主要是通过路由路径和在数组中的下标计算得出;
- 根据
Routes
的matches
对子路由进行匹配; - 找出匹配的子路由数组后,遍历对子路由的
params
和pathname
与父路由的数据进行聚合; - 最后对子路由数组通过
reduceRight
,从右到左,其实就是从最底下到最上层的element
进行渲染;
还有HashRouter
,MemeoryRouter
,NativeRouter
,核心原理大致相同,就不一一介绍了。
至此,整个应用就实现了路由匹配渲染,接下来就是通过react-router
的API
对路由进行切换操作。
常用API
react-router v6
对路由的操作主要分为两种,对路由数据params
的操作和对路由路径pathname
的操作。
Link
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
function LinkWithRef(
{ onClick, reloadDocument, replace = false, state, target, to, ...rest },
ref
) {
...
return (
<a
{...rest}
href={href}
onClick={handleClick}
ref={ref}
target={target}
/>
);
}
);
Link
组件是在a
标签上进行了封装,a
标签的href
属性是可以直接改变url
的,这样做是最直接的办法。
Navigate
Navigate
组件在渲染时就会进行跳转,因为它本身就是useNavigate
钩子的包装器。
export function Navigate({ to, replace, state }: NavigateProps): null {
let navigate = useNavigate();
React.useEffect(() => {
navigate(to, { replace, state });
});
return null;
}
Outlet
Outlet
组件可以展示匹配的路由组件,和Navigate
类似,它也是useOutlet
的包装器。
export function Outlet(props: OutletProps): React.ReactElement | null {
return useOutlet(props.context);
}
useLocation
useLocation
有点类似于window.location
对象,我们能从这个钩子上得到pathname,hash,search,state
等信息,通过这个钩子基本能满足我们获取路由数据的需求。
这个钩子的写法就很简单,直接从我们之前解析的<LocationContext.Provider />
上拿数据:
export function useLocation(): Location {
return React.useContext(LocationContext).location;
}
useNavigate
useNavigate
比较强大,它既可以像go
方法往前,往后跳转,也可以跳转指定路径,并携带参数,是v6
中主要用来实现路由跳转的钩子。
在useNavigate
中会充分利用前面分析到的NavigationContext,RouteContext,LocationContext
。从他们身上得到navigator,matches,pathname
。
最终useNavigate
会返回一个navigate
:
let navigate: NavigateFunction = React.useCallback(
(to: To | number, options: NavigateOptions = {}) => {
if (typeof to === "number") { // 当to参数是数字时,直接通过go跳转
navigator.go(to);
return;
}
// 通过to参数和location中的pathname还有matches得到最终要跳转的路径
let path = resolveTo(
to,
JSON.parse(routePathnamesJson),
locationPathname
);
// basename可以理解为根路由
if (basename !== "/") {
path.pathname = joinPaths([basename, path.pathname]);
}
// 通过用户配置的options判断是replace还是push
(!!options.replace ? navigator.replace : navigator.push)(
path,
options.state
);
},
[basename, navigator, routePathnamesJson, locationPathname]
);
总结
react-router
整体流程已经梳理的差不多了,这里总结一个流程图:
小技巧
Object getter
假设有个这样的场景,一个函数返回一个对象,这个对象的属性能够动态获取并且只读。
实现:
function getterFn() {
let a = 1;
let b = 1;
function add() {
a += 1;
b += 1;
}
return {
get a() {
return a
},
b
}
}
const obj = getterFn()
obj.add()
obj.a // 2
obj.b // 1
popstate拦截
因为popstate
是在跳转行为之后触发的,此时做拦截毫无意义。react-router
的做法是,既然你跳过去我管不住,但我可以让你再跳回来,给你一种没有跳转的假象,并且通过逻辑判断,在跳回来的时候,触发拦截器函数。
React.Children
当我们在父元素希望对子元素的每个节点做些操作时就可以考虑使用React.Children
。参考React官网API
React.Children.map(children, function[(thisArg)])
React.Children.forEach(children, function[(thisArg)])
React.Children.count(children) // 返回子节点数量
React.Children.only(children) // 判断是否只有一个子节点
React.Children.toArray(children) // 将 `children` 这个复杂的数据结构以数组的方式扁平展开并返回