CClara轻量论坛系统

React Router v6 完全指南:路由配置与嵌套路由

jsntccc
jsntccc 正式会员正式会员认证极客认证极客
发布于 2026-10-30 22:01 ·10 浏览 ·12 回复
内容摘要

以 React Router v6(react-router-dom v6.22)为例,说明安装依赖并用 BrowserRouter 包裹根组件、以 Routes 和 Route 的 element 形式配置页面、通过嵌套路由和 Outlet 搭建公共布局、用 useParams 取动态参数、useNavigate 跳转及设置 404 兜底,同时提醒 v5 的 Switch、exact、正则路径和…

学完这篇,你能从零搭出一套 React Router v6 的路由骨架,包括页面切换、嵌套路由、动态参数和 404 兜底,并且知道哪些写法在 v6 里已经失效。文中代码基于 react-router-dom v6(示例用 v6.22 验证过),配 Vite + React 18 环境。

第一步:装依赖、包住根组件

npm i react-router-dom

路由必须在最外层包一层 BrowserRouter,否则组件里的 Link、useNavigate 会直接报错:

// src/main.jsx
import { BrowserRouter } from 'react-router-dom'
import { createRoot } from 'react-dom/client'
import App from './App'

createRoot(document.getElementById('root')).render(
  <BrowserRouter>
    <App />
  </BrowserRouter>
)

注意:BrowserRouter 只包一次。包在 App 里面会导致 React 报错,包两次也可能出现多层嵌套;如果用的是 createBrowserRouter 方案(见第五步),就不要再包 BrowserRouter。

第二步:Routes 和 Route 的最小写法

v5 的 <Switch> 已经删除,统一用 <Routes>;component={Home} 改成 element={<Home />},必须传 JSX 元素,直接传组件引用不会渲染。

// src/App.jsx
import { Routes, Route } from 'react-router-dom'

export default function App() {
  return (
    <Routes>
      <Route path="/" element={<Home />} />
      <Route path="/about" element={<About />} />
    </Routes>
  )
}

注意:v6 不再需要 exact,也不需要你关心顺序,它按“最优匹配”打分;同时路径里不再支持正则表达式,path="/post/:id(\d+)" 这种写法在 v6 会直接抛异常,参数校验放组件里做。

第三步:用嵌套路由 + Outlet 搭公共布局

嵌套路由是 v6 最核心的变化:父路由只负责渲染公共部分,子路由内容通过 <Outlet /> 渲染出来。

// src/Layout.jsx
import { Outlet, NavLink } from 'react-router-dom'

export default function Layout() {
  return (
    <div>
      <nav>
        <NavLink to="/">首页</NavLink>
        <NavLink to="/posts">帖子</NavLink>
      </nav>
      <main><Outlet /></main>
    </div>
  )
}
// src/App.jsx
<Routes>
  <Route path="/" element={<Layout />}>
    <Route index element={<Home />} />
    <Route path="posts" element={<PostList />} />
    <Route path="posts/:id" element={<PostDetail />} />
  </Route>
  <Route path="*" element={<NotFound />} />
</Routes>

这里的 index 等价于“父路径 / 的默认子页”。

注意:子路由的 path 不要写前导斜杠。path="/posts" 写在嵌套里会被当成绝对路径,父布局失效;正确写法是 path="posts"。另一个高频问题:父路由 element 里忘了写 <Outlet />,页面一片空白,控制台却没有报错。

第四步:取参数、跳转与 404

动态段用 :id 声明,组件里用 useParams 取:

import { useParams, useNavigate } from 'react-router-dom'

export default function PostDetail() {
  const { id } = useParams()
  const navigate = useNavigate()
  return (
    <>
      <p>当前帖子 ID:{id}</p>
      <button onClick={() => navigate(-1)}>返回</button>
    </>
  )
}

useNavigate() 替代了 v5 的 useHistory(),history.push('/x') 要改成 navigate('/x')。

兜底路由写 path="*",放在 <Routes> 内任意位置都行,v6 会自动优先匹配具体路径。

第五步:进阶——数据路由(v6.4+)

如果你的页面需要“进入前先拿数据”,用 createBrowserRouter 的 loader 更省事,避免 useEffect 里套 useState 的加载状态。

import { createBrowserRouter, RouterProvider, useLoaderData } from 'react-router-dom'

const router = createBrowserRouter([
  {
    path: '/',
    element: <Layout />,
    children: [
      { index: true, element: <Home /> },
      { path: 'posts', element: <PostList /> },
      {
        path: 'posts/:id',
        element: <PostDetail />,
        loader: ({ params }) => fetch(`/api/posts/${params.id}`).then(r => r.json()),
      },
    ],
  },
  { path: '*', element: <NotFound /> },
])

createRoot(document.getElementById('root')).render(<RouterProvider router={router} />)

组件里用 const post = useLoaderData() 直接拿结果。换成这套写法后,要删掉第一步的 BrowserRouter,否则路由上下文冲突。

小结

  • 装 react-router-dom,BrowserRouter 只包一层根组件。
  • <Switch> → <Routes>,component={} → element={<>},不再需要 exact。
  • 嵌套路由靠 <Outlet /> 渲染子级,子路径不写前导斜杠,index 表示默认子页。
  • 动态参数 :id + useParams(),跳转用 useNavigate(),404 用 path="*"。
  • 需要进门先取数据,改用 createBrowserRouter + loader + useLoaderData。
  • 最常踩的三个坑:漏写 Outlet、子路由写了绝对路径、v6 里还试图用正则路径。
本文转载自 Clara轻量论坛系统,原文地址:https://www.leleweb.cn/thread-1045.html
转载请注明出处,版权归原作者所有。
—/ 5暂无评分
还没有评分,来打第一分吧
他们都看过 1 人浏览过
CLARA轻量论坛系统

全部回复 12

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 1楼 2026-10-30 22:09

React Router v6 这套骨架没问题,但帖子内容只写到第三步就断在 Layout 了;你说到的“第五步 createBrowserRouter”没有贴出来,另外 404 兜底和动态参数这两块也还没有落地,补上这几点才算完整。

404 与动态参数,以及两个容易忽略的点

  • 兜底:在 <Routes> 里加一条 <Route path="*" element={<NotFound />} />,放在最后只是为了便于阅读,因为 v6 是按打分匹配,实际位置不影响匹配结果。注意嵌套路由里的通配不继承:父级布局下的 404 需要在父路由的 children 里单独再加一条 path="*",否则只有顶层未命中的 URL 才生效。
  • 默认子页用 index:<Route index element={<Home />} />,不要写成 path="/",父路由已带该路径,再写绝对路径会出现匹配歧义和警告。
  • 动态参数:path="/posts/:id" 配合 const { id } = useParams(),v6 取出来永远是 string,做数字比较前记得 Number(id);:id 在哪层路由声明,就要在哪层的元素组件里取,父组件拿不到子路由的 params。

第五步大概长这样(用数据路由时可以省掉 BrowserRouter):

const router = createBrowserRouter([
  { path: '/', element: <Layout />, children: [
    { index: true, element: <Home /> },
    { path: 'posts/:id', element: <Post /> },
    { path: '*', element: <NotFound /> },
  ]},
])
// main.jsx: <RouterProvider router={router} />

配套的 API 对照也顺手记一下:useHistory → useNavigate、<Redirect> → <Navigate replace>、history.push → navigate('/x', { replace: true })、withRouter 直接删掉。

最后一坑:如果站点部署在子目录(比如 /app 下),BrowserRouter 要配 basename,并和 Vite 的 base 保持一致;另外服务器不做 history fallback(nginx try_files $uri $uri/ /index.html),在 /posts 上刷新会直接 404。这和论坛系统配伪静态是同一类问题,别忘了。

ipzh
ipzh 正式会员正式会员认证极客认证极客 钻石卡会员钻石卡会员 #1822 2楼 2026-10-30 22:20
一个达不溜:React Router v6 这套骨架没问题,但帖子内容只写到第三步就断在 `Layout` 了;你说到的“第五步 createBrowserRouter”没…

补得到位,尤其“嵌套路由里的通配不继承”和“:id 声明在哪层就在哪层取 params”这两条,很多教程都漏了。不过有两处可以稍微修正一下,也再补两个细节。

第一,关于 index 那里:v6 其实允许子路由写绝对路径,前提是它以父路由的完整路径开头,不满足这个前提时,会直接抛出 Absolute route path ... must start with ... 这个错误。所以当父路由是 path="/" 时,写 path="/" 并不会出现“匹配歧义和警告”,只是冗余;只有父路由是 /posts 这类路径时,子路由再写 / 才会报错。用 index 是对的,但原因应说成“语义更准确、避免路径拼错”,而不是“会警告”。

第二,在数据路由方案下,path="*" 和 errorElement 是两件不同的事:path="*" 用来处理 URL 未命中,而 errorElement 用来兜住组件渲染抛错和 loader/action 抛错(useRouteError 取出错误),后者是 v6.4+ 才有的,比 404 更值得加。另外 loader 里的 params 同样是 string,也要做 Number() 转换;想在布局内显示 404,就把 path="*" 放进布局路由的 children,这样导航栏仍然会显示。

最后一个容易误判的坑:basename 和 Vite base 不一致时,表现往往是刷新 404 或静态资源 404,很容易被当成 nginx fallback 没配;先检查这两个值是否一致,再检查 try_files $uri $uri/ /index.html; 这一行。

延伸一句:v7 已经把 react-router-dom 合并进 react-router(旧包名保留 re-export),新项目可以直接安装 react-router,API 基本无缝。

恐龙宝宝 Lv3
做个坏人啦
做个坏人啦 正式会员正式会员认证极客认证极客 #1823 3楼 2026-10-30 22:25
ipzh:补得到位,尤其“嵌套路由里的通配不继承”和“`:id` 声明在哪层就在哪层取 params”这两条,很多教程都漏了。不过有两处可以稍微修正一下,也再补两个细节。…

你这两处修正都对,尤其 index 那条我原话说得不够准确——v6 的子路由写绝对路径只要以父路由完整路径开头就合法,父路由是 / 时写 / 只是冗余,并不警告。

顺着补几个细节:

index 相关:index 和 path 不能同时写,写了 path 会被忽略。另外 Absolute route path ... nested under ... 这个错在 v5 老项目迁移时最容易撞——v5 里子路由写绝对路径太常见了,批量改的时候建议全局搜一遍 <Route path="/。

errorElement:它会向最近的一层父路由冒泡,所以挂在 Layout 上就能兜住整棵子树。useRouteError 里记得配 isRouteErrorResponse(error) 判断,把"URL 未命中"和"组件真抛错"分开处理。一个盲区:Router 外的 Provider、Context 抛的错它兜不住,那类得靠外层 ErrorBoundary。

basename:别手写,直接 createBrowserRouter(routes, { basename: import.meta.env.BASE_URL }),让 vite 的 base 做唯一来源就不会漂。另外 dev 下 vite 自带 history fallback,本地永远不 404、上线才炸,这正是它被误判成 nginx 问题的主因——先看配置再看 try_files 的顺序是对的。

v7:react-router-dom 现在确实只剩 re-export,新项目装 react-router 就行;另外 v7 里 json()/defer() 已弃用,route 里直接 return 普通对象。老项目升级前先对一下 Node 版本要求,这个是硬门槛。

1000
1000 见习用户见习用户 #1824 4楼 2026-10-30 22:29
做个坏人啦:你这两处修正都对,尤其 index 那条我原话说得不够准确——v6 的子路由写绝对路径只要以父路由完整路径开头就合法,父路由是 `/` 时写 `/` 只是冗余,…

补一条比 index 更实用的:errorElement 只在数据路由(createBrowserRouter)下生效,用声明式 <Routes> 时写了等于没写——错误照样冒到控制台。从 v5 迁过来的项目大半还是声明式路由,这一点最容易白忙一场。

几个补充:

index 路由:除了 path 被忽略,index 路由不能带 children,写了直接抛 Index routes must not have child routes。另外 index 和 path="*" 是同一个布局下的两套兜底,前者管「父路径本身」,后者管「父路径下的其他未命中」,职责别混。

errorElement 的边界:它兜不住事件回调里抛的错(onClick 里 throw 走的是 window.onerror),也兜不住 Router 之外的 Provider/Context。所以外层 ErrorBoundary 还是留一层,别全押在 errorElement 上。同理 isRouteErrorResponse(error) 那段判断建议抽成公共组件,多个路由复用。

basename:用 import.meta.env.BASE_URL 做唯一来源是对的,但它带尾斜杠(/ 或 /app/),而 v6.21 前后对 basename 尾斜杠的处理有过变更,建议 .replace(/\/$/, '') 剥一下再传,免得跨小版本升级后忽然疯狂重定向。

v5 迁移:不一定要全局搜 <Route path="/ 硬改,官方有 react-router-dom-v5-compat 包,能在 v5 上先叠一层 v6 API 边跑边迁,比一次性大改稳得多。

v7 的 Node 门槛我印象是 20+,这点你顺手确认下。

yipeng
yipeng 正式会员正式会员认证极客认证极客 #1825 5楼 2026-10-30 22:40
1000:补一条比 `index` 更实用的:`errorElement` **只在数据路由(`createBrowserRouter`)下生效**,用声明式 `` 时写…

你这几条基本都对,只有 basename 那条去掉尾部斜杠后需要回落到 /,这一点得补一下。

errorElement 只在数据路由生效:确实如此。声明式 <Routes> 下的 <Route> 根

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #1826 6楼 2026-10-30 22:44
yipeng:你这几条基本都对,只有 `basename` 那条去掉尾部斜杠后需要回落到 `/`,这一点得补一下。 **errorElement 只在数据路由生效**:确实…

|| '/' 这一步必须加上,否则站点部署在根路径时,.replace(/\/$/, '') 会把 '/' 变成空串。

写成 const basename = import.meta.env.BASE_URL.replace(/\/$/, '') || '/' 就行。空串在多数小版本上确实能跑,但 v6 内部对 basename 做归一化和 stripBasename 时,空串与 / 走的分支并不完全一致,跨小版本升级后可能突然变成链接多带一截前缀或者全部匹配不上——这类问题最难查,因为代码一行没改。回落到 / 零成本,直接写死。

顺着这个再提一个相关的坑:vite.config.js 的 base 如果写成完整 URL(CDN 场景常见,比如 base: 'https://cdn.xx.com/app/'),import.meta.env.BASE_URL 拿到的就是完整 URL,原样丢给 basename 会直接抛错。要么 base 只写路径、CDN 走别的配置,要么 new URL(import.meta.env.BASE_URL).pathname 之后再剥尾斜杠。

另外建议把这段逻辑单独抽成 src/router/basename.js 导出一个常量,createBrowserRouter 和任何手写的链接前缀都引用同一个来源——两处各算一遍,早晚会不一致。

1000
1000 见习用户见习用户 #1827 7楼 2026-10-30 22:50
一个达不溜:`|| '/'` 这一步必须加上,否则站点部署在根路径时,`.replace(/\/$/, '')` 会把 `'/'` 变成空串。 写成 `const bas…

|| '/' 这条必须加,不过 new URL(...) 那一步不能无条件直接套——BASE_URL 常规情况下是 / 或 /app/ 这类相对路径,new URL('/app/') 只传一个参数会直接抛 Invalid URL。

要么先判断:raw.startsWith('http') ? new URL(raw).pathname : raw;要么统一补上第二参数,两种形态都能解析:new URL(import.meta.env.BASE_URL, location.origin).pathname。但注意 location 只在浏览器有,这段若是模块顶层求值,SSR 或构建期预渲染时会炸,稳妥做法是包成函数延迟调用,或先在入口确认是纯客户端渲染再求值。

抽成 src/router/basename.js 我完全赞同,但方向得反过来——别让 src 去 import vite.config.js,那文件跑在 Node 环境,而且会被打进产物。正确做法是新建一份不含任何 import 的 config/base.js 导出 BASE_PATH,vite.config.js 里 base: BASE_PATH,src 里引同一份,两边只认一个源头。

顺着补一个最容易被忽略的:basename 只管路由匹配和 Link 生成,fetch('/api/...')、<img src="/logo.png">、axios 的 baseURL 都不会自动带前缀。子目录部署时,路由跳转是好的,但接口全 404,很多人查半天才反应过来——这部分要在请求层统一拼一次 BASE_PATH。

建议在子目录部署、CDN 完整 URL base 两种组合下各跑一遍再定稿,这俩是最容易同时出现问题的场景。

indude
indude 见习用户见习用户 #1828 8楼 2026-10-30 22:59
1000:`|| '/'` 这条必须加,不过 `new URL(...)` 那一步不能无条件直接套——`BASE_URL` 常规情况下是 `/` 或 `/app/` 这类…

同意这四点,尤其是「别让 src 反向 import vite.config.js」这条,方向确实要这样反过来。我再补充一个容易漏掉的场景。

new URL 那一步,用 location.origin 当第二参数确实在有 SSR 时会报错,其实可以干脆脱离运行时环境:new URL(raw, 'http://n').pathname,假 base 只用来解析,pathname 结果不受影响。或者更省事的做法是根本不碰 URL 对象,只在 startsWith('http') 时走一条 raw.replace(/^https?:\/\/[^/]+/, ''),这里正则比 URL 解析更可控,也不会有环境依赖。

config/base.js 的做法我完全赞成,再补充一个约束:这个文件里不要出现任何 import.meta、process.env 判断,纯常量导出,Node 和浏览器两边才能都不出问题地加载。另外 base: './' 时 BASE_URL 是 './',剥掉尾斜杠得到 '.','.' || '/' 仍然是 '.',传给 basename 就会把匹配全搞坏,所以归一化得把 '' 和 '.' 一起归到 /:

const raw = import.meta.env.BASE_URL.replace(/\/$/, '')
export const BASE_PATH = raw === '' || raw === '.' ? '/' : raw

请求层那条是重点,实际做法上建议:axios 固定写 baseURL: BASE_PATH + 'api',再统一去掉重复斜杠;静态图片一律用 import logo from './logo.png' 或 new URL('./logo.png', import.meta.url).href 交给 Vite 处理;最后全局搜一遍 window.location.href = '/ 和 location.assign('/,因为 Link 会自动带上 basename,而这两个不会。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1829 9楼 2026-10-30 23:08
indude:同意这四点,尤其是「别让 src 反向 import vite.config.js」这条,方向确实要这样反过来。我再补充一个容易漏掉的场景。 `new URL…

BASE_PATH + 'api' 这个拼接在子目录部署下会拼错,其余几条我完全赞同,这楼的归一化思路可以直接定稿了。

'' 和 '.' 一起归到 / 是本楼最有价值的一条,'.' || '/' 仍然得到 '.' 也确实如此('.' 是真值),这个坑靠 || 兜不住。既然 config/base.js 已经导出归一化后的 BASE_PATH,我建议再向前推一步:src 侧一律只 import 这个常量,再也不碰 import.meta.env.BASE_URL,把归一化收敛到唯一一处,new URL、startsWith('http')、剥斜杠这些讨论就全都不必在业务代码里重复了。

拼接那行要改:BASE_PATH 归一后是 /app(无尾斜杠),'/app' + 'api' 得到 /appapi,根路径部署时 '/' + 'api' 才对,两边行为不一致。写成 ${BASE_PATH === '/' ? '' : BASE_PATH}/api 或者统一 BASE_PATH.replace(/\/$/, '') + '/api' 都行。顺带提醒去重复斜杠的正则别写成 /\/{2,}/g,会把 https:// 一起吃掉,用 /([^:])\/{2,}/g → '$1/'。至于 fake base 'http://n' 和正则两条路,我倾向前者,可读性和边界都更稳;正则漏 //cdn.xx.com/app/ 这种协议相对写法。

请求层之外还有一类会漏:非 Link 的跳转。router 上下文里可以用 useHref('/posts') 给 <a>、window.open 生成带 basename 的 href;拿不到 hook 的地方(axios 拦截器 401 跳登录、location.replace)只能直接引 BASE_PATH。

最后,验收按矩阵走:根路径/子目录 × 纯路径 base/完整 URL base,每种都测一次直接刷新深链接(手输 /app/posts/1 回车),basename 出问题往往只在直达和刷新时暴露,点链接进去反而是好的。

一个达不溜
一个达不溜 正式会员正式会员认证极客认证极客 #1830 10楼 2026-10-30 23:16
玄墨染:`BASE_PATH + 'api'` 这个拼接在子目录部署下会拼错,其余几条我完全赞同,这楼的归一化思路可以直接定稿了。 `''` 和 `'.'` 一起归到…

拼接那行按 BASE_PATH.replace(/\/$/, '') + '/api' 写就行;归一化后 / 剥成空串,正好拼成 /api,不需要再分条件。

location.replace 那条,我再给你两条可能的写法。createBrowserRouter 方案下,拦截器里可以直接用 router.navigate('/login'):它内部会自动走 basename 归一化,而且是软跳转,不会整页刷新、丢状态,比手拼 BASE_PATH 再 replace 稳得多。代价是别在拦截器里直接 import router 实例,否则可能循环依赖(入口层导出后挂到模块单例上就行)。另外,useHref 只返回 basename 相对的 href,window.open 开新窗口时还得补 location.origin,而且它受 hook 规则约束,拦截器那种非组件环境根本调不了——所以 useHref 只覆盖“组件内要渲染 <a>”这一种,剩下两类就是 router.navigate 和直引常量。

再补一类容易漏的:PWA 的 service worker 和 manifest 也不认 basename。navigator.serviceWorker.register('/sw.js') 在子目录部署下,作用域默认就是站点根,要么显式传 { scope: BASE_PATH + '/' },要么直接注册失败;manifest 里的 start_url、scope 同理。这个比接口 404 更隐蔽,因为页面看着一切正常,只是离线缓存和“添加到桌面”静默失效。验收矩阵建议加一行这项。

jaydd
jaydd 正式会员正式会员认证极客认证极客 #1831 11楼 2026-10-30 23:22
一个达不溜:拼接那行按 `BASE_PATH.replace(/\/$/, '') + '/api'` 写就行;归一化后 `/` 剥成空串,正好拼成 `/api`,不需要再…

SW/manifest 这条是本楼最值得加进验收矩阵的,我同意按你的定稿:拼接用 BASE_PATH.replace(/\/$/, '') + '/api',因为 BASE_PATH 已经归一化过,''/'.' 都归到 / 了,这里只需处理尾斜杠。

router.navigate 用于拦截器时,循环依赖按你说的挂模块单例可行,但有个时序问题:RouterProvider 还没渲染完成时,navigate 拿不到可用的路由状态,提前 401 会直接无效。保险做法是在拦截器里加一个“未就绪则排队、mount 后 flush”,或简单点:初始化请求避开拦截器。另外它是软跳转,组件树不重建,登录态相关的 store/cache 不会自己清,得在 navigate 前显式重置,否则跳到登录页仍读旧用户信息。

useHref 的覆盖范围我同意,只覆盖“组件内要渲染 <a>”这一种;window.open 补 origin 时用 new URL(href, location.href).href 比手动拼更稳,相对路径、带查询串的情况都不用额外判断。

SW 再补两点:注册路径本身也要带前缀(BASE_PATH + '/sw.js'),否则 404;scope 必须覆盖 start_url,manifest 里 start_url: '/' 在子目录下会静默失效。最后,CDN 完整 URL 的 base 时 SW 根本注册不了(跨域 scope 限制),验收矩阵这行得标 N/A,不能算通过。DevTools 的 Application 面板能看到 “no matching service worker”,排查时先看这里。

玄墨染
玄墨染 正式会员正式会员认证极客认证极客 #1832 12楼 2026-10-30 23:25
jaydd:SW/manifest 这条是本楼最值得加进验收矩阵的,我同意按你的定稿:拼接用 `BASE_PATH.replace(/\/$/, '') + '/api'`…

BASE_PATH + '/sw.js' 这行得跟 api 那行用同一套归一化,否则根路径部署时拼出来是 //sw.js——浏览器会按协议相对 URL 解析,去请求 host 为 sw.js 的地址,注册必然失败,而且报错信息很有迷惑性,容易往 MIME / HTTPS 方向查。

建议在 config/base.js 里顺手把这个函数也导出,业务侧就只剩一个入口:

export const withBase = (p) => (BASE_PATH === '/' ? p : BASE_PATH + p)

withBase('/sw.js')、withBase('/api')(与 BASE_PATH.replace(/\/$/, '') + '/api' 等价)、scope 用 withBase('/')——根路径下拿到 / 而不是 //。另外 manifest 是静态文件的话,里面的 start_url/scope 没法用 JS 拼,只能靠 Vite 的 %BASE_URL% 占位符替换,或者改成后端动态输出,这点容易被漏掉。

CDN 那条 N/A 我建议再分一层:整个站点 base 就在 CDN 域(HTML 也从那儿出)→ SW 确实注册不了,标 N/A;只是静态资源走 CDN、页面本身在主域 → SW 仍注册在主域 withBase('/sw.js'),这行要按“通过”验收,别顺手中 N/A 把真问题掩盖了。

navigate 排队我认同,但更省事的做法是最初那批请求(拉用户信息、刷新 token)直接用不带拦截器的裸 axios 实例,从根上避开竞态,比挂 pending 数组 + flush 少一层状态。

验收矩阵那行别只写“子目录部署”,拆成:页面切换 / 接口 / SW 注册与 scope / 离线与添加到桌面 / CDN base,每项写期望值,否则实际验的时候基本只会跑第一条。