路由错误边界与嵌套路由
问题:所有失败都应该显示同一个错误页吗
访问不存在的 URL、接口返回 500、组件渲染时抛错,是三种不同失败。把它们全塞进一个 catch 会失去恢复边界:404 可以继续使用导航,数据失败通常允许重试,渲染异常则需要隔离崩溃的子树并记录组件栈。
React 核心没有路由器。下面手写一个很小的匹配器,是为了观察职责,不是推荐把它扩成生产框架。
完整实验:父布局、子页面、404 与 Error Boundary
保存为 src/App.jsx,在已有 Vite React 19 项目运行。
import { Component, useEffect, useState } from 'react'
function matchLocation(pathname) {
if (pathname === '/') return { name: 'home', params: {} }
const match = /^\/projects\/([^/]+)$/.exec(pathname)
if (!match) return { name: 'not-found', params: {} }
try {
return { name: 'project', params: { projectId: decodeURIComponent(match[1]) } }
} catch {
return { name: 'bad-url', params: {} }
}
}
class PageErrorBoundary extends Component {
state = { error: null }
static getDerivedStateFromError(error) {
return { error }
}
componentDidCatch(error, info) {
console.error('page-render-failed', { message: error.message, stack: info.componentStack })
}
render() {
if (this.state.error) {
return <section role="alert"><h2>页面渲染失败</h2><p>请返回项目列表。</p></section>
}
return this.props.children
}
}
function ProjectPage({ projectId }) {
if (projectId === 'broken') throw new Error('演示渲染错误')
return <section><h2>项目:{projectId}</h2><p>这里是嵌套在应用布局中的页面。</p></section>
}
function RouteView({ route }) {
if (route.name === 'home') return <section><h2>项目列表</h2></section>
if (route.name === 'project') return <ProjectPage projectId={route.params.projectId} />
if (route.name === 'bad-url') return <section><h2>地址编码无效</h2></section>
return <section><h2>404:没有这个页面</h2></section>
}
export default function App() {
const [pathname, setPathname] = useState(window.location.pathname)
useEffect(() => {
const restore = () => setPathname(window.location.pathname)
window.addEventListener('popstate', restore)
return () => window.removeEventListener('popstate', restore)
}, [])
function go(event, to) {
event.preventDefault()
window.history.pushState(null, '', to)
setPathname(to)
}
const route = matchLocation(pathname)
return (
<main>
<h1>项目中心</h1>
<nav aria-label="主导航">
<a href="/" onClick={event => go(event, '/')}>首页</a>{' · '}
<a href="/projects/alpha" onClick={event => go(event, '/projects/alpha')}>Alpha</a>{' · '}
<a href="/projects/broken" onClick={event => go(event, '/projects/broken')}>崩溃演示</a>
</nav>
<PageErrorBoundary key={pathname}>
<RouteView route={route} />
</PageErrorBoundary>
</main>
)
}依次打开首页、Alpha、不存在的路径和 broken。<main>、标题和导航始终属于父布局;RouteView 是子页面插槽。key={pathname} 让导航到另一个地址时创建新的边界实例,清掉旧错误状态。
四条互不替代的规则
- 匹配结果不是异常。 没有规则匹配时返回
not-found,正常渲染 404。它不该污染错误监控。 - 嵌套代表 UI 所有权。 父布局拥有跨页面保持的导航,子页面只拥有该路径对应内容。成熟路由器通常用 outlet 表达同一关系。
- 错误边界只捕获后代渲染错误。 它不捕获自身错误、事件处理器错误、任意异步回调错误或服务端渲染错误。数据加载失败应进入明确的加载状态或框架的 route error 通道。
- 记录与回退分开。
getDerivedStateFromError纯粹决定回退 UI;componentDidCatch执行上报。React 19 仍没有等价的函数组件错误边界 API,因此这个小类组件是有意保留的稳定做法。
改变 key,观察恢复单位是否改变
先访问 broken,再点击 Alpha:pathname 改变,边界的 key 改变,旧 error 状态不再属于新实例。现在暂时删去 key={pathname} 重试,边界会继续显示旧回退页,因为它仍是原来那个保存 error 的组件;下面的 route 数据变化并没有自动清除边界自身状态。
恢复 key 后,在 ProjectPage 中加 const [draft, setDraft] = useState('') 和一个受控输入。Alpha 输入文字后去首页再返回,草稿会重置,因为边界连同子树被重新创建。这不是 key 只清除错误、却保留其他状态的特殊机制。若产品要求跨路由保留草稿,应把它放在按 projectId 管理的更高层状态或外部存储,并明确离开时是否丢弃。
访问未知地址不会抛错,因此边界不参与处理,只显示 RouteView 的 404。浏览器页面上写了“404”也不意味着服务器响应状态码是 404;客户端匹配与 HTTP 响应由不同层负责,服务端路由需要另行映射。
教学匹配器的范围
它没有 base path、相对链接、查询参数规则、重定向、焦点/标题管理、滚动恢复、懒加载、服务端渲染、水合或数据 loader。客户端切页也不会像整页导航那样自动提示读屏软件,产品需要在路由提交后管理标题与焦点。需求超过实验范围时,应安装并按版本文档配置路由库,而不是继续堆条件分支。
练习:匹配两层动态段
实现 matchTask(pathname):仅接受 /projects/:projectId/tasks/:taskId;每个动态段都必须非空并安全解码;无匹配返回 { status: 404 },编码非法返回 { status: 400 },成功返回 { status: 200, params }。参数对象不得含原始 %xx 编码。用断言覆盖正常中文、缺少 task、额外斜杠和非法 %E0%A4%A。
下一篇
缓存、失效与请求竞态会把跨页面复用数据时的所有权与时间边界写成可运行协议。