React.js 路由

用了几年 react-router,从 v4 的动态路由到 v6 的嵌套路由,API 变了好几轮。但翻开源码会发现,不管外层怎么变,核心机制始终是三件事:监听 URL 变化、匹配路由表、渲染对应组件。

这篇文章从 v6 的用法讲起,然后拆解 BrowserRouterRouteshistory 库的源码链路,最后对比 react-router 和 vue-router 在实现上的关键差异——如果你需要在一个项目里同时用这两个框架,这些差异值得提前知道。

react-router 基本使用

路由配置

JSX 用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
import { render } from "react-dom";
import {
BrowserRouter,
Routes,
Route,
} from "react-router-dom";
// import your route components too
render(
<BrowserRouter>
<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>
</BrowserRouter>,
document.getElementById("root")
);

config + hooks 用法:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
import { render } from 'react-dom'
import { useRoutes, BrowserRouter } from 'react-router-dom'
function Routes () {
return useRoutes([
{
path: '/',
element: <App>,
children: [
{
path: '/',
element: <Home />,
},
{
path: '/teams',
element: <Teams />,
children: [
{
path: ':tamId',
element: <Team />
}
]
}
]
},
])
}
render(
<BrowserRouter>
<Routes />
</BowserRouter>,
document.getElementById("root")
)

页面跳转

JSX 用法:

1
<Link to="/">To</Link>

hooks 用法:

1
2
const location = useLocation()
location.push("/")

源码:从 BrowserRouter 到 history

react-router 的代码分在三个包里:react-router(核心,跨平台)、react-router-dom(Web 端实现)、history(浏览器 history API 的封装层)。

从一个最简单的例子开始追调用链:

1
2
3
4
5
6
ReactDOM.render(
<BrowserRouter>
<App />
</BrowserRouter>,
document.getElementById('root')
)

BrowserRouter 实现:

react-router-dom/index.tsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
export function BrowserRouter({
basename,
children,
window,
}: BrowserRouterProps) {
let historyRef = React.useRef<BrowserHistory>();
// history ref保存的是history对象,而不是history的实例
if (historyRef.current == null) {
// createBrowserHistory 是 history库中提供的方法,下面的history.listen也是
historyRef.current = createBrowserHistory({ window });
}

let history = historyRef.current;
let [state, setState] = React.useState({
action: history.action,
location: history.location,
});
// history对象变化时,重新监听history的变更
React.useLayoutEffect(() => history.listen(setState), [history]);

return (
<Router
basename={basename}
children={children}
location={state.location}
navigationType={state.action}
navigator={history}
/>
);
}

BrowserRouter 做了三件事:创建 history 对象、监听 history 变化后触发 setState 让组件重新渲染、把 history 传给 <Router>

<Router> 的核心逻辑:

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
export function Router({
basename: basenameProp = "/",
children = null,
location: locationProp,
navigationType = NavigationType.Pop,
navigator, // 导航器,每个平台不一样
static: staticProp = false,
}: RouterProps): React.ReactElement | null {
// 处理basename
let basename = normalizePathname(basenameProp);

let navigationContext = React.useMemo(
() => ({ basename, navigator, static: staticProp }),
[basename, navigator, staticProp]
);

if (typeof locationProp === "string") {
locationProp = parsePath(locationProp);
}

let {
pathname = "/",
search = "",
hash = "",
state = null,
key = "default",
} = locationProp;

let location = React.useMemo(() => {
let trailingPathname = stripBasename(pathname, basename);

if (trailingPathname == null) {
return null;
}

return {
pathname: trailingPathname,
search,
hash,
state,
key,
};
// 当 location 中有如下变化时重新生成 location 对象,触发页面重新渲染
}, [basename, pathname, search, hash, state, key]);

if (location == null) {
return null;
}

// const LocationContext = React.createContext<LocationContextObject>(null!);
/**
* 创建了全局的 context,用于存放 history 对象
*/
return (
<NavigationContext.Provider value={navigationContext}>
<LocationContext.Provider
children={children}
value={{ location, navigationType }}
/>
</NavigationContext.Provider>
);
}

<Router>NavigationContext 包裹 history 对象,让所有子组件都能通过 context 访问到它。

<Routes> 的实现:

jsx
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
export function Routes({
children,
location,
}: RoutesProps): React.ReactElement | null {
return useRoutes(createRoutesFromChildren(children), location);
}

export function useRoutes(
routes: RouteObject[],
locationArg?: Partial<Location> | string
): React.ReactElement | null {

let { matches: parentMatches } = React.useContext(RouteContext);

let routeMatch = parentMatches[parentMatches.length - 1];
let parentParams = routeMatch ? routeMatch.params : {};
let parentPathname = routeMatch ? routeMatch.pathname : "/";
let parentPathnameBase = routeMatch ? routeMatch.pathnameBase : "/";
let parentRoute = routeMatch && routeMatch.route;

let locationFromContext = useLocation();

let location;
if (locationArg) {
let parsedLocationArg =
typeof locationArg === "string" ? parsePath(locationArg) : locationArg;
location = parsedLocationArg;
} else {
location = locationFromContext;
}

let pathname = location.pathname || "/";
let remainingPathname =
parentPathnameBase === "/"
? pathname
: pathname.slice(parentPathnameBase.length) || "/";
// 匹配路径
let matches = matchRoutes(routes, { pathname: remainingPathname });

/**
* 渲染匹配到的节点
*/
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
);
}

export function createRoutesFromChildren(
children: React.ReactNode
): RouteObject[] {
let routes: RouteObject[] = [];

React.Children.forEach(children, (element) => {
if (!React.isValidElement(element)) {
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;
}

Routes 通过 Route ⼦组件⽣成路由列表,通过 location 中的 pathname 匹配组件并渲染。
通过以上代码,我们基本理解了 react-router 如何感知 history 中的 pathname 变化,并渲染对应组件。
但我们具体是如何操作 history 变化的呢?
我们在回到最上⾯的 createBrowserHistory 和 history.listen ⽅法,看看 history 对象是怎么被创建和改变的:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
export function createBrowserHistory(
options: BrowserHistoryOptions = {}
): BrowserHistory {
let { window = document.defaultView! } = options;
let globalHistory = window.history;
// 获取浏览器history对象中的location和idx 并转换成标准格式的对象
function getIndexAndLocation(): [number, Location] { }

let blockedPopTx: Transition | null = null;
// 处理history pop(返回上一页)
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);
}
} else {
// Trying to POP to a location with no index. We did not create
// this location, so we can't effectively block the navigation.
}
} else {
applyTx(nextAction);
}
}
}

// 监听浏览器的 popState 事件
window.addEventListener(PopStateEventType, handlePop);

let action = Action.Pop;
let [index, location] = getIndexAndLocation();
let listeners = createEvents<Listener>();
let blockers = createEvents<Blocker>();

if (index == null) {
index = 0;
globalHistory.replaceState({ ...globalHistory.state, idx: index }, "");
}

function createHref(to: To) {
return typeof to === "string" ? to : createPath(to);
}

// state defaults to `null` because `window.history.state` does
function getNextLocation(to: To, state: any = null): Location {
return readOnly<Location>({
pathname: location.pathname,
hash: "",
search: "",
...(typeof to === "string" ? parsePath(to) : to),
state,
key: createKey(),
});
}

function getHistoryStateAndUrl(
nextLocation: Location,
index: number
): [HistoryState, string] {
return [
{
usr: nextLocation.state,
key: nextLocation.key,
idx: index,
},
createHref(nextLocation),
];
}

function allowTx(action: Action, location: Location, retry: () => void) {
return (
!blockers.length || (blockers.call({ action, location, retry }), false)
);
}
// 这里触发listener的回调函数
function applyTx(nextAction: Action) {
action = nextAction;
[index, location] = getIndexAndLocation();
listeners.call({ action, location });
}
// push
function push(to: To, state?: any) { }

// replace 操作
function replace(to: To, state?: any) {
let nextAction = Action.Replace;
let nextLocation = getNextLocation(to, state);
function retry() {
replace(to, state);
}

if (allowTx(nextAction, nextLocation, retry)) {
let [historyState, url] = getHistoryStateAndUrl(nextLocation, index);

// TODO: Support forced reloading
globalHistory.replaceState(historyState, "", url);

applyTx(nextAction);
}
}

// 前进或后退 n 页
function go(delta: number) {
globalHistory.go(delta);
}

let history: BrowserHistory = {
get action() {
return action;
},
get location() {
return location;
},
createHref,
push,
replace,
go,
back() {
go(-1);
},
forward() {
go(1);
},
listen(listener) {
return listeners.push(listener);
},
block(blocker) {
let unblock = blockers.push(blocker);

if (blockers.length === 1) {
window.addEventListener(BeforeUnloadEventType, promptBeforeUnload);
}

return function () {
unblock();

// Remove the beforeunload listener so the document may
// still be salvageable in the pagehide event.
// See https://html.spec.whatwg.org/#unloading-documents
if (!blockers.length) {
window.removeEventListener(BeforeUnloadEventType, promptBeforeUnload);
}
};
},
};

return history;
}

createBrowserHistory 创建了⼀个标准的 history 对象,以及对 history 对象操作的各⽅法,且操作变更后,通过 listen ⽅法将变更结果回调给外部。

getIndexAndLocation实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
function getIndexAndLocation(): [number, Location] {
let { pathname, search, hash } = window.location;
let state = globalHistory.state || {};
return [
state.idx,
readOnly<Location>({
pathname,
search,
hash,
state: state.usr || null,
key: state.key || "default",
}),
];
}

push 实现:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
function push(to: To, state?: any) {
let nextAction = Action.Push;
let nextLocation = getNextLocation(to, state);
function retry() {
push(to, state);
}

if (allowTx(nextAction, nextLocation, retry)) {
let [historyState, url] = getHistoryStateAndUrl(nextLocation, index + 1);
// 先pushState改变浏览器history堆栈,后触发react router更新state,重新渲染页面
try {
globalHistory.pushState(historyState, "", url);
} catch (error) {

window.location.assign(url);
}

applyTx(nextAction);
}
}

你可能注意到,代码里我们写的是 const location = useLocation(); location.push("/"),这个 push 是怎么跟上面 createBrowserHistory 里的 push 关联的?

回顾 <Router> 中的这行:const LocationContext = React.createContext<LocationContextObject>(null!)——history 对象被注入到 LocationContext 中。useLocation 实际上就是从 context 里取出 location:

1
2
3
export function useLocation(): Location {
return React.useContext(LocationContext).location;
}

BrowserRouter 核心链路总结

  1. createBrowserHistory 创建 history 对象,封装对 window.history 的操作
  2. <Router> 将 history 注入全局 context
  3. <Routes> 遍历子组件生成路由表,根据 location.pathname 匹配组件并渲染

HashRouter 原理一致,只是监听的 URL 部分从 pathname 变为 hash

react-router vs vue-router:实现差异

路由类型

React:

  • browserRouter
  • hashRouter
  • memoryRouter
    Vue:
  • history
  • hash
  • abstract

memoryRouter 和 abstract 作用类似,都在不支持浏览器的环境(如 SSR、测试)中充当 fallback。

路由拦截:vue-router 提供了全局守卫、路由守卫、组件守卫三套拦截机制。react-router 没有内置守卫,需要在渲染过程中自己处理——类组件在 componentWillMountgetDerivedStateFromProps 中通过 props.history 拦截,函数组件用 useHistoryuseNavigate

Hash 模式(新版本已趋同):react-router 的 hash 模式基于 window.location.hash + hashchange;vue-router 优先使用 pushState/replaceState + popstate,只在浏览器不支持 pushState 时回退到 hash 方案。

History 模式 fallback:react-router 不支持 pushState 时直接 window.location.href 重新加载。vue-router 可以通过 fallback 配置决定是回退到 hash 模式还是重新加载。

懒加载:vue-router 在懒加载页面时,先等 JS 文件加载并执行完毕,再渲染页面。react-router 则先展示 loading 状态(Suspense fallback),JS 加载完成后触发更新渲染。

实战:几个常见需求

登录校验

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
import { createContext, ReactNode, useContext, useEffect, useState } from 'react'
import { Navigate } from 'react-router'

export function Auth ({ children }) {
const auth = useAuth()
if (!auth.userInfo) {
return <Navigate to="/login" replace />
}
return children
}

const AuthContext = createContext(null)

export function AuthProvider ({ children }) {
const [userInfo, setUserInfo] = useState(null)

const login = (name) => {
const user = { name, age: 18 };
localStorage.setItem('user-info', JSON.stringify(user));
setUserInfo(user);
}

const logout = () => {
localStorage.setItem('user-info', '');
setUserInfo(null);
}

const getUserInfo = () => {
let userInfoLocal = localStorage.getItem('user-info');
if (userInfoLocal) {
userInfoLocal = JSON.parse(userInfoLocal)
}
setUserInfo(userInfoLocal);
}

useEffect(() => {
getUserInfo()
}, [])

const value = {
userInfo, login, logout
}

return (
<AuthContext.Provider value={value}>
{children}
</AuthContext.Provider>
)
}

export function useAuth () {
return useContext(AuthContext)
}

路由懒加载

  • React.lazy()
  • dynamic import()
  • React.Suspense
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const Account = React.lazy(() => import("./pages/Account"));

ReactDOM.render(
<React.StrictMode>
<BrowserRouter>
<Routes>
<Route path="/" element={<Layout />}>
<Route path="/account" element={
<React.Suspense fallback={(<div>loading...</div>)}>
<Account />
</React.Suspense>
} />
</Route>
</Routes>
</BrowserRouter>
</React.StrictMode>,
document.getElementById("root")
);

离开页面前确认

基于 history 的 block 实现

usePrompt hook
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
import { useCallback, useContext, useEffect, useState } from 'react';
import { UNSAFE_NavigationContext, useLocation, useNavigate } from 'react-router';
import type { History, Transition } from 'history';

export function usePrompt(when: boolean): (boolean | (() => void))[] {
const navigate = useNavigate();
const location = useLocation();
const [showPrompt, setShowPrompt] = useState(false);
const [lastLocation, setLastLocation] = useState<any>(null);
const [confirmedNavigation, setConfirmedNavigation] = useState(false);

const cancelNavigation = useCallback(() => {
setShowPrompt(false);
}, []);

const handleBlockedNavigation = useCallback(
(nextLocation) => {
if (
!confirmedNavigation &&
nextLocation.location.pathname !== location.pathname
) {
setShowPrompt(true);
setLastLocation(nextLocation);
return false;
}
return true;
},
[confirmedNavigation],
);

const confirmNavigation = useCallback(() => {
setShowPrompt(false);
setConfirmedNavigation(true);
}, []);

useEffect(() => {
if (confirmedNavigation && lastLocation) {
navigate(lastLocation.location.pathname);
}
}, [confirmedNavigation, lastLocation]);

const navigator = useContext(UNSAFE_NavigationContext).navigator as History;

useEffect(() => {
if (!when) return;

const unblock = navigator.block((tx: Transition) => {
const autoUnblockingTx = {
...tx,
retry() {
unblock();
tx.retry();
},
};

handleBlockedNavigation(autoUnblockingTx);
});

return unblock;
}, [navigator, handleBlockedNavigation, when]);

return [showPrompt, confirmNavigation, cancelNavigation];
}

服务端渲染

在服务端使用 StaticRouter 替代 BrowserRouter

1
2
3
<StaticRouter location={req.url}>
<App />
</StaticRouter>

为什么要拆成三个包?

react-router(核心、跨平台)、react-router-dom(Web 端)、react-router-native(React Native 端)。核心与平台解耦——这和 React 的 reconciler 分层是同一个设计思路。


react-router v6 相比 v5 最大的变化是用 <Routes> + element 替代了 <Switch> + component,嵌套路由的写法更直观。但源码层面,history 监听 → context 注入 → 路由匹配 → 组件渲染这条链路从 v4 开始就没怎么变过。理解了这条链路,不管 API 怎么迭代,核心思路都在那里。