我手写了个极简版 React Router——才搞懂 v6 为什么砍了这么多 API

上个月项目从 v5 升到 v7,我对着迁移文档改了一整天。<Switch> 没了,withRouter 没了,<Prompt> 也没了。AI 帮我生成的迁移代码能跑,但我说不清为什么改完是这样。

我决定自己写一个极简版 React Router,不为造轮子,就为搞明白一件事:v6 砍掉的那些 API,到底是不是必要的?

写完之后回头看,发现 v6 的设计不是"砍功能",是把路由从"事件驱动"改成了"声明式匹配"。这篇文章是我手写过程的完整记录,代码不超过 200 行,但覆盖了路由库的核心机制。

手写之前,先搞清楚路由库到底在做什么

React Router 本质上只做三件事:

  1. 监听 URL 变化
  2. 根据 URL 匹配路由规则
  3. 渲染对应的组件

v5 时代我们用了大量 API 来处理这三件事------history.listen<Route exact><Redirect>useHistory------但仔细想想,这些 API 里有一半是在处理"URL 变了之后怎么通知组件"这个底层问题,另一半是在处理"多个路由规则冲突了听谁的"这个匹配问题。

v6 的做法是:把通知机制统一成 React Context,把匹配逻辑统一成 ranking 算法。这两步做完,上层 API 自然就少了。

第一步:监听 URL 变化

最底层的浏览器 API 只有两个:popstate 事件和 history.pushState

jsx 复制代码
function createHistory() {
  const listeners = new Set();

  window.addEventListener("popstate", () => {
    const location = window.location;
    listeners.forEach((fn) => fn(location));
  });

  return {
    get location() {
      return window.location;
    },
    push(path) {
      window.history.pushState(null, "", path);
      listeners.forEach((fn) => fn(window.location));
    },
    listen(fn) {
      listeners.add(fn);
      return () => listeners.delete(fn);
    },
  };
}

这段代码不到 30 行,但已经是路由库的全部底层能力了。v5 的 history 包在这个基础上加了 replaceblockgo 等方法,但核心就是 pushState + popstate 的组合。

v6 砍掉 <Prompt> 的原因就在这里<Prompt> 依赖 history.block,而 block 需要拦截浏览器的原生导航行为------这在 SPA 内部跳转时可以实现,但在用户关闭标签页、刷新页面时根本拦不住。v6 选择不暴露这个"不可靠的 API",改用 beforeUnload 作为替代方案。

第二步:路由匹配

路由匹配的核心是一个 ranking 函数。给定一个 URL 和一组路由规则,找出最匹配的那条。

jsx 复制代码
function matchRoute(pathname, routes) {
  let bestMatch = null;
  let bestScore = -1;

  for (const route of routes) {
    const result = matchPath(pathname, route.path);
    if (result && result.score > bestScore) {
      bestMatch = { route, ...result };
      bestScore = result.score;
    }
  }

  return bestMatch;
}

matchPath 需要处理动态参数(:id)、通配符(*)和精确匹配。ranking 的思路是给每条规则打分:

  • 静态段(/users)得分最高
  • 动态段(/:id)次之
  • 通配符(*)最低
jsx 复制代码
function matchPath(pathname, pattern) {
  const pathSegments = pathname.split("/").filter(Boolean);
  const patternSegments = pattern.split("/").filter(Boolean);

  if (patternSegments[patternSegments.length - 1] !== "*") {
    if (pathSegments.length !== patternSegments.length) return null;
  }

  const params = {};
  let score = 0;

  for (let i = 0; i < patternSegments.length; i++) {
    const seg = patternSegments[i];

    if (seg === "*") {
      score += 1;
      break;
    }

    if (seg.startsWith(":")) {
      params[seg.slice(1)] = pathSegments[i];
      score += 10;
    } else if (seg === pathSegments[i]) {
      score += 100;
    } else {
      return null;
    }
  }

  return { params, score };
}

这就是 v6 用 <Routes> 替换 <Switch> 的原因<Switch> 是"先到先得"------谁先匹配到就用谁,路由顺序靠开发者手写。<Routes> 内部做了 ranking,自动选最精确的匹配。你不用再把 /users/new 放在 /users/:id 前面了,ranking 算法会自动判断静态段优先于动态段。

第三步:把匹配结果传给组件

React 里跨层级传数据只有两种方式:props drilling 和 Context。路由库选了 Context。

jsx 复制代码
const RouterContext = React.createContext(null);

function Router({ children, history }) {
  const [location, setLocation] = React.useState(history.location);

  React.useEffect(() => {
    return history.listen(setLocation);
  }, [history]);

  const value = React.useMemo(
    () => ({ location, history }),
    [location, history]
  );

  return (
    <RouterContext.Provider value={value}>
      {children}
    </RouterContext.Provider>
  );
}

useLocationuseNavigateuseParams 这些 hook,全都是从这一个 Context 里取数据的:

jsx 复制代码
function useLocation() {
  return React.useContext(RouterContext).location;
}

function useNavigate() {
  const { history } = React.useContext(RouterContext);
  return history.push;
}

function useParams() {
  const match = React.useContext(RouterContext).match;
  return match?.params ?? {};
}

v5 的 withRouter HOC 被砍掉的原因也清楚了 :它做的事就是把 Context 里的值取出来当 props 传,这在 class component 时代有意义(class 组件不方便读 Context),但在 hook 时代完全多余。一行 useContext 能解决的事,不值得维护一个 HOC。

嵌套路由:v6 改动最大的地方

v5 的嵌套路由靠"在每个子路由里再写一组 <Route>"实现,v6 改成了 <Outlet>

jsx 复制代码
function Layout() {
  return (
    <div>
      <nav>{/* 侧边栏 */}</nav>
      <main>
        <Outlet />
      </main>
    </div>
  );
}

<Outlet> 本质就是一个 Context Consumer------它从当前匹配结果里取出"子路由应该渲染什么",然后渲染出来。

jsx 复制代码
function Outlet() {
  const match = React.useContext(RouteContext);
  return match?.outlet ?? null;
}

这个改动让嵌套路由从"组件树里的多层 <Route> 嵌套"变成了"路由配置里的 children 数组"。v7 的 Framework Mode 在此基础上又加了 loaderactionerrorElement,把路由变成了数据加载+UI 渲染的一体化方案。

手写版 vs v6/v7:一张对照表

写到这里,我把手写极简版和 v6/v7 的核心 API 做了一张对照:

能力 手写极简版 v6 v7 Framework Mode
URL 监听 popstate + pushState 同左(封装在 history 包) 同左 + SSR hydration
路由匹配 ranking 打分 rankRouteBranches 同 v6
匹配结果传递 单层 Context 多层 Context 嵌套 同 v6
嵌套路由 <Outlet> <Outlet> + loader
编程导航 history.push useNavigate() useNavigate() + redirect()
路由守卫 无(需自己实现) loader 里 return redirect()
数据加载 无(用 useEffect loader 函数
错误处理 <ErrorBoundary> errorElement
代码分割 React.lazy React.lazy + 预加载

从表里能看出来,v6 砍掉的那些 API(<Switch>withRouter<Prompt>component/render props)在手写版里根本不需要------它们要么是旧范式的残留(class component 时代的 HOC),要么是在处理 v5 自身设计引入的问题(<Switch> 的顺序依赖)。

v7 在 v6 基础上加的东西(loaderactionerrorElement)则是另一个维度的扩展:把路由从"URL→UI"的映射,变成"URL→数据→UI"的管道。这已经接近 Remix 的完整框架思路了。

一个容易踩的坑:Context 更新粒度

手写过程中我踩了一个坑。最初我把 match 也放进 RouterContext:

jsx 复制代码
// 错误做法
const value = { location, history, match };

结果每次 URL 变化,所有消费 RouterContext 的组件都会重新渲染------包括那些只关心 location 不关心 match 的组件。

v6 的做法是把 Context 拆成多层:LocationContext 只管 URL,NavigationContext 只管导航方法,RouteContext 只管当前匹配。每层独立更新,不会互相干扰。

jsx 复制代码
// v6 的做法(简化)
<NavigationContext.Provider value={{ navigator }}>
  <LocationContext.Provider value={{ location }}>
    <RouteContext.Provider value={{ match, outlet }}>
      {children}
    </RouteContext.Provider>
  </LocationContext.Provider>
</NavigationContext.Provider>

这种 Context 拆分策略在任何需要频繁更新的场景都适用------不只是路由,状态管理库也一样。

回头看:v6 砍 API 的逻辑

手写一遍之后,v6 的"减法"思路很清晰:

砍的不是功能,是实现同一功能的多种方式。

v5 里你可以用 <Route component={X}> 也可以用 <Route render={() => <X />}> 也可以用 <Route children={() => <X />}> 也可以用 <Route><X /></Route>。四种写法做的事情完全一样,但每种都有微妙的区别和适用场景。v6 只留了一种:<Route element={<X />}>

同样,导航也是:v5 有 useHistorywithRouter<Redirect><Link> 四种方式。v6 收敛到 useNavigate<Link> 两种------hook 用于逻辑里跳转,组件用于声明式跳转。

这种"单一正确方式"的设计哲学,和 React 自身的演进方向一致------从 createClassclassfunction,从 componentWillMountuseEffect,每一步都是在减少"同一件事的多种做法"。

写完之后的感受

200 行代码覆盖了路由库 80% 的核心机制。剩下的 20% 是边界处理:SSR、basename、相对路由、路由懒加载------这些是工程化层面的事,和"路由原理"关系不大。

最大的收获不是"会写路由了",而是理解了 v6/v7 的设计决策不是拍脑袋砍功能,是把底层模型从"事件驱动的命令式"重构为"声明式匹配+Context 分发"。底层模型一变,上层 API 自然就精简了。

下次再遇到"这个库怎么又改 API 了"的时候,不妨先手写个极简版。200 行代码能省掉看两天迁移文档的时间。

相关推荐
默_笙1 小时前
🏝 Docker 就是"房地产开发":从施工图纸到小区物业的容器化指南
前端·javascript
a1117761 小时前
Shirone 二次元博客主题 开源
前端·开源
IT_陈寒1 小时前
Python的切片赋值把我坑惨了,这不是bug是特性
前端·人工智能·后端
掘金酱1 小时前
【社区公告】致每一位掘友:关于这次调整, 想再说几句
前端·人工智能
CoderLiu1 小时前
程序化工具调用(PTC)与动态工作流引擎:深入大模型工具调用的架构演进与实践
前端·人工智能·后端
码农胖大海2 小时前
我的第一个产品,只有一段提示词
前端·ai编程·产品
邋遢道2 小时前
# 企业级 Agent 从 0 到 1(二):技术选型与最小骨架
前端·chrome
计算机魔术师2 小时前
我看了 OpenAI 首席科学家的最新长文,把原来的认知推翻了一遍
前端
乱码三千3 小时前
使用ComfyUI+MinMax-H3音视频模型生成视频
前端·后端·github