Bytebase 前端去 Vue 化迁移实战:基于 react-router 的路由、应用外壳与认证体系重构设计
2026/9/15 21:12:30 网站建设 项目流程

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.vueAuthContext.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-routerpiniavue-i18n@vueuse@vitejs/plugin-vuevue-tsc的身影,取而代之的是react-router ^8.2.0zustand ^5.0.13frontend/src/app/下已经存在完整的 React 路由壳(AppRoot.tsxRootLayout.tsxAuthGate.tsxrouter/目录),而vue(3.5.40)与pev2(1.23.0)两个依赖仍被保留——这正是设计文档中“pev2 反向岛屿”尚未被移除的体现(详见 §3.3)。

二、锁定决策:四条不可动摇的约束

设计文档在一开始就锁定了四条硬约束与四项关键决策,它们是整个迁移的“宪法”:

硬约束

  1. 零破坏性变更:纯粹替换宿主,不改变任何用户可见行为。
  2. 守卫逐行忠实移植:新的守卫是旧router/index.tsbeforeEach的逐行移植——同样的检查、同样的顺序、同样的重定向。

锁定决策

  1. 策略:big-bang 单 PR(不搞双路由共存)。
  2. 路由方案:react-router 数据路由(createBrowserRouter、嵌套路由对象、lazy、loader),与嵌套的 vue-router 配置保持 1:1 翻译。设计文档写作时计划的是 react-router-dom v7;当前仓库实际落地为react-router ^8.2.0,从 frontend/package.json 与源码 import(react-routerreact-router/dom)可以确认版本在实现过程中完成了升级。
  3. 守卫实现beforeEach的忠实移植落地为根路由loader
  4. 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 中的全局浮层——WatermarkToasterAgentWindowSessionExpiredSurfaceGate,并用<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.tsxBodyLayout.tsxDashboardLayout.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 依次展开authRoutesdashboardRoutessqlEditorRoutes,最后以path: "*"的 catch-all 兜底 404,确保具体应用路径先被匹配。同一个文件里的buildRouteNameIndex()把嵌套路由表摊平为“路由名 → 完整路径模式”映射,注册进navigation.ts,供守卫与认证生命周期按名解析重定向目标。

四、认证/会话模型与守卫:逐行移植的艺术

4.1 会话唯一事实来源:zustand 应用仓库的 AuthSlice

设计文档的核心决定:会话状态的唯一事实来源从 Pinia 认证仓库迁到应用仓库(app store)的AuthSlice,方法体原样复制,只把导航调用从router.push换成 React Router 实例的router.navigate(...)

需要移植的状态与方法清单(来自设计文档):

  • 状态:currentUserunauthenticatedOccurredrequireResetPasswordauthSessionKeyisSelfEmailUpdate
  • Getter:isLoggedIn
  • 方法:loginsignuplogoutfetchCurrentUsersetRequireResetPasswordsendEmailLoginCodeupdateCurrentUserNameForEmailChange

当前仓库的 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的四类职责:

  1. 未登录重定向:非认证路由 + 非公开路由(403/404)且未登录时,navigate到 signin 并携带buildSigninRedirectQuery构造的redirect查询参数(保留idp/workspace/email/token/invitation等登录专用参数,同时从 redirect 值中剥离它们,避免回跳时残留);
  2. 就绪门控:登录后并行加载工作区数据(loadSubscriptionfetchWorkspaceIamPolicyloadWorkspaceListlistRoles、分组批量拉取),加载完成前显示 spinner;认证路由(signin、OAuth/OIDC 回调、MFA 等)绕开就绪门控——设计文档特别指出,若在加载期间隐藏认证页面,会在登录中途卸载 OAuth 回调页(login()翻转isLoggedIn恰好触发加载),导致首屏白屏;
  3. 会话轮询:以 1 分钟(dev)/ 5 分钟(prod)间隔调用fetchCurrentUser(true)重新校验会话,并伴随刷新 IAM policy 与角色,镜像旧AuthContext.vueCHECK_AUTHORIZATION_INTERVAL
  4. 跨标签页切换:通过currentUserName变化检测另一标签页的用户切换,若非自我邮箱更新(isSelfEmailUpdate),则重定向到工作区根并弹出通知。

4.3 守卫:beforeEach 的 13 步忠实移植

设计文档要求守卫“逐行、同序、同重定向”地复刻旧守卫,落地形态是根路由 loader:根路由匹配所有 URL,loader 每次导航都执行,命中分支则throw redirect()(React 侧为返回redirect()Response),放行则返回null。当前仓库的实现分两层:

  • frontend/src/app/AppRoot.tsx 的rootLoadermatchRoutes解析命中叶子路由的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)取消
2403/404 直接访问toName === WORKSPACE_ROUTE_403/404且路径匹配 → 放行
3OAuth/OIDC 回调直接访问AUTH_OAUTH_CALLBACK_MODULE/AUTH_OIDC_CALLBACK_MODULE→ 放行
4OAuth2 consentOAUTH2_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强制 2FAhasFeature(FEATURE_TWO_FA) && requireMfa && !mfaEnabled→ 重定向 2FA 设置
11强制密码重置requireResetPassword()→ 重定向密码重置页
12允许路由模式ALLOWED_ROUTE_PATTERNS前缀匹配(account/environment/instance/project/database/setting/workspace/sql-editor)→ 放行
13404 兜底未命名匹配(布局/重定向壳)放行;具名未知路由 →redirect(WORKSPACE_ROUTE_404)

两个额外分支值得注意:AUTH_SIGNUPdisallowSignup限制开启时重定向回 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_MODULEWORKSPACE_ROUTE_*等)保留,挂为handle: { name },让守卫、useRecentVisit、模块回跳路径追踪无需改动继续工作。

路由名常量集中维护在 frontend/src/app/router/handles.ts,并在 frontend/src/app/router/index.ts 统一 re-export,使 React 侧消费方保持从@/app/router导入不变。

handle上还承载了额外的每路由元数据(RouteHandle):requiredPermissionListtitleoverrideDocumentTitle,由 frontend/src/app/router/index.ts 的assembleRoute聚合成ReactRoute——它的字段刻意镜像 vue-router 的route.metameta: 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()→ 把:paramprojectIdinstanceIddatabaseNameissueId…)作为 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注册的守卫由runBeforeEachGuardsuseBlocker中执行),旧消费方只需改 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.0vue: 3.5.40仍在——从依赖结构可以推断,这条“反向岛屿”尚未随本 PR 移除,vue依赖目前只服务于它,这也与设计文档把 pev2 重写列为最后一步的顺序一致。

六、单 PR 内部构建顺序:每步可构建、可评审

设计文档把 big-bang PR 拆成 7 个内部步骤,保证任何一步被切出时树都能编译、能评审:

  1. 引入react-router;编写嵌套路由表 +handle.name+lazy导入(暂不接到根上);
  2. 移植认证 slice(方法体原样)+ 守卫 loader +<AuthGate>+ 重定向拦截器;
  3. 构建<RootLayout>+ 3 个 React 布局路由;
  4. 切换main.ts到单一 React 根(RouterProvider);删除createApp(App)+#react-app
  5. 清扫 79 个useVueState桥梁 → React Router hooks / 应用仓库选择器;删除useVueState+ Vue 反应式原语 + glob 注册表;
  6. 用 React 计划可视化替换 pev2;
  7. 删除 Vue 壳(App.vueAuthContext.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 的部分内容(依赖清理)随实现推进已经完成。

七、验证门禁:“零破坏性变更”的兑现方式

设计文档为“零破坏”承诺定义了可执行验证清单,当前仓库可以逐一对应到实际资产:

  1. 守卫单元测试:镜像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 适配器)等。
  2. 全量 type-check / check / test:页面本身是未改动的 React,因此既有测试(设计文档记载为 2308 条)全部继续成立;前端入口命令见 frontend/package.json 的 scripts(pnpm testscripts/run-gate.mjspnpm release走 Vite 构建)。
  3. 关键流程手工 QA:登录、登出、会话过期(401→surface)、OAuth/OIDC 回调、2FA 设置、密码重置、深链受保护路由(→signin→回跳)、工作区切换、SQL 编辑器入口。会话过期与闲置提醒的表面积聚在 frontend/src/app/components/SessionExpiredSurface.tsx 与AuthGateInactiveRemindModal
  4. 历史/返回按钮一致性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),仅供参考

需要专业的网站建设服务?

联系我们获取免费的网站建设咨询和方案报价,让我们帮助您实现业务目标

立即咨询