React-Router@v6 源码解析(history路由)

1,760 阅读6分钟

React-Router v6

Browser路由

先看官方例子:

  • App.tsx

    export default function App() {
      return (
        <div>
          <h1>Basic Example</h1>
    
          <p>
            This example demonstrates some of the core features of React Router
            including nested <code>&lt;Route&gt;</code>s,{" "}
            <code>&lt;Outlet&gt;</code>s, <code>&lt;Link&gt;</code>s, and using a
            "*" route (aka "splat route") to render a "not found" page when someone
            visits an unrecognized URL.
          </p>
    
          {/* Routes nest inside one another. Nested route paths build upon
                parent route paths, and nested route elements render inside
                parent route elements. See the note about <Outlet> below. */}
          <Routes>
            <Route path="/" element={<Layout />}>
              <Route index element={<Home />} />
              <Route path="about" element={<About />} />
              <Route path="dashboard" element={<Dashboard />} />
    
              {/* Using path="*"" means "match anything", so this route
                    acts like a catch-all for URLs that we don't have explicit
                    routes for. */}
              <Route path="*" element={<NoMatch />} />
            </Route>
          </Routes>
        </div>
      );
    }
    
  • main.tsx

    import React from "react";
    import ReactDOM from "react-dom";
    import { BrowserRouter } from "react-router-dom";
    
    import "./index.css";
    import App from "./App";
    
    ReactDOM.render(
      <React.StrictMode>
        <BrowserRouter>
          <App />
        </BrowserRouter>
      </React.StrictMode>,
      document.getElementById("root")
    );
    

可以看到 BrowserRouter是入口,那么就先从BrowserRouter开始看

BrowserRouter

export function BrowserRouter({
  basename,
  children,
  window,
}: BrowserRouterProps) {
  let historyRef = React.useRef<BrowserHistory>();
  if (historyRef.current == null) {
    historyRef.current = createBrowserHistory({ window });
  }

  let history = historyRef.current;
  let [state, setState] = React.useState({
    action: history.action,
    location: history.location,
  });
	// 当history更改时重新监听
  // 当切换路由时会触发setState
  React.useLayoutEffect(() => history.listen(setState), [history]);

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

很短很好理解,主要就是通过 history库中 createBrowserHistory函数创建history实例,然后通过historyRef保存,返回Router组件

接下来看 Router组件

Router

packages/react-router/lib/components.tsx#L172

// line:172
export function Router({
  basename: basenameProp = "/",
  children = null,
  location: locationProp,
  navigationType = NavigationType.Pop,
  navigator,
  static: staticProp = false,
}: RouterProps): React.ReactElement | null {
  invariant(
    // 不能嵌套<Router>
    !useInRouterContext(),
    `You cannot render a <Router> inside another <Router>.` +
      ` You should never have more than one in your app.`
  );
	// 规范basename
  let basename = normalizePathname(basenameProp);
  // 设置navigationContext
  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(() => {
    // 将pathname上的basename去掉返回出来,并且判断做一些判断
    let trailingPathname = stripBasename(pathname, basename);

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

    return {
      pathname: trailingPathname,
      search,
      hash,
      state,
      key,
    };
  }, [basename, pathname, search, hash, state, key]);

  warning(
    location != null,
    `<Router basename="${basename}"> is not able to match the URL ` +
      `"${pathname}${search}${hash}" because it does not start with the ` +
      `basename, so the <Router> won't render anything.`
  );

  if (location == null) {
    // 因为pathname没有以basename开头,所以无法匹配,不会渲染任何东西
    return null;
  }

  return (
    // 一共注入两个context——NavigationContext和LocationContext
    <NavigationContext.Provider value={navigationContext}>
      <LocationContext.Provider
        children={children}
        value={{ location, navigationType }}
      />
    </NavigationContext.Provider>
  );
}
  • normalizePathname:

    const normalizePathname = (pathname: string): string => 
     		pathname.replace(/\/+$/, "").replace(/^\/*/, "/");
    
    // '/a/b/c' => '/a/b/c'
    // '/a/b/c/' => '/a/b/c'
    // 'a/b/c////' => '/a/b/c'
    // 给第一位的零或多个'/'替换成'/' ,去掉最后一位的多个'/'
    
  • parsePath:

    let pathPieces = parsePath("/the/path?the=query#the-hash");
    // pathPieces = {
    //   pathname: '/the/path',
    //   search: '?the=query',
    //   hash: '#the-hash'
    // }
    
  • stripBasename

    export function stripBasename(
      pathname: string,
      basename: string
    ): string | null {
      if (basename === "/") return pathname;
    
      if (!pathname.toLowerCase().startsWith(basename.toLowerCase())) {
       	// 判断是否以basename开头
        return null;
      }
    
      let nextChar = pathname.charAt(basename.length);
      if (nextChar && nextChar !== "/") {
        // pathname不能以 basename/ 开头
        return null;
      }
    	// 截取basename后面一段
      return pathname.slice(basename.length) || "/";
    }
    

Router组件同样很好理解,注入navigationContext,对location做处理,之后当做context注入。相当于一个外层容器,初始化好一些数据供内部组件使用。<Router>它是一个上下文提供者,为应用程序的其余部分提供路由信息。

接下来我们来看 Routes组件

Routes

packages/react-router/lib/components.tsx#L252

export function Routes({
  children,
  location,
}: RoutesProps): React.ReactElement | null {
  
  return useRoutes(createRoutesFromChildren(children), location);
}

很短很简单,主要是引用了两个函数,使用useRoutes这个hooks来渲染,通过createRoutesFromChildren生成hooks接受的routes数据结构

createRoutesFromChildren

packages/react-router/lib/components.tsx#L270

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) {
			// 支持展开Fragment
      routes.push.apply(
        routes,
        createRoutesFromChildren(element.props.children)
      );
      return;
    }
		
    // 如果子组件不是Route,则报错
    invariant(
      element.type === Route,
      `[${
        typeof element.type === "string" ? element.type : element.type.name
      }] is not a <Route> component. All component children of <Routes> must be a <Route> or <React.Fragment>`
    );
		// 创建路由对象
    let route: RouteObject = {
      caseSensitive: element.props.caseSensitive,
      element: element.props.element,
      index: element.props.index,
      path: element.props.path,
    };
		// 如果有children就递归
    if (element.props.children) {
      route.children = createRoutesFromChildren(element.props.children);
    }

    routes.push(route);
  });
	// 最后返回出这个数组
  return routes;
}

就是一个遍历的过程,很简单。最后返回的是一个数组,符合useRoutes第一个参数数据结构的数组

useRoutes

packages/react-router/lib/hooks.tsx#L266

export function useRoutes(
  routes: RouteObject[],
  locationArg?: Partial<Location> | string
): React.ReactElement | null {
  invariant(
    // 首先检测是否在LocationContext.provider下
    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.`
  );
	// 获取RouteContext,因为上文并没有RouteContext,
  // 所以可以看出Routes和Route都是是可以嵌套的
  let { matches: parentMatches } = React.useContext(RouteContext);
  // 获取matches最后一个,获取对应参数
  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;

  // 相当于 locationFromContext = React.useContext(LocationContext).location;
  let locationFromContext = useLocation();

  let location;
  if (locationArg) {
    // 这个操作跟Router中的location处理类似
    let parsedLocationArg =
      typeof locationArg === "string" ? parsePath(locationArg) : locationArg;

    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 {
    // 如果没传这个参数就默认取LocationContext中de
    location = locationFromContext;
  }

  let pathname = location.pathname || "/";
  // 获取剩余pathname
  let remainingPathname =
    parentPathnameBase === "/"
      ? pathname
  		// 截取从父路径开始的后半段pathname
      : pathname.slice(parentPathnameBase.length) || "/";
  // matchRoutes 路由匹配的核心算法
  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
  );
}
  • RouteContext

    export const RouteContext = React.createContext<RouteContextObject>({
      outlet: null,
      matches: [],
    });
    

matchRoutes

packages/react-router/lib/router.ts#L141

matchRoutes针对给定的一组路由运行路由匹配算法,location以查看哪些路由(如果有)匹配。如果找到匹配项,RouteMatch则返回一个数组,每个匹配的路由对应一个对象。

这是 React-Router 匹配算法的核心。useRoutes在内部使用它来确定哪些路径与当前位置匹配。在想要手动匹配一组路由的某些情况下,它也很有用。

export function matchRoutes(
  routes: RouteObject[],
  locationArg: Partial<Location> | string,
  basename = "/"
): RouteMatch[] | null {
  // 相同的操作上文已有叙述
  let location =
    typeof locationArg === "string" ? parsePath(locationArg) : locationArg;

  let pathname = stripBasename(location.pathname || "/", basename);

  if (pathname == null) {
    return null;
  }
	// routes数组拍平
  let branches = flattenRoutes(routes);
  // 降序排列
  rankRouteBranches(branches);

  let matches = null;
  for (let i = 0; matches == null && i < branches.length; ++i) {
    // 注意这里的 matches==null 
    // 只要其中一个未匹配到则进入下一轮循环,匹配到则结束循环
    matches = matchRouteBranch(branches[i], pathname);
  }

  return matches;
}
  • rankRouteBranches

    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)
            )
      );
    }
    
  • compareIndexes

    function compareIndexes(a, b) {
      // 判断是否是同级
      let siblings =
        a.length === b.length && a.slice(0, -1).every((n, i) => n === b[i]);
    
      return siblings
        ? // 同级则判断最后一位
          a[a.length - 1] - b[b.length - 1]
        : // 否则,按索引对非同级进行排序是没有意义的,所以它们的排序是相等的
          0;
    }
    
    • 只看代码可能不太直观,举个例子

      {
        path: "/courses",
          element: " <Courses />",
            children: [
              // 同级
              // 上文 a[a.length - 1] - b[b.length - 1] 指的是
              // childrenIndex的对比 ,/a index为0  /b index为1
              { path: "/courses/a", element: "<Course />" },
              { path: "/courses/b", element: "<Course />" },
            ],
      },
      

flattenRoutes

packages/react-router/lib/router.ts#L179

关键部分

function flattenRoutes(
  routes: RouteObject[],
  branches: RouteBranch[] = [],
  parentsMeta: RouteMeta[] = [],
  parentPath = ""
): RouteBranch[] {
  routes.forEach((route, index) => {
    let meta: RouteMeta = {
      // 相对路径
      relativePath: route.path || "",
      // 区分大小写
      caseSensitive: route.caseSensitive === true,
      childrenIndex: index,
      route,
    };

    if (meta.relativePath.startsWith("/")) {
      // 如果路径以 / 开头并且没有包含父路径则报错
      invariant(
        meta.relativePath.startsWith(parentPath),
        `Absolute route path "${meta.relativePath}" nested under path ` +
          `"${parentPath}" is not valid. An absolute child route path ` +
          `must start with the combined path of all its parent routes.`
      );
			// 将父路径截掉,保留后半段
      // 例1
      meta.relativePath = meta.relativePath.slice(parentPath.length);
    }
		// 与父路径合并路径
    let path = joinPaths([parentPath, meta.relativePath]);
    // 类似于把meta push到了parentsMeta数组,但是不会改变原数组(parentsMeta)
    let routesMeta = parentsMeta.concat(meta);

    // Add the children before adding this route to the array so we traverse the
    // route tree depth-first and child routes appear before their parents in
    // the "flattened" version.
    if (route.children && route.children.length > 0) {
      // 索引路由,也称默认子路由,该路由不能有子路由
      invariant(
        route.index !== true,
        `Index routes must not have child routes. Please remove ` +
          `all child routes from route path "${path}".`
      );

      flattenRoutes(route.children, branches, routesMeta, path);
    }

    // 没有路径的路由本身不应该匹配,除非它们是索引路由
    if (route.path == null && !route.index) {
      return;
    }
		
    branches.push({ path, score: computeScore(path, route.index), routesMeta });
  });

  return branches;
}

  • 例1

    {
      path:'/', // 父路径为'',相当于 '/'.slice(0) => '/'
      children:[
        {
          path:'/a' // => a
        }
      ]
    }
    // 如果子路径带/ 则要写父路径
    
  • joinPaths

    export const joinPaths = (paths: string[]): string =>
    // 路径通过/拼接,并且将多个 // 替换为 /
      paths.join("/").replace(/\/\/+/g, "/");
    
  • computeScore

    packages/react-router/lib/router.ts#L251

    const paramRe = /^:\w+$/;
    const dynamicSegmentValue = 3;
    const indexRouteValue = 2;
    const emptySegmentValue = 1;
    const staticSegmentValue = 10;
    const splatPenalty = -2;
    const isSplat = (s: string) => s === "*";
    
    function computeScore(path: string, index: boolean | undefined): number {
      let segments = path.split("/");
      // 初始分数为路径深度
      let initialScore = segments.length;
      if (segments.some(isSplat)) {
        // 只要有*,则
        initialScore += splatPenalty;
      }
    
      if (index) {
        // 如果是索引路由
        initialScore += indexRouteValue;
      }
    
      return segments
      // 挑出不带星号的
        .filter((s) => !isSplat(s))
      // 计算分数
        .reduce(
          (score, segment) =>
            score +
        // 动态路径,例如: /:id
            (paramRe.test(segment)
              ? dynamicSegmentValue
              : segment === ""
             // 空字符串
              ? emptySegmentValue
              : staticSegmentValue),
          initialScore
        );
    }
    
    • 例如:

      path='/' // => 分数:4 segments=['','']
      path='/abc' // => 分数:13 segments=['','abc']
      
  • 举个例子:

    let routes = [
      {
        path: "/",
        element:' <Layout />',// branches第6次push
        children: [
          { index: true, element: '<Home />' }, // branches第1次push 因为是索引路由,所以score多加了2
          {
            path: "/courses",
            element:' <Courses />',// branches第4次push
            children: [
              { index: true, element: '<CoursesIndex />' }, // branches第2次push
              { path: "/courses/:id", element: '<Course />' },// branches第3次push
            ],
          },
          { path: "*", element: '<NoMatch />' },// branches第5次push
        ],
      },
    ];
    flattenRoutes(routes)
    

    输出结果 image-20220520111840929的副本.png 排序之后

image-20220523172741348.png routesMeta为父-子的顺序,在后面匹配时会遍历 routesMeta

matchRouteBranch

packages/react-router/lib/router.ts#L291

查找匹配的路由分支

function matchRouteBranch<ParamKey extends string = string>(
  branch: RouteBranch,
  pathname: string
): RouteMatch<ParamKey>[] | null {
  let { routesMeta } = branch;

  let matchedParams = {};
  let matchedPathname = "/";
  let matches: RouteMatch[] = [];
  for (let i = 0; i < routesMeta.length; ++i) {
    // 一层层路径遍历匹配
    let meta = routesMeta[i];
    let end = i === routesMeta.length - 1;
    // 剩余路径
    let remainingPathname =
        // 匹配路径
        // pathname = `${matchedPathname}xxx`,remainingPathname = 'xxx'
      matchedPathname === "/"
        ? pathname
        : pathname.slice(matchedPathname.length) || "/";
    let match = matchPath(
      { path: meta.relativePath, caseSensitive: meta.caseSensitive, end },
      remainingPathname
    );
		// 只要有一层未匹配到则跳出当前循环,routesMeta是一个数组,从0到最后一项顺序为父->子
    if (!match) return null;

    Object.assign(matchedParams, match.params);

    let route = meta.route;

    matches.push({
      params: matchedParams,
      pathname: joinPaths([matchedPathname, match.pathname]),
      pathnameBase: normalizePathname(
        joinPaths([matchedPathname, match.pathnameBase])
      ),
      route,
    });

    if (match.pathnameBase !== "/") {
      // 匹配到对应层级的路径之后,要把这个层级算上,以便下次循环匹配remainingPathname
      // 例如 /a/b/c  matchedPathname=> / => /a => /a/b => /a/b/c
      matchedPathname = joinPaths([matchedPathname, match.pathnameBase]);
    }
  }

  return matches;
}

matchPath

packages/react-router/lib/router.ts#L388

matchPath将路由路径与 URL 路径名匹配并返回有关匹配的信息。当您需要手动运行路由器的匹配算法以确定路由路径是否匹配时,这很有用。如果模式与给定的路径名不匹配,则返回 null

useMatch钩子在内部使用此函数来匹配相对于当前位置的路由路径。

export function matchPath<
  ParamKey extends ParamParseKey<Path>,
  Path extends string
>(
  pattern: PathPattern<Path> | Path,
  pathname: string
): PathMatch<ParamKey> | null {
  if (typeof pattern === "string") {
    pattern = { path: pattern, caseSensitive: false, end: true };
  }
	// 编译路径,根据对应的路径生成对应的匹配正则
  let [matcher, paramNames] = compilePath(
    pattern.path,
    pattern.caseSensitive,
    pattern.end
  );
	// 与当前路径匹配
  let match = pathname.match(matcher);
  // 没匹配到不作处理
  if (!match) return null;

  let matchedPathname = match[0];
  // 其实就是去掉结尾任意数量的 /
  let pathnameBase = matchedPathname.replace(/(.)\/+$/, "$1");
  // 捕获组 其实就是参数(动态参数或者*)
  let captureGroups = match.slice(1);
  let params: Params = paramNames.reduce<Mutable<Params>>(
    (memo, paramName, index) => {
      // 直接根据index在捕获组中匹配,参数有可能被编码,所以先decode
      if (paramName === "*") {
        let splatValue = captureGroups[index] || "";
        // 去掉参数后的路径
        pathnameBase = matchedPathname
          .slice(0, matchedPathname.length - splatValue.length)
          .replace(/(.)\/+$/, "$1");
      }
			// 解码
      memo[paramName] = safelyDecodeURIComponent(
        captureGroups[index] || "",
        paramName
      );
      return memo;
    },
    {}
  );

  return {
    params,
    pathname: matchedPathname,
    pathnameBase,
    pattern,
  };
}
  • conpilePath

    全是正则匹配,不了解的可以先恶补下正则,主要有这几种 (?:) (?=) \b

    function compilePath(
      path: string,
      caseSensitive = false,
      end = true
    ): [RegExp, string[]] {
      warning(
        // 例如 '/abc*' 路径被视为 '/abc/*'
        path === "*" || !path.endsWith("*") || path.endsWith("/*"),
        `Route path "${path}" will be treated as if it were ` +
          `"${path.replace(/\*$/, "/*")}" because the \`*\` character must ` +
          `always follow a \`/\` in the pattern. To get rid of this warning, ` +
          `please change the route path to "${path.replace(/\*$/, "/*")}".`
      );
    
      let paramNames: string[] = [];
      let regexpSource =
        "^" +
        path
      		// 0次或多次 / 和 零次或者一次 * 
          .replace(/\/*\*?$/, "") // 忽略 / and /*, 我们将在下面处理它
          .replace(/^\/*/, "/") // 确保最前面有 /
          .replace(/[\\.*+^$?{}|()[\]]/g, "\\$&") // Escape特殊正则字符
          .replace(/:(\w+)/g, (_: string, paramName: string) => {
            // 例:/a/:id/ 
            // paramName = id
            paramNames.push(paramName);
            // 将:id替换
            return "([^\\/]+)";
          });
    
      if (path.endsWith("*")) {
        // 以*结尾
        paramNames.push("*");
        regexpSource +=
          path === "*" || path === "/*"
            ? "(.*)$" // 已匹配首字母/,只匹配其余的
            : "(?:\\/(.+)|\\/*)$"; // 不要在参数[“*”]中包含/  (?:) 非捕获组
      } else {
        regexpSource += end
          ? "\\/*$" // 匹配到结尾时,忽略尾部斜杠
          : //否则,请匹配单词边界。单词边界限制父路由仅匹配其自己的单词,仅此而已,
            //例如父路由“/home”不应匹配“/home2”。
            // 此外,允许以“.`、`-`、` ~`、` ~`”开头的路径,
            // 和url编码的实体,但不使用匹配路径中的字符,以便它们可以与嵌套路径匹配。
    		    // 例 可匹配 /abc/d%22
            "(?:(?=[.~-]|%[0-9A-F]{2})|\\b|\\/|$)";
      }
    	// 创建正则
      let matcher = new RegExp(regexpSource, caseSensitive ? undefined : "i");
    
      return [matcher, paramNames];
    }
    

_renderMatches

packages/react-router/lib/hooks.tsx#L377

export function _renderMatches(
  matches: RouteMatch[] | null,
  parentMatches: RouteMatch[] = []
): React.ReactElement | null {
  if (matches == null) return null;

  return matches.reduceRight((outlet, match, index) => {
    // 使用reduceRight,遍历渲染
    return (
      <RouteContext.Provider
        children={
          match.route.element !== undefined ? match.route.element : outlet
        }
        value={{
          outlet,
          matches: parentMatches.concat(matches.slice(0, index + 1)),
        }}
      />
    );
  }, null as React.ReactElement | null);
}
  • 为什么使用reduceRight,还是上面的例子,例如当前路由为/courses,传入的matches的数据结构为
  • matches: parentMatches.concat(matches.slice(0, index + 1)), 这句解释了上文useRoutes中 let routeMatch = parentMatches[parentMatches.length - 1] 为什么要取最后一位

image-20220523164940353.png