React-Router之路由匹配

531 阅读5分钟

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提供了手动触发路由匹配算法的方法matchPathmatchRoutes,以及相应的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;
}
  1. 处理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;
}
  1. 路由数组拍平后,生成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 />,
    },
  ],
},
  1. 对经过打分排序过的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
    );
}