react-router-dom v6解析

1,862 阅读12分钟

v6使用hooks+ts对进行了重新的实现,相比与v5,打包之后的体积有所减少 性能有所提升。

npm地址 包分析网站

image.png

注:由于我们在项目中大多使用的BrowserRouter路由, 所以我们在本文只关注了history库中的BrowserHistory以及react-router-dom中的BrowserRouter的实现。

react-router-dom的实现结构

history库

  1. 重新抽象、定义window.History、window.Location等对象
  1. 提供 createBrowserHistory等三种类型的history ,封装增强window.history,history对象作为三种主要路由的导航器使用。

react-router: 实现了路由的核心功能

  1. 提供Router、Routes、Route等核心组件
  1. 提供根据路由配置以及当前location 生成或者重渲染组件树的能力
  1. 提供useLocation、useNavigate等hooks

react-router-dom 基于 react-router ,加入了在浏览器运行环境下的一些功能

  1. 提供BrowserRouter、HashRouter、Link等的定义
  1. 提供 useSearchParams等hooks

image.png

History库

使用 React 开发稍微复杂一点的应用,React Router 几乎是路由管理的唯一选择。history 库是 实现React Router 的核心依赖库。

history库所做的事情

  1. 借鉴HTML 5 history对象的理念,再次基础上进行扩展。
  1. 提供了3种类型的history:browserHistory,hashHistory,memoryHistory,并保持统一的api。 对应生成不同的router。 老浏览器的history: 主要通过hash来实现,对应createHashHistory 高版本浏览器: 通过html5里面的history,对应createBrowserHistory node环境下: 主要存储在memeory里面,对应createMemoryHistory
  1. 支持发布/订阅功能,当history发生改变的时候,可以自动触发订阅的函数。 history.listen()
  1. 提供跳转拦截、跳转确认和basename等功能。

原生的window.history对象

在浏览器进行前进/后退操作的时候,实际上就是调用history对象的对应方法(forward/back),取出对应的state,从而进行页面的切换。

除了操作url,history对象还提供2个不用通过操作url也能更新内部state的方法,分别是pushStatereplaceState。还能将额外的数据存到state中,然后在onpopstate事件中再通过event.state取出来。 了解更多history对象

History库与原生window.history的对比

History库是对原生history的重新实现,功能更强大。

let history: BrowserHistory = {
    get action() {
      return action; // Pop、Push、Replace
    },
    get location() {  // Location对象 封装了pathname、search、hash、state、key等属性
      return location;
    },
    createHref, // 将history内部定义的Path对象转换为原始的 url
    push,    // 导航到新的路由,并记录在history中
    replace,    // 替换掉当前记录在history中的路由信息
    go,    // 前进或后退n个记录
    back() {
      go(-1);
    },
    forward() {
      go(1);
    },
    listen(listener) { // 订阅history变更事件
      return listeners.push(listener);
    },
    block(blocker) {} //跳转前让用户确定是否要跳转
  };

  return history;

抽象化的Path与Location

Link组件中的to支持传入string类型 也支持传入此处定义的Path类型的对象。

对于一次路由的跳转,history进行了两层抽象:

  1. 基于url创建的Path对象,把url解析为path、query、hash。
export type Pathname = string;
export type Search = string;
export type Hash = string;

export type To = string | Partial<Path>;

// 一次跳转 url 对应的对象
export interface Path {
  pathname: Pathname;
  search: Search;
  hash: Hash;
}

下面是history提供的url与Path对象相互转换的方法:

/**
 *  pathname + search + hash 创建完整 url
 */
 export function createPath({
  pathname = '/',
  search = '',
  hash = ''
}: Partial<Path>) {
  if (search && search !== '?')
    pathname += search.charAt(0) === '?' ? search : '?' + search;
  if (hash && hash !== '#')
    pathname += hash.charAt(0) === '#' ? hash : '#' + hash;
  return pathname;
}

/**
 * 解析 url,将其转换为 Path 对象
 */
 export function parsePath(path: string): Partial<Path> {
  let parsedPath: Partial<Path> = {};

  if (path) {
    let hashIndex = path.indexOf('#');
    if (hashIndex >= 0) {
      parsedPath.hash = path.substr(hashIndex);
      path = path.substr(0, hashIndex);
    }

    let searchIndex = path.indexOf('?');
    if (searchIndex >= 0) {
      parsedPath.search = path.substr(searchIndex);
      path = path.substr(0, searchIndex);
    }

    if (path) {
      parsedPath.pathname = path;
    }
  }

  return parsedPath;
}
  1. 抽象形成的Location对象 每次导航相关联的state的信息 以及 这次导航唯一对应的key
// 唯一字符串,与每次跳转的 location 匹配
export type Key = string;

// 路由跳转抽象化的导航对象
export interface Location extends Path {
  // 与当前 location 关联的 state 值,可以是任意手动传入的值
  state: unknown;
  // 当前 location 的唯一 key,一般都是自动生成 Math.random().toString(36).substr(2, 8);
  key: Key;
}

History对象

定义createBrowserHistory,return一个BrowserHistory的实例对象history:

一个基础的history包含

  • 两个属性actionlocation
  • 一个方法:createHref: 用户将 history 内部定义的 path 对象转换为原始的 url
  • 五个路由跳转方法:push() replace() go() back() forward()
  • 两个路由监听方法: 监听路由跳转的钩子(类似后置守卫)listen() 阻止路由跳转的钩子(如果想正常跳转必须要取消监听,可以封装成类似前置钩子的功能)block()

关于 block 监听的阻止路由跳转

这里的阻止跳转并不是阻止新的路由的推入,而是监听到新路由跳转后马上返回到前一个路由,也就是强制执行了history.go(index - nextIndex)index是原来的路由在路由栈的索引,nextIndex是跳转路由在路由栈的索引。

// 开始监听路由跳转
let unlisten = history.listen(({ action, location }) => {
  // The current location changed.
});

// 取消监听
unlisten();

// 开始阻止路由跳转
let unblock = history.block(({ action, location, retry }) => {
  // retry 方法可以让我们重新进入被阻止跳转的路由// 取消监听,如果想要 retry 生效,必须要在先取消掉所有 block 监听,否则 retry 后依然会被拦截然后进入 block 监听中unblock();
  retry();
});

react-router-dom

BrowserRouter

BrowserRouter 主要干了这几件事:

  1. 调用createBrowserHistory创建history单例,
  1. 维护一个当前location、action的状态,并向history对象挂载监听;变化时,重新渲染
  1. 把history对象与location、action传给react-router.js提供的Router
// react-router-dom index.tsx 
export function BrowserRouter({
  basename,    // 当前项目中使用的BrowserRouter的路由统一的前缀
  children,
  window,   
}: BrowserRouterProps) {
  let historyRef = React.useRef<BrowserHistory>();
  if (historyRef.current == null) {
    // 创建history单例
    historyRef.current = createBrowserHistory({ window });
  }
  
  let history = historyRef.current;  
  
  let [state, setState] = React.useState({
    action: history.action,
    location: history.location,
  });

  // 向history对象挂载监听, 变化时,重渲染Router
  React.useLayoutEffect(() => history.listen(setState), [history]); 

  // 用Router组件传入一些参数且包裹着children返回出去
  return (
    <Router
      basename={basename} // basename传入
      children={children}
      location={state.location}
      navigationType={state.action}
      navigator={history}
    />
  );
}

目前项目中发现可以改变的地方

BrowserRouter中传入basename指定当前项目的全局统一前缀 不需要在每一个路由里面都写。

<BrowserRouter>
  <Entry />
</BrowserRouter>

const routes: ExtendRouteProps[] = [
  {
    path: '/veen/journal',
    title: '日志管理',
    component: Journal,
    showMenu: true,
  },
  {
    path: '/veen/dashboard',
    title: '服务概览',
    component: Dashboard,
    showMenu: true,
  },
}

history.push('/veen/journal')

react-router

实现了URL与UI界面的同步。其中在react-router中,URL 对应 location对象,而UI是由react element来决定的,这样就转变成locationelement之间的同步问题。

image.png

Router

  1. 接收到BrowerRouter传过来的location、navigate、basename并进行处理
  1. 提供 NavigationContext,使 useNavigate() 等hooks能轻易获取到navigator(history单例),调用.push .replace 等方法
  1. 提供 LocationContext,使 useLocation() 等hooks能轻易获取到location状态及更新。
 // packages\react-router\lib\components.tsx
export function Router(){

  // normalizePathname用于对basename格式化,如normalizePathname('//asd/')=>'/asd'
  let basename = normalizePathname(basenameProp);
  
  let navigationContext = React.useMemo(
  );
  
  // 如果locationProp为字符串则把他转为对象(包含pathname,search,state,key,hash)
  if (typeof locationProp === "string") {
    locationProp = parsePath(locationProp);
  }

  // 此 location 与 开始传入的location 的区别在于pathname做了去掉basename的处理
  let location = React.useMemo(() => {
    // trailingPathname为当前location.pathname中截掉basename的那部分,如
    // stripBasename('/prefix/a', '/prefix') => '/a'
    // 如果basename为'/',则不对pathname处理直接返回原值
    let trailingPathname = stripBasename(pathname, basename);
    
    return {
      pathname: trailingPathname,
      search,
      hash,
      state,
      key,
    };
  }, [basename, pathname, search, hash, state, key]);
 
  // 最后返回被NavigationContext和LocationContext包裹着的children出去
  return (
    <NavigationContext.Provider value={navigationContext}>
      <LocationContext.Provider 
        children={children}
        value={{ location, navigationType }}
      />
     </NavigationContext.Provider>
  );
}

image.png

useLocation()

useLocation是react-router内组件获取、监听location更新的核心hook,也是export给用户使用的常用工具。它很简单,直接通过全局单例LocationContext获取状态。

此处location中的pathname就是经过去掉basename之后的。

 // packages\react-router\lib\hooks.tsx
export function useLocation(): Location {
  return React.useContext(LocationContext).location;
}

Route

<Routes>包在<BrowerRouter>中,<Route>包在<Routes>中,并且<Routes>的jsx子树中的节点只能是<Route>。这是因为Routes会遍历它的children,解析Route树jsx得到一份路由配置(所以 <Route>只是个语法糖,源码中Route只有定义类型,并无实际内容)。

Route 组件内部没有进行任何操作,仅仅只是定义 props,我们就是为了使用它的 props。

Routes

Routes用于解析传入Route的props:

  • 本质上还是用useRoutes这个hook实现的路由的定义
  • 使用了createRoutesFromChildren这个方法将children转换为了useRoutes的配置参数,从而得到最后的路由元素。
export function Routes({
  children,
  location,
}: RoutesProps): React.ReactElement | null {
  return useRoutes(createRoutesFromChildren(children), location);
}

function createRoutesFromChildren(children: React.ReactNode): RouteObject[]


interface RouteObject {
  caseSensitive?: boolean; // 匹配时是否区分大小写 默认为 false
  children?: RouteObject[]; // 子路由
  element?: React.ReactNode; // 要渲染的元素
  index?: boolean; // 是否是索引路由
  path?: string;
}
  • react-router在路由定义时同时提供了两种方式:命令式与声明式,而这两者本质上都是调用的同一种路由 useRoutes() 生成的方法。
// 命令式
<Routes>
  <Route path="/" element={<App />}>
    <Route index element={<Home />} />
    <Route path="teams" element={<Teams />}>
      <Route path=":teamId" element={<Team />} />
      <Route path="new" element={<NewTeamForm />} />
      <Route index element={<LeagueStandings />} />
    </Route>
  </Route>
</Routes>
  • Route可以被看作一个挂载用户传入参数的对象,它不会在页面中渲染,而是会被**Routes**接受并解析,我们也不能单独使用它。
  • RoutesRoute强绑定,有Routes则必定要传入且只能传入Route

useRoutes()

******useRoutes是整个react-router v6**的核心所在,内部包含了大量的解析与匹配逻辑,主要的渲染逻辑都在这个hook中。

declare function useRoutes(
  routes: RouteObject[],
  location?: Partial<Location> | string;
): React.ReactElement | null;
  1. 路由匹配阶段 matchRoutes()

 // packages\react-router\lib\hooks.tsx
export function useRoutes(){
    let matches = matchRoutes(routes, { pathname: remainingPathname });
}

看一个例子,猜测下路由会如何匹配:路由匹配

matchRoutes,帮我们解决上面例子中路由规则冲突的问题。通过一系列的匹配算法来检测哪一个路由规则最契合给定的location。如果有匹配的,则返回类型为RouteMatch[]的数组。

matchRoutes的核心步骤:

  1. flattenRoutes( ) :用于把所有路由规则,包括children里的子路由规则全部铺平成一个一维数组。并且给每个路由打上分数,分数代表路由的质量。
  1. rankRouteBranches():根据branch的score和routeMeta中的childrenIndex进行排行。
  1. 遍历一维数组,通过matchRouteBranch获取第一个匹配pathname的路由规则作为matches
export function matchRoutes(
): RouteMatch[] | null {
  let branches = flattenRoutes(routes);
  
  rankRouteBranches(branches);

  let matches = null;
  
  for (let i = 0; matches == null && i < branches.length; ++i) {
   // branches 中有一个匹配到了就终止循环,或者全都没有匹配到
    matches = matchRouteBranch(branches[i], pathname);
  }

  return matches;
}

flattenRoutes( )

经过flattenRoutes( )处理后返回的branches:

[
  { 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> },
    ]
  }
]
function flattenRoutes(): RouteBranch[] {
  // 递归遍历routes
  routes.forEach((route, index) => {
    let meta: RouteMeta = {
      relativePath: route.path || "",
      caseSensitive: route.caseSensitive === true,
      childrenIndex: index,
      route,
    };
    
    // 合并父路由数据,生成新的路由数据列表
    let routesMeta = parentsMeta.concat(meta);
    
    if (route.children && route.children.length > 0) {
      flattenRoutes(route.children, branches, routesMeta, path);
    }
    
    // 解析出一条路由分支时,计算该branch的score
    branches.push({ path, score: computeScore(path, route.index), routesMeta });
  });

  return branches;
}

interface RouteBranch {
  path: string;
  score: number; // 排序
  routesMeta: RouteMeta[]; // branch的嵌套路由信息
}
interface RouteMeta {
  relativePath: string;
  caseSensitive: boolean;
  childrenIndex: number;
  route: RouteObject;
}

flattenRoutes中score的计算规则:

  1. 对传入的path以'/'进行切分,initialScore为path.length
  1. 如果路径片段中中含一个或多个*,都在初始分数上减2分
  1. 如果路径为index:score+=2
  1. 如果路径片段为常量: score+=10
  1. 如果路径片段为空字符串:score+=1
  1. 如果路径片段是 :arg之类的匹配: score+=3

image.png

rankRouteBranches()

  • 先score的大小进行排序 score大的在前
  • 如果两个route的score相等,则按照传入的顺序 childrenIndex进行排序 同级的

matchRouteBranch()

  • 遍历已经排好序的branch 找到匹配的matches并返回
  • 从传入的branch中我们拿到了routesMeta这个包含了所有路由层级的数组,又开始了一层层的路由匹配,然后把每一次匹配上的完整路径与参数都推入matches数组中,最后返回。
for (let i = 0; matches == null && i < branches.length; ++i) {
   // branches 中有一个匹配到了就终止循环,或者全都没有匹配到
    matches = matchRouteBranch(branches[i], pathname);
}
  1. 路由渲染阶段

useRoutes在内部是调用_renderMatches来实现渲染的,将之前的matches数组渲染为React元素。

 // packages\react-router\lib\hooks.tsx
export function useRoutes() {
    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
    );
}

_renderMatches的实现:

其实就是渲染 RouteContext.Provider 组件(包括多个嵌套的 Provider)

/**
 * 其实就是渲染 RouteContext.Provider 组件(包括多个嵌套的 Provider)
 */
 function _renderMatches(): React.ReactElement | null {

  // 生成 outlet 组件,注意这里是从后往前 reduce,所以索引在前的 match 是最外层,也就是父路由对应的 match 是最外层
  /**
   *  可以看到 outlet 是通过不断递归生成的组件,最外层的 outlet 递归层数最多,包含有所有的内层组件,
   *  所以我们在外层使用的 <Outlet /> 是包含有所有子组件的聚合组件
   * */
   return matches.reduceRight((outlet, match, index) => {
    return (
      <RouteContext.Provider
        // 如果有 element 就渲染 element如果没有填写 element则默认是 <Outlet />,继续渲染内嵌的 <Route />
        children={
          match.route.element !== undefined ? match.route.element : <Outlet />
        }
        // 代表当前 RouteContext 匹配到的值,matches 并不是全局状态一致的,会根据层级不同展示不同的值,最后一个层级是完全的 matches,这也是之前提到过的不要在外部使用 RouteContext 的原因
        value={{
          outlet,
          matches: parentMatches.concat(matches.slice(0, index + 1))
        }}
      />
    );
    // 最内层的 outlet 为 null,也就是最后的子路由
  }, null as React.ReactElement | null);
}

useRoute主要做了两件事

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

image.png

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

image.png

Outlet & useOutlet()

Outlet使用举例1 Outlet使用举例2

当定义的路由信息包含子路由的时候,使用到了Outlet。

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

Outlet的实现是直接调用useOutlet的结果。

<Outlet/>就是把当前RouteContextoutlet值渲染出来。

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

Navigate & useNavigate()

useNavigate()的使用

v6 版本的 useNavigate() 与 v5 版本的useHistory只是暴露出来的方法有区别,其实本质都是context获取初始的NavigationContext.Provider中的值。

  • v5将 整个history对象暴露出来 v6只是对history对象的push、replace、go、goBack、goForward方法进行封装并返回一个方法。

// useNavigate()的使用

const navigate = useNavigate()
navigate('/user'); // push
navigate('/login',{replace: true}) // replace
navigate(1) // 前进
navigate(-1) // 后退1
navigate(-2) // 后退2

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


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

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

BrowserRouter的主要渲染流程