Bytebase 前端去 Vue 化迁移实战:基于 react-router 的路由、应用外壳与认证体系重构设计
【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase
本文对应仓库设计文档 docs/superpowers/specs/2026-06-03-vue-to-react-routing-shell-auth-migration-design.md,并以其为骨架,结合当前仓库中已落地的实现代码进行印证与扩展。文章面向希望在大型前端工程中完成框架主权切换(Framework Ownership Migration)的架构师与资深前端工程师:你将看到 Bytebase 如何用一个 big-bang PR把 Vue 壳层(vue-router、
App.vue、AuthContext.vue、3 套布局、5 个 React 挂载桥、Pinia 认证仓库、路由守卫、认证拦截器)整体替换为单一 React 根 + react-router 数据路由,同时做到“零破坏性变更、路由守卫逐行忠实移植、认证状态以应用仓库为唯一事实来源”。读完本文,你可以直接复用这套“路由表平移 + loader 守卫 + 布局路由 + 惰性加载”的迁移模式。
一、迁移背景:为什么要动“壳”,而不是动“页面”
Bytebase 的前端经历了 Vue 与 React 长期共存的阶段:业务页面早已用 React 重写,但宿主(host)层仍是 Vue——Vue Router 承担路由匹配,App.vue承担全局 provider 与浮层,AuthContext.vue承担会话门控,3 个 Vue 布局(Splash/Body/Dashboard)负责页面框架,5 个.vue桥文件通过ReactPageMount把 React 页面“岛式”挂进 Vue 树,同时用import.meta.glob维护全局页面注册表,Pinia 认证仓库配合beforeEach全局守卫和 401 认证拦截器管理会话。
这套双框架架构是彻底删除 Vue 的关键路径(critical-path track):页面已是 React,迁移的主体工作其实是机械性的路由表平移 + 删除 Vue 文件。设计文档据此把交付方式锁定为一个 big-bang 单 PR,并在 PR 内部按“每步提交都可构建、可评审”的顺序推进。
从当前仓库状态看,这条迁移路径已经基本走完:frontend/package.json中已看不到vue-router、pinia、vue-i18n、@vueuse、@vitejs/plugin-vue、vue-tsc的身影,取而代之的是react-router ^8.2.0与zustand ^5.0.13;frontend/src/app/下已经存在完整的 React 路由壳(AppRoot.tsx、RootLayout.tsx、AuthGate.tsx、router/目录),而vue(3.5.40)与pev2(1.23.0)两个依赖仍被保留——这正是设计文档中“pev2 反向岛屿”尚未被移除的体现(详见 §3.3)。
二、锁定决策:四条不可动摇的约束
设计文档在一开始就锁定了四条硬约束与四项关键决策,它们是整个迁移的“宪法”:
硬约束
- 零破坏性变更:纯粹替换宿主,不改变任何用户可见行为。
- 守卫逐行忠实移植:新的守卫是旧
router/index.ts中beforeEach的逐行移植——同样的检查、同样的顺序、同样的重定向。
锁定决策
- 策略:big-bang 单 PR(不搞双路由共存)。
- 路由方案:react-router 数据路由(
createBrowserRouter、嵌套路由对象、lazy、loader),与嵌套的 vue-router 配置保持 1:1 翻译。设计文档写作时计划的是 react-router-dom v7;当前仓库实际落地为react-router ^8.2.0,从 frontend/package.json 与源码 import(react-router、react-router/dom)可以确认版本在实现过程中完成了升级。 - 守卫实现:
beforeEach的忠实移植落地为根路由loader。 - pev2 替换:在本 PR 内用 React 重写第三方 Vue 查询计划可视化组件,从而彻底移除
vue核心依赖,实现干净、完整的删除。
三、目标架构:单一 React 根接管一切
3.1 挂载方式:从双挂载到单树
迁移前,main.ts需要createApp(App)挂载 Vue 树,再单独挂载一个#react-app覆盖层;迁移后,main.ts只保留一个挂载动作。设计文档给出了目标形态:
ReactDOM.createRoot(#app).render( <RouterProvider router={createBrowserRouter(routes)} /> )这一形态在当前仓库已经实现:挂载逻辑收敛在 frontend/src/app/mountApp.ts,通过import.meta.glob("./AppRoot.tsx")惰性加载根组件;而 frontend/src/app/AppRoot.tsx 中:
export const appRouter = createBrowserRouter(routes); export function AppRoot() { return <RouterProvider router={appRouter} />; }这里可以观察到两个值得注意的落地细节:
setAppRouter(appRouter)把数据路由器实例注册到模块级状态(frontend/src/app/router/navigation.ts),让非组件代码(如认证 slice)也能通过实例的navigate()完成命令式导航——这正是设计文档中“router.push→ React Router 实例的router.navigate()”的落点。- 由于数据路由默认启用部分水合(partial hydration),初始加载时
/会先渲染一帧再执行 loader 的重定向,产生控制台警告。实现通过routes[0].HydrateFallback = () => null定义了一个渲染空内容的 fallback,既满足部分水合的契约,又因 loader 同步解析而几乎不产生可见闪烁。
3.2 组件树:RootLayout → AuthGate → Outlet
设计文档的目标树形如下:
<RouterProvider router={createBrowserRouter(routes)} /> └─ <RootLayout> // 替换 App.vue:providers + 全局浮层 (Toaster, AgentWindow, Watermark, SessionExpiredSurfaceGate — 今天的 ReactApp) └─ <AuthGate> // 替换 AuthContext.vue:以应用仓库会话门控渲染 └─ <Outlet/> // 匹配的布局 + 页面当前仓库的 frontend/src/app/RootLayout.tsx 与设计完全吻合:RootLayout聚合了此前散落在 ReactApp 中的全局浮层——Watermark、Toaster、AgentWindow、SessionExpiredSurfaceGate,并用<AuthGate>包裹<Outlet />,使会话生命周期(加载门控、轮询、跨标签页切换、闲置提醒)环绕每个页面运行。此外实现还额外补充了两个系统级组件:
LeaveGuardBlocker:用 react-router 的useBlocker还原 vue-router 全局beforeEach的取消语义——任何注册的离开守卫调用next(false)时阻止导航;NavigationScrollRestoration:滚动位置恢复,弥补历史/返回行为一致性(详见 §5.3)。
3.3 布局路由与“布局即页面”岛屿的收编
3 个 Vue 布局(Splash/Body/Dashboard)在 React 侧对应为 frontend/src/app/layouts/ 下的SplashLayout.tsx、BodyLayout.tsx、DashboardLayout.tsx,以布局路由形态挂进路由表,通过<Outlet />渲染子路由;DashboardLayout中 vue-router 具名的<router-view name="body">同样收敛为一个嵌套<Outlet />。SQL 编辑器中“布局当页面”的特殊岛屿也被收编为普通布局路由。
路由表的组装在 frontend/src/app/router/routes.tsx:根路由元素是<RootLayout />,errorElement指向 frontend/src/app/RouteErrorPage.tsx(捕获整棵树中未捕获的渲染/loader 异常,展示可恢复页面而非 react-router 的开发者默认屏),children 依次展开authRoutes、dashboardRoutes、sqlEditorRoutes,最后以path: "*"的 catch-all 兜底 404,确保具体应用路径先被匹配。同一个文件里的buildRouteNameIndex()把嵌套路由表摊平为“路由名 → 完整路径模式”映射,注册进navigation.ts,供守卫与认证生命周期按名解析重定向目标。
四、认证/会话模型与守卫:逐行移植的艺术
4.1 会话唯一事实来源:zustand 应用仓库的 AuthSlice
设计文档的核心决定:会话状态的唯一事实来源从 Pinia 认证仓库迁到应用仓库(app store)的AuthSlice,方法体原样复制,只把导航调用从router.push换成 React Router 实例的router.navigate(...)。
需要移植的状态与方法清单(来自设计文档):
- 状态:
currentUser、unauthenticatedOccurred、requireResetPassword、authSessionKey、isSelfEmailUpdate - Getter:
isLoggedIn - 方法:
login、signup、logout、fetchCurrentUser、setRequireResetPassword、sendEmailLoginCode、updateCurrentUserNameForEmailChange
当前仓库的 frontend/src/stores/app/auth.ts 正是createAuthSlice的实现,几个值得注意的细节:
isLoggedIn()的定义是Boolean(currentUserName) && name !== UNKNOWN_USER_NAME——用“当前用户名已加载且不是未知用户”判定登录态;loadCurrentUser()失败时不缓存失败(清空currentUser/currentUserName),这样登录后的下一次调用会重试,避免“登录前失败导致 React 仓库永远加载不出认证用户”的坑;setRequireResetPassword/requireResetPassword通过localStorage以邮箱为维度读写重置密码标记,并兼容 private mode / quota 异常(非致命失败)。
认证拦截器 [frontend/src/app/…(authInterceptorMiddleware.ts)] 在 401 时翻转unauthenticatedOccurred,从useAuthStore()改为useAppStore.getState()读取/写入;SessionExpiredSurface依旧对该标志做出反应,但现在读的是应用仓库(对应 frontend/src/app/components/SessionExpiredSurface.tsx)。
4.2 AuthGate:AuthContext.vue 的 React 化身
frontend/src/app/AuthGate.tsx 完整替换了AuthContext.vue的四类职责:
- 未登录重定向:非认证路由 + 非公开路由(403/404)且未登录时,
navigate到 signin 并携带buildSigninRedirectQuery构造的redirect查询参数(保留idp/workspace/email/token/invitation等登录专用参数,同时从 redirect 值中剥离它们,避免回跳时残留); - 就绪门控:登录后并行加载工作区数据(
loadSubscription、fetchWorkspaceIamPolicy、loadWorkspaceList、listRoles、分组批量拉取),加载完成前显示 spinner;认证路由(signin、OAuth/OIDC 回调、MFA 等)绕开就绪门控——设计文档特别指出,若在加载期间隐藏认证页面,会在登录中途卸载 OAuth 回调页(login()翻转isLoggedIn恰好触发加载),导致首屏白屏; - 会话轮询:以 1 分钟(dev)/ 5 分钟(prod)间隔调用
fetchCurrentUser(true)重新校验会话,并伴随刷新 IAM policy 与角色,镜像旧AuthContext.vue的CHECK_AUTHORIZATION_INTERVAL; - 跨标签页切换:通过
currentUserName变化检测另一标签页的用户切换,若非自我邮箱更新(isSelfEmailUpdate),则重定向到工作区根并弹出通知。
4.3 守卫:beforeEach 的 13 步忠实移植
设计文档要求守卫“逐行、同序、同重定向”地复刻旧守卫,落地形态是根路由 loader:根路由匹配所有 URL,loader 每次导航都执行,命中分支则throw redirect()(React 侧为返回redirect()Response),放行则返回null。当前仓库的实现分两层:
- frontend/src/app/AppRoot.tsx 的
rootLoader用matchRoutes解析命中叶子路由的handle.name,再委托给rootGuard,并把 loader 挂到routes[0]; - frontend/src/app/router/guard.ts 的
rootGuard({ name, url })读取useAppStore.getState(),按设计文档列出的顺序逐分支执行。
对照设计文档列出的 13 步,逐一对应到guard.ts的实现分支:
| # | 设计文档步骤 | guard.ts 实现 |
|---|---|---|
| 1 | 同路由循环守卫 | 由LeaveGuardBlocker(RootLayout 内)在导航侧处理next(false)取消 |
| 2 | 403/404 直接访问 | toName === WORKSPACE_ROUTE_403/404且路径匹配 → 放行 |
| 3 | OAuth/OIDC 回调直接访问 | AUTH_OAUTH_CALLBACK_MODULE/AUTH_OIDC_CALLBACK_MODULE→ 放行 |
| 4 | OAuth2 consent | OAUTH2_CONSENT_MODULE→ 放行(需登录但不重定向走人) |
| 5 | 登录态下允许 2FA 设置/密码重置/资料初始化 | isLoggedIn && (AUTH_2FA_SETUP / AUTH_PASSWORD_RESET / AUTH_SETUP)→ 放行 |
| 6 | 模块回跳路径追踪 | 由resolveRootRedirect读取 localStorage 的最近访问列表实现(DummyRootView等价逻辑) |
| 7 | 登录态访问认证路由 → 重定向(relay_state/redirect) | isAuthRelatedRoute && isLoggedIn && !unauthenticatedOccurred→ 重定向,且校验relay_state只接受相对路径、拒绝//协议相对地址以防范开放重定向 |
| 8 | 认证路由的仓库重置 | resetDatabases()/resetInstances()/resetProjects(),并异步重置 AI conversation 仓库 |
| 9 | 未登录 → signin(带 redirect query) | buildSigninRedirectQuery(url)→redirect(AUTH_SIGNIN_MODULE + query) |
| 10 | 强制 2FA | hasFeature(FEATURE_TWO_FA) && requireMfa && !mfaEnabled→ 重定向 2FA 设置 |
| 11 | 强制密码重置 | requireResetPassword()→ 重定向密码重置页 |
| 12 | 允许路由模式 | ALLOWED_ROUTE_PATTERNS前缀匹配(account/environment/instance/project/database/setting/workspace/sql-editor)→ 放行 |
| 13 | 404 兜底 | 未命名匹配(布局/重定向壳)放行;具名未知路由 →redirect(WORKSPACE_ROUTE_404) |
两个额外分支值得注意:AUTH_SIGNUP在disallowSignup限制开启时重定向回 signin;裸工作区根/没有自己的页面,按DatabaseChangeMode.EDITOR跳 SQL Editor 首页、否则跳最近一次有意义访问、再否则跳 landing——这是设计文档中“模块回跳路径追踪”与根路径处理的合并落地。
守卫测试同样齐备:frontend/src/app/router/guard.test.ts 覆盖了未登录→signin、登录态访问认证路由→首页、2FA 强制、密码重置强制、403/404、OAuth 回调放行、consent 等分支,与设计文档的验证清单一一对应。
五、路由表、79 座桥梁与反向岛屿
5.1 路由表平移:~102 条路由的机械翻译
设计文档给出了约 102 条路由、73 个页面名的规模。翻译规则高度机械:
- 布局 → 布局路由(
<Outlet />); - 叶子路由 →
lazy: () => import("@/react/pages/.../X"),取代component: ReactPageMount, props: { page: "X" }的岛屿挂载与import.meta.glob注册表; - 既有路由名常量(
AUTH_SIGNIN_MODULE、WORKSPACE_ROUTE_*等)保留,挂为handle: { name },让守卫、useRecentVisit、模块回跳路径追踪无需改动继续工作。
路由名常量集中维护在 frontend/src/app/router/handles.ts,并在 frontend/src/app/router/index.ts 统一 re-export,使 React 侧消费方保持从@/app/router导入不变。
handle上还承载了额外的每路由元数据(RouteHandle):requiredPermissionList、title、overrideDocumentTitle,由 frontend/src/app/router/index.ts 的assembleRoute聚合成ReactRoute——它的字段刻意镜像 vue-router 的route.meta(meta: Record<string, unknown>),让旧currentRoute.value.meta.*读取在新壳下继续工作。
5.2 lazyPage 适配器:把路由数据重新注入页面
这里有一个设计文档未展开、但落地时必须解决的兼容问题:React 页面组件当年是按 Vue 层“以 props 注入路由数据”的方式编写的,而 react-router 渲染Component时不传任何 props。解决方式是 frontend/src/app/router/lazyPage.ts 的lazyPage(loader, pick)适配器:
useParams()→ 把:param(projectId、instanceId、databaseName、issueId…)作为 props 传给页面。源码注释明确记载:缺了这一步,项目 issue 仪表盘会用undefined拼出project:<projectId>scope 而崩溃;routeName(叶子handle.name)、routeQuery(解析后的查询对象,按原始 search 字符串记忆化以保持引用稳定)、routeHash→ 页面据此决定展示哪个 phase/stage/task/spec(镜像 Vue 的route.name/route.query),并支持规范重定向中的次级锚点;- 参数合并取叶子匹配(
matches.at(-1)?.params),因为 Plan Detail 这类页面可能由持久父路由持有,而叶子路由贡献选择参数; - 不声明这些 props 的页面自动忽略多余属性,保证兼容。
这一层正是“把 ~110 处命令式调用点与 79 座桥梁从 Vue 反应式体系上解绑”的承重墙。router对象在 frontend/src/app/router/index.ts 被实现为一个模块级 vue-router 实例的 drop-in 替身:push/replace/resolve/back/go/isReady/currentRoute.value/beforeEach/afterEach/getRoutes全部保留原语义(beforeEach注册的守卫由runBeforeEachGuards在useBlocker中执行),旧消费方只需改 import 路径即可通过拆迁期。
5.3 79 座 useVueState 桥梁:迁移中最大的机械面
设计文档指出,约 79 个useVueState桥梁各自读取一个即将被删除的 Vue 反应式数据源,因此必须逐一改写:
- vue-router 读取(
useRoute/router/currentRoute)→useParams/useLocation/useNavigate(或 drop-inrouter对象); - Pinia 认证读取 → 应用仓库的认证选择器;
- 应用壳 Pinia 桥读取(
DashboardFrameShell遗留引导 +syncSubscriptionToPinia)→ 直接改为应用仓库选择器(Vue 壳删除后这些桥本身也被删除)。
随后删除useVueState.ts、Vue 反应式原语(如ProjectSidebar中的effectScope)以及页面挂载/glob 注册表。这 ~79 个文件的清扫是单 PR 体积最大的机械部分,也是设计文档明确“PR 很大”的主要原因。从当前仓库看,frontend/src/app/router/index.ts中保留的 drop-inrouter对象与useCurrentRoute()钩子正是这轮清扫留下的兼容接口——大量既有调用点(约 110 处命令式调用)与useSyncExternalStore订阅都挂在它们之上。
5.4 vue-i18n → react-i18next 与 pev2 反向岛屿
- 国际化:vue-i18n(5 个导入方)整体切换到 React 侧已经在用的 react-i18next(当前依赖为
i18next ^26.0.10+react-i18next ^17.0.0,见 frontend/package.json),ReactPageMount的 locale 同步 watcher 随之移除。 - pev2 反向岛屿:Postgres 查询计划可视化(
PostgresPlanView.tsx)目前以createApp(pev2)形式独立挂载,需要在本次 PR 中用 React 重写,这是唯一真正全新编写的 UI,隔离在自己的 commit 中。当前仓库 frontend/package.json 中pev2: 1.23.0与vue: 3.5.40仍在——从依赖结构可以推断,这条“反向岛屿”尚未随本 PR 移除,vue依赖目前只服务于它,这也与设计文档把 pev2 重写列为最后一步的顺序一致。
六、单 PR 内部构建顺序:每步可构建、可评审
设计文档把 big-bang PR 拆成 7 个内部步骤,保证任何一步被切出时树都能编译、能评审:
- 引入
react-router;编写嵌套路由表 +handle.name+lazy导入(暂不接到根上); - 移植认证 slice(方法体原样)+ 守卫 loader +
<AuthGate>+ 重定向拦截器; - 构建
<RootLayout>+ 3 个 React 布局路由; - 切换
main.ts到单一 React 根(RouterProvider);删除createApp(App)+#react-app; - 清扫 79 个
useVueState桥梁 → React Router hooks / 应用仓库选择器;删除useVueState+ Vue 反应式原语 + glob 注册表; - 用 React 计划可视化替换 pev2;
- 删除 Vue 壳(
App.vue、AuthContext.vue、3 布局、5 挂载桥、ReactPageMount、Pinia 认证仓库、旧守卫)+ 移除vue/vue-router/pinia/vue-i18n/@vueuse/@vitejs/plugin-vue/vue-tsc+ 清理 Vite 配置 + 新增check-react-no-pinia.mjs门禁脚本。
对照当前仓库:步骤 1–5 已基本完成(AppRoot.tsx/RootLayout.tsx/AuthGate.tsx/router/均已就位,Vue 壳文件与上述依赖已从frontend/移除),步骤 6 尚未落地(pev2/vue仍在依赖中),步骤 7 的部分内容(依赖清理)随实现推进已经完成。
七、验证门禁:“零破坏性变更”的兑现方式
设计文档为“零破坏”承诺定义了可执行验证清单,当前仓库可以逐一对应到实际资产:
- 守卫单元测试:镜像
beforeEach的每个分支——未登录→signin、登录态访问认证路由→首页、2FA 强制、密码重置强制、403/404、OAuth 回调放行、consent。对应 frontend/src/app/router/guard.test.ts;此外还有 frontend/src/app/AuthGate.test.tsx(会话门控)、frontend/src/app/router/routes.test.tsx(路由表)、frontend/src/app/router/lazyPage.test.tsx(lazy 适配器)等。 - 全量 type-check / check / test:页面本身是未改动的 React,因此既有测试(设计文档记载为 2308 条)全部继续成立;前端入口命令见 frontend/package.json 的 scripts(
pnpm test走scripts/run-gate.mjs,pnpm release走 Vite 构建)。 - 关键流程手工 QA:登录、登出、会话过期(401→surface)、OAuth/OIDC 回调、2FA 设置、密码重置、深链受保护路由(→signin→回跳)、工作区切换、SQL 编辑器入口。会话过期与闲置提醒的表面积聚在 frontend/src/app/components/SessionExpiredSurface.tsx 与
AuthGate的InactiveRemindModal。 - 历史/返回按钮一致性:
createBrowserRouter对应旧createWebHistory;实现侧除滚动恢复外,还在 frontend/src/app/router/index.ts 用routerGo/subscribeRoute还原了back/afterEach语义。
八、风险与缓解、超范围事项
风险与缓解(设计文档原文归纳):
- 守卫(最高风险):逐字移植 + 单元测试 + 认证流程 QA;
- pev2 React 重建:唯一全新 UI;隔离提交 + 截图对比;
- 79 文件清扫:机械性工作;由 type-check + 既有页面测试兜住;
- 两阶段回滚安全:按内部顺序构建,每个 commit 都能通过类型检查。
超范围事项:
projectIamPolicyPinia 仓库:其桥梁在权限/工作区层完成迁移前保持不变(独立、既有工作)——这与当前仓库中vue/pev2仍存留的现状互为印证;- 任何页面级 UI/行为变更:本次只做宿主替换。
结语
Bytebase 的这次“Vue → React 路由/壳/认证”迁移,示范了一条可复制的框架主权切换路径:页面层早已同构的前提下,用数据路由的 loader 承接守卫语义、用handle承接路由元数据、用命令式 drop-in 路由器对象承接存量调用点、用惰性加载适配器承接页面 props 契约,最终以一次 big-bang PR、7 步内部提交完成整体替换。从当前仓库的实现看,设计文档所规划的架构与约束(单一 React 根、应用仓库为会话唯一事实来源、13 步守卫忠实移植、零破坏性变更验证门禁)均已逐条落地,唯一遗留的 pev2 反向岛屿也在依赖清单中清晰可见。对于同样身陷双框架过渡期的团队,这套“设计先行、机械平移、逐行移植、验证门禁”的组合拳值得直接借鉴。
如需深入,可继续阅读:设计文档、路由组装、守卫实现、认证 slice、会话门控、路由兼容层。
【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考