这一次彻底弄懂 React Router 实现原理

react-router 等前端路由的原理大致相同,就是页面的URL发生改变时,页面的显示结果可以根据URL的变化而变化,但是页面不会刷新

如何实现这个功能,那么我们需要解决两个核心问题

  • 如何改变 URL 却不引起页面刷新?
  • 如何监测 URL 变化?

在前端路由的实现模式有两种模式,hash 和 history 模式,本文将以这两种模式进行讲解

前端路由

hash 模式

  • hash 是 url 中 hash(#) 及后面的部分,常用锚点在页面内做导航,改变 url 中的 hash 部分不会引起页面的刷新
  • 通过 hashchange 事件监听 URL 的改变。改变 URL 的方式只有以下几种:通过浏览器导航栏的前进后退、通过<a>标签、通过window.location,这几种方式都会触发hashchange事件
tsx 复制代码
function onLoad() {
  routeView = document.getElementById('routeView')
  changeView()
}

function changeView() {
  routeView = document.getElementById('routeView')
  
  switch (location.hash) {
    case '#/home':
      routeView.innerHTML = 'home'
      break;
    case '#/about':
      routeView.innerHTML = 'about'
      break;
  }
}

window.addEventListener('DOMContentLoaded', onLoad)
window.addEventListener('hashchange', changeView)

history 模式

  • history 提供了 pushState 和 replaceState 两个方法,这两个方法改变 URL 的 path 部分不会引起页面刷新
  • 通过 popchange 事件监听 URL 的改变。需要注意只在通过浏览器导航栏的前进后退改变 URL 时会触发popstate事件,通过<a>标签和pushState/replaceState不会触发popstate方法。但我们可以拦截<a>标签的点击事件和pushState/replaceState的调用来检测 URL 变化,也是可以达到监听 URL 的变化
tsx 复制代码
/**
 * state:一个与指定网址相关的状态对象, popstate 事件触发时,该对象会传入回调函数。如果不需要可填 null。
 * title:新页面的标题,但是所有浏览器目前都忽略这个值,可填 null。
 * path:新的网址,必须与当前页面处在同一个域。浏览器的地址栏将显示这个地址。
 */
history.pushState(state,title,path)

// 修改当前的 history 对象记录, history.length 的长度不会改变
history.replaceState(state,title,path)


// 监听路由
window.addEventListener('popstate',function(e){
    /* 监听改变 */
})

history.pushState 可以使浏览器地址改变,但是无需刷新页面。

⚠️ 需要注意的是:用 <a>标签、 pushState 或者 history.replaceState 不会触发 popstate 事件。 popstate 事件只会在浏览器某些行为下触发, 比如点击后退、前进按钮或者调用 history.back()、history.forward()、history.go()方法。

React Route

  • history 可以理解为 react-router 的核心,也是整个路由原理的核心,里面集成了popState、pushState 等底层路由实现的原理方法

  • react-router 可以理解为是 react-router-dom 的核心,里面封装了Router,Route,Switch等核心组件,实现了从路由的改变到组件的更新的核心功能

  • react-router-dom 是对 react-router 更上一层封装。添加了用于跳转的Link组件,以及基于 history 实现的 BrowserRouter 和 HashRouter 组件等

History

react-router 路由离不开 history 库,history 专注于记录路由 history 状态,以及 path 改变了,我们应该做写什么,在 history 模式下用 popstate 监听路由变化,在 hash 模式下用 hashchange 监听路由的变化。

接下来我们看 Browser 模式下的 createBrowserHistory 和 Hash模式下的 createHashHistory 方法。

createBrowserRouter

tsx 复制代码
const router = createBrowserRouter([
  {
    path: "/",
    element: (
      <div>
        <h1>Hello World</h1>
        <Link to="about">About Us</Link>
      </div>
    ),
  },
  {
    path: "about",
    element: <div>About</div>,
  },
]);

我们通过debug可以看到 createBrowserRouter 只是调用了 createRouter

这里调用了 createBrowserHistory 的函数进行创建 history,我们点击进去,发现它返回了getUrlBasedHistory函数,目测这里返回的就是 history

tsx 复制代码
const PopStateEventType = "popstate";

function getUrlBasedHistory(
  getLocation: (window: Window, globalHistory: Window["history"]) => Location,
  createHref: (window: Window, to: To) => string,
  validateLocation: ((location: Location, to: To) => void) | null,
  options: UrlHistoryOptions = {}
): UrlHistory {
  let { window = document.defaultView!, v5Compat = false } = options;
  let globalHistory = window.history; // 获取全局下的history
  let action = Action.Pop;
  let listener: Listener | null = null;
  // ...

  let history: History = {
    get action() {/* */},
    get location() {/* */},
    listen(fn: Listener) {
      if (listener) {
        throw new Error("A history only accepts one active listener");
      }
      window.addEventListener(PopStateEventType, handlePop);
      listener = fn;

      return () => {
        window.removeEventListener(PopStateEventType, handlePop);
        listener = null;
      };
    },
    createHref(to) {/* */},
    createURL,
    encodeLocation(to) {/* */},
    push,
    replace,
    go(n) {/* */},
  };

  return history;
}

我们发现在 getUrlBasedHistory 里,history 的 listen 就是监听 popstate 事件,并对 push、replace、go 都进行了二次封装

tsx 复制代码
function push(to: To, state?: any) {
  action = Action.Push;
  let location = createLocation(history.location, to, state);
  if (validateLocation) validateLocation(location, to);

  index = getIndex() + 1;
  let historyState = getHistoryState(location, index);
  let url = history.createHref(location);

  try {
    // 使用 pushState 进行跳转
    globalHistory.pushState(historyState, "", url);
  } catch (error) {
    if (error instanceof DOMException && error.name === "DataCloneError") {
      throw error;
    }
    window.location.assign(url);
  }

  if (v5Compat && listener) {
    listener({ action, location: history.location, delta: 1 });
  }
}

function replace(to: To, state?: any) {
  action = Action.Replace;
  let location = createLocation(history.location, to, state);
  if (validateLocation) validateLocation(location, to);

  index = getIndex();
  let historyState = getHistoryState(location, index);
  let url = history.createHref(location);

  // 使用 pushState 进行跳转
  globalHistory.replaceState(historyState, "", url);

  if (v5Compat && listener) {
    listener({ action, location: history.location, delta: 0 });
  }
}

let history = {
  // ...
  push,
  replace,
  go(n) {
    return globalHistory.go(n);
  },
}

我们发现 react-router 里面的 location 也是自己实现的

tsx 复制代码
export function createLocation(
  current: string | Location,
  to: To,
  state: any = null,
  key?: string
): Readonly<Location> {
  let location: Readonly<Location> = {
    pathname: typeof current === "string" ? current : current.pathname,
    search: "",
    hash: "",
    ...(typeof to === "string" ? parsePath(to) : to),
    state,
    key: (to && (to as Location).key) || key || createKey(),
  };
  return location;
}

当返回 history 后,我们重新跳回 createRouter,createRouter 里面 我们主要关注matchRoutes就行

tsx 复制代码
export function createRouter(init: RouterInit): Router {
  // ...

  // 对 routes 配置和当前的 location 做一次 match
  let initialMatches = matchRoutes(dataRoutes, init.history.location, basename);
  let initialErrors: RouteData | null = null;

  // ...
}

在 matchRoutes 中我们可以看到我们发现路由会全部平铺再进行匹配,再通过他们的score进行排序

tsx 复制代码
for (let i = 0; matches == null && i < branches.length; ++i) {
  let decoded = decodePath(pathname);

  // 路由匹配
  matches = matchRouteBranch<string, RouteObjectType>(branches[i], decoded);
}

匹配到了要渲染的组件以及它包含的子路由这样当组件树渲染的时候,就知道渲染什么组件了。

createRoutesFromElements

tsx 复制代码
// Configure nested routes with JSX
createBrowserRouter(
  createRoutesFromElements(
    <Route path="/" element={<Root />}>
      <Route path="contact" element={<Contact />} />
      <Route
        path="dashboard"
        element={<Dashboard />}
        loader={({ request }) =>
          fetch("/api/dashboard.json", {
            signal: request.signal,
          })
        }
      />
      <Route element={<AuthLayout />}>
        <Route
          path="login"
          element={<Login />}
          loader={redirectIfUser}
        />
        <Route path="logout" action={logoutUser} />
      </Route>
    </Route>
  )
);

当第一次看到 createRoutesFromElements 这个方法,我猜测这个方法是将它转换成数组,再次走上面 createBrowserRouter 的流程,我们先看看 createRoutesFromElements

tsx 复制代码
export function createRoutesFromChildren(
  children: React.ReactNode,
  parentPath: number[] = []
): RouteObject[] {
  let routes: RouteObject[] = [];

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

    let treePath = [...parentPath, index];

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

    // ...

    let route: RouteObject = {
      id: element.props.id || treePath.join("-"),
      caseSensitive: element.props.caseSensitive,
      element: element.props.element,
      Component: element.props.Component,
      index: element.props.index,
      path: element.props.path,
      loader: element.props.loader,
      action: element.props.action,
      errorElement: element.props.errorElement,
      ErrorBoundary: element.props.ErrorBoundary,
      hasErrorBoundary:
        element.props.ErrorBoundary != null ||
        element.props.errorElement != null,
      shouldRevalidate: element.props.shouldRevalidate,
      handle: element.props.handle,
      lazy: element.props.lazy,
    };

    if (element.props.children) {
      route.children = createRoutesFromChildren(
        element.props.children,
        treePath
      );
    }

    routes.push(route);
  });

  return routes;
}

貌似跟我的猜想是一样的,将它们转换为对象,继续走上面的流程

createHashRouter

我们发现 createHashRouter 也是调用了 createRouter 的方法,那么后续的实现流程大致一样

tsx 复制代码
export function createHashRouter(
  routes: RouteObject[],
  opts?: DOMRouterOpts
): RemixRouter {
  return createRouter({
    basename: opts?.basename,
    future: {
      ...opts?.future,
      v7_prependBasename: true,
    },
    history: createHashHistory({ window: opts?.window }),
    hydrationData: opts?.hydrationData || parseHydrationData(),
    routes,
    mapRouteProperties,
    window: opts?.window,
  }).initialize();
}

我们先看看 createHashHistory 做了什么事

tsx 复制代码
export function createHashHistory(
  options: HashHistoryOptions = {}
): HashHistory {
  // 创建location 对象
  function createHashLocation(
    window: Window,
    globalHistory: Window["history"]
  ) {
    let {
      pathname = "/",
      search = "",
      hash = "",
    } = parsePath(window.location.hash.substr(1));

    if (!pathname.startsWith("/") && !pathname.startsWith(".")) {
      pathname = "/" + pathname;
    }

    // 返回location对象
    return createLocation(
      "",
      { pathname, search, hash },
      // state defaults to `null` because `window.history.state` does
      (globalHistory.state && globalHistory.state.usr) || null,
      (globalHistory.state && globalHistory.state.key) || "default"
    );
  }

  function createHashHref(window: Window, to: To) {
    let base = window.document.querySelector("base");
    let href = "";

    if (base && base.getAttribute("href")) {
      let url = window.location.href;
      let hashIndex = url.indexOf("#");
      href = hashIndex === -1 ? url : url.slice(0, hashIndex);
    }

    return href + "#" + (typeof to === "string" ? to : createPath(to));
  }

  return getUrlBasedHistory(
    createHashLocation,
    createHashHref,
    validateHashLocation,
    options
  );
}

我们发现这里和 createBrowserHistory 的方法大致一样,但这里没有监听 hashChange 事件,我想应该是内部将hash路由转换成了browser路由

Link

tsx 复制代码
export const Link = React.forwardRef<HTMLAnchorElement, LinkProps>(
  function LinkWithRef(
    {
      onClick,
      relative,
      reloadDocument,
      replace,
      state,
      target,
      to,
      preventScrollReset,
      unstable_viewTransition,
      ...rest
    },
    ref
  ) {
    let { basename } = React.useContext(NavigationContext);

    // Rendered into <a href> for absolute URLs
    let absoluteHref;
    let isExternal = false;

    if (typeof to === "string" && ABSOLUTE_URL_REGEX.test(to)) {
      // Render the absolute href server- and client-side
      absoluteHref = to;

      // Only check for external origins client-side
      if (isBrowser) {
        try {
          let currentUrl = new URL(window.location.href);
          let targetUrl = to.startsWith("//")
            ? new URL(currentUrl.protocol + to)
            : new URL(to);
          let path = stripBasename(targetUrl.pathname, basename);

          if (targetUrl.origin === currentUrl.origin && path != null) {
            // Strip the protocol/origin/basename for same-origin absolute URLs
            to = path + targetUrl.search + targetUrl.hash;
          } else {
            isExternal = true;
          }
        } catch (e) {
          // We can't do external URL detection without a valid URL
        }
      }
    }

    // Rendered into <a href> for relative URLs
    let href = useHref(to, { relative });

    let internalOnClick = useLinkClickHandler(to, {
      replace,
      state,
      target,
      preventScrollReset,
      relative,
      unstable_viewTransition,
    });
    function handleClick(
      event: React.MouseEvent<HTMLAnchorElement, MouseEvent>
    ) {
      if (onClick) onClick(event);
      if (!event.defaultPrevented) {
        internalOnClick(event);
      }
    }

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

Link 标签回去执行 useLinkClickHandler,而它里面回去执行 navigate

tsx 复制代码
function useLinkClickHandler(to, _temp) {
  let {
    target,
    replace: replaceProp,
    state,
    preventScrollReset,
    relative,
    unstable_viewTransition
  } = _temp === void 0 ? {} : _temp;
  let navigate = useNavigate();
  let location = useLocation();
  let path = useResolvedPath(to, {
    relative
  });
  return React.useCallback(event => {
    if (shouldProcessLinkClick(event, target)) {
      event.preventDefault();
      // If the URL hasn't changed, a regular <a> will do a replace instead of
      // a push, so do the same here unless the replace prop is explicitly set
      let replace = replaceProp !== undefined ? replaceProp : createPath(location) === createPath(path);
      navigate(to, {
        replace,
        state,
        preventScrollReset,
        relative,
        unstable_viewTransition
      });
    }
  }, [location, navigate, path, replaceProp, state, target, to, preventScrollReset, relative, unstable_viewTransition]);
}
tsx 复制代码
// Trigger a navigation event, which can either be a numerical POP or a PUSH
// replace with an optional submission
async function navigate(
  to: number | To | null,
  opts?: RouterNavigateOptions
): Promise<void> {
  if (typeof to === "number") {
    init.history.go(to);
    return;
  }
  
  let historyAction = HistoryAction.Push;
  // ...

  // historyAction = 'push'
  return await startNavigation(historyAction, nextLocation, {
    submission,
    // Send through the formData serialization error if we have one so we can
    // render at the right error boundary after we match routes
    pendingError: error,
    preventScrollReset,
    replace: opts && opts.replace,
    enableViewTransition: opts && opts.unstable_viewTransition,
    flushSync,
  });
}

然后又到了 matchRoutes 的流程

tsx 复制代码
// Start a navigation to the given action/location.  Can optionally provide a
  // overrideNavigation which will override the normalLoad in the case of a redirect
  // navigation
  async function startNavigation(
    historyAction: HistoryAction,
    location: Location,
    opts?: {
      initialHydration?: boolean;
      submission?: Submission;
      fetcherSubmission?: Submission;
      overrideNavigation?: Navigation;
      pendingError?: ErrorResponseImpl;
      startUninterruptedRevalidation?: boolean;
      preventScrollReset?: boolean;
      replace?: boolean;
      enableViewTransition?: boolean;
      flushSync?: boolean;
    }
  ): Promise<void> {
    // ...
    
    let matches = matchRoutes(routesToUse, location, basename);
    let flushSync = (opts && opts.flushSync) === true;

    // ...
    
    completeNavigation(location, {
      matches,
      ...(pendingActionData ? { actionData: pendingActionData } : {}),
      loaderData,
      errors,
    });
  }

match 完会 pushState 或者 replaceState 修改 history,然后更新 state

tsx 复制代码
  // Complete a navigation returning the state.navigation back to the IDLE_NAVIGATION
  // and setting state.[historyAction/location/matches] to the new route.
  // - Location is a required param
  // - Navigation will always be set to IDLE_NAVIGATION
  // - Can pass any other state in newState
  function completeNavigation(
    location: Location,
    newState: Partial<Omit<RouterState, "action" | "location" | "navigation">>,
    { flushSync }: { flushSync?: boolean } = {}
  ): void {
    // ...

    if (isUninterruptedRevalidation) {
      // If this was an uninterrupted revalidation then do not touch history
    } else if (pendingAction === HistoryAction.Pop) {
      // Do nothing for POP - URL has already been updated
    } else if (pendingAction === HistoryAction.Push) {
      init.history.push(location, location.state);
    } else if (pendingAction === HistoryAction.Replace) {
      init.history.replace(location, location.state);
    }

    // ...
    
    updateState(
      {
        ...newState, // matches, errors, fetchers go through as-is
        actionData,
        loaderData,
        historyAction: pendingAction,
        location,
        initialized: true,
        navigation: IDLE_NAVIGATION,
        revalidation: "idle",
        restoreScrollPosition: getSavedScrollPosition(
          location,
          newState.matches || state.matches
        ),
        preventScrollReset,
        blockers,
      },
      {
        viewTransitionOpts,
        flushSync: flushSync === true,
      }
    );

    // Reset stateful navigation vars
    // ...
  }

然后触发了 setState,组件树会重新渲染,这里我删除一些不必要的代码

tsx 复制代码
// Update our state and notify the calling context of the change
  function updateState(
    newState: Partial<RouterState>,
    opts: {
      flushSync?: boolean;
      viewTransitionOpts?: ViewTransitionOpts;
    } = {}
  ): void {
    state = {
      ...state,
      ...newState,
    };

    // Prep fetcher cleanup so we can tell the UI which fetcher data entries
    // can be removed
    let completedFetchers: string[] = [];
    let deletedFetchersKeys: string[] = [];
    
    // Iterate over a local copy so that if flushSync is used and we end up
    // removing and adding a new subscriber due to the useCallback dependencies,
    // we don't get ourselves into a loop calling the new subscriber immediately
    [...subscribers].forEach((subscriber) =>
      subscriber(state, {
        deletedFetchers: deletedFetchersKeys,
        unstable_viewTransitionOpts: opts.viewTransitionOpts,
        unstable_flushSync: opts.flushSync === true,
      })
    );
  }

router.navigate 会传入新的 location,然后和 routes 做 match,找到匹配的路由。之后会 pushState 修改 history,并且触发 react 的 setState 来重新渲染,重新渲染的时候通过renderMatches 把当前 match 的组件渲染出来

另外在 createRouter 后,会调用 initialize 方法

tsx 复制代码
function initialize() {
    // 这里调用了 history 的 listen 方法
    unlistenHistory = init.history.listen(
      ({ action: historyAction, location, delta }) => {
        // ...

        return startNavigation(historyAction, location);
      }
    );

    if (isBrowser) {
      // 监听保存和恢复应用的过渡效果
      // ...
    }

    // 尚未初始化
    if (!state.initialized) {
      // 在启动时进行导航,确保在应用程序开始时加载必要的数据
      startNavigation(HistoryAction.Pop, state.location, {
        initialHydration: true,
      });
    }

    return router;
  }

在初始化的时候调用了 listen 方法,并执行 startNavigation

tsx 复制代码
const PopStateEventType = "popstate";

let history: History = {
  get action() {/* */},
  get location() {/* */},
  listen(fn: Listener) {
    if (listener) {
      throw new Error("A history only accepts one active listener");
    }
    window.addEventListener(PopStateEventType, handlePop);
    listener = fn;

    return () => {
      window.removeEventListener(PopStateEventType, handlePop);
      listener = null;
    };
  },
  createHref(to) {/* */},
  createURL,
  encodeLocation(to) {/* */},
  push,
  replace,
  go(n) {/* */},
};

在初始化的时候,reactRouter注册了监听 popstate 事件

Outlet

tsx 复制代码
// Outlet 实现
function Outlet(props) {
  return useOutlet(props.context);
}

// useOutlet 实现
export function useOutlet(context?: unknown) {
  // 从最近的RouteContext中取出其outlet值并渲染出来
  let outlet = React.useContext(RouteContext).outlet;
  if (outlet) {
    return (
      <OutletContext.Provider value={context}>{outlet}</OutletContext.Provider>
    );
  }
  return outlet;
}

Outlet 组件的实现正如我们上面说的,就是通过 useContext 去取最近的 RouteContext 保存的 outlet 值

相关推荐
WMYeah5 分钟前
【无标题】
前端·rust·抽奖程序·跨平台抽奖程序
Unbelievabletobe6 分钟前
免费外汇api的响应时间在不同时段下的波动分析
大数据·开发语言·前端·python
大哥,带带弟弟15 分钟前
Grafana 前端嵌入与 JWT 鉴权实战
前端·grafana
小小小小宇16 分钟前
前端 V8 引擎垃圾回收机制与内存问题排查
前端
前端老石人27 分钟前
CSS 值定义语法
前端·css
sheeta199838 分钟前
Vue 前端基础笔记
前端·vue.js·笔记
小小小小宇38 分钟前
GitLab + GitLab Runner + Qiankun 微前端 + Nginx + Node 中间件 前端开发机从零搭建 CI/CD 全流程
前端
前端那点事42 分钟前
别再写垃圾组件!Vue3 如何设计「真正可复用」的高质量通用组件
前端·vue.js
卷帘依旧1 小时前
JavaScript 中的 Symbol
前端·javascript
老王以为1 小时前
Claude Code 从 GUI 到 TUI:开发者界面的范式回归
前端·人工智能·全栈