上个月项目从 v5 升到 v7,我对着迁移文档改了一整天。<Switch> 没了,withRouter 没了,<Prompt> 也没了。AI 帮我生成的迁移代码能跑,但我说不清为什么改完是这样。
我决定自己写一个极简版 React Router,不为造轮子,就为搞明白一件事:v6 砍掉的那些 API,到底是不是必要的?
写完之后回头看,发现 v6 的设计不是"砍功能",是把路由从"事件驱动"改成了"声明式匹配"。这篇文章是我手写过程的完整记录,代码不超过 200 行,但覆盖了路由库的核心机制。
手写之前,先搞清楚路由库到底在做什么
React Router 本质上只做三件事:
- 监听 URL 变化
- 根据 URL 匹配路由规则
- 渲染对应的组件
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 包在这个基础上加了 replace、block、go 等方法,但核心就是 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>
);
}
useLocation、useNavigate、useParams 这些 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 在此基础上又加了 loader、action、errorElement,把路由变成了数据加载+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 基础上加的东西(loader、action、errorElement)则是另一个维度的扩展:把路由从"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 有 useHistory、withRouter、<Redirect>、<Link> 四种方式。v6 收敛到 useNavigate 和 <Link> 两种------hook 用于逻辑里跳转,组件用于声明式跳转。
这种"单一正确方式"的设计哲学,和 React 自身的演进方向一致------从 createClass 到 class 到 function,从 componentWillMount 到 useEffect,每一步都是在减少"同一件事的多种做法"。
写完之后的感受
200 行代码覆盖了路由库 80% 的核心机制。剩下的 20% 是边界处理:SSR、basename、相对路由、路由懒加载------这些是工程化层面的事,和"路由原理"关系不大。
最大的收获不是"会写路由了",而是理解了 v6/v7 的设计决策不是拍脑袋砍功能,是把底层模型从"事件驱动的命令式"重构为"声明式匹配+Context 分发"。底层模型一变,上层 API 自然就精简了。
下次再遇到"这个库怎么又改 API 了"的时候,不妨先手写个极简版。200 行代码能省掉看两天迁移文档的时间。