路由模式

hash

  • url 中的 hash 部分不会引起页面的刷新
  • hashchange 监听 url 变化,浏览器导航栏的前进后退,a 标签,window.location 等方式触发事件

history

  • pushState 和 replaceState 改变 url 的 path 部分不会引起页面刷新
  • popchange 监听 url 变化,只有在浏览器导航栏的前进后退改变 url 时会触发事件,pushState/replaceState 不会触发 popstate 方法,需要进行拦截或重写 pushState/replaceState 来监听
1
2
3
4
5
6
7
8
9
10
11
const _wr = function (type) {
const orig = history[type];
return function () {
const e = new Event(type);
e.arguments = arguments;
const rv = orig.apply(this, arguments);
window.dispatchEvent(e);
return rv;
};
};
history.pushState = _wr("pushstate");

react-router 架构

BrowserHistory

  • 监听路由变化的 listen 方法以及对应的清理监听 unlisten 方法
  • 重写路由的 push 方法,自动触发 popstate 方法
  • window.location 获取参数
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
// 创建和管理listeners的方法
export const EventEmitter = () => {
const events = [];
return {
subscribe(fn) {
events.push(fn);
return function () {
events = events.filter((handler) => handler !== fn);
};
},
emit(arg) {
events.forEach((fn) => fn && fn(arg));
},
};
};

const createBrowserHistory = () => {
const EventBus = EventEmitter();
let location = { pathname: "/" };
// 路由变化时的回调
const handlePop = function () {
const currentLocation = { pathname: window.location.pathname };
EventBus.emit(currentLocation);
};
// 定义history.push方法
const push = (path) => {
const history = window.history;
history.pushState(null, "", path);

// 由于push并不触发popstate,手动调用回调函数
location = { pathname: path };
EventBus.emit(location);
};

const listen = (listener) => EventBus.subscribe(listener);

// 处理浏览器的前进后退
window.addEventListener("popstate", handlePop);

const history = {
location,
listen,
push,
};
return history;
};

HashHistory

  • 监听 hashchange
  • 解析 hash 获取参数,比如 hash 部分是 #/a/b?c=1#/d,解析出 { hash: ‘#/d’, search: ‘?c=1’, pathname: ‘/a/b’ }
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
const createHashHistory = () => {
const EventBus = EventEmitter();
let location = { pathname: "/" };

const handlePop = function () {
const currentLocation = { pathname: window.location.hash.slice(1) };
EventBus.emit(currentLocation);
};

const push = (path) => (window.location.hash = path);
const listen = (listener: Function) => EventBus.subscribe(listener);

window.addEventListener("hashchange", handlePop);

const history = {
location,
listen,
push,
};
return history;
};

react-router@6

  • v6 版本中移出了先前的 <Switch>,引入了新的替代者: <Routes>
  • <Routes><Route> 配合使用,用 <Routes> 包裹 <Route>
  • <Routes> 本质上调用 useRoutes 返回的对象, <Route> 相当于一个 case 语句,当 url 发生变化时,<Routes> 都会查看其所有子 <Route> 元素以找到最佳匹配并呈现组件
  • withRouter: HOC ,非路由组件通过 withRouter 包裹来获取 history、location、match 信息

示例

App.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
export default function App() {
return (
<div>
{/* 设置路由链接 */}
{/* className 接收一个函数,可以改变激活的类名 */}
<Link className="menu-item" to="/about">
About
</Link>
<Link className="menu-item" to="/home">
Home
</Link>

{/* 注册路由 */}
{/* 用 Routes 组件进行包裹*/}
{/* Route 组件的 element 属性值为对应的组件*/}
{/* caseSensitive 严格区分大小写*/}
{/* 调用 useRoutes(),嵌入路由映射表 */}
<Routes>
<Route path="/about" caseSensitive element={<About />}></Route>
<Route path="/home" element={<Home />}></Route>
{/* Navigate 组件,页面渲染就显示对应组件,实现重定向效果 */}
<Route path="/" element={<Navigate to="/about " />}></Route>
{useRoutes(routes)}
</Routes>
</div>
);
}

index.js

1
2
3
4
5
6
const root = createRoot(document.getElementById("root"));
root.render(
<BrowserRouter>
<App />
</BrowserRouter>
);

Home.jsx

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
export default function Home() {
return (
<div>
<h3>Home</h3>
<li>
{/* 传递 params 参数,在路径后面用 / 进行拼接,useParams */}
<Link to={`detail/${id}/${title}/${content}`}>{m.title}</Link>
{/* 传递 search 参数,在路径后面用 ? 进行拼接,useSearchParams */}
<Link to={`detail?id=${id}&title=${title}&content=${content}`}>
{title}
</Link>
{/* 传递 state 参数,添加 state 属性,值为一个对象,useLocation */}
<Link to="detail" state={{ id, title, content }}>
{title}
</Link>
</li>
<div>
{/* Outlet 路由占位符,表示"路由映射表"中匹配的组件将在此处展示 */}
<Outlet />
</div>
</div>
);
}

Routes 标签

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
export interface RoutesProps {
children?: React.ReactNode;
// 用户传入的 location 对象,一般不传,默认用当前浏览器的 location
location?: Partial<Location> | string;
}

export function Routes({
children,
location,
}: RoutesProps): React.ReactElement | null {
// 此处调用了 useRoutes 这个 hook,并且使用了 createRoutesFromChildren 将 children 转换成了 useRoutes 所需要配置的参数格式
return useRoutes(createRoutesFromChildren(children), location);
}

// 内部通过 React.Children.forEach 把 Route 组件给结构化,并且内部调用递归,深度递归 children 结构
// 把 <Route> 类型的 react element 对象,变成了普通的 route 对象结构。Route 本质是一个空函数,并没有实际挂载,是通过 createRoutesFromChildren 处理转化了
export function createRoutesFromChildren(
children: React.ReactNode
): RouteObject[] {
let routes: RouteObject[] = [];

// 就是递归遍历 children 然后格式化后推入 routes 数组中
React.Children.forEach(children, (element) => {
// Ignore non-elements.
if (!React.isValidElement(element)) {
return;
}

// 如果类型为 React.Fragment 继续递归遍历
if (element.type === React.Fragment) {
// 相当于 routes.push(...createRoutesFromChildren(element.props.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;
}

useRoutes

三个步骤: 路由上下文解析(父子路由)、路由匹配、路由渲染

  • 解析上下文: 调用 useRoutes 的地方,如果是子路由调用,合并父路由的匹配信息,生成对应的 pathname
  • 路由匹配: 调用 matchRoutes 返回 matches 数组,找到匹配的路由分支
  • 路由渲染: 调用 _renderMatches 方法,通过 reduceRight 来形成 react 结构 elmenet
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
function useRoutes(
routes: RouteObject[],
locationArg?: Partial<Location> | string
): React.ReactElement | null {
invariant(
// 外层需要 router 包裹,否则报错
useInRouterContext(),
// TODO: This error is probably because they somehow have 2 versions of the
// router loaded. We can help them understand how to avoid that.
`useRoutes() may be used only in the context of a <Router> component.`
);
let { matches: parentMatches } = React.useContext(RouteContext);

let routeMatch = parentMatches[parentMatches.length - 1]; // 最后的一个 route 将作为父路由,后续的 routes 都是其子路由
let parentParams = routeMatch ? routeMatch.params : {}; // 父路由参数
// 父路由完整 pathname,如果路由设置 /article/*,当前导航 /article/1,那么值为 /article/1
let parentPathname = routeMatch ? routeMatch.pathname : "/";
// 同上类比,看 base 命名可以看出值为 /article
let parentPathnameBase = routeMatch ? routeMatch.pathnameBase : "/";
let parentRoute = routeMatch && routeMatch.route;

let locationFromContext = useLocation(); // 获取当前的 location 状态
let location;
// 判断是否传入 locationArg 参数,没有的话使用当前的 location
if (locationArg) {
// 格式化为 Path 对象
let parsedLocationArg =
typeof locationArg === "string" ? parsePath(locationArg) : locationArg;
// 如果传入了 location,判断是否与父级路由匹配(作为子路由存在)
invariant(
parentPathnameBase === "/" ||
parsedLocationArg.pathname?.startsWith(parentPathnameBase),
`When overriding the location using \`<Routes location>\` or \`useRoutes(routes, location)\`, ` +
`the location pathname must begin with the portion of the URL pathname that was ` +
`matched by all parent routes. The current pathname base is "${parentPathnameBase}" ` +
`but pathname "${parsedLocationArg.pathname}" was given in the \`location\` prop.`
);

location = parsedLocationArg;
} else {
location = locationFromContext;
}

let pathname = location.pathname || "/";
let remainingPathname =
parentPathnameBase === "/"
? pathname
: pathname.slice(parentPathnameBase.length) || "/";

// 通过传入的 routes 配置项与当前的路径,匹配对应渲染的路由
let matches = matchRoutes(routes, { pathname: remainingPathname });

// 调用_renderMatches方法,返回的是 React.Element,渲染所有的 matches 对象
return __renderMatches(
matches &&
// 合并外层调用 useRoutes 得到的参数,内部的 Route 会有外层 Route(其实这也叫父 Route) 的所有匹配属性。
matches.map((match) =>
Object.assign({}, match, {
params: Object.assign({}, parentParams, match.params),
// joinPaths 函数用于合并字符串
pathname: joinPaths([parentPathnameBase, match.pathname]),
pathnameBase:
match.pathnameBase === "/"
? parentPathnameBase
: joinPaths([parentPathnameBase, match.pathnameBase]),
})
),
// 外层 parentMatches 部分,最后会一起加入最终 matches 参数中
parentMatches
);
}
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
function matchRoutes(
routes: RouteObject[],
locationArg: Partial<Location> | string, // 当前匹配到的 location
basename = "/"
): RouteMatch[] | null {
// 转为 path 对象
let location =
typeof locationArg === "string" ? parsePath(locationArg) : locationArg; // 转为 path 对象
let pathname = stripBasename(location.pathname || "/", basename);
if (pathname == null) {
return null;
}
let branches = flattenRoutes(routes); // 扁平化 routes 为一维数组,包含当前路由的权重
rankRouteBranches(branches); // 根据权重排序

let matches = null;
// 这边遍历,判断条件如果没有匹配到就继续,匹配到就结束,知道所有的全部遍历完
for (let i = 0; matches == null && i < branches.length; ++i) {
// 遍历扁平化的 routes,查看每个 branch 的路径匹配规则是否能匹配到 pathname
matches = matchRouteBranch(branches[i], pathname);
}
return matches;
}
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
function _renderMatches(
matches: RouteMatch[] | null,
// 如果在已有 match 的 route 内部调用,会合并父 context 的 match
parentMatches: RouteMatch[] = []
): React.ReactElement | null {
if (matches == null) return null;
// 生成 outlet 组件,这边就是渲染 RouteContext.Provider 组件(嵌套关系)
return matches.reduceRight((outlet, match, index) => {
// 有 element 就渲染 element,如果没有则默认是 <Outlet />,继续渲染内嵌的 <Route />
return (
<RouteContext.Provider
children={
match.route.element !== undefined ? match.route.element : <Outlet />
}
value={{
outlet,
matches: parentMatches.concat(matches.slice(0, index + 1)),
}}
/>
);
// 最内层的 outlet 为 null,也就是最后的子路由
}, null as React.ReactElement | null);
}

export type Params<Key extends string = string> = {
readonly [key in Key]: string | undefined;
};

export interface RouteMatch<ParamKey extends string = string> {
// params 参数,比如 :id 等
params: Params<ParamKey>;
pathname: string;
// 子路由匹配之前的路径 url,这里可以把它看做是只要以 /* 结尾路径(这是父路由的路径)中 /* 之前的部分
pathnameBase: string;
route: RouteObject;
}

interface RouteContextObject {
// 一个 ReactElement,内部包含有所有子路由组成的聚合组件,其实 Outlet 组件内部就是它
outlet: React.ReactElement | null;
// 一个成功匹配到的路由数组,索引从小到大层级依次变深
matches: RouteMatch[];
}

// 包含全部匹配到的路由,官方不推荐在外直接使用
const RouteContext = React.createContext<RouteContextObject>({
outlet: null,
matches: [],
});

export { RouteContext as UNSAFE_RouteContext };

Outlet

内部渲染 RouteContext 的 outlet 属性,本质就是用了 useOutlet

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
// 在 outlet 中传入的上下文信息
const OutletContext = React.createContext < unknown > null;

//可以在嵌套的 routes 中使用,这里的上下文信息是用户在使用 <Outlet /> 或者 useOutlet 时传入的
export function useOutletContext<Context = unknown>(): Context {
return React.useContext(OutletContext) as Context;
}

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;
}

export interface OutletProps {
// 可以传入要提供给 outlet 内部元素的上下文信息
context?: unknown;
}

export function Outlet(props: OutletProps): React.ReactElement | null {
return useOutlet(props.context);
}

useNavigate

替代 history 的方案

  • navigate 默认是 history.push
  • naviaget(to, { replace: true }) 等同于 history.replace\
  • naviaget(number) 等同于 history.go

组件渲染过程总结

BrowserRouter 举例

  1. 路由更新,触发 listen 事件,新城新的 location 对象,更新 locationContext
  2. useRoutes 消费 locationContext,locationContext 的变化会重新执行 useRoutes
  3. 重新执行内部调用 matchRoutes_renderMatchers 找到新的渲染分支,渲染页面

v5 和 v6 对比

  1. 组件层面:
  • v5: Router Switch Route 结构,Router -> 传递状态,负责派发更新;Switch -> 匹配唯一路由;Route -> 真实渲染路由组件
  • v6: Router Routes Route 结构,Router 抽离了 context;Routes -> 形成路由渲染分支,渲染路由;Route 并非渲染真实路由,而是形成路由分支结构
  1. 使用层面:
  • v5: 嵌套路由,配置二级路由,需要写在具体的业务组件中
  • v6: 在外层统一配置路由结构,结构更清晰,通过 Outlet 来实现子路由的渲染,一定程度上类似于 vue 中的 view-router。搭配新的 api
  1. 原理层面:
  • v5: 本质在于 Route 组件,当路由上下文 context 改变的时候,Route 组件重新渲染,然后通过匹配来确定业务组件是否渲染
  • v6: 本质在于 Routes 组件,当 location 上下文改变的时候,Routes 重新渲染,重新形成渲染分支,然后通过 provider 方式逐层传递 Outlet,进行匹配渲染