Instatic 内部自研 Admin Router 深度指南:零依赖路由的架构、匹配机制与实战 Cookbook
【免费下载链接】InstaticThe open-source alternative to Webflow, Framer and WordPress. Agentic self-hosted visual CMS outputting clean static pages. Users, roles, plugins, content, database, it's all there.项目地址: https://gitcode.com/GitHub_Trending/in/Instatic
Instatic 的管理后台(admin app)没有使用 react-router-dom,而是维护了一套位于src/admin/lib/routing/的自研路由器,以六个组件加四个 Hook 的极简 API 覆盖当前全部管理路由表。本文以 docs/reference/admin-router.md 为核心骨架,结合 Router.tsx、routerHooks.ts、urlState.ts 等源码实现,系统讲解这套路由器的设计动机、路径匹配规则、导航生命周期、过渡动画集成以及在实际组件中的用法,帮助你写出符合仓库规范的内部导航代码。
为什么管理后台需要一套自研路由器
Instatic 的 admin 应用此前使用react-router-dom,但它的路由表规模很小、形态固定(静态段、:param参数段、*通配段),根本用不上 react-router 提供的 loaders、actions、嵌套布局与 data router 等重型能力。这些能力被打包进 eager 冷启动路径,意味着每个访问者在编辑器挂载之前都要下载无用的路由功能。
从 Router.tsx 的头部注释可以看到替换动机:
react-router-dom@7 ships ~30 KB gz on the eager cold path for an admin with a small static route table. That's the worst kind of bundle bloat — features we're not using (loaders, actions, nested layouts, data routers) shipped to every visitor before the editor even mounts.
自研路由器只实现了 admin 实际用到的路由特性,后续若真需要 data loader 或嵌套布局,可以在现有文件上增量扩展。react-router-dom已从package.json中移除,并且有专门的架构 gate 测试(src/tests/architecture/admin-router-usage.test.ts)阻止它被重新引入。
快速上手:导入与挂载
所有 API 统一从 barrel 文件@admin/lib/routing导出(对应 src/admin/lib/routing/index.ts):
import { Router, MemoryRouter, Routes, Route, Navigate, Link, matchPath, useLocation, useNavigate, useParams, useInRouterContext, } from '@admin/lib/routing'不要从react-router-dom导入——该依赖已不在package.json中,架构测试会直接判定违规(见下文"架构约束")。
路由器在应用入口处挂载。实际的 src/admin/main.tsx 中,<Router>包裹<AdminRoutes />,外层再包一层<ErrorBoundary location="admin-shell">:
flushSync(() => { root.render( <StrictMode> <ErrorBoundary location="admin-shell"> <Router> <AdminRoutes /> </Router> <AdminZoomGuard /> <AdminContextMenuGuard /> </ErrorBoundary> <ToastProvider /> </StrictMode>, ) })Router会在window上注册popstate监听器,并把history.pushState/replaceState桥接为自定义的instatic:locationchange事件(详见 Router.tsx 的browserSubscribe)。MemoryRouter用于测试场景——API 相同但不触碰 DOM history,维护自己的内存快照。
需要特别注意:src/core/与src/modules/不得导入这套路由器。它们是共享引擎和发布页代码,不是 admin UI;二者对路由的依赖被 gate 测试强制隔离(见 admin-router-usage.test.ts)。
路由表:声明式的路由配置
当前路由表位于 src/admin/router.tsx,比文档中展示的版本略有演进(新增了/admin/ai/oauth/authorize以及每路由的 Suspense 与 ErrorBoundary 包装):
export function AdminRoutes() { return ( <Routes> <Route path="/" element={<Navigate to="/admin/dashboard" replace />} /> <Route path="/admin" element={<Navigate to="/admin/dashboard" replace />} /> <Route path="/admin/dashboard" element={withRouteBoundary(<AdminEntry section="dashboard" />)} /> <Route path="/admin/site" element={withRouteBoundary(<AdminEntry section="site" />)} /> <Route path="/admin/content" element={withRouteBoundary(<AdminEntry section="content" />)} /> <Route path="/admin/data" element={withRouteBoundary(<AdminEntry section="data" />)} /> <Route path="/admin/media" element={withRouteBoundary(<AdminEntry section="media" />)} /> <Route path="/admin/plugins" element={withRouteBoundary(<AdminEntry section="plugins" />)} /> <Route path="/admin/users" element={withRouteBoundary(<AdminEntry section="users" />)} /> <Route path="/admin/ai" element={withRouteBoundary(<AdminEntry section="ai" />)} /> <Route path="/admin/ai/oauth/authorize" element={withRouteBoundary(<AdminEntry section="ai" />)} /> <Route path="/admin/account" element={withRouteBoundary(<AdminEntry section="account" />)} /> <Route path="/admin/plugins/:pluginId/:pageId" element={withRouteBoundary(<AdminEntry section="pluginPage" />)} /> {/* Catch-all for ADMIN paths only ... */} <Route path="/admin/*" element={<Navigate to="/admin/dashboard" replace />} /> </Routes> ) }支持的模式
- 静态段:
/admin/dashboard、/admin/site - 参数段:
:pluginId、:pageId,匹配任意非/的 token 并写入params - 通配段:
*匹配任意内容(含后续斜杠),如*、/admin/*,用于兜底重定向
不支持可选段、嵌套路由和正则。这一点被强制约束:若需要更复杂的匹配,正确做法是重构路由树,而不是扩展匹配语法。
匹配顺序与兜底
Routes自上而下遍历其<Route>子节点,第一个匹配生效。因此当模式可能重叠时顺序至关重要——*兜底必须放在最后,否则会遮蔽其后的所有路由。实际路由表中/admin/*就是最后一条,它把未知的 admin URL(拼写错误、失效深链接、/admin/login等)重定向到/admin/dashboard:未认证时展示登录表单,已认证则展示仪表盘,绝不会渲染出空白树。
兜底被刻意限定在/admin/*作用域内——公开站点的 404 由发布管线的 NotFound 模板处理,绝不能交给 admin SPA 吞掉(router.tsx 的注释明确说明了这一点)。
组件逐个解析
<Route>—— 纯声明元数据
Route是一个 marker 组件,自己不渲染 element。看 Router.tsx 的实现,它直接返回null:
export function Route(_props: RouteProps): null { // Marker only — <Routes> reads props from the React element directly. return null }Routes通过collectRouteChildren读取子元素上的path与elementprops。由于它单独渲染时永远返回null,把它放进条件分支是安全的——如果忘了用Routes包裹,什么都不会渲染。
<Routes>—— 匹配与渲染
Routes读取当前pathname(来自useLocation()),遍历子Route,用matchPath找到第一个匹配项,然后以RouteContext.Provider包裹并渲染该element,从而让useParams可用(Router.tsx):
export function Routes({ children }: RoutesProps) { const { pathname } = useLocation() const list = collectRouteChildren(children) let matched: { element: ReactNode; params: Record<string, string> } | null = null for (const route of list) { const result = matchPath(route.path, pathname) if (result) { matched = { element: route.element, params: result.params } break } } if (!matched) return null return ( <RouteContext.Provider value={{ params: matched.params }}> {matched.element} </RouteContext.Provider> ) }源码注释解释了为什么不 memoize:children每次父渲染都是新的 JSX 引用,缓存必然 miss;而路由匹配只是对小型 admin 路由表做正则匹配,代价很低。没有路由匹配时Routes渲染null——这就是兜底路由必须存在的原因。
实际路由表中每条路由都经withRouteBoundary包装:RouteBoundary使用useLocation().pathname作为ErrorBoundary的resetKeys,导航离开故障路由后会自动清除失败状态,用户不会被困在错误页(router.tsx)。
<Navigate>—— 组件形态的重定向
Navigate以 effect 形式在挂载时触发一次导航(Router.tsx):
export function Navigate({ to, replace = false }: { to: string; replace?: boolean }) { const navigate = useNavigate() const fired = useRef(false) useEffect(() => { if (fired.current) return fired.current = true navigate(to, { replace }) }, [navigate, to, replace]) return null }| Prop | 默认值 | 行为 |
|---|---|---|
to | - | 目标路径 |
replace | false | 使用history.replaceState而非pushState |
useRef标记保证在 StrictMode 双调用下 effect 也只执行一次。它用于索引重定向(/→/admin/dashboard)和权限不足时的重定向(如AuthenticatedAdmin中的<Navigate to={workspacePath(fallbackWorkspace)} replace />,见 AuthenticatedAdmin.tsx)。
<Link>—— 拦截点击的锚点
Link渲染<a href={to}>,在左键点击时通过路由器导航(不刷新页面)。看 Router.tsx 的实现,它完整保留了原生锚点的语义:
const handleClick = (event: MouseEvent<HTMLAnchorElement>) => { onClick?.(event) if (event.defaultPrevented) return if (event.button !== 0) return if (event.metaKey || event.ctrlKey || event.shiftKey || event.altKey) return if (rest.target && rest.target !== '_self') return if (!inRouter || !ctx) return event.preventDefault() ctx.navigate(to, { replace }) }以下情况回退到浏览器原生导航:
- 带修饰键点击(cmd / ctrl / shift / alt)——在新标签页打开
- 非左键点击
target="_blank"
可以传入任意标准锚点属性(className、aria-*、style等)。Link内部通过use(RouterContext)判断是否处于路由上下文中;若在 Router 外渲染(如 SSR / 预挂载),则退化为普通锚点,保证安全。
Hook 全家桶
Hooks 与组件分文件存放(routerHooks.ts)是刻意的设计:Vite 的 React Fast Refresh 要求一个文件要么只导出组件、要么只导出非组件,混在一起会导致 HMR 变成整页刷新。组件放Router.tsx、hooks/类型/context 放.ts文件,既能保持公共 API 一致,又能保住热更新体验(routerHooks.ts 的注释有完整解释)。
useLocation()
const { pathname, search, hash } = useLocation()返回当前 location,每次导航都会触发组件重渲染。实现基于useRouterContextOrThrow,在 Router 外调用会抛出'Router hooks must be used inside <Router> or <MemoryRouter>'。类型定义中Location只有pathname和search两个字段(routerHooks.ts),hash并不在类型里——解析查询串请用new URLSearchParams(search)。
useNavigate()
const navigate = useNavigate() navigate('/admin/site') // push navigate('/admin/site', { replace: true }) // replace返回一个函数,调用时通过startTransition触发导航(见下文"导航生命周期"),让 React 19 能平滑推迟 Suspense 回退。
useParams<T>()
const { pluginId, pageId } = useParams<{ pluginId: string; pageId: string }>()返回匹配Route模式的参数。类型参数只是提示——运行时始终返回Record<string, string>。实现直接从RouteContext取值(routerHooks.ts)。
useInRouterContext()
const inRouter = useInRouterContext() if (!inRouter) { // Render a fallback for use outside the router (e.g. test harness) }返回是否处于 Router 上下文中,实现只是use(RouterContext) !== null。AuthenticatedAdmin用它来在无路由上下文时(如单元测试)渲染非重定向的降级 UI(AuthenticatedAdmin.tsx)。
useAdminNavigate:带视图过渡的程序化导航
对于工具栏下拉、模态框、面板按钮这类"按钮式"程序化导航,优先使用useAdminNavigate(src/admin/lib/useAdminNavigate.ts),而不是裸useNavigate。它把路由导航包进document.startViewTransition+flushSync,实现 admin 导航的淡入淡出过渡:
const navigate = useAdminNavigate() navigate('/admin/site') navigate('/admin/ai') navigate('/admin/plugins/acme.x/dashboard')函数签名为(to: string) => void,直接传完整路径。核心实现如下:
startViewTransition.call(document, () => { flushSync(() => { void navigate(to) }) })flushSync强制 React 在同一个动画帧内同步提交导航,否则 View Transitions API 捕获到的是上一页的 after-state,会出现一闪而过的旧 DOM。在不支持document.startViewTransition的环境(旧浏览器、jsdom 测试)中自动退化为普通navigate(to)。
为什么是 Hook 而不是包装组件?源码注释给出了两个理由:其一,锚点类包装适合<a href>语义(中键开新标签、修饰键、无障碍性),而下拉菜单里点按钮没有这种语义,函数引用才是正确原语;其二,调用点更扁平——navigate('/admin/account')一行搞定,无需 JSX 包裹。
使用建议:锚点式导航用<Link>(中键/修饰键语义重要),程序化导航用useAdminNavigate。仓库中 AdminSectionNavigation、AccountMenuButton、SpotlightRoot 等都是它的实际使用方。
导航生命周期:startTransition是关键承重结构
文档给出了完整的导航时序:
useNavigate()(path) │ ▼ React.startTransition(() => { history.pushState(null, '', path) window.dispatchEvent(new Event(LOCATION_CHANGE_EVENT)) }) │ ▼ RouterContext subscribers re-read location.pathname │ ▼ <Routes> picks the matching <Route> │ ▼ <Suspense> shows the prior route until the next workspace chunk resolves对应源码见 Router.tsx:history.pushState/replaceState同步执行,事件的派发被包在startTransition里,因此useSyncExternalStore触发的重读属于低优先级 Transition。
startTransition是整套机制的承重结构:没有它,在懒加载 chunk 期间切换 workspace 会闪现<AppLoadingScreen>;有了它,React 会保持展示上一个 workspace,直到新 chunk 就绪后原子提交——用户感知到的导航是即时的。这与AuthenticatedAdmin的prewarmedLazy工作区预载策略配合(AuthenticatedAdmin.tsx):活动页面先加载,其余 9 个 workspace 页面在requestIdleCallback空闲时段后台预热,点击导航时目标页面走缓存快速路径同步渲染,无微任务、无 Suspense 回退、无闪烁。
路径匹配:matchPath的内部实现
matchPath(pattern, pathname)从 barrel 导出,直接可用:
matchPath('/admin/plugins/:pluginId/:pageId', '/admin/plugins/acme.x/dashboard') // → { params: { pluginId: 'acme.x', pageId: 'dashboard' } } matchPath('/admin/dashboard', '/admin/site') // → null匹配规则:
- 静态段必须精确匹配(正则对特殊字符做了转义)
:param段匹配任意非/的 token,值写入params- 无可选段、无通配、无正则
- 容忍尾部斜杠,要求全路径匹配
看 routerHooks.ts 的compilePattern,它把模式按/拆段编译::开头的段编译为([^/]+)并记录参数名,*段编译为.*(因此能匹配含斜杠的路径,这是 catch-all 的基础),其余段做正则转义后拼接,最终生成^.../?$的完整匹配正则。匹配成功后参数值还会经过decodeURIComponent解码,所以 URL 编码的中文等字符能正确还原。
instatic:locationchange事件:history 才是唯一真相源
LOCATION_CHANGE_EVENT = 'instatic:locationchange'定义在 routerHooks.ts。Router在window上同时监听popstate和这个自定义事件。只要代码通过路由器的 navigate 调用history.pushState/replaceState,就会派发该事件。
这个模式让多个组件可以订阅导航,而 React 不掌握真相源——history就是真相源,事件只是通知订阅者重新读取:
window.addEventListener('instatic:locationchange', () => { // ... re-read location })实际开发中优先用useLocation(),事件监听只适合在 React 之外需要感知导航的场景。
Cookbook:常见实战模式
新增一个 workspace 路由
- 在
src/admin/workspace.ts的AdminWorkspace中添加 section。 - 在
src/admin/router.tsx中添加<Route path="/admin/<section>" element={<AdminEntry section="<section>" />} />。 - 在
src/admin/AuthenticatedAdmin.tsx中添加lazy(...)+ 预热导入(参考其中的prewarmedLazy用法,见 AuthenticatedAdmin.tsx)。 - 创建
src/admin/pages/<section>/<Section>Page.tsx。
完整流程参见 docs/editor.md 的 "Adding a new workspace" 一节。
组件内的条件导航
function MyComponent() { const navigate = useAdminNavigate() const handleSave = async () => { await saveSomething() navigate('/admin/content') } return <Button onClick={handleSave}>Save</Button> }读取 URL 参数
function PluginPage() { const { pluginId, pageId } = useParams<{ pluginId: string; pageId: string }>() return <div>Plugin: {pluginId} · Page: {pageId}</div> }从 React 之外导航(例如命令)
Spotlight 命令通过CommandContext拿到ctx.navigate直接使用(详见 docs/features/spotlight.md):
run: (ctx) => { ctx.navigate('/admin/media') }不要写window.location.href = '/admin/media'——那会触发整页刷新,摧毁 SPA 状态。
带查询串的链接
<Link to={`/admin/data?table=${tableId}`}>Edit table</Link>useLocation()返回{ pathname, search, hash },查询串从search读取。当前没有useSearchParams辅助函数,直接用new URLSearchParams(search)解析。
用MemoryRouter测试
import { MemoryRouter, Routes, Route } from '@admin/lib/routing' render( <MemoryRouter initialEntries={['/admin/dashboard']}> <Routes> <Route path="/admin/dashboard" element={<Dashboard />} /> </Routes> </MemoryRouter>, )MemoryRouter不触碰history,非常适合单元测试。从实现看它取initialEntries的最后一项作为初始快照,导航同样走startTransition,因此测试对 Suspense 回退行为的断言与生产环境行为一致(Router.tsx)。
Forbidden patterns:红线清单
| 模式 | 应改为 |
|---|---|
import { ... } from 'react-router-dom' | @admin/lib/routing。该依赖已移除。 |
admin UI 中使用裸<a href="/admin/..."> | <Link to="/admin/...">或useAdminNavigate()。 |
src/core/导入路由 | 被 gate 测试禁止。 |
src/modules/导入路由 | 被 gate 测试禁止。 |
用window.location.href = '...'做导航 | useNavigate()/useAdminNavigate()——整页刷新会杀死 SPA 状态 |
直接调用history.pushState | 用路由器——它会替你派发instatic:locationchange |
嵌套路由(<Route path="/admin/site"><Route ...>...) | 只使用扁平路由表,用 workspace 内部状态组合。 |
| 可选 URL 段 / 通配段 | 重构路由树。 |
| catch-all 404 路由 | 保持受限的/admin/*重定向在最后——非法 admin 路径路由到 dashboard/登录流。 |
其中两条由架构 gate 测试机器化执行(src/tests/architecture/admin-router-usage.test.ts):
- admin UI 禁止用裸锚点硬导航:扫描
src/admin下所有.ts/.tsx中href="/admin..."的写法并报错。 - 禁止重新引入 react-router-dom:扫描
admin、core、modules三个目录的from 'react-router-dom'导入。 - core 与 modules 禁止导入 admin 路由:扫描
@admin/lib/routing及相对路径形式的 admin 路由导入。
@admin/lib/urlState:查询串同步而不触发路由重匹配
companion 模块src/admin/lib/urlState/为 workspace 的选中态提供 URL 查询串原语:
import { useInitialQueryParams, useUrlQuerySync } from '@admin/lib/urlState'| Hook | 用途 |
|---|---|
useInitialQueryParams() | 返回首次挂载时的查询参数(稳定、只读一次,用于"打开即定位"的深链接)。 |
useUrlQuerySync(params, opts?) | 把给定的 key→value 映射镜像到 URL(通过replaceState)。null值移除对应 key;未列出的 key 不受影响。 |
两个 Hook 都直接操作window.history,刻意不派发instatic:locationchange——选中态的查询串更新绝不能触发路由重匹配。useUrlQuerySync内部用JSON.stringify序列化依赖,只有期望参数实际变化时 effect 才重跑;replaceState(而非pushState)保证在行/页之间切换不会污染浏览器后退栈(urlState.ts)。
使用方包括 site 编辑器(useSiteEditorUrlSync)、Content workspace 与 Data workspace。完整的契约与 URL 形态见 docs/editor.md 的 "URL state and workspace deep links" 一节。
源码索引
- 组件实现:src/admin/lib/routing/Router.tsx(
Router、MemoryRouter、Routes、Route、Navigate、Link) - Hooks 与匹配:src/admin/lib/routing/routerHooks.ts(
useLocation、useNavigate、useParams、useInRouterContext、matchPath) - Barrel 导出:src/admin/lib/routing/index.ts
- 路由表:src/admin/router.tsx
- 程序化导航过渡:src/admin/lib/useAdminNavigate.ts
- URL 查询串同步:src/admin/lib/urlState/urlState.ts
- 挂载入口:src/admin/main.tsx
- 工作区预载与懒加载:src/admin/AuthenticatedAdmin.tsx
- 架构 gate 测试:src/tests/architecture/admin-router-usage.test.ts
相关文档:docs/editor.md(admin shell 与路由放置、URL state 契约)、docs/architecture.md(/admin/*命名空间归 SPA 所有)、docs/features/spotlight.md(Spotlight 命令导航)。
【免费下载链接】InstaticThe open-source alternative to Webflow, Framer and WordPress. Agentic self-hosted visual CMS outputting clean static pages. Users, roles, plugins, content, database, it's all there.项目地址: https://gitcode.com/GitHub_Trending/in/Instatic
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考