简单易懂地剖析React-RouterV6源码(代码示例+画图总结)

前言

React-Router6.0正式版本发布后,经过四个月的迭代已经更新到目前的6.2.2版本。目前代码结构逐渐趋于稳定,也是时候对其源码进行剖析学习,因此这篇文章主要是对React-Router6.2.2版本的源码进行分析。

**注意:**由于React-RouterV6比较依赖于historyV5.2.0,而我之前已经写了一篇分析historyV5.2.0源码的文章react-routerV6 依赖的 history 库源码分析,因此请先看我上述的文章后才阅读下面的内容,这样子会理解得更透彻。

源码分析(基础部分)

第一步:先从 BrowserRouter 开始分析

大多情况下,我们的单页面应用都是采用history模式的路由变化,因此下面主要分析history模式下涉及到的源码,其余的路由模式如hash模式memory模式的原理其实都大同小异,就不再一一分析了。

在使用React-Router时,我们会先以BrowserRouter包裹着入口组件App如下所示:

ReactDOM.render(
  <React.StrictMode>
    <BrowserRouter>
      <App />
    </BrowserRouter>
  </React.StrictMode>,
  document.getElementById("root")
);
复制代码

那么就先通过阅读BrowserRouter的源码来分析其作用吧:

// packages\react-router-dom\index.tsx
export function BrowserRouter({
  basename, // 路由统一的前缀
  children,
  window, // 指明要监控和操作哪个页面对象的路由变化。默认是window对象,但我们可以传入iframe对象
}: BrowserRouterProps) {
  // 创建ref对象historyRef,用于存储createBrowserHistory创建出来的会话历史管理实例
  let historyRef = React.useRef<BrowserHistory>();
  if (historyRef.current == null) {
    historyRef.current = createBrowserHistory({ window });
  }

  let history = historyRef.current;
  let [state, setState] = React.useState({
    /*
     * 到达当前路由的行为,有以下值:
     *    "POP": 代表路由的变化是通过 history.go这类操作历史栈索引的API 或者 浏览器导航栏上的前进和后退键 触发。
     *    "PUSH": 代表路由的变化是通过 history.push 触发的
     *    "REPLACE": 代表路由的变化是通过 history.replace 触发的
     */
    action: history.action,
    /**
     * 一个记录当前路由数据的对象,包含以下属性:
     *    pathname:等同于window.location.pathname
     *    search:等同于window.location.search
     *    hash:等同于window.location.hash
     *    state:当前路由地址的状态,类似但不等于window.history.state
     *    key:代表当前路由地址的唯一值
     */
    location: history.location,
  });
  /*
   * useEffect 与 useLayoutEffect的区别:
   *   useEffect: 在react执行commit后,也就是页面渲染变化后执行
   *   useLayoutEffect: 在react执行commit前执行,会阻塞页面渲染变化
   */
  React.useLayoutEffect(() => history.listen(setState), [history]);
  // 用Router组件传入一些参数且包裹着children返回出去
  return (
    <Router
      basename={basename}
      children={children}
      location={state.location}
      navigationType={state.action}
      navigator={history}
    />
  );
}
复制代码

接下来我们看一下Router组件的源码:

// packages\react-router\index.tsx
export function Router({
  basename: basenameProp = "/",
  children = null,
  location: locationProp, // history.location
  navigationType = NavigationType.Pop, // history.action
  navigator, // history
  static: staticProp = false, // 该属性在BrowserRouter上用不到
}: RouterProps): React.ReactElement | null {
  // normalizePathname用于对basename格式化,如normalizePathname('//asd/')=>'/asd'
  let basename = normalizePathname(basenameProp);
  let navigationContext = React.useMemo(
    () => ({ basename, navigator, static: staticProp }),
    [basename, navigator, staticProp]
  );
  // 如果locationProp为字符串则把他转为对象(包含pathname,search,state,key,hash)
  if (typeof locationProp === "string") {
    locationProp = parsePath(locationProp);
  }

  let {
    pathname = "/",
    search = "",
    hash = "",
    state = null,
    key = "default",
  } = locationProp;
  // 此 location 与 locationProp 的区别在于pathname做了去掉basename的处理
  let location = React.useMemo(() => {
    // trailingPathname为当前location.pathname中截掉basename的那部分,如
    // stripBasename('/prefix/a', '/prefix') => '/a'
    // 如果basename为'/',则不对pathname处理直接返回原值
    let trailingPathname = stripBasename(pathname, basename);

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

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

  if (location == null) {
    return null;
  }
  // 最后返回被NavigationContext和LocationContext包裹着的children出去
  return (
    <NavigationContext.Provider value={navigationContext}>
      <LocationContext.Provider
        children={children}
        value={{ location, navigationType }}
      />
    </NavigationContext.Provider>
  );
}
复制代码

截止现在我们结合开头的例子去分析就知道,其实BrowserRouter内部就是把basenamenavigator放入NavigationContextlocationnavigationType放入LocationContext,最后用NavigationContext包裹着LocationContext再包裹着App,如下图所示:

image.png

第二步:分析 useRoutes 和 Outlet

1. 分析 useRoutes

我们紧接着用开头的例子继续分析,我们把App的代码写成下面的样子:

// 为了演示不同路由的效果,新建了Route1和Route2组件
const Route1 = () => <div>Route1</div>;
const Route2 = () => <div>Route2</div>;
const Page = () => <div>Page</div>;

const App = () => {
  // 通过把路由规则对象传入useRoutes,返回的是根据路由规则生成的Routes
  // 如果按照我们下面传的路由规则对象,则生成的Routes如下所示
  /**
   *  <Routes>
   *    <Route path="/" element={<Page />}/>
   *    <Route path="/route1" element={<Route1 />} />
   *    <Route path="/route2" element={<Route2 />} />
   *  </Routes>
   */
  const element = useRoutes([
    { path: "/", element: <Page /> },
    { path: "/route1", element: <Route1 /> },
    { path: "/route2", element: <Route2 /> },
  ]);

  return <div>{element}</div>;
};

export default App;
复制代码

最终能实现页面内容随着路由的变化而变化,如下动图所示:

2.gif

代码我放到StackBlitz


至于怎么做到这个效果,那我们先以上面的例子从useRoutes开始分析:

// packages\react-router\index.tsx
export function useRoutes(
  routes: RouteObject[],
  locationArg?: Partial<Location> | string
): React.ReactElement | null {
  /*
    RouteContext的声明代码为:
      const RouteContext = React.createContext<RouteContextObject>({
        outlet: null,
        matches: [],
      });
    由于在`BrowserRouter`中没有引用 RouteContext 的逻辑,因此此情况下解构取出的matches为空数组
  */
  let { matches: parentMatches } = React.useContext(RouteContext);
  // 此情况下routeMatch为undefined
  let routeMatch = parentMatches[parentMatches.length - 1];
  // 此情况下parentParams为空对象
  let parentParams = routeMatch ? routeMatch.params : {};
  // 此情况下parentPathname为"/"
  let parentPathname = routeMatch ? routeMatch.pathname : "/";
  // 此情况下parentPathnameBase为"/"
  let parentPathnameBase = routeMatch ? routeMatch.pathnameBase : "/";
  // 此情况下parentRoute为false
  let parentRoute = routeMatch && routeMatch.route;
  /*
    useLocation的代码如下所示:
      function useLocation(): Location {
        return React.useContext(LocationContext).location;
      }
    可看出该函数作用是用于取出LocationContext中的location
   */
  let locationFromContext = useLocation();

  // 如果存在传入的locationArg,则此处的location为locationArg,否则是上面的locationFromContext
  let location;
  if (locationArg) {
    let parsedLocationArg =
      typeof locationArg === "string" ? parsePath(locationArg) : locationArg;
    location = parsedLocationArg;
  } else {
    location = locationFromContext;
  }

  let pathname = location.pathname || "/";
  // 从location.pathname中截取父路由的pathname部分
  let remainingPathname =
    parentPathnameBase === "/"
      ? pathname
      : pathname.slice(parentPathnameBase.length) || "/";
  /*
    matchRoutes的TyprScript声明如下所示:
      declare function matchRoutes(
        routes: RouteObject[],
        location: Partial<Location> | string,
        basename?: string
      ): RouteMatch[] | null;
    该函数会从routes中找出所有匹配location的路由(包括父子路由),然后组成RouteMatch[]格式的数组返回出去
    RouteMatch的TyprScript声明如下所示:
      interface RouteMatch<ParamKey extends string = string> {
        params: Params<ParamKey>;
        pathname: string;
        route: RouteObject;
      }
    此函数的源码由于比较复杂,所以被放在源码分析(深入部分)的内容里分析:
   */
  let matches = matchRoutes(routes, { pathname: remainingPathname });
  /*
    _renderMatches其实就是renderMatches,其声明类型如下所示:
      declare function renderMatches(
        matches: RouteMatch[] | null
      ): React.ReactElement | null;
    其用于把 matchRoutes 函数返回的结果渲染成 React.ReactElement
    */
  return _renderMatches(
    // 对matches进行增强处理
    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
  );
}
复制代码

接下来我们深入分析useRoutes里的_renderMatches函数。在路径名为"/route1"的情况下,此时remainingPathname"/route1",从 matchRoutes(routes, { pathname: remainingPathname })中得出matches的值如下所示:

[
  {
    params: {},
    pathname: "/route1",
    pathnameBase: "/route1",
    route: {
      element: <Route1 />,
      path: "/route1",
    },
  },
];
复制代码

我们把matches代入到_renderMatches进行分析,如下所示:

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

  return matches.reduceRight((outlet, match, index) => {
    return (
      <RouteContext.Provider
        // 此情况下该值为<Route1/>
        children={
          match.route.element !== undefined ? match.route.element : outlet
        }
        /*
          此情况下该值为:
            {
              outlet: null,
              matches: [
                {
                  params:{},
                  pathname: "/route1",
                  pathnameBase: "/route1",
                  route: {
                    element: <Route1/>,
                    path:"/route1"
                  }
                }
              ]
            }
        */
        value={{
          outlet,
          matches: parentMatches.concat(matches.slice(0, index + 1)),
        }}
      />
    );
  }, null as React.ReactElement | null);
}
复制代码

分析到此,我们可以知道,其实useRoutes主要做了两件事:

  1. 根据当前的路由location,从传入的routes中找出所有匹配的路由对象,放到数组matches
  2. renderMatchesmatches渲染成一个React元素,期间会把macthes从尾到头遍历用RouteContext包裹起来,如下所示:

image.png

如果路由规则对象没有定义element属性,则RouteContext.Providerchildren会指向其自身的outlet,如下所示。

image.png

2. 分析 Outlet

上面的过程是在每个路由里都没有子路由的情况下分析的。那我们把App的代码改成带子路由的情况如下面的样子:

// Route1增加Outlet组件用于渲染匹配子路由对应的组件
const Route1 = () => (
  <div>
    Route1
    <Outlet />
  </div>
);
const Route2 = () => <div>Route2</div>;
const Page = () => <div>Page</div>;

const App = () => {
  const element = useRoutes([
    { path: "/", element: <Page /> },
    {
      path: "/route1",
      element: <Route1 />,
      // 新增子路由规则
      children: [
        { path: "name1", element: <div>name1</div> },
        { path: "name2", element: <div>name2</div> },
      ],
    },
    { path: "/route2", element: <Route2 /> },
  ]);

  return <div>{element}</div>;
};
复制代码

在路径名为/roue1/name1下,页面如下所示:

image.png

以上代码我放到StackBlitz


根据上一章节可知,当路径名为'/route1/name1'时,在useRoutes中,matchRoutes(routes, { pathname: remainingPathname })中得出matches的值如下所示:

[
  {
    params: {},
    pathname: "/route1",
    pathnameBase: "/route1",
    route: {
      path: "/route1",
      element: <Route1 />,
      children: [
        { path: "name1", element: <div>name1</div> },
        { path: "name2", element: <div>name2</div> },
      ],
    },
  },
  {
    params: {},
    pathname: "/route1/name1",
    pathnameBase: "/route1/name1",
    route: {
      path: "name1",
      element: <div>name1</div>,
    },
  },
];
复制代码

此时useRoutes返回的React元素如下所示:

image.png

基于上面的条件下,我们来分析Outlet的源码:

// packages\react-router\index.tsx
export function Outlet(props: OutletProps): React.ReactElement | null {
  return useOutlet(props.context);
}
复制代码

由于Outlet是直接返回调用useOutlet的结果,因此我们继而分析useOutlet函数:

// packages\react-router\index.tsx
export function useOutlet(context?: unknown): React.ReactElement | null {
  let outlet = React.useContext(RouteContext).outlet;
  if (outlet) {
    return (
      <OutletContext.Provider value={context}>{outlet}</OutletContext.Provider>
    );
  }
  return outlet;
}
复制代码

这里就可以知道,其实<Outlet/>就是把当前RouteContextoutlet值渲染出来,如下所示。

image.png

第三步:分析 useNavigate

基于上面的代码示例我们再改善一下,在AppRoute1中加入能更改路径名的按钮,如下所示:

const Route1 = () => {
  // 获取能改变路由的navigate函数
  const navigate = useNavigate();
  // 用于改变路由的函数
  const pushPage = (pathname) => {
    navigate(pathname);
  };

  return (
    <div>
      Route1
      <div>
        <button onClick={() => pushPage({ pathname: "name1" })}>/name1</button>
        <button onClick={() => pushPage({ pathname: "name2" })}>/name2</button>
      </div>
      <Outlet />
    </div>
  );
};
const Route2 = () => <div>Route2</div>;
const Page = () => <div>Page</div>;

const App = () => {
  const element = useRoutes([
    { path: "/", element: <Page /> },
    {
      path: "/route1",
      element: <Route1 />,
      // 新增子路由规则
      children: [
        { path: "name1", element: <div>name1</div> },
        { path: "name2", element: <div>name2</div> },
      ],
    },
    { path: "/route2", element: <Route2 /> },
  ]);
  // 获取能改变路由的navigate函数
  const navigate = useNavigate();
  // 用于改变路由的函数
  const pushPage = (pathname: string) => {
    navigate(pathname);
  };

  return (
    <div>
      <button onClick={() => pushPage("/")}>/</button>
      <button onClick={() => pushPage("/route1")}>/route1</button>
      <button onClick={() => pushPage("/route1/name1")}>/route1/name1</button>
      <button onClick={() => pushPage("/route1/name2")}>/route1/name2</button>
      <button onClick={() => pushPage("/route2")}>/route2</button>

      {element}
    </div>
  );
};
复制代码

至于为什么要在Route1App里都用上useNavigate,是因为useNavigate路由函数组件(被RouteContext.Provider包裹的组件,如<Route1/>)和非路由函数组件(如<App/>)有不同的执行逻辑。这样子有利于我们在分析useNavigate源码时做横向对比。

动态效果如下所示:

useNavigate.gif

从上面的动图结合示例代码可以看出:

  1. navigate可以传入对象或字符串,都可以达到改变路由的效果
  2. navigate传入对象时,其pathname是相对于当前路由的pathname变化的。

以上示例的代码我放在stackblitz


现在我们基于上面的示例,从<App/><Route1/>的角度来分析下useNavigate内部的执行逻辑:

export function useNavigate(): NavigateFunction {
  // 在BrowserRouter中,用NavigationContext包裹着children,
  // 其中basename为BrowserRouter中的basename,navigator为createBrowserHistory创建出的history
  let { basename, navigator } = React.useContext(NavigationContext);
  // 在<App/>中,matches是空数组。
  // 在<Route1/>中,matches是[ RouteMatch(对应Route1) ]
  let { matches } = React.useContext(RouteContext);
  // 把hsitory.location.pathname的值赋给locationPathname
  let { pathname: locationPathname } = useLocation();
  // 在<App/>中,routePathnamesJson为"[]"。
  // 在<Route1/>中,routePathnamesJson为'["/route1"]'。
  // 这里用字符串而非数组的格式是为了下面由useCallback生成的navigate不会因为其值的变化而变化
  let routePathnamesJson = JSON.stringify(
    matches.map((match) => match.pathnameBase)
  );
  // 标志位,在初次渲染之前activeRef为false,其余情况为true
  let activeRef = React.useRef(false);
  React.useEffect(() => {
    activeRef.current = true;
  });

  let navigate: NavigateFunction = React.useCallback(
    (to: To | number, options: NavigateOptions = {}) => {
      // 如果调度所在的函数组件还没初次渲染,则不走下面的流程
      if (!activeRef.current) return;
      // 如果传入参数是数字类型数据如navigate(-1),则调用go函数(即history.go)
      if (typeof to === "number") {
        navigator.go(to);
        return;
      }
      /*
        resolveTo的作用可以参考useResolvedPath(https://reactrouter.com/docs/en/v6/api#useresolvedpath),useResolvedPath内部其实也是调用了resolveTo。其作用在于把to变量转换为数据结构为
        {hash:string,search:string,pathname:string}的path变量,以作为形参被navigate.push和navigate.replace调用。
        关于resolveTo的详细分析我在分析完 useNavigate 后的内容里补充呈现。
      */
      let path = resolveTo(
        to,
        JSON.parse(routePathnamesJson),
        locationPathname
      );

      if (basename !== "/") {
        // const joinPaths = (paths: string[]): string => paths.join("/").replace(/\/\/+/g, "/");
        path.pathname = joinPaths([basename, path.pathname]);
      }
      // 根据navigate中的第二形参是否带{repalce: true}来决定调用history.push还是history.replace
      (!!options.replace ? navigator.replace : navigator.push)(
        path,
        options.state
      );
    },
    [basename, navigator, routePathnamesJson, locationPathname]
  );

  return navigate;
}
复制代码

useNavigate得知,其内部逻辑主要用于处理to(即第一形参)的格式:

  1. 如果tonumber类型数据,则调用history.go处理
  2. 如果tostringobject类型数据,则通过resolveTo把其转换为Path类型的数据然后调用history.pushhistory.replace处理。

navigate自身只是用于无刷新地改变路由。但因为在BrowserRouter中有这部分逻辑:

export function BrowserRouter({
  basename,
  children,
  window,
}: BrowserRouterProps) {
  // ...省略其他代码
  React.useLayoutEffect(() => history.listen(setState), [history]);
  // ...省略其他代码
}
复制代码

history.gohsitory.pushhsitory.replace在执行后都会触发执行history.listen中注册的函数,而setState的执行会让BrowserRouter及其children更新,从而让页面响应式变化。


现在来看一下useNavigate中用到的resolveTo函数,从TypeScript的语法上来看,该函数是把 传入的变量从To声明类型 转换成 Path声明类型。ToPath的声明类型如下所示:

export declare type To = string | PartialPath;
type PartialPath = Partial<Path>

export interface Path {
  pathname: Pathname;
  search: Search;
  hash: Hash;
}
export declare type Pathname = string;
export declare type Search = string;
export declare type Hash = string;
复制代码

除了转换成Path声明类型的数据resolveTo函数还有一个更重要的作用:重点处理to.pathname中涉及到相对路径的写法

举个例子:如果在"/route1/name1"对应的组件里中使用navigate({pathname: "../name2"}),则路由会跳转到"/route/name2"。大家可以通过这个例子stackblitz来体验一下。

说了这么多了,是时候来看一下resolveTo的源码了:

// packages\react-router\index.tsx
function resolveTo(
  toArg: To,
  // 即matches.map((match) => match.pathnameBase),macthes从当前RouteContext中取出
  routePathnames: string[],
  // 即location.pathname
  locationPathname: string
): Path {
  // parsePath引用自history库,用于把路径字符串转换为Path类型的数据
  let to = typeof toArg === "string" ? parsePath(toArg) : toArg;
  let toPathname = toArg === "" || to.pathname === "" ? "/" : to.pathname;

  // 官方注释:
  //   If a pathname is explicitly provided in `to`, it should be relative to the
  //   route context. This is explained in `Note on `<Link to>` values` in our
  //   migration guide from v5 as a means of disambiguation between `to` values
  //   that begin with `/` and those that do not. However, this is problematic for
  //   `to` values that do not provide a pathname. `to` can simply be a search or
  //   hash string, in which case we should assume that the navigation is relative
  //   to the current location's pathname and *not* the route pathname.
  // 从官方注释可知:
  //    1. 如果to.pathname被定义了,则该值是相对于当前路由上下文去运行的。
  //    2. 而且存在一种情况是to没有定义pathname而是定义了hash或search,这种情况下也是可以运行的,此时会基于当前路由而变化
  // 由于to.pathname可以用相对路径的写法。因此需要from记录把哪个路由作为起点进行跳转的,
  // 这里把from记录的路由成为“基准路由”
  let from: string;
  if (toPathname == null) {
    from = locationPathname;
  } else {
    // routePathnameIndex用于记录“基准路由”是取自routePathnames的第几个元素
    let routePathnameIndex = routePathnames.length - 1;

    if (toPathname.startsWith("..")) {
      let toSegments = toPathname.split("/");

      // Each leading .. segment means "go up one route" instead of "go up one
      // URL segment".  This is a key difference from how <a href> works and a
      // major reason we call this a "to" value instead of a "href".
      // 从官方注释可知,".."代表以父路由路径名为基准进行跳转。可以有多个".."合并使用例如"../../"
      // 处理to.pathname中的".."情况,每当存在一个"..",则routePathnameIndex减1
      while (toSegments[0] === "..") {
        toSegments.shift();
        routePathnameIndex -= 1;
      }

      to.pathname = toSegments.join("/");
    }

    // If there are more ".." segments than parent routes, resolve relative to
    // the root / URL.
    // 如果to.pathname中的".."太多导致routePathnameIndex<0,则from取根目录
    from = routePathnameIndex >= 0 ? routePathnames[routePathnameIndex] : "/";
  }
  /*
    resolvePath是一个对外export的API(https://reactrouter.com/docs/en/v6/api#resolvepath)
    其声明类型如下:
      declare function resolvePath(
        to: To,
        fromPathname?: string
      ): Path;
    其作用在于根据to和from生成一个pathname为绝对路径的Path类型变量,
    为什么需要pathname为绝对路径?
      因为打最后我们调用navigator.push或navigator.replace时,传入的Path类型变量的pathname只能是绝对路径
      react-router中的navigate支持其形参中pathname为相对路径或绝对路径,
      但history.push和history.replace只支持其形参中的pathname为绝对路径
  */
  let path = resolvePath(to, from);

  // Ensure the pathname has a trailing slash if the original to value had one.
  if (
    toPathname &&
    toPathname !== "/" &&
    toPathname.endsWith("/") &&
    !path.pathname.endsWith("/")
  ) {
    path.pathname += "/";
  }

  return path;
}
复制代码

对于resolveTo以及useNavigate分析就到此为止,如果有什么不懂的可以在评论区

第四步:分析 Link

我们继续在上面App的基础上去修改,把<button/>改成<Link/>,如下所示:

const Route1 = () => {
  return (
    <div>
      Route1
      <div>
        {/* <button onClick={() => pushPage({ pathname: 'name1' })}>/name1</button>
        <button onClick={() => pushPage({ pathname: 'name2' })}>/name2</button> */}
        {/*换成Link*/}
        <Link to={{ pathname: "name1" }}>{`{pathname: 'name1'}`}</Link>&emsp;
        <Link to={{ pathname: "name2" }}>{`{pathname: 'name2'}`}</Link>
      </div>
      <Outlet />
    </div>
  );
};
const Route2 = () => <div>Route2</div>;
const Page = () => <div>Page</div>;

const App = () => {
  const element = useRoutes([
    { path: "/", element: <Page /> },
    {
      path: "/route1",
      element: <Route1 />,
      // 新增子路由规则
      children: [
        { path: "name1", element: <div>name1</div> },
        { path: "name2", element: <div>name2</div> },
      ],
    },
    { path: "/route2", element: <Route2 /> },
  ]);

  return (
    <div>
      {/* <button onClick={() => pushPage('/')}>/</button>
      <button onClick={() => pushPage('/route1')}>/route1</button>
      <button onClick={() => pushPage('/route1/name1')}>/route1/name1</button>
      <button onClick={() => pushPage('/route1/name2')}>/route1/name2</button>
      <button onClick={() => pushPage('/route2')}>/route2</button> */}
      {/*换成Link*/}
      <Link to="/">/</Link>&emsp;
      <Link to="/route1">/route1</Link>&emsp;
      <Link to="/route1/name1">/route1/name1</Link>&emsp;
      <Link to="/route1/name2">/route1/name2</Link>&emsp;
      <Link to="/route2">/route2</Link>&emsp;
      {element}
    </div>
  );
};
复制代码

动图效果如下所示:

Link.gif

以上例子我放在stackblitz上。


接下来我们要基于上面的例子来学习Link的源码,但在学习之前,我们先了解Link源码中重点用到的两个函数useHrefuseLinkClickHandler

useHref

// packages\react-router\index.tsx
/*
  https://reactrouter.com/docs/en/v6/api#usehref
  官方解释:该API用于根据给定的to变量生成一个可以跳转到指定路由的URL字符串
*/
export function useHref(to: To): string {
  let { basename, navigator } = React.useContext(NavigationContext);
  /*
    https://reactrouter.com/docs/en/v6/api#useresolvedpath
    useResolvedPath的作用和resolveTo一样(useResolvedPath内部就是调用了resolveTo且返回该函数的处理结果)
    即根据给定的to变量返回Path类型的变量,其中会处理to.pathname的相对路径写法。
  */
  let { hash, pathname, search } = useResolvedPath(to);

  let joinedPathname = pathname;
  if (basename !== "/") {
    /*
      getToPathname作用在于从给定to获取pathname,源码如下所示:
        function getToPathname(to: To): string | undefined {
          // Empty strings should be treated the same as / paths
          return to === "" || (to as Path).pathname === ""
            ? "/"
            : typeof to === "string"
            ? parsePath(to).pathname
            : to.pathname;
        }
    */
    let toPathname = getToPathname(to);
    let endsWithSlash = toPathname != null && toPathname.endsWith("/");
    joinedPathname =
      pathname === "/"
        ? basename + (endsWithSlash ? "/" : "")
        // const joinPaths = (paths: string[]): string => paths.join("/").replace(/\/\/+/g, "/");
        : joinPaths([basename, pathname]);
  }
  // 使用history.createHref(https://github.com/remix-run/history/blob/main/docs/api-reference.md#historycreatehrefto-to)把To类型变量转变成URL字符串,
  // 因为hsitory.createHref不具备支持basename和to.pathname的相对路径写法。因此有了上面的处理这两者的逻辑
  return navigator.createHref({ pathname: joinedPathname, search, hash });
}
复制代码

useLinkClickHandler

// packages\react-router-dom\index.tsx
/*
  https://reactrouter.com/docs/en/v6/api#uselinkclickhandler
  官方解释:该API用于生成一个用于导航的点击事件,这个点击事件用于自定义的`<Link/>`组件
*/
export function useLinkClickHandler<E extends Element = HTMLAnchorElement>(
  to: To,
  {
    target,
    replace: replaceProp,
    state,
  }: {
    // React.HTMLAttributeAnchorTarget声明类型:type HTMLAttributeAnchorTarget = '_self' | '_blank' | '_parent' | '_top'  | (string & {});(与a标签的target一样)
    target?: React.HTMLAttributeAnchorTarget;
    // 定义跳转的行为是PUSH还是REPLACE
    replace?: boolean;
    // 跳转的时候可以在此定义即将跳转的路由的location.state
    state?: any;
  } = {}
): (event: React.MouseEvent<E, MouseEvent>) => void {
  let navigate = useNavigate();
  let location = useLocation();
  // useResolvedPath在上面已经介绍过了,这里就不再重复介绍了
  let path = useResolvedPath(to);

  return React.useCallback(
    (event: React.MouseEvent<E, MouseEvent>) => {
      if (
        /*
          MouseEvent.button是只读属性,它返回一个number类型值来代表什么键被操作,例如:
            0:主按键,通常指鼠标左键或默认值(译者注:如document.getElementById('a').click()这样触发就会是默认值)
            1:辅助按键,通常指鼠标滚轮中键
          更多内容可看:https://developer.mozilla.org/zh-CN/docs/Web/API/MouseEvent/button
        */
        event.button === 0 &&
        // 如果target已被定义且并非_self值,则执行默认事件
        (!target || target === "_self") &&
        /*
          isModifiedEvent用于检测该事件是否是鼠标 + 键盘键一并触发
          isModifiedEvent源码:
            function isModifiedEvent(event: React.MouseEvent) {
              return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
            }
        */
        !isModifiedEvent(event)
      ) {
        // 阻止a标签被点击后默认事件的执行
        event.preventDefault();

        /*
          replace变量用于决定此次跳转行为是PUSH还是REPLACE,
          其中createPath(location) === createPath(path)的逻辑是,如果新路由的url与当前路由的一致,则使用REPLACE
          此处createPath其实是history.createPath,用于给定的Partial<Path>类型的变量生成URL字符串,官方地址:
            https://github.com/remix-run/history/tree/main/docs/api-reference.md#createpath
        */
        let replace = !!replaceProp || createPath(location) === createPath(path);

        navigate(to, { replace, state });
      }
    },
    [location, navigate, path, replaceProp, state, target, to]
  );
}
复制代码

在分析完上面的两个函数后,我们就直接来分析Link组件的代码:

// packages/react-router-dom/index.tsx
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
  function LinkWithRef(
    { onClick, reloadDocument, replace = false, state, target, to, ...rest },
    ref
  ) {
    let href = useHref(to);
    let internalOnClick = useLinkClickHandler(to, { replace, state, target });
    function handleClick(
      event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
    ) {
      // 如果Link中还定义了onClick,则先执行onClick中定义的事件
      if (onClick) onClick(event);
      // event.defaultPrevented 返回一个布尔值,表明当前事件是否调用了 event.preventDefault()方法。
      // 因为onClick定义的事件里可能调用了event.preventDefault,因此这里做个判断
      if (!event.defaultPrevented && !reloadDocument) {
        internalOnClick(event);
      }
    }

    return (
      <a
        {...rest}
        href={href}
        onClick={handleClick}
        ref={ref}
        target={target}
      />
    );
  }
);
复制代码

这里总结一下,当<Link/>被点击时,它会调用navigate去跳转路由(除非指定特定参数使其不跳转),而从上一章节分析useNavigate我们知道navigate的调用会间接性触发页面响应式更新,这里就不再重复了。


截止到现在,基本整个react-router的工作原理已经基本讲完,如果对其中细节想深究的可以看下面的源码深入分析部分。

源码分析(深入部分)

matchRoutes

在学习matchRoutes前,我们先看一个例子:

const App = () => {
  // 路由规则顺序随意,都不影响
  const element = useRoutes([
    { path: '/', element: <div>/</div> },
    { path: '/app', element: <div>/app</div> },
    { path: '/:arg', element: <div>/:arg</div> },
    { path: '*', element: <div>*</div> },
  ]);

  return (
    <div>
      {element}
    </div>
  );
};
复制代码

根据上面App中的路由规则,如果页面路由为下面这些的时候,你能猜出页面会显示什么吗?

  1. /
  2. /app
  3. /app1

例子代码我放在stackblitz里了,大家有兴趣可以点进去玩玩。


我们现在来说说matchRoutes,其实他就是用于帮我们解决上面例子中路由规则冲突的问题。**它通过一系列的匹配算法来检测哪一个路由规则最契合给定的location。如果有匹配的,则返回类型为RouteMatch[]的数组。**现在我们来分析一下matchRoutes的源码:

/*
packages\react-router\index.tsx
官网地址:https://reactrouter.com/docs/en/v6/api#matchroutes
*/
export function matchRoutes(
  routes: RouteObject[],
  locationArg: Partial<Location> | string,
  basename = "/"
): RouteMatch[] | null {
  /*
    如果locationArg为字符串,则转化为Path类型的变量,Path与Location两者的声明类型如下:
      export interface Location extends Path {
        state: unknown;
        key: Key;
      }

      export interface Path {
        pathname: Pathname;
        search: Search;
        hash: Hash;
      }
  */
  let location = typeof locationArg === "string" ? parsePath(locationArg) : locationArg;
  /*
    stripBasename用于去掉location.pathname中作为basename的前缀部分,
    如果location.pathname没有这部分前缀,则返回null
  */
  let pathname = stripBasename(location.pathname || "/", basename);

  if (pathname == null) {
    return null;
  }
  // 以下为matchRoutes的核心步骤:
  /*
    1. flattenRoutes用于把所有路由规则,包括children里的子路由规则全部铺平成一个一维数组,
    且给每个路由打上分数,分数代表路由的质量
  */
  let branches = flattenRoutes(routes);
  // 2. rankRouteBranches用于根据flattenRoutes中路由被打上的分数进行倒序排序
  rankRouteBranches(branches);
  // 3. 遍历branches,通过matchRouteBranch获取第一个匹配pathname的路由规则作为matches
  let matches = null;
  for (let i = 0; matches == null && i < branches.length; ++i) {
    matches = matchRouteBranch(branches[i], pathname);
  }

  return matches;
}
复制代码

对于matchRoutes,我们要重点分析注释在其上的核心三个步骤,才能更好的掌握其原理,接下来就开始依次分析这三个步骤及其用到的函数:

1. flattenRoutes(铺平路由与给路由打分)

flattenRoutes主要用于铺平路由,其里面的computeScore会给路由打分。下面而我们依次分析这两个函数

/*
  flattenRoutes用于把所有路由规则,包括children里的子路由规则全部铺平成一个一维数组,
  且给每个路由打上分数,分数代表路由的质量
*/
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,
    };
    /*
      如果path带"/"作为前缀,默认其路由是绝对路径,此时要把父路由截取掉,
      使其成为相对路径后,赋予给meta.relativePath
    */
    if (meta.relativePath.startsWith("/")) {
      meta.relativePath = meta.relativePath.slice(parentPath.length);
    }
    // 与父路由路径名合并成一个绝对路径,赋予给path
    let path = joinPaths([parentPath, meta.relativePath]);
    // 合并父路由数据,生成新的路由数据列表
    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) {
      flattenRoutes(route.children, branches, routesMeta, path);
    }

    // Routes without a path shouldn't ever match by themselves unless they are
    // index routes, so don't add them to the list of possible branches.
    // 如果存在路由规则中path和index都没定义,则跳过。这种路由规则属于语法错误
    if (route.path == null && !route.index) {
      return;
    }
    // 把path,score(此变量和computeScore留在后面解释),routesMate存入branches标签里
    branches.push({ path, score: computeScore(path, route.index), routesMeta });
  });

  return branches;
}
复制代码

为了让大家更深入地了解flattenRoutes的执行逻辑,我们举一个路由规则routes作为例子,routes数据值如下所示:

[
  { path: "master1",  element: <M1/> },
  {
    path: "master2",
    element: <M2/>,
    children: [
      { index : true, element: <M2-1/> },
      { path : "branch2", element: <M2-2/> },
    ]
  },
  {
    path: "master3",
    element: <M3/>,
    children: [
      { path: "branch1", element: <M3-1> },
      { path: ":arg", element: <M3-2> },
    ]
  }
]
复制代码

在经过flattenRoutes(routes)转换后得出的branches如下所示:

// score和route和parentRoutesMate不展开展示,parentRoutesMate即其父路由的routesMeta,这里重点是展示`flattenRoutes`把routes铺平后的效果
[
  // 对应<M1/>所在的路由规则
  { path:"master1", score, routesMeta: [ {relativePath: "master1", caseSensitive: false, childrenIndex: 0, route}] },
  // 对应<M2-1/>所在的路由规则
  { path:"master2", score, routesMeta: [ ...parentRoutesMata, {relativePath: "", caseSensitive: false, childrenIndex: 0, route} ] },
  // 对应<M2-2/>所在的路由规则
  { path:"master2/branch2", score, routesMeta:[ ...parentRoutesMata, {relativePath: "branch2", caseSensitive: false, childrenIndex: 1, route} ] },
  // 对应<M2/>所在的路由规则
  { path:"master2", score, routesMeta: [ {relativePath: "master2", caseSensitive: false, childrenIndex: 1, route} ] },
  // 对应<M3-1/>所在的路由规则
  { path:"master3/branch1", score, routesMeta: [ ...parentRoutesMata, {relativePath: "branch1", caseSensitive: false, childrenIndex: 0, route} ] },
  // 对应<M3-2/>所在的路由规则
   { path:"master3/:arg", score, routesMeta: [ ...parentRoutesMata, {relativePath: ":arg", caseSensitive: false, childrenIndex: 1, route} ] },
  // 对应<M3/>所在的路由规则
  { path:"master3", score, routesMeta: [ {relativePath: "master3", caseSensitive: false, childrenIndex: 2, route} ] },
]
复制代码

如果只从path属性去看routesbranches的转换可看下图:

image.png

现在我们来说一下flatternRoutes打分环节涉及到的函数computeScore:

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;
  // segments中含一个或多个*,都在初始分数上减2分
  if (segments.some(isSplat)) {
    initialScore += splatPenalty;
  }
  // 如果route.index为true,则加2分
  if (index) {
    initialScore += indexRouteValue;
  }
  /*
    对路径片段进行检测累加分数:
    1. 如果是如:arg之类的匹配,则加3分
    2. 如果是空字符串,则加1分
    3. 如果是常量字符串如"branch1",则加10分
  */
  return segments
    .filter((s) => !isSplat(s))
    .reduce(
      (score, segment) =>
        score +
        (paramRe.test(segment)
          ? dynamicSegmentValue
          : segment === ""
          ? emptySegmentValue
          : staticSegmentValue),
      initialScore
    );
}
复制代码

为了加深对computeScore的理解,我们可以举几个路由得例子来看看对应的score的计算方式,如下图所示:

image.png

2. rankRouteBranches(给铺平的路由排序)

已知rankRouteBranches的功能是给flatternRoutes生成的branches排序,我们来看一下其源码设计:

/*
  根据branch的score和routeMeta中的childrenIndex进行排行
*/
function rankRouteBranches(branches: RouteBranch[]): void {
  branches.sort((a, b) =>
    a.score !== b.score
      ? b.score - a.score // Higher score first
      : compareIndexes(
          a.routesMeta.map((meta) => meta.childrenIndex),
          b.routesMeta.map((meta) => meta.childrenIndex)
        )
  );
}

function compareIndexes(a: number[], b: number[]): number {
  // 判断是否为兄弟路由
  let siblings =
    a.length === b.length && a.slice(0, -1).every((n, i) => n === b[i]);

  return siblings
    ? // If two routes are siblings, we should try to match the earlier sibling
      // first. This allows people to have fine-grained control over the matching
      // behavior by simply putting routes with identical paths in the order they
      // want them tried.
      // 官方注释意思为:如果是兄弟路由,则允许开发者自己通过调整兄弟路由彼此的索引来决定其排行,
      // 路由索引较小的,即在父路由children中靠前的,其排行也相对靠前
      a[a.length - 1] - b[b.length - 1]
    : // Otherwise, it doesn't really make sense to rank non-siblings by index,
      // so they sort equally.
      // 如果不是兄弟路由,则不作处理
      0;
}
复制代码

对于rankRouteBranches就不作补充了,因为比较好理解,如果有不懂的可以在评论留言。

3. matchRouteBranch(获取第一个匹配的路由规则)

我们再次看一下matchRoutes中是如何调用matchRouteBranch的:

let matches = null;
// matches == null条件决定了一旦找到匹配的就结束循环,
// 等同于branches.find(branch=>matchRouteBranch(branches[i], pathname))
for (let i = 0; matches == null && i < branches.length; ++i) {
  matches = matchRouteBranch(branches[i], pathname);
}
复制代码

现在我们来看一下matchRouteBranch的源码:

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

  let matchedParams = {};
  let matchedPathname = "/";
  // matchedPathname用于存储上一次循环中匹配成功的路径
  let matches: RouteMatch[] = [];
  // 遍历routesMeta,用matchPath去校验是否所有路由信息都与给定路径名匹配,
  // 如果存在一个不匹配,则直接返回null
  for (let i = 0; i < routesMeta.length; ++i) {
    let meta = routesMeta[i];
    let end = i === routesMeta.length - 1;
    /*
      remainingPathname会用于matchPath的第二参数,
      基于pathname截掉matchedPathname后的值,以保证每次匹配的路径都处于前缀
    */
    let remainingPathname =
      matchedPathname === "/"
        ? pathname
        : pathname.slice(matchedPathname.length) || "/";
    /*
      官方地址:https://reactrouter.com/docs/en/v6/api#matchpath
      matchPath用于将路由路径与给定的路径名pathname匹配,并返回有关匹配的信息。
    */
    let match = matchPath(
      /*
        注意此处的end,如果end为false,只会匹配路径名的前缀。
        如果end为true或undefined,则严格匹配整个路径名,如:

          matchPath(
            {path:"app"},
            "/app/name"
          )
          结果会返回null,表示匹配失败

          matchPath(
            {path:"app",end: false},
            "/app/name"
          )
          结果会返回PathMatch类型的对象,表示匹配成功
      */
      { path: meta.relativePath, caseSensitive: meta.caseSensitive, end },
      remainingPathname
    );

    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 !== "/") {
      matchedPathname = joinPaths([matchedPathname, match.pathnameBase]);
    }
  }

  return matches;
}
复制代码

对于matchRouteBranch,其核心部分就是for循环里的matchPath的调用,期间要注意的是matchedPathnameremainingPathname的变化。只需要通过例子待入思考即可。


至此关于macthRoutes的内容已全部分析完。

<Routes/><Route/>

平时开发里,我们直接把路由规则写在对象routes里。然后再通过useRoutes(routes)去获取匹配当前路由的组件,把组件渲染出来,如下所示:

const Route1 = () => <div>Route1<Outlet/></div>;
const Route2 = () => <div>Route2</div>;
const Page = () => <div>Page</div>;

const App = () => {
  const element = useRoutes([
    { path: "/", element: <Page /> },
    {
      path: "/route1",
      element: <Route1 />
      children: [
        { path: 'branch1', element: <div>branch1</div>}
      ]
    },
    { path: "/route2", element: <Route2 /> },
  ]);

  return <div>{element}</div>;
};

export default App;
复制代码

但其实还有另一种直接在jsx中通过RoutesRoute定义路由规则,组件会根据当前路由渲染对应的组件,如下所示:

const Route1 = () => <div>Route1<Outlet/></div>;
const Route2 = () => <div>Route2</div>;
const Page = () => <div>Page</div>;

const App = () => {
  return (<Routes>
    <Route path="/" element={ <Page /> }/>
    <Route path="/route1" element={ <Route1 /> }>
      <Route path="branch1" element={ <div>branch1</div> }/>
    </Route>
    <Route path="/route2" element={ <Route2 /> }/>
  </Routes>);
};

export default App;
复制代码

上面两种写法在页面实现的效果是一样的。


那么现在我们来分析以下<Routes/><Route/>的源码:

我们先看<Route/>的源码:

// packages\react-router\index.tsx
export interface PathRouteProps {
  caseSensitive?: boolean;
  children?: React.ReactNode;
  element?: React.ReactNode | null;
  index?: false;
  path: string;
}

export interface LayoutRouteProps {
  children?: React.ReactNode;
  element?: React.ReactNode | null;
}

export interface IndexRouteProps {
  element?: React.ReactNode | null;
  index: true;
}
/*
  官方地址:https://reactrouter.com/docs/en/v6/api#route

*/
export function Route(
  _props: PathRouteProps | LayoutRouteProps | IndexRouteProps
): React.ReactElement | null {

}
复制代码

这里的Route甚至连返回语句都没有,即返回结果为undefined,但我们却要对其填写如path,element,children这些属性,究竟为什么这么设计的?我们从Routes的源码中寻找思路:

// packages\react-router\index.tsx
/*
  官方地址:https://reactrouter.com/docs/en/v6/api#routes
*/
export function Routes({
  children,
  location,
}: RoutesProps): React.ReactElement | null {
  // 从useRoutes的调用我们可以推理出,Routes会通过createRoutesFromChildren函数把其children,也就是Route组件转换成RouteObject[],
  return useRoutes(createRoutesFromChildren(children), location);
}
复制代码

Routes中的核心是调用useRoutescreateRoutesFromChildren,那么接下来我们要去分析以下createRoutesFromChildren的源码:

// packages\react-router\index.tsx
/*
  官方地址:https://reactrouter.com/docs/en/v6/api#createroutesfromchildren
*/
export function createRoutesFromChildren(
  children: React.ReactNode
): RouteObject[] {
  let routes: RouteObject[] = [];
  // 调用React.Children.forEach遍历children
  React.Children.forEach(children, (element) => {
    // 如果不是React组件,则不做处理。
    // 例如{condition&&<Route path="xx"/>},如果condition为false,
    // 则此处为布尔值false而非Route组件,此情况下不作处理
    if (!React.isValidElement(element)) {
      return;
    }
    // 如果element为React.Fragment,则遍历其子组件
    if (element.type === React.Fragment) {
      // 此写法相当于routes.push(...createRoutesFromChildren(element.props.children))
      routes.push.apply(
        routes,
        createRoutesFromChildren(element.props.children)
      );
      return;
    }
    // 从element,也就是Route组件中取出其注入值
    // 这里我们就可以看出,其实Route函数不需要返回jsx,
    // 因为Routes的核心是从在其内部的每一个Route.prop取出部分属性组成RouteObject
    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;
}
复制代码

RoutesRoute的分析就到此为止了,源码相对简单。总结来说就是<Routes/>把在其内部定义的<Route/>转换为RouteObject[],然后给useRoutes调用。

后记

这篇文章到这里就结束了,目前上面分析到的都是我开发中比较常用的部分。如果之后在开发中用到别的部分也会在继续分析然后总结到这篇文章里。

猜你喜欢

转载自juejin.im/post/7075146381907722276