ARTICLE DETAIL

资讯详情

深耕网站视觉设计与运营推广的一线实战洞察。

Bytebase 前端 Vue→React 迁移:React App 挂载架构设计与 `frontend/src/components/` 目录清理实战

Bytebase 前端 Vue→React 迁移:React App 挂载架构设计与 `frontend/src/components/` 目录清理实战 Bytebase 前端 Vue→React 迁移React App 挂载架构设计与frontend/src/components/目录清理实战【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase本篇技术指南以 Bytebase 仓库内部分设计文档 docs/superpowers/specs/2026-05-14-react-app-and-empty-components-design.md 为核心骨架完整讲解 Bytebase 在 Vue→React 迁移后期如何为「全局 Portal 型 React 表面」引入独立 React 挂载根、如何在同一次提交中清空并删除历史遗留的frontend/src/components/目录并结合当前仓库源码还原该设计的落地形态与演进方向。读完本文你将掌握如何判断一个 React 表面是否应该脱离宿主布局 DOM 独立挂载、如何设计跨 Vue/React 双框架的 i18n 同步与条件渲染 Gate、如何规划大规模文件重定位以及该类改造的验证清单与风险权衡。背景迁移尾声的frontend/src/components/与三份 Vue shimBytebase 前端正在进行大规模的 Vue→React 迁移。随着页面级组件陆续迁移完毕frontend/src/components/目录被「逐渐清空」到设计文档落笔时仅剩 10 个文件且成分混杂3 个 Vue 文件AgentWindowMount.vue、SessionExpiredSurfaceMount.vue、misc/OverlayStackManager.vue7 个非组件文件AdvancedSearch/types.ts、InstanceForm/constants.ts、Member/utils.ts、Member/projectRoleBindings.ts、Member/types.ts、RoleGrantPanel/DatabaseResourceForm/common.ts、RolloutV1/constants/task.ts——它们属于工具函数、类型或常量只因历史原因落进了components/。目录名已经无法反映其内容。其中最关键的是两个 Vue shim 的存在方式AgentWindowMount.vue与SessionExpiredSurfaceMount.vue是「Vue 壳」它们各自拥有一个div并在onMounted中调用mountReactPage(container, ...)它们渲染的 React 组件AgentWindow、SessionExpiredSurface本身已经通过createPortal(..., getLayerRoot(...))把可见 DOM 挂到document.body级别的 layer root 上。也就是说shim 的可见 DOM 从来不在 Vue 子树内Vue mount 只是一个承载 React 根的「生命周期锚点」。因此它们对 Vue 父组件并不构成承重关系——只要在 Vue 树之外存在一个能够持有 React 根的位置这两个 shim 当天就可以删除。设计文档提出的「某个位置」就是一个sibling React app随 Vue 应用一起在启动时挂载专门承载「已经全局 Portal、不需要定位进 Vue 布局 DOM」的 React 表面首批住户是AgentWindow与SessionExpiredSurface同一 PR 还把 7 个工具文件迁回自然归属并彻底删除frontend/src/components/。设计目标与明确排除项Goals本次 PR 的目标在 index.html 中建立长生命周期 React 根div idreact-app并在 Vue 应用挂载后从 main.ts 挂载将AgentWindow与SessionExpiredSurface移入该 React 应用删除两个*Mount.vueshim把frontend/src/components/下剩余每个文件重定位到自然归属彻底删除frontend/src/components/目录。Non-goals明确不做的事不改变AgentWindow、SessionExpiredSurface的视觉或行为不迁移OverlayStackManager.vue到 React——它仍是BBModal.vueVue 组件的 Vue 基础设施只做搬迁不迁移BodyLayout.vue、AuthContext.vue到 React二者保持 Vue只是各自少挂一个子元素不改动现有ReactPageMount.vue/mountReactPage的用法——对于「必须定位进 Vue 布局 DOM 内部」的 React 表面这套机制依然是正确工具。这一组 Goal/Non-goal 划分清晰地划定了改造边界全局 Portal 型表面走新 React 根布局内嵌型表面继续走 mountReactPageVue 基础设施不趁机改写。React app 设计三份新文件与启动流程1.index.html增加 sibling 挂载点在 Vue 根div idapp/div旁新增兄弟节点div idapp/div div idreact-app/div !-- NEW -- div idbb-toaster-root/div关键设计点是#react-app自身没有任何可见 DOM——它的子组件全部通过getLayerRoot(agent)与getLayerRoot(critical)Portal 出去因此这个节点的布局位置无关紧要。当前仓库的 index.html 已经保留了这一结构并带有注释说明它是「globally-portaled surfacesAgentWindow、SessionExpiredSurface、Toaster的 React 根视觉上为空子组件 Portal 进 getLayerRoot(...)」。2.src/react/app/ReactApp.tsx顶层组件树export function ReactApp() { return ( I18nextProvider i18n{i18n} AgentWindow / SessionExpiredSurfaceGate / /I18nextProvider ); }顶层树通过I18nextProvider注入 React i18n 实例首批住户即两个全局 Portal 型表面。3.src/react/app/SessionExpiredSurfaceGate.tsx条件渲染 路由路径接线这个 Gate 承担了原先 Vue shim 里的「条件判断 路由路径」职责export function SessionExpiredSurfaceGate() { const unauthenticatedOccurred useVueState( () useAuthStore().unauthenticatedOccurred ); const currentPath useVueState(() router.currentRoute.value.fullPath); if (!unauthenticatedOccurred) return null; return SessionExpiredSurface currentPath{currentPath} /; }useAuthStore与router按代码库惯例直接导入。这段代码展示了跨框架状态订阅的核心模式Vue store / router 的响应式状态通过useVueState桥接进 React 渲染周期——这与当前仓库 SessionExpiredSurfaceGate.tsx 的实现思路一脉相承后者进一步收紧了显式条件只有当「当前路由非 auth 相关路由、用户已登录、且发生过 unauthenticated」三者同时满足时才渲染表面避免弹窗阻断登录/注册流程对应原AuthContext.vue中的守卫逻辑。4.src/react/app/mount.tsx启动 Vue→React i18n 同步挂载函数必须是.tsx因为渲染了 JSXimport { watch } from vue; import { createRoot } from react-dom/client; import { StrictMode } from react; import i18n, { i18nReady } from /react/i18n; import { locale } from /plugins/i18n; import { ReactApp } from ./ReactApp; export async function mountReactApp(selector: string) { const container document.querySelector(selector); if (!container) throw new Error(Missing React app mount point: ${selector}); await i18nReady; const root createRoot(container); root.render( StrictMode ReactApp / /StrictMode ); // One-time Vue→React locale sync. Replaces the per-shim watch(locale, ...). watch(locale, async (next) { if (i18n.language ! next) await i18n.changeLanguage(next); }); return root; }这里有两个值得深挖的实现细节await i18nReadyReact 挂载前必须等 i18next 初始化完成。当前仓库 frontend/src/lib/i18n/index.ts 中i18nReady正是i18n.use(initReactI18next).init({...})的返回值resources 覆盖全部支持语言、fallbackLng: en-US而在新的统一挂载入口 frontend/src/app/runtime.ts 中同样在组装核心依赖时await i18nModule.i18nReady——「等 i18n 就绪再渲染」已成为所有 React 挂载路径的统一前提。一次性 locale 同步旧 shim 各自持有watch(locale, ...)新应用收敛为单一watch用i18n.changeLanguage驱动 React 侧语言切换未来入住该 React 应用的组件如需响应语言变化统一走标准的useTranslation()订阅即可。5.src/main.tsVue 挂载后的 fire-and-forget在既有createApp(App).mount(#app)之后追加const { mountReactApp } await import(./react/app/mount); void mountReactApp(#react-app); // fire-and-forget; boot continues两个细节是刻意的设计决策fire-and-forget 是有意为之mountReactApp要等i18nReady才 resolve若同步 await 会拖慢后续 Vue 启动步骤而它的首批住户AgentWindow、SessionExpiredSurface都是「用户触发」或「认证状态触发」型表面启动时并不可见后台初始化完全可接受动态 import把 React bundle 从 Vue 启动的关键路径上摘除避免拖慢首屏。连带修改Knock-on editsfrontend/src/app/layouts/BodyLayout.tsx从模板移除AgentWindowMount /删除对应 import——当前仓库该文件的快捷键处理Ctrl/CmdShiftA切换 Agent见第 68-78 行仍在但挂载职责已迁走src/AuthContext.vue移除SessionExpiredSurfaceMount v-ifauthStore.unauthenticatedOccurred /src/layouts/layout-bridge.test.ts删掉vi.mock(/components/AgentWindowMount.vue, ...)块src/react/components/auth/SessionExpiredSurface.test.tsx若引用旧 shim 则更新 import 路径组件本身不变frontend/src/react/mount.ts即pageLoaders/mount 层mountReactPage路径不再需要AgentWindow.tsx、SessionExpiredSurface.tsx核对并剪除多余 glob 条目。文件重定位7 个遗留文件去向与落点理由设计文档给出了完整的重定位清单当前仓库中多数落点已可实际找到SourceDestinationNotesAdvancedSearch/types.ts️ delete0 importers孤儿文件AgentWindowMount.vue️ delete被 React app 取代SessionExpiredSurfaceMount.vue️ delete被SessionExpiredSurfaceGate取代misc/OverlayStackManager.vuesrc/bbkit/OverlayStackManager.vueBBModal的 Vue 基础设施1 个 importerInstanceForm/constants.tsmerge intosrc/utils/v1/instance.ts10 importersengine port/icon 辅助Member/utils.tssrc/utils/v1/member.ts新建1 importerMembersPage.tsxMember/projectRoleBindings.tsappend tosrc/utils/v1/member.ts1 importer合并以提升密度Member/types.tssrc/types/v1/member.ts新建匹配既有src/types/v1/{database,project,user}.ts模式顶层src/types/member.ts无关——它持有DatabaseResourceRoleGrantPanel/DatabaseResourceForm/common.tssrc/utils/v1/databaseResource.ts新建2 importers纯辅助函数RolloutV1/constants/task.tssrc/utils/v1/issue/task.ts新建1 importer同级已有rollout.ts所有 import 站点在同一 PR 内更新7 个 .ts 文件的 importer 合计约 18 条 import 语句需要改写。落点理由Destination rationalesrc/utils/v1/instance.ts已存在且包含supportedEngineV1List等辅助——engine port/icon 常量应与之归位当前仓库supportedEngineV1List被 DatabaseResourceSelector.tsx 等多处使用src/types/v1/member.ts新建呼应src/types/v1/{database,project,user}.ts的兄弟模式顶层src/types/member.ts只放DatabaseResource无关本次不扩展src/utils/v1/member.ts新建镜像既有src/utils/v1/{database,user,project}.ts模式src/utils/v1/databaseResource.ts新建parseStringToResource等是纯 DatabaseResource 辅助、无 UI 依赖——当前仓库该文件确实存在于 frontend/src/utils/v1/databaseResource.ts并配有 databaseResource.test.ts 验证parseStringToResource(projects/acme/databases/app)的解析结果src/utils/v1/issue/task.ts新建但src/utils/v1/issue/rollout.ts已在同目录task 状态常量应与之一并列放——当前仓库 frontend/src/utils/v1/issue/task.ts 确实存在src/bbkit/OverlayStackManager.vue与其它BB*Vue 原语同处一室组件内部name已是BBOverlayStack。这一清单体现的重定位原则是孤儿文件直接删除0 importer纯辅助逻辑按「v1 资源类型」归入utils/v1/或types/v1/兄弟目录Vue 原语归入 bbkit——目录成为代码意图的投影而非历史沉积的容器。验证清单从静态检查到手工冒烟设计文档为 PR 定义了四层验证与当前仓库的工程化体系一一对应pnpm --dir frontend check——ESLint Biome import 排序。当前仓库 frontend/package.json 的test脚本实际是node scripts/run-gate.mjsgate 统一入口而fix脚本执行biome check --write说明静态检查由 Biome 主导pnpm --dir frontend type-check——同时覆盖 Vuevue-tsc与 Reacttsconfig.react.json两条类型检查链路pnpm --dir frontend test——至少包含layout-bridge.test.ts更新后、SessionExpiredSurface.test.tsx、no-react-to-vue-imports.test.ts、no-legacy-vue-deps.test.ts。这些守卫测试的存在意味着代码库用自动化测试锁死了 React↔Vue 的 import 边界与旧依赖淘汰进度手工冒烟打开应用用Cmd/CtrlShiftA验证 AgentWindow 开关当前仓库 BodyLayout.tsx 中该快捷键处理被保留并 port 到 React 布局层在 DevTools 里强制触发 401如 revoke token确认 SessionExpired 表面出现收尾确认frontend/src/components/目录已不存在。风险清单与缓解策略风险缓解启动顺序竞争boot order racemountReactApp在 Vue 挂载后才 resolve期间AgentWindow、SessionExpiredSurface不可见AgentWindow 由用户触发延迟不可感知SessionExpiredSurface 只在「已认证请求失败」后才需要而失败必然发生在启动很久之后。可接受Vue→React i18n 同步覆盖旧 shim 各自watch(locale, ...)新应用收敛为单点未来入住组件如需响应 locale 变化标准做法是useTranslation()订阅已是规范useVueState重渲染抖动Gate 订阅两个 Vue 响应式源两个源更新频率都很低无预期性能问题图层顺序layer ordering新应用住户继续用getLayerRoot(agent \| critical)图层根位于document.body而非#react-app/#app内部图层顺序不受影响关于图层顺序当前仓库 frontend/src/components/ui/layer.ts 给出了更完整的机制佐证LAYER_ROOT_ID定义overlay / agent / critical / watermark四个图层家族LAYER_Z_INDEX依次为 2500 / 2600 / 7000 / 7100ensureRoot会在document.body上按家族顺序插入div idbb-react-layer-*根节点并设置z-index与isolation: isolateHIGHER_LAYER_FAMILIES还用于保护更高层级的可访问性aria-hidden/inert。由于这些根节点常驻document.body#react-app的布局位置确实与渲染结果无关——这正是「#react-app没有可见 DOM、布局位置无所谓」这一论断的底层依据。从设计到落地的演进sibling React app 与最终单根方案值得指出的是该文档定位是designpre-implementation它提出的「sibling React app」是当时最合适的中间态。而从当前仓库的源码结构看后续演进进一步收敛为单根 React-Router 应用frontend/src/app/mountApp.ts 的注释明确写道mountReactRouterApp「替代了 VuecreateApp(App)mount 单独的#react-appoverlay mount」——全局 overlayToaster、AgentWindow、SessionExpiredSurface、Watermark现在全部住在 RootLayout 中由这一单根统一托管frontend/src/app/RootLayout.tsx 中可以看到最终形态Watermark /、Toaster /、AgentWindow /、SessionExpiredSurfaceGate /与LeaveGuardBlocker /、RouteBehaviorRecorder /、AuthGate一起构成根路由元素#react-app挂载点仍保留在 index.html注释记录了它的历史使命。从「sibling React app 承载全局 Portal 表面」到「单根 React-Router 应用内置于 RootLayout」演进逻辑是一致的这些表面之所以能脱离 Vue 布局 DOM正是设计文档确立的「已全局 Portal、无需定位进 Vue 布局」判据而一旦 React Router 全面接管应用外壳把这些全局 overlay 收编进单一根树比维持双根挂载更简单。设计文档留下的扩展空间Out of scope, but enabled文档末尾明确列出「本次不做、但已被该架构使能」的未来候选任何未来「已经全局 Portal」的 React 表面都可以直接入住 React 应用根并卸下 Vue mount shim——例如未来的 agent 相关面板、不依赖 Vue notification 状态的 body 级 toast、全局键盘快捷键 overlay。这条路线在后续单根方案中已兑现Toaster、Watermark与两个首批住户同处 RootLayout。小结一套可复用的迁移决策框架这份设计文档对任何正在做 Vue→React 渐进迁移的前端项目都有直接参考价值用「是否已经全局 Portal」判断 React 表面的归属——可见 DOM 不在宿主子树内的组件其 Vue shim 只是生命周期锚点可以整体搬出跨框架状态桥接收敛为单一入口——useVueState订阅 Vue 响应式源、watch(locale)单点同步 i18n取代每个 shim 各写一份的样板代码目录清理与代码重定位同 PR 完成——孤儿文件删除、纯辅助归utils/v1/types/v1、Vue 原语归 bbkitimport影响面约 18 处被一次算清验证靠 gate 脚本 守卫测试 手工冒烟三层保障其中no-react-to-vue-imports/no-legacy-vue-deps这类边界测试是把架构纪律固化为 CI 检查的典型做法风险与收益书面化启动顺序、i18n 覆盖、重渲染抖动、图层顺序四项风险逐一给出缓解结论让「可接受的竞态」成为有据可依的工程决策而非默认行为。Bytebase 前端的这次改造证明迁移工程的收官阶段往往是架构清晰度提升最快的时候——当最后一层历史 shim 被移除、最后一个混排目录被拆散代码库剩下的就是「全局 Portal 表面进 React 根、布局内嵌表面走 mountReactPage、Vue 原语留在 bbkit」的干净分界。【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表