Route
route组件是react-router中最重要的部分,可以将url片段和对应的component连接起来。 下面介绍route的一些属性。
path
判断route是否和url匹配的字段
// 以‘:’开头,表示该字段为动态字段,一个path中支持配置多个动态字段
<Route
// 如下url都可以被匹配到,因为配置了动态的teamId值
// - /teams/hot
// - /teams/real
path="/teams/:teamId"
// teamId在loader中也可以通过入参的形式拿到
loader={({ params }) => {
console.log(params.teamId); // "hot"
}}
element={<Team />}
/>;
// /teams/cat 通过useParams hook可以获取到动态的teamId值
function Team() {
let params = useParams();
console.log(params.teamId); // "cat"
}
// 使用‘?’结尾标识该字段为可选字段,因为标识了lang为可选,可以标识动态|静态 字段为可选项
<Route
// 如下url可以被匹配到
// - /categories
// - /en/categories
// - /fr/categories
path="/:lang?/categories"
// 同样的,loader中也支持获取可选动态路由
loader={({ params }) => {
console.log(params["lang"]); // "en"
}}
element={<Categories />}
/>;
function Categories() {
let params = useParams();
console.log(params.lang);
}
// * 号
<Route
// 使用*号匹配任意字段,下述url都可以被匹配到
// - /files
// - /files/one
// - /files/one/two
// - /files/one/two/three
path="/files/*"
// loader中 使用*作为key,访问*号匹配到的内容
loader={({ params }) => {
console.log(params["*"]); // "one/two"
}}
element={<Team />}
/>;
function Team() {
let params = useParams();
// 类似的
console.log(params["*"]); // "one/two"
}
// 布局路由,没有path属性配置,结合outlet,添加ui(用于为一些页面添加共有的ui)
<Route
element={
<div>
<h1>Layout</h1>
<Outlet />
</div>
}
>
<Route path="/" element={<h2>Home</h2>} />
<Route path="/about" element={<h2>About</h2>} />
</Route>
index
决定当前路由是否是一个index路由,在路由嵌套里使用,可以理解为默认孩子路由,该route没有path属性,路由匹配使用的是父节点的path,渲染在父节点的outlet所在位置。 比较实用的场景:业务模块有个主页展示页面,就不需要额外配置一个url,使用index路由即可。
// 如下route配置
{
path: '/products',
element: <Outlet />,
children: [
{
path: ':id',
element: <Product />,
},
{
index: true,
element: <Home />,
},
],
}
// url为/products/10,渲染的组件为
<Product>
<Product />
</Product>
// url为/products,渲染的组件为
<Product>
<Home />
</Product>
caseSensitive
// 大小写敏感
// ✅ wIll-Match ❌ will-match
<Route caseSensitive path="/wIll-Match" />
和路由匹配相关的属性介绍完,下面关注下具体的路由匹配流程。react-router提供了手动触发路由匹配算法的方法matchPath和matchRoutes,以及相应的hook。
matchRoutes
matchRoutes方法会执行路由匹配算法,入参是route数组和location,如果在route数组中找到location对应的路由,就会返回RouteMatch数组。
declare function matchRoutes(
routes: RouteObject[],
location: Partial<Location> | string,
basename?: string
): RouteMatch[] | null;
interface RouteMatch<ParamKey extends string = string> {
params: Params<ParamKey>;
pathname: string;
route: RouteObject;
}
路由匹配的过程大致分为四个步骤
export function matchRoutes(
routes: RouteObjectType[],
locationArg: Partial<Location> | string,
basename = "/"
){
// 1.处理loation和pathname
let location = typeof locationArg === "string" ? parsePath(locationArg) : locationArg;
let pathname = stripBasename(location.pathname || "/", basename);
if (pathname == null) {
return null;
}
// 2. 拍平routes数组,获得branch数组
let branches = flattenRoutes(routes);
// 3. 根据branch中的score值进行排序,得分高的分支排在前面
rankRouteBranches(branches);
let matches = null;
// 4. 进行匹配
for (let i = 0; matches == null && i < branches.length; ++i) {
let decoded = decodePath(pathname);
matches = matchRouteBranch<string, RouteObjectType>(branches[i], decoded);
}
return matches;
}
- 处理location入参为字符串的情况,需要将字符串转换为location对象,其实就是处理url中hash、query、pathname的过程。
export function parsePath(path: string): Partial<Path> {
let parsedPath: Partial<Path> = {};
if (path) {
// 处理hash值
let hashIndex = path.indexOf("#");
if (hashIndex >= 0) {
parsedPath.hash = path.substr(hashIndex);
path = path.substr(0, hashIndex);
}
// 处理query
let searchIndex = path.indexOf("?");
if (searchIndex >= 0) {
parsedPath.search = path.substr(searchIndex);
path = path.substr(0, searchIndex);
}
// 最后剩余的为pathname
if (path) {
parsedPath.pathname = path;
}
}
return parsedPath;
}
2.路由中会存在父子路由的嵌套逻辑,需要递归调用,将routes数组拍平,并进行打分computeScore
function flattenRoutes(
routes: RouteObjectType[],
branches: RouteBranch<RouteObjectType>[] = [],
parentsMeta: RouteMeta<RouteObjectType>[] = [],
parentPath = ""
){
let flattenRoute = (
route: RouteObjectType,
index: number,
relativePath?: string
) => {
let meta: RouteMeta<RouteObjectType> = {
relativePath:
relativePath === undefined ? route.path || "" : relativePath,
caseSensitive: route.caseSensitive === true,
childrenIndex: index,
route,
};
// 处理path为绝对路径的情况
if (meta.relativePath.startsWith("/")) {
meta.relativePath = meta.relativePath.slice(parentPath.length);
}
// 拼接父路由的path,保证子路由生成的branch中path是完整的
let path = joinPaths([parentPath, meta.relativePath]);
let routesMeta = parentsMeta.concat(meta);
// 深度优先,处理子路由,所以最后的branch数组中父路由会排在子路由后面
if (route.children && route.children.length > 0) {
flattenRoutes(route.children, branches, routesMeta, path);
}
// 不包含path属性,且不是index路由,不会参与匹配
if (route.path == null && !route.index) {
return;
}
branches.push({
path,
// 计算得分 !imporant *文章后面再详细说明*
score: computeScore(path, route.index),
routesMeta,
});
};
routes.forEach((route, index) => {
// 对于包含可选字段的路由,会有额外分支进行处理,
// 对于可选字段出现和不出现的情况都需要加入branch数组中
// *文章结尾处再详细进行介绍*
if (route.path === "" || !route.path?.includes("?")) {
flattenRoute(route, index);
} else {
for (let exploded of explodeOptionalSegments(route.path)) {
flattenRoute(route, index, exploded);
}
}
});
return branches;
}
- 路由数组拍平后,生成branch数组,根据branch中的score值进行排序。排序原则为
得分高的排在前面 -〉兄弟路由中得分相同,按照声明顺序排序
function rankRouteBranches(branches: RouteBranch[]): void {
branches.sort((a, b) =>
a.score !== b.score
? b.score - a.score // 得分高的排在前面
: compareIndexes(
a.routesMeta.map((meta) => meta.childrenIndex),
b.routesMeta.map((meta) => meta.childrenIndex)
)
);
}
// 得分相同,根据route在父路由中的位置再进行逻辑处理,*示例在下面的代码块中*
function compareIndexes(a: number[], b: number[]): number {
let siblings =
a.length === b.length && a.slice(0, -1).every((n, i) => n === b[i]);
// siblings为true是指两个路由为兄弟路由,声明在前的路由排在前面
// 不是兄弟节点,没办法根据index去进行排序,返回为0
return siblings
? a[a.length - 1] - b[b.length - 1]
: 0;
}
// 如下路由配置中,index路由和动态路由得分计算相同,
// 传入compareIndexes方法的入参即为 a:[2,1], b: [2,0]
// 省略前两个路由配置
{...},{...}
{
// [2]
path: '/products',
element: <Outlet />,
children: [
// [2,0]
{
path: ':id',
element: <Product />,
},
// [2,1]
{
index: true,
element: <Home />,
},
],
},
- 对经过打分排序过的branch数组,进行匹配,完成匹配流程,其中主要使用的是
matchPath方法。 TODO 后续补充
explodeOptionalSegments 匹配过程中对路由中可选字段的处理
路由中出现可选字段时,会有逻辑进行特殊处理。涉及到的递归调用比较复杂,最好还是调试一下
/**
* 简单来说,就是去掉?的过程,对每个可选字段出现和不出现的情况进行排列组合,一个path会爆炸式增长到多个path
* 复杂的情况如官方的示例
* For example, `/one/:two?/three/:four?/:five?` explodes to:
* - `/one/three`
* - `/one/:two/three`
* - `/one/three/:four`
* - `/one/three/:five`
* - `/one/:two/three/:four`
* - `/one/:two/three/:five`
* - `/one/three/:four/:five`
* - `/one/:two/three/:four/:five`
*/
function explodeOptionalSegments(path: string): string[] {
let segments = path.split("/");
if (segments.length === 0) return [];
let [first, ...rest] = segments;
// 以?号结尾,表示为可选字段
let isOptional = first.endsWith("?");
// 去除?号 `foo?` -> `foo`
let required = first.replace(/\?$/, "");
// 递过程 终止条件
if (rest.length === 0) {
// 以“”空字符串表示可选字段不出现的情况
return isOptional ? [required, ""] : [required];
}
// 递归开始
let restExploded = explodeOptionalSegments(rest.join("/"));
let result: string[] = [];
// 个人理解为排列组合从右至左开始,具体可看官方代码注释
// 处理前面的以“”空字符串表示可选字段不出现的情况,如果为“”字符串则不拼接
result.push(
...restExploded.map((subpath) =>
subpath === "" ? required : [required, subpath].join("/")
)
);
// 如果当前处理的字段为可选,因为该字段会有不出现的情况,需要将之前处理的result push进去
// 如果为必选,上面的push已将字段拼接进去的result保存进去了
if (isOptional) {
result.push(...restExploded);
}
// 处理绝对路径的情况,'/'替换''
return result.map((exploded) =>
path.startsWith("/") && exploded === "" ? "/" : exploded
);
}
computeScore 对路由进行打分
// 匹配是否为动态路由
const paramRe = /^:[\w-]+$/;
// 路由中各种配置对应的分数
// 动态路由
const dynamicSegmentValue = 3;
// index路由
const indexRouteValue = 2;
// 空字符串
const emptySegmentValue = 1;
// 静态路由
const staticSegmentValue = 10;
// path中含有*号
const splatPenalty = -2;
const isSplat = (s: string) => s === "*";
// 根据route的path计算分值,index表示是否为index路由
function computeScore(path: string, index: boolean | undefined): number {
let segments = path.split("/");
// 初始分数为segments的长度
let initialScore = segments.length;
if (segments.some(isSplat)) {
initialScore += splatPenalty;
}
if (index) {
initialScore += indexRouteValue;
}
return segments
.filter((s) => !isSplat(s))
.reduce(
(score, segment) =>
score +
(paramRe.test(segment)
? dynamicSegmentValue
: segment === ""
? emptySegmentValue
: staticSegmentValue),
initialScore
);
}