react-router-dom V6

912 阅读6分钟

前置知识

  1. HTML5 history API 以及 popstate 事件
  2. URL hash 以及 hashchange 事件
  3. 前端路由模式:hash 模式、history
  4. React 相关: 如 Context Api, hooks 等
  5. 理解 router 与 react 组件设计与匹配

SPA 需要的路由模式

单页面对路由提出了这样的需求:URL 地址发生变化,但是不会刷新页面。hash 是 SPA 应用一种可行的方式。在 HTML5 推出 history api 之后:

  • history.pushState
  • history.replaceState

也有了类似于 hashchange 效果,但是也不完全一致。

SPA 应用 hash 模式与 history 模式的区别

  1. hash 本质通过 hashchange 事件来达到路由切换的效果,但是 hashchange 不会刷新浏览器当前页面,实现路由切换,可能需要 HTML history API 协同。
  2. HTML history API 本身具有刷新浏览器的能力,与 hash 有本质区别。

社区做出的努力:history

history 包是 react-router 的底层包,但是它也适用于其他的 javascript 运行环境,下面我们一 html 环境为示例简单使用:

  • type="module"
  • 从cdn中加载 history
  • 在全局环境中找到 HistoryLibrary

index.html 主页面,使用 hash 模式作为示例,使用定时器进行定时跳转

<!DOCTYPE html>
<html lang="en">
<head>
	<script src="https://cdn.bootcdn.net/ajax/libs/history/5.3.0/history.development.js"></script>
	<title>Document</title>
</head>
<body>
	<div>index: page</div>
	<script type="module">
		const history = HistoryLibrary.createHashHistory()
		setTimeout(() => {
			history.push('/about.html');
		}, 1000);
	</script>
</body>
</html>

createHashHistory 表现

用同样的方法定义 about.html, 使用服务打开页面发现 createHashHistory() 创建的 history url 发生了变化,http://127.0.0.1:5500/#/about.html, 但是 about.html 并没有加载,刷新浏览器页面,同样不会加载新的页面。

createHistoryHistory 表现

使用 createHistoryHistory 创建 history 发生了变化:跳转到 about.html 路由,但是没有加载 about.html。刷新当前页面,页面内容变成了 about.html。 这一点与 hash 有很大的区别。如果我们需要在新的页面刷新页面,可能用 history 模式更加好。

history api 分析

  • action 触发的类型:POP/PUSH/REPLACE
  • createBrowserHistory 创建history模式
  • createHashHistory 创建hash模式
  • createMemoryHistory 创建缓存模式
  • createPath 创建 path 路径
  • parsePath 解析 path 路径

location 对象

  • hash hash值
  • key 特定 key
  • pathname 路径
  • search 查询参数
  • state 状态对象

history 对象

  • "action" 路由动作类型
  • "location" 地址对象
  • "createHref" href 创建函数
  • "push" 栈方法
  • "replace" 替换方法
  • "go" 栈跳转方法
  • "back" 栈返回方法
  • "forward" 栈前进
  • "listen" 监听路由方法
  • "block" 阻塞方法
  • index (memory 模式)

history 提供多种路由模式,api 上基本一致,页面表现上也基本一致。 history 作为 react-router 基础 api 提供者。

react-router-dom

react-router-dom 是在 react-router 基础上进行封装,而 router 是与平台无关

react-router-dom 组件: 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,
  });

  React.useLayoutEffect(() => history.listen(setState), [history]);

  return (
    <Router
      basename={basename}
      children={children}
      location={state.location}
      navigationType={state.action}
      navigator={history}
    />
  );
}
  • basename 基础路径, 默认值是 "/"
  • children 需要包含 Routes 和 Route
  • location 即 history 对象

接下来,进入 react-router 查看 Router 的实现

export function Router({
  basename: basenameProp = "/",
  children = null,
  location: locationProp,
  navigationType = NavigationType.Pop,
  navigator,
  static: staticProp = false,
}: RouterProps): React.ReactElement | null {
  // ...

  let basename = normalizePathname(basenameProp);
  let navigationContext = React.useMemo(
    () => ({ basename, navigator, static: staticProp }),
    [basename, navigator, staticProp]
  );

  if (typeof locationProp === "string") {
    locationProp = parsePath(locationProp);
  }
  // ...
  let location = React.useMemo(() => {
    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;
  }

  return (
    <NavigationContext.Provider value={navigationContext}>
      <LocationContext.Provider
        children={children}
        value={{ location, navigationType }}
      />
    </NavigationContext.Provider>
  );
}

数据通过 context api 进行渗透,子组件通过 context 相关的钩子函数访问 value 属性中的数据。

所以: 所有路由组件 Routes、Route 必须要包裹在顶层提供了 context api 的数据的组件内部作为 children 才有意义,才能正确使用。

Routes 组件

Routes 不在需要进行二次正对平台封装,因为,Routes 本来就是一个 Route 的管理器组件。它调用 useRoutes 来匹配路由:

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

createRoutesFromChildren 函数也是一种重要的函数,将 Routes 有 html 形式,转换成对象形式进行描述 Route 对象:

export function createRoutesFromChildren(
  children: React.ReactNode
): RouteObject[] {
  let routes: RouteObject[] = [];

  React.Children.forEach(children, (element) => {
    if (!React.isValidElement(element)) {
      // Ignore non-elements. This allows people to more easily inline
      // conditionals in their route config.
      return;
    }

    if (element.type === React.Fragment) {
      // Transparently support React.Fragment and its children.
      routes.push.apply(
        routes,
        createRoutesFromChildren(element.props.children)
      );
      return;
    }

    // ...

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

先看匹配结果返回的组件

组件还是 Context.Provider 组件

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

  return matches.reduceRight((outlet, match, index) => {
    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);
}

react-router 核心: 匹配

根据当前的浏览器的路由进行匹配

匹配函数

匹配函数接收 routes, 进过 flattenRoutes 将router 转换成 branches

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

  let branches = flattenRoutes(routes);
  rankRouteBranches(branches);

  let matches = null;
  for (let i = 0; matches == null && i < branches.length; ++i) {
    matches = matchRouteBranch(branches[i], pathname);
  }

  return matches;
}

branches 是 flattenRoutes 会将 routes 全部 <Route /> 中 数据提取出来,组成一个数组。然后遍历 brances 得到匹配结果

for (let i = 0; matches == null && i < branches.length; ++i) {
    matches = matchRouteBranch(branches[i], pathname);
  }

这个记过就是当前路由所有需要的 Route 的内容。

渲染匹配

在获取到匹配对象之后,我们就可以渲染组件了 _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
        children={
          match.route.element !== undefined ? match.route.element : outlet
        }
        value={{
          outlet,
          matches: parentMatches.concat(matches.slice(0, index + 1)),
        }}
      />
    );
  }, null as React.ReactElement | null);
}

很容易看出,match 中 element 元素被 RouteContext.Provider 渲染到其 children,然后将 outlet/matches 等数据,传递下去。

react-router 跳转的方式

  1. Link 组件
  2. useNavigate 钩子函数

Link

Link 是 react-router-dom 中的组件。

本质是一个 a 标签,点击之后跳转到对应的路由,浏览器路由,其次触发 onClick 事件,导航到对应的地址,本质是调用钩子函数。

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>
    ) {
      if (onClick) onClick(event);
      if (!event.defaultPrevented && !reloadDocument) {
        internalOnClick(event);
      }
    }

    return (
      // eslint-disable-next-line jsx-a11y/anchor-has-content
      <a
        {...rest}
        href={href}
        onClick={handleClick}
        ref={ref}
        target={target}
      />
    );
  }
);

点击之后,会触发如何函数

export function useLinkClickHandler<E extends Element = HTMLAnchorElement>(
  to: To,
  {
    target,
    replace: replaceProp,
    state,
  }: {
    target?: React.HTMLAttributeAnchorTarget;
    replace?: boolean;
    state?: any;
  } = {}
): (event: React.MouseEvent<E, MouseEvent>) => void {
  let navigate = useNavigate();
  let location = useLocation();
  let path = useResolvedPath(to);

  return React.useCallback(
    (event: React.MouseEvent<E, MouseEvent>) => {
      if (
        event.button === 0 && // Ignore everything but left clicks
        (!target || target === "_self") && // Let browser handle "target=_blank" etc.
        !isModifiedEvent(event) // Ignore clicks with modifier keys
      ) {
        event.preventDefault();
				
        let replace =
          !!replaceProp || createPath(location) === createPath(path);

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

本质还是调用了 useNavigate 来进行路由切换

useNavigate

useNavigate 是 react-router 中的 react-钩子函数:

export function useNavigate(): NavigateFunction {
  //...

  let { basename, navigator } = React.useContext(NavigationContext);
  let { matches } = React.useContext(RouteContext);
  let { pathname: locationPathname } = useLocation();

  let routePathnamesJson = JSON.stringify(
    matches.map((match) => match.pathnameBase)
  );

  let activeRef = React.useRef(false);
  React.useEffect(() => {
    activeRef.current = true;
  });

  let navigate: NavigateFunction = React.useCallback(
    (to: To | number, options: NavigateOptions = {}) => {
      //...

      if (!activeRef.current) return;

      if (typeof to === "number") {
        navigator.go(to);
        return;
      }

      let path = resolveTo(
        to,
        JSON.parse(routePathnamesJson),
        locationPathname
      );

      if (basename !== "/") {
        path.pathname = joinPaths([basename, path.pathname]);
      }

      (!!options.replace ? navigator.replace : navigator.push)(
        path,
        options.state
      );
    },
    [basename, navigator, routePathnamesJson, locationPathname]
  );

  return navigate;
}

useNavigate 返回的是一个函数, 最后是闭包 navigate 来调用 push/replace 方法来确定如何条状。navigate 的来源还是保存在 context 中

let { basename, navigator } = React.useContext(NavigationContext);

NavigationContext 保存的contxt 要回溯到 Router 组件中

navigator 是 Router 组件的 props。

let navigationContext = React.useMemo(
    () => ({ basename, navigator, static: staticProp }),
    [basename, navigator, staticProp]
  );

Router 本身是底层组件,BrowserRouter、HashRouter、MemoryRouter 都是基于它

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

得知 navigator 即 history, 所以具有 push 等 history 底层包的方法。函数式编程,组合大于继承。

Outout

类似于 Vue 中 router-view, 也就是路由组件要显示的地方

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

调用了 useOutlet 钩子函数,来看源码

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

获取了 RouteContext 的上下文,获取到了 outlet 属性, 如果存在 outlet, 渲染 OutletContext.Provider 组件 children。 否在直接渲染 outlet。

这样从定义 HistoryRouter 到编写 Routes 和 Route, 到 Outlet, 两种导航方式的跳转。就得到了一个较为完整的前端路由方案。

钩子函数

location、history 对象上有很多的有用信息数据,react-router 提供了很多的钩子函数来满足,业务需求(其实这些钩子函数很大一部分已经在分析流程的时候,已经用到了)

  • useHref
  • useInRouterContext
  • useLocation
  • useMatch
  • useNavigate
  • useNavigationType
  • useOutlet
  • useParams
  • useResolvedPath
  • useRoutes
  • useOutletContext

useNavigate 编程式导航

const navigator = useNavigate()

navigator({url,  {})

导航数据

let location = useLocation(); // location 对象
let urlParams = useParams(); // params 参数
let [urlSearchParams] = useSearchParams(); //查询对象

路由概念

  • 路由元素 Route
  • 父路由、子路由
  • index 路由,呈现在一组路由的子路由中,用 index 属性标记
  • 布局路由,存在于父组件中,但是没有 path 属性
  • 嵌套路由,在现有的路由里面,递归一套路由

参考

  1. developer.mozilla.org/zh-CN/docs/…
  2. www.npmjs.com/package/his…
  3. github.com/remix-run/h…