React-Router v6 完全解读指南 - react-router-dom / native 篇

前言

接上一篇 React-Router v6 完全解读指南 - react-router 篇,我们讲完了react-router v6的核心库react-router。这篇文章将是本系列的最后一篇,我们将基于react-router库,扩展讲一讲官方是如何基于不同平台提供导航功能的。

react-router-dom

react-router-dom是基于react-router,专门用于在 web 端使用的路由库。其本身主要基于浏览器历史的 api 进行了扩展,我们最常使用的两个路由:BrowserRouterHashRouter,都是在这个包里封装的。

用于浏览器的 Router - BrowserRouter & HashRouter

这两个Router的用户使用率是最高的,分别结合了history库的createBrowserHistorycreateHashHistory创建的导航对象。

下面是源码解析:

import React from 'react';
import type { BrowserHistory, HashHistory, History } from "history";
import { createBrowserHistory, createHashHistory, createPath } from "history";
import { Router } from 'react-router';


export interface BrowserRouterProps {
  basename?: string;
  children?: React.ReactNode;
  /**
   * window 对象可自定义,因为不一定是当前页面的 window,如果不传在 history 库中就会使用 document.defaultView,也就是当前页面的 window
   */
  window?: Window;
}

/**
 * 在 Router 中传入了 history 的 createBrowserHistory 作为 navigator
 */
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
  });
  // 简单对状态做监听,然后更新 location 与 action
  React.useLayoutEffect(() => history.listen(setState), [history]);

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


export interface HashRouterProps {
  basename?: string;
  children?: React.ReactNode;
  window?: Window;
}

/**
 * 在 Router 中传入了 history 的 createHashHistory 作为 navigator
 */
export function HashRouter({ basename, children, window }: HashRouterProps) {
  let historyRef = React.useRef<HashHistory>();
  if (historyRef.current == null) {
    historyRef.current = createHashHistory({ window });
  }

  let history = historyRef.current;
  let [state, setState] = React.useState({
    action: history.action,
    location: history.location
  });

  // 简单对状态做监听,然后更新 location 与 action
  React.useLayoutEffect(() => history.listen(setState), [history]);

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

自定义导航对象的 Router - HistoryRouter

HistoryRouterreact-router v6.2.1中新增的,主要用于用户想自主控制history对象的情况,比如使用history.block()拦截路由跳转。

下面是源码解析:

export interface HistoryRouterProps {
  basename?: string;
  children?: React.ReactNode;
  // 手动传入的 History 对象
  history: History;
}

/**
 * 主要用于用户提供自定义的 history 对象,否则只能引用 react-router 包中的 Router 组件手动传入
 * 尽量不要使用自己的 history 对象,否则可能会与 react-router 内部版本不一致
 */
function HistoryRouter({ basename, children, history }: HistoryRouterProps) {
  const [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}
    />
  );
}

// 注意这里是 unstable,因为是 v6.2.1 新增的,后续可能还会改变
export { HistoryRouter as unstable_HistoryRouter };
复制代码

适用于 ssr 的 Router - StaticRouter

react-router-dom/server下导出了一个专用于ssr环境的Router,但不能只在服务端使用它,还需要配合其它Router实现同构。下面是官方的例子:

服务端入口:

// server-entry.tsx
import express from "express";
import ReactDOMServer from "react-dom/server";
import { StaticRouter } from "react-router-dom/server";
import App from "./App";

let app = express();

app.get("*", (req, res) => {
  // 这里是服务端,使用 StaticRouter
  let html = ReactDOMServer.renderToString(
    <StaticRouter location={req.url}>
      <App />
    </StaticRouter>
  );
  res.send("<!DOCTYPE html>" + html);
});

app.listen(3000);
复制代码

客户端入口:

// client-entry.tsx
import ReactDOM from "react-dom";
import { BrowserRouter } from "react-router-dom";
import App from "./App";

ReactDOM.hydrate(
  // 客户端,使用 BrowserRouter
  <BrowserRouter>
    <App />
  </BrowserRouter>,
  document.documentElement
);
复制代码

这样,我们就完成了路由的同构,关于ssr的详细内容请查看官方文档

下面是源码解析:

/**
 * 服务器端,ssr 路由
 * StaticRouter 实际上就是一个只提供参数传递,没有任何 navigator 操作的 Router
 */
import  React from "react";
import { Action, Location, To, createPath, parsePath } from "history";
import { Router } from "react-router-dom";

export interface StaticRouterProps {
  basename?: string;
  children?: React.ReactNode;
  location: Partial<Location> | string;
}

/**
 * 没有任何操作,只是简单对 location 做验证,然后传入到 Router 中
 * A <Router> that may not transition to any other location. This is useful
 * on the server where there is no stateful UI.
 */
export function StaticRouter({
  basename,
  children,
  location: locationProp = "/"
}: StaticRouterProps) {
  // 转换为 location 对象
  if (typeof locationProp === "string") {
    locationProp = parsePath(locationProp);
  }

  // 所有 action 都是 Pop
  let action = Action.Pop;
  let location: Location = {
    pathname: locationProp.pathname || "/",
    search: locationProp.search || "",
    hash: locationProp.hash || "",
    state: locationProp.state || null,
    key: locationProp.key || "default"
  };

  let staticNavigator = {
    createHref(to: To) {
      // createPath 格式化 path
      return typeof to === "string" ? to : createPath(to);
    },
    push(to: To) {
      throw new Error(
        `You cannot use navigator.push() on the server because it is a stateless ` +
          `environment. This error was probably triggered when you did a ` +
          `\`navigate(${JSON.stringify(to)})\` somewhere in your app.`
      );
    },
    replace(to: To) {
      throw new Error(
        `You cannot use navigator.replace() on the server because it is a stateless ` +
          `environment. This error was probably triggered when you did a ` +
          `\`navigate(${JSON.stringify(to)}, { replace: true })\` somewhere ` +
          `in your app.`
      );
    },
    go(delta: number) {
      throw new Error(
        `You cannot use navigator.go() on the server because it is a stateless ` +
          `environment. This error was probably triggered when you did a ` +
          `\`navigate(${delta})\` somewhere in your app.`
      );
    },
    back() {
      throw new Error(
        `You cannot use navigator.back() on the server because it is a stateless ` +
          `environment.`
      );
    },
    forward() {
      throw new Error(
        `You cannot use navigator.forward() on the server because it is a stateless ` +
          `environment.`
      );
    }
  };

  return (
    <Router
      basename={basename}
      children={children}
      location={location}
      navigationType={action}
      navigator={staticNavigator}
      static={true}
    />
  );
}
复制代码

代替 a 标签跳转链接 - Link

Link组件在react=router-dom中用于代替浏览器平台的原生a标签,用户可以通过点击它实现导航跳转。该组件不同于原生a标签,它不会刷新页面,而是使用浏览器historyapi 实现页面跳转,优化用户体验。

import { useHref } from 'react-router';
import type { To } from "react-router";


export interface LinkProps
  extends Omit<React.AnchorHTMLAttributes<HTMLAnchorElement>,
  "href"> {
  // 去掉 a 标签的 href,这里让用户使用我们内部定义的 to 属性  
  /**
   * 如果为 true,代表真实的 a 标签跳转
   */
  reloadDocument?: boolean;
  // 是否替换页面栈
  replace?: boolean;
  state?: any;
  // react-router 中的 to,可以传 string 或者对象,详情见 react-router 篇
  to: To;
}


export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
  function LinkWithRef(
    { onClick, reloadDocument, replace = false, state, target, to, ...rest },
    ref
  ) {
    // 如果 Router 提供了 basename,这里会加上 basename,详情见 react-router 篇
    let href = useHref(to);
    // 将用户传入的参数传入最终生成一个点击事件用户跳转页面
    let internalOnClick = useLinkClickHandler(to, { replace, state, target });
    function handleClick(
      event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
    ) {
      // 如果用户传入了 onClick 事件,则调用用户传入的 onClick 事件,注意这里没有 return,会继续执行下面的语句
      if (onClick) onClick(event);
      // 这里的 defaultPrevented 是用户在外部 preventDefault 后的情况,阻止了 internalOnClick 的调用,也就是说不会执行内部定义的路由跳转了
      // internalOnClick 中也有调用 preventDefault 方法,但这样是为了阻止 a 标签的默认跳转,从而实现内部路由跳转,和上面的意义是不同的。
      // 同时,如果带了 reloadDocument 证明走的不是内部的 history 跳转,而是真实的 a 标签跳转
      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}
      />
    );
  }
);
复制代码

除了调用react-router提供的useHrefLink组件还通过useLinkClickHandler生成了一个内部的跳转事件,它的源码是这样的:

import { useNavigate, useLocation, useResolvedPath } from 'react-router';
/**
 * 判断用户是否有按下修辞符
 * @param event
 * @returns
 */
function isModifiedEvent(event: React.MouseEvent) {
  return !!(event.metaKey || event.altKey || event.ctrlKey || event.shiftKey);
}

/**
 * 返回拦截了默认 a 标签跳转的 click 事件函数
 */
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 {
  // 调用 react-router 中的三个 hooks
  // 导航函数
  let navigate = useNavigate();
  // 当前的 location
  let location = useLocation();
  // 格式化 path
  let path = useResolvedPath(to);

  return React.useCallback(
    (event: React.MouseEvent<E, MouseEvent>) => {
      if (
        // 必须是左键单击才触发
        event.button === 0 && // Ignore everything but left clicks
        // 如果是 target 为 _blank 这种不受处理,只能是当前页面打开
        (!target || target === "_self") && // Let browser handle "target=_blank" etc.
        // 如果用户按了修辞符也不触发
        !isModifiedEvent(event) // Ignore clicks with modifier keys
      ) {
        // 阻止 a 标签默认跳转
        event.preventDefault();

        // 如果用户指定了 replace 或者 pathname 没有发生改变,则调用 replace
        let replace =
          !!replaceProp || createPath(location) === createPath(path);

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

可以看出,Link组件内部其实就是阻止了a标签的默认跳转,转而使用了上下文中的navigate函数。

具有状态的 Link 组件 - NavLink

NavLink组件是基于Link组件的一层封装,它除了永远后者全部功能外,还提供了检测对应Link标签是否处于active状态的功能(active状态,即当前页面的pathname能够与Link中传入的to相匹配)。


export interface NavLinkProps
  extends Omit<LinkProps, "className" | "style" | "children"> {
  // 可以看到,下面的 className、style与 children 都可以传入函数获取到当前 Link 状态
  // children 可直接传入函数
  children:
    | React.ReactNode
    | ((props: { isActive: boolean }) => React.ReactNode);
  /**
   * 这里的 caseSensitive 是改变传入的 to 的 pathname 的大小写,主要用于 active 状态的计算
   */
  caseSensitive?: boolean;
  className?: string | ((props: { isActive: boolean }) => string);
  // end 为 true 时需要当前 pathname 与 to 的 pathname 完全匹配,否则不需要完全匹配,只要当前 pathname 完全包含 toPathname 并且使用 / 做分割
  // /home 可以匹配 /home/home2,但是不能匹配 /home2,也就是说必须要只有 /home,或者有 /home/ 作为前缀
  end?: boolean;
  style?:
    | React.CSSProperties
    | ((props: { isActive: boolean }) => React.CSSProperties);
}


export const NavLink = React.forwardRef<HTMLAnchorElement, NavLinkProps>(
  function NavLinkWithRef(
    {
      "aria-current": ariaCurrentProp = "page",
      caseSensitive = false,
      className: classNameProp = "",
      end = false,
      style: styleProp,
      to,
      children,
      ...rest
    },
    ref
  ) {
    let location = useLocation();
    let path = useResolvedPath(to);

    let locationPathname = location.pathname;
    let toPathname = path.pathname;
    // 改变大小写
    if (!caseSensitive) {
      locationPathname = locationPathname.toLowerCase();
      toPathname = toPathname.toLowerCase();
    }

    // 是否匹配当前 NavLink 对应的 pathname
    let isActive =
      locationPathname === toPathname ||
      (!end &&
        locationPathname.startsWith(toPathname) &&
        locationPathname.charAt(toPathname.length) === "/");

    // 适配障碍人群
    let ariaCurrent = isActive ? ariaCurrentProp : undefined;

    let className: string;
    if (typeof classNameProp === "function") {
      className = classNameProp({ isActive });
    } else {
      // 如果没有传函数,会多一个 active 的 class 作为状态
      // If the className prop is not a function, we use a default `active`
      // class for <NavLink />s that are active. In v5 `active` was the default
      // value for `activeClassName`, but we are removing that API and can still
      // use the old default behavior for a cleaner upgrade path and keep the
      // simple styling rules working as they currently do.
      className = [classNameProp, isActive ? "active" : null]
        .filter(Boolean)
        .join(" ");
    }

    let style =
      typeof styleProp === "function" ? styleProp({ isActive }) : styleProp;

    return (
      <Link
        {...rest}
        aria-current={ariaCurrent}
        className={className}
        ref={ref}
        style={style}
        to={to}
      >
        {typeof children === "function" ? children({ isActive }) : children}
      </Link>
    );
  }
);
复制代码

从源码中可以看出,NavLink内部实时判断了当前pathname是否能与其匹配,进而向外传递isActive值。

辅助 API

useLinkClickHandler

useLinkClickHandler(to, { target, replace, state})用于生成点击跳转事件,在Link 源码分析中讲过。

useSearchParams

useSearchParams(defaultInit)返回一个[state, setState]格式的值,用于帮助我们快速修改浏览器pathnamesearch部分(内部基于 URLSearchParams API)。

export type ParamKeyValuePair = [string, string];

// 可传入的 query 字符串格式
export type URLSearchParamsInit =
  | string
  | ParamKeyValuePair[]
  | Record<string, string | string[]>
  | URLSearchParams;
  
/**
 * 将 init 的值(拓展出的值)转换为标准的 URLSearchParams 对象,主要拓展了对象形式参数的转换
 * 使用 URLSearchParams API,你必须这样做:
 *
 *   let searchParams = new URLSearchParams([
 *     ['sort', 'name'],
 *     ['sort', 'price']
 *   ]);
 *
 * 现在可以这样:
 *
 *   let searchParams = createSearchParams({
 *     sort: ['name', 'price']
 *   });
 */
export function createSearchParams(
  init: URLSearchParamsInit = ""
): URLSearchParams {
  return new URLSearchParams(
    // 前面两个都是 URLSearchParams 本身就可转换的
    typeof init === "string" ||
    Array.isArray(init) ||
    init instanceof URLSearchParams
      ? init
      : // init 为普通 key value 对象
        Object.keys(init).reduce((memo, key) => {
          let value = init[key];
          return memo.concat(
            Array.isArray(value) ? value.map(v => [key, v]) : [[key, value]]
          );
        }, [] as ParamKeyValuePair[])
  );
}

export function useSearchParams(defaultInit?: URLSearchParamsInit) {
  // 需要支持 URLSearchParams API
  warning(
    typeof URLSearchParams !== "undefined",
    `You cannot use the \`useSearchParams\` hook in a browser that does not ` +
      `support the URLSearchParams API. If you need to support Internet ` +
      `Explorer 11, we recommend you load a polyfill such as ` +
      `https://github.com/ungap/url-search-params\n\n` +
      `If you're unsure how to load polyfills, we recommend you check out ` +
      `https://polyfill.io/v3/ which provides some recommendations about how ` +
      `to load polyfills only for users that need them, instead of for every ` +
      `user.`
  );

  // 保存着 URLSearchParams 对象,初始化的值
  let defaultSearchParamsRef = React.useRef(createSearchParams(defaultInit));

  let location = useLocation();
  let searchParams = React.useMemo(() => {
    // 当前 location.search 对应的 params
    let searchParams = createSearchParams(location.search);

    // 如果当前 params 有初始化的 key,不做任何处理,否则加上没有的 key
    for (let key of defaultSearchParamsRef.current.keys()) {
      if (!searchParams.has(key)) {
        defaultSearchParamsRef.current.getAll(key).forEach(value => {
          searchParams.append(key, value);
        });
      }
    }

    return searchParams;
  }, [location.search]);

  let navigate = useNavigate();
  // 设置 query 值,实际是调用了一遍 navigate 的方法
  let setSearchParams = React.useCallback(
    (
      nextInit: URLSearchParamsInit,
      navigateOptions?: { replace?: boolean; state?: any }
    ) => {
      navigate("?" + createSearchParams(nextInit), navigateOptions);
    },
    [navigate]
  );

  return [searchParams, setSearchParams] as const;
}
复制代码

react-router-native

react-router-native是基于react-router,专门用于在react-native端使用的路由库。

react-router-dom一样,该库也为react-native端提供了专用的RouterComponentshooks

因为react-native的用户量会少很多,所以这里就只是简单介绍了。

NativeRouter

其实就是react-router导出的MemoryRouter,使用内存栈作为导航。

import type { MemoryRouterProps } from 'react-router';
import { MemoryRouter } from 'react-router';
export interface NativeRouterProps extends MemoryRouterProps {}

/**
 * NativeRouter 就是 react-router 里的 MemoryRouter,使用内存做导航
 */
export function NativeRouter(props: NativeRouterProps) {
  return <MemoryRouter {...props} />;
}
复制代码

嗯,好像做了什么,又好像什么都没做。

Link

import { TouchableHighlight, GestureResponderEvent, TouchableHighlightProps } from 'react-native';
import type { To } from 'react-router';

export interface LinkProps extends TouchableHighlightProps {
  children?: React.ReactNode;
  onPress?: (event: GestureResponderEvent) => void;
  replace?: boolean;
  state?: any;
  to: To;
}

/**
 * 基本同 react-router-dom 一样的组件封装,不过是使用的 react-native 的组件
 */
export function Link({
  onPress,
  replace = false,
  state,
  to,
  ...rest
}: LinkProps) {
  // 和 react-router-dom 的 Link 逻辑大致相同,不过内部实现更简单一点
  let internalOnPress = useLinkPressHandler(to, { replace, state });
  function handlePress(event: GestureResponderEvent) {
    if (onPress) onPress(event);
    if (!event.defaultPrevented) {
      internalOnPress(event);
    }
  }

  return <TouchableHighlight {...rest} onPress={handlePress} />;
}
复制代码

生成点击函数的useLinkPressHandler源码:

import { useNavigate } from 'react-router';
/**
 * 返回 onPress 方法的 hook
 */
export function useLinkPressHandler(
  to: To,
  {
    replace,
    state
  }: {
    replace?: boolean;
    state?: any;
  } = {}
): (event: GestureResponderEvent) => void {
  let navigate = useNavigate();
  return function handlePress() {
    navigate(to, { replace, state });
  };
}
复制代码

辅助 API

useLinkPressHandler

useLinkPressHandler(to, { replace, state})用于生成react-native端的点击跳转事件,在Link 源码分析中讲过。

useHardwareBackButton / useAndroidBackButton

useHardwareBackButton/useAndroidBackButton用于启用对 Android 上的硬件后退按钮的支持。

const HardwareBackPressEventType = "hardwareBackPress";


/**
 * 启用硬件后退按钮的支持,目前只是挂载事件监听,内部后续应该还有具体实现代码
 */
export function useHardwareBackButton() {
  React.useEffect(() => {
    function handleHardwardBackPress() {
      return undefined;
      // TODO: 实现应该类似下面这样,但是官方还没有实现
      // if (history.index === 0) {
      //   return false; // home screen
      // } else {
      //   history.back();
      //   return true;
      // }
    }

    BackHandler.addEventListener(
      HardwareBackPressEventType,
      handleHardwardBackPress
    );

    return () => {
      BackHandler.removeEventListener(
        HardwareBackPressEventType,
        handleHardwardBackPress
      );
    };
  }, []);
}

// 注意导出函数名
export { useHardwareBackButton as useAndroidBackButton };
复制代码

useDeepLinking

useDeepLinking用于启用深层链接模式,每次链接跳转时都会自动再加一层导航栈。

import { Linking } from 'react-native';
import { useNavigate } from 'react-router';
const URLEventType = "url";

/**
 * 去除掉 url 中的协议内容
 * @param url
 * @returns
 */
function trimScheme(url: string) {
  return url.replace(/^.*?:\/\//, "");
}

/**
 * 启用深层链接模式,包括启动和后续传入
 */
export function useDeepLinking() {
  let navigate = useNavigate();

  // Get the initial URL
  React.useEffect(() => {
    let current = true;
    Linking.getInitialURL().then(url => {
      // 让初始化的链接更深入
      if (current) {
        if (url) navigate(trimScheme(url));
      }
    });

    return () => {
      current = false;
    };
  }, [navigate]);

  // 监听 url 改变,改变后继续往同一个 url 跳转,让链接更深入
  React.useEffect(() => {
    function handleURLChange(event: { url: string }) {
      navigate(trimScheme(event.url));
    }

    Linking.addEventListener(URLEventType, handleURLChange);

    return () => {
      Linking.removeEventListener(URLEventType, handleURLChange);
    };
  }, [navigate]);
}
复制代码

useSearchParams

用法同react-router-dom中的useSearchParams

export type ParamKeyValuePair = [string, string];

export type URLSearchParamsInit =
  | string
  | ParamKeyValuePair[]
  | Record<string, string | string[]>
  | URLSearchParams;


export function createSearchParams(
  init: URLSearchParamsInit = ""
): URLSearchParams {
  return new URLSearchParams(
    typeof init === "string" ||
    Array.isArray(init) ||
    init instanceof URLSearchParams
      ? init
      : Object.keys(init).reduce((memo, key) => {
          let value = init[key];
          return memo.concat(
            Array.isArray(value) ? value.map(v => [key, v]) : [[key, value]]
          );
        }, [] as ParamKeyValuePair[])
  );
}

type SetURLSearchParams = (
  nextInit?: URLSearchParamsInit | undefined,
  navigateOpts?: NavigateOptions | undefined
) => void;

/**
 * 同 react-router-dom,但是不需要判断 URLSearchParams 是否存在
 */
export function useSearchParams(
  defaultInit?: URLSearchParamsInit
): [URLSearchParams, SetURLSearchParams] {
  let defaultSearchParamsRef = React.useRef(createSearchParams(defaultInit));

  let location = useLocation();
  let searchParams = React.useMemo(() => {
    // 当前 location.search 对应的 params
    let searchParams = createSearchParams(location.search);
    
    // 如果当前 params 有初始化的 key,不做任何处理,否则加上没有的 key
    for (let key of defaultSearchParamsRef.current.keys()) {
      if (!searchParams.has(key)) {
        defaultSearchParamsRef.current.getAll(key).forEach(value => {
          searchParams.append(key, value);
        });
      }
    }

    return searchParams;
  }, [location.search]);

  let navigate = useNavigate();
  let setSearchParams: SetURLSearchParams = React.useCallback(
    (nextInit, navigateOpts) => {
      navigate("?" + createSearchParams(nextInit), navigateOpts);
    },
    [navigate]
  );

  return [searchParams, setSearchParams];
}
复制代码

总结

本文分别讲了react-router-domreact-router-native的源码,两者的结构大体一致,只是根据不同平台而分别提供了相同 API 的不同实现方式。我们从中学习了Router二次封装,Link标签的跳转以及各种辅助路由导航的 API。

至此,React-Router v6 完全解读指南系列就告一段落了,不过后面可能还会有番外篇,讲一讲前端路由的发展及原理,当然也有可能就这样鸽了(。

本系列相关的代码仓库:github

本系列的其余文章:

猜你喜欢

转载自juejin.im/post/7068101548584206350