Bytebase 模式编辑器 Vue 到 React 迁移设计:Context 状态管理、无头组件库与渐进式共存实践
2026/9/15 13:30:28 网站建设 项目流程

Bytebase 模式编辑器 Vue 到 React 迁移设计:Context 状态管理、无头组件库与渐进式共存实践

【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase

SchemaEditorLite 是 Bytebase 前端中最复杂的交互面之一:约 88 个文件、1.03 万行代码,覆盖表/列/索引/分区/视图/存储过程/函数的可视化 DDL 编辑。本文以 设计文档 为主体,结合仓库中已落地的 React 实现源码,完整讲解这套"用 React Context 替换 provide/inject + Emittery、用无头组件库替换 naive-ui、纯 TS 算法层零改动复用、分五阶段渐进迁移"的方案。读完本文,你将掌握大规模 Vue 3 组件套件向 React 渐进迁移的完整方法论,以及 Bytebase 项目中每个关键决策的落地细节。

迁移背景与设计目标

Bytebase 前端正处于从 Vue 3 到 React 的增量迁移期,已有 React 页面(ProjectDatabaseDetailPage、ProjectSyncSchemaPage、IssueDetailPage)通过useVueState()桥接 Vue store,并复用 Vue 层的纯 TS 工具。迁移遵循 迁移 Playbook 的约束:迁移有边界的界面、复用既有 store 与工具、只有在调用方消失后才能删除 Vue 文件。

SchemaEditorLite 之所以是迁移的重中之重,是因为它承载了 Plan/Issue 流程中数据库变更规格的可视化编辑。原 Vue 实现(frontend/src/components/SchemaEditorLite/)的三大痛点在于:

  • 依赖 naive-ui(NTree 虚拟滚动、NSplit 可伸缩面板、NDropdown 等);
  • 使用 Vue provide/inject 组合上下文(provideSchemaEditorContext()组合useTabsuseEditStatususeSelectionuseScrollStatus四个子 hook);
  • 使用 Emittery 事件总线做组件间通信(共 6 种事件)。

这些模式在 React 生态中没有直接对应物。设计文档给出五条设计目标,每一项都有明确的可验证标准:

  1. 与 Vue 版功能对等:数据库/模式/表/视图/存储过程/函数编辑、列 CRUD(类型/默认值/外键单元格)、索引编辑、分区编辑、树导航右键菜单、Tab 管理、编辑状态跟踪、选择/rollout 对象跟踪、DDL 预览全部要有 React 对应物;
  2. Vue 调用方零回归:SchemaEditorDrawer、EditorView 等 Vue 父组件在迁移期间和迁移后保持原样工作,Vue 版组件及其导入不被修改或删除;
  3. 复用纯 TS 层、不 forkalgorithm/types.tsspec.tsutils/由 React 组件从原位置直接导入;
  4. 遵循既有 React 模式:用 React Context 而非 zustand/redux,用useVueState访问 store,用 shadcn 风格 UI 与@/react/components/ui/原语,不引入新的状态管理库;
  5. 增量交付:每个阶段产出可用、可测试的组件。

架构总览:镜像 Vue 组件树

React SchemaEditorLite 镜像 Vue 组件的架构:根节点是 Context Provider,左侧是可伸缩的 aside 树,右侧是带 Tab 的编辑面板,用户动作触发模态框。关键差异在于把 Vue 专属模式换成 React 等价物:

SchemaEditorLite (React Context provider + react-resizable-panels) ├── AsideTree (react-arborist with custom node renderers) │ ├── Node renderers (database, schema, table, view, procedure, function) │ ├── Node checkboxes (selection state) │ └── Context menu (dropdown-menu from ui/) ├── EditorPanel (tab-based routing) │ ├── DatabaseEditor │ ├── TableEditor │ │ ├── TableColumnEditor (table from ui/ with editable cells) │ │ │ ├── DataTypeCell (combobox) │ │ │ ├── DefaultValueCell (input) │ │ │ ├── ForeignKeyCell (button + modal trigger) │ │ │ ├── OperationCell │ │ │ ├── ReorderCell (drag handle) │ │ │ └── SelectionCell (checkbox) │ │ ├── IndexesEditor │ │ └── PartitionsEditor │ ├── ViewEditor (Monaco wrapper) │ ├── ProcedureEditor (Monaco wrapper) │ ├── FunctionEditor (Monaco wrapper) │ └── PreviewPane (Monaco wrapper, DDL diff output) └── Modals ├── TableNameModal (dialog) ├── SchemaNameModal (dialog) ├── EditColumnForeignKeyModal (sheet, wide) ├── ViewNameModal (dialog) ├── FunctionNameModal (dialog) ├── ProcedureNameModal (dialog) └── ActionConfirmModal (alert-dialog)

这套架构已经在仓库中落地:实际 React 实现位于frontend/src/modules/schema-editor/,目录结构与设计图一一对应(Aside/Modals/Panels/EditorPanel.tsxTabsContainer.tsxcontext.tsxuseTabs.ts等)。根组件 SchemaEditorLite.tsx 用forwardRef导出,内部渲染SchemaEditorProvider包裹的PanelGroup双栏布局。

状态管理:React Context 替换 provide/inject 与 Emittery

设计文档的核心决策是:schema editor 的状态是编辑器实例作用域内的,不是全局的。React Context 天然把状态限定在组件子树内,这与 Vue provide/inject 精确对应;引入 zustand 反而会把组件局部关注点提升为全局状态。因此设计目标 4 明确禁止新增 zustand/TanStack Query(尽管frontend/package.json中仓库已有 zustand 依赖,但 schema editor 不使用它)。

Emittery 六事件的 React 等价物

原 Vue 实现通过 Emittery 总线传播 6 种事件,设计文档将其逐一映射为 context 上的回调函数:

Emittery 事件React 等价物
update:selected-rollout-objectsonSelectedRolloutObjectsChangeprop(提升到父组件)
rebuild-treerebuildTree()context 函数,组件直接调用
rebuild-edit-statusrebuildEditStatus(resets)context 函数
clear-tabsclearTabs()context 函数(tabs hook 的一部分)
refresh-previewrefreshPreview()context 函数
merge-metadatamergeMetadata(metadatas)context 函数

React 的单向数据流取代了 Vue 的双向事件模式。实际实现中,SchemaEditorLite.tsx 正是这样做的:rebuildTree通过自增treeBuildVersion让 AsideTree 重新派生树数据;refreshPreview通过自增previewVersion让 PreviewPane 重新生成 DDL;mergeMetadata遍历传入的DatabaseMetadata,用mergeTableMetadataToTarget把新表合并进 target 与 baselineMetadata。

Provider 组合模式

设计文档给出的 provider 结构:

SchemaEditorProvider (creates context with composed hooks) └── provides: tabs, editStatus, selection, scrollStatus, callbacks └── children consume via useSchemaEditorContext()

落地代码 context.tsx 就是标准模式:createContext<SchemaEditorContextValue | null>(null)SchemaEditorProvider组件、以及一个在 Provider 外调用时抛错的useSchemaEditorContext()

四个子 hook 的 Vue→React 映射是直接对应的:

  • Vueref()→ ReactuseState()
  • Vuecomputed()→ ReactuseMemo()
  • Vuewatch()→ ReactuseEffect()

以 useTabs.ts 为例,可看到实际实现比设计更精细:tabMapuseRef(new Map())保存、currentTabIduseStatetabListuseMemo派生;addTabfindTab复用已存在 Tab(按 type + database + schema/table/view/procedure/function 名称匹配),否则用uuidv1()生成新 id,并通过requestAnimationFrame延迟设置当前 Tab(与 Vue 行为一致)。closeTab关闭当前 Tab 时自动切换到相邻 Tab。而 useEditStatus.ts 则用dirtyPathsRef记录"对象路径 → 编辑状态"映射,通过version计数器触发重渲染,并提供getSchemaStatus/getTableStatus/getColumnStatus等派生查询与isDirty

可伸缩面板:react-resizable-panels 替换 NSplit

Vue 版用 naive-ui 的NSplit,参数为min=0.15max=0.4default-size=0.25。设计文档选定 react-resizable-panels(2025-2026 年的主流方案,shadcn/ui 的 Resizable 组件也基于它),理由是无头无样式(契合 shadcn 模式)、WAI-ARIA 无障碍、支持布局持久化与命令式 API。对应的 JSX 结构:

PanelGroup (direction="horizontal") Panel (defaultSize=25, minSize=15, maxSize=40) → AsideTree PanelResizeHandle Panel (defaultSize=75) → EditorPanel

实际代码 SchemaEditorLite.tsx 与设计完全一致:PanelGroupid="schema-editor"、左侧Panel defaultSize="25%" minSize="15%" maxSize="40%"、右侧Panel defaultSize="75%",中间用自定义resizeHandleClass("vertical", "w-0.5")分隔条。依赖版本见 frontend/package.json:react-resizable-panels^4.11.0

侧边树:react-arborist 替换 NTree

Vue aside 用 naive-ui 的 NTree + 虚拟滚动展示 database/schema/table 层级。设计文档选定 react-arborist(基于 react-window 的无头虚拟化树,支持 1 万+ 节点、键盘导航、拖拽、内联重命名与自定义节点渲染)。对比 NTree,react-arborist 的核心能力映射为:

  • 大 schema 的虚拟化渲染(1 万+ 节点);
  • 自定义节点渲染器(database、schema、table group、table、view、procedure、function 节点);
  • 键盘导航与 ARIA 支持;
  • 选中状态管理。

落地实现 AsideTree.tsx 中可见:buildTree(targets, { byInstance })从 metadata 构建TreeNode[]nodeMap,再convertToArboristData转换为 arborist 兼容格式;Tree组件设置rowHeight={28}indent={16}searchTerm/searchMatch支持搜索;节点渲染通过NodeRenderer按类型分发图标(DatabaseIconTable2ViewFileCodeFunctionSquare),并用statusClassName给 created/updated/dropped 状态着色(dropped 为红色删除线)。值得一提的细节:组件用ErrorBoundary包裹Tree,因为 react-arborist 对畸形数据(如 falsy 的 node id)会硬抛异常,需要将其隔离在单个 pane 内。

右键菜单与"+"菜单

设计文档提出右键菜单用@/react/components/ui/的 dropdown-menu 替换 NDropdown,菜单项(建表、重命名、删除等)由节点类型决定,逻辑与 Vuecontext-menu.ts对齐。实际实现 useContextMenu.ts 返回{ menuState, menuOptions, showMenu, hideMenu }menuOptions按节点类型 + 引擎能力(engineSupportsEditViews等 spec 谓词)计算。AsideTree 同时提供了始终可见的"+"下拉菜单(放在搜索框旁),路由到第一个 target + 第一个 schema,覆盖最常见的单库单 schema 场景;多 schema 的高级用户仍可右键具体 schema。handleMenuSelect中可以看到完整的操作语义:create-table/rename-table 打开TableNamePopover、drop-table 对已创建的表直接splice并从 editStatus 移除、对既有表markEditStatus("dropped")并关闭其已打开 Tab、create-view/procedure/function 用create(ViewMetadataSchema, ...)构造新 metadata 并打开对应 Tab。

节点复选框:10+ 变体收敛为 1 个组件

Vue 版有 10+ 个节点复选框变体(因 Vue 模板组合需要独立组件文件)。设计文档的方案是收敛为单个NodeCheckbox组件,接收节点的 metadata 类型并委托给 selection context——React 的 JSX 条件渲染在一个组件内即可完成。落地见Aside/NodeCheckbox.tsx,树行内同时渲染NodeCheckbox(选择状态)与StatusBadge(BYT-9473 的改进:纯文字颜色太弱,改为在 created/updated/dropped 旁加单字符徽标+/~/)。

Tab 管理:useReducer 风格的状态与类型路由

VueuseTabs维护Map<string, TabContext>与当前 Tab。设计文档建议 React 用useReducer配合ADD_TABCLOSE_TABSET_CURRENT_TABCLEAR_TABS动作;实际实现 useTabs.ts 选择了useRef<Map>+useState版本号的自定义 hook 形态(无 reducer),语义等价。Tab 列表用 ui/ 的Tabs原语渲染。

Tab 到面板的路由由 EditorPanel.tsx 完成:顶部渲染TabsContainer,下方按currentTab.type分发——"database"DatabaseEditor"table"TableEditor"view"ViewEditor"procedure"ProcedureEditor"function"FunctionEditor,并用key={currentTab?.id ?? "empty"}强制 Tab 切换时重挂载。无 Tab 时显示"选择对象"占位提示。额外细节:每个 Tab 的 schema 选择状态按 Tab id 存储(selectedSchemas),避免跨 Tab 泄漏。

编辑器面板与单元格组件

每个面板都是其 Vue 对应物的直接移植:

  • TableEditor:Columns/Indexes/Partitions 多模式 Tab,工具栏含 Add Column/Add Index/Add Partition 与选择汇总徽标,底部渲染 PreviewPane;
  • TableColumnEditor(最复杂的子组件):用 ui/ 的Table渲染可内联编辑的表,6 类单元格各成一个 React 组件——DataTypeCell(Combobox 类型建议,来自getDataTypeSuggestionList(engine))、DefaultValueCell(null/expression/value 下拉 + 输入,复用getColumnDefaultValuePlaceholder())、ForeignKeyCell("schema.table(column)" 链接 + 编辑按钮)、OperationCell(删除/恢复,禁止删除最后一列)、ReorderCell(上移/下移,arraySwap())、SelectionCell(列选择复选框);主键检测通过查找primary=true的索引并检查列是否在pk.expressions中;
  • IndexesEditor / PartitionsEditor:较简单的表式编辑器直接移植,分区编辑器仅支持 MySQL/TiDB 分区类型(依 spec.ts 谓词);
  • ViewEditor / ProcedureEditor / FunctionEditor:三个几乎相同的 Monaco 编辑器包装,分别编辑view.definitionprocedure.definitionfunction.definition,变更即markEditStatus("updated")
  • PreviewPane:只读 Monaco 展示generateDiffDDL()输出,监听 context 的refreshPreview触发重新生成,hidePreview控制显隐。

模态框:映射到现有 React UI 原语

设计文档给出的模态框映射表:

Vue ModalReact 组件UI 原语
ActionConfirmModalActionConfirmDialogAlertDialog
TableNameModalTableNameDialogDialog
SchemaNameModalSchemaNameDialogDialog
EditColumnForeignKeyModalEditColumnForeignKeySheetSheet(wide)
ViewNameModalViewNameDialogDialog
FunctionNameModalFunctionNameDialogDialog
ProcedureNameModalProcedureNameDialogDialog

只有 EditColumnForeignKeyModal 值得用宽版 Sheet——它包含带表/列选择器的多字段表单;其余都是单字段对话框(名称输入 + 确认/取消)。落地实现全部位于frontend/src/modules/schema-editor/Modals/TableNamePopover.tsxSchemaNameDialog.tsxEditColumnForeignKeySheet.tsxActionConfirmDialog.tsx等)。其中 TableName 采用了 Popover 形式锚定在右键位置,符合设计文档"anchor 于菜单位置"的意图;EditColumnForeignKeySheet 用级联选择(选 schema 重置 table+column、选 table 重置 column),依赖upsertColumnFromForeignKey()/removeColumnFromForeignKey()编辑工具函数。

纯 TS 层零改动复用:保住约 1,200 行经过测试的逻辑

设计文档最重要的工程决策之一,是识别出以下模块不含任何 Vue 导入,可被 React 直接原样导入(实际路径为frontend/src/modules/schema-editor/core/):

  • algorithm/diff-merge.ts— metadata 比较与调和(790 行)
  • algorithm/rebuild.ts— 从编辑操作重建 metadata
  • algorithm/apply.ts— 将变更应用到内存 metadata
  • types.ts— EditTarget、TabContext、RolloutObject、EditStatus 类型
  • spec.ts— 引擎功能支持谓词(engineSupportsEditIndexesengineSupportsEditTablePartitions等)
  • utils/columnDefaultValue.ts— 默认值占位逻辑
  • utils/metadata.ts— metadata 转换辅助
  • common.tsgenerateDiffDDL()RPC 调用

这样 Vue 与 React 两个实现共享完全相同的 DDL 生成行为,避免双实现分叉。计划文档 T8 进一步说明接入方式:DiffMerge类只依赖 context 的markEditStatusByKey,所以 React 侧只需定义一个仅含该方法的最小DiffMergeContext接口并做类型断言,无需改动 Vue 算法文件。

公共 API 与 Bridge 桥接

React 版对外暴露与 Vue 版相同的接口:

  • PropsprojectreadonlyselectedRolloutObjectstargetsloadinghidePreview
  • 回调onSelectedRolloutObjectsChangeonIsEditingChange
  • 命令式句柄useImperativeHandle/forwardRef):applyMetadataEdit()refreshPreview()isDirty

实际实现 SchemaEditorLite.tsx 中useImperativeHandle正是暴露这三个方法。设计文档还给出了未来 Vue 父组件嵌入 React 版的方案:用createRoot挂载的薄 Vue 包装组件。但这不是初始迁移的必需项——React 版只被 React 父组件消费,Vue 父组件继续用 Vue 版(对应设计目标 2 与"不迁移所有 Vue 调用方"的非目标)。仓库中frontend/src/routes/project/plan-detail/components/SchemaEditorSheet.tsx即 React 侧的消费方示例。

分五阶段迁移:每阶段可交付可验证

设计文档把迁移拆成五个阶段,每阶段都有明确的规模估算、入口与退出标准:

Phase 1: Foundation(约 2,000 行)

  • React context provider(替换provideSchemaEditorContext+ Emittery)
  • useTabsuseEditStatususeSelectionuseScrollStatus四个 hook
  • 带 react-resizable-panels 的 SchemaEditorLite 外壳
  • 入口:确认types.tsspec.tsutils/algorithm/可被 React 导入
  • 退出:空编辑器以可伸缩 pane 渲染,context 对子组件可用

Phase 2: Aside Tree(约 1,500 行)

  • react-arborist 集成 + 自定义节点渲染器
  • 含 create/rename/drop 动作的右键菜单
  • 节点复选框选择(单组件 + 类型分发渲染)
  • 搜索/过滤
  • 退出:树可导航 database metadata,右键菜单触发桩

Phase 3: Core Editors(约 2,500 行)

  • 基于类型的 Tab 容器与路由
  • TableEditor + 列编辑器(6 类单元格组件)
  • DatabaseEditor(schema 选择器、建表触发)
  • 退出:表列可编辑、可新建表

Phase 4: Extended Editors(约 1,500 行)

  • IndexesEditor、PartitionsEditor
  • ViewEditor、ProcedureEditor、FunctionEditor(Monaco 包装)
  • PreviewPane(DDL diff 展示)
  • 退出:所有面板类型可用

Phase 5: Modals and Polish(约 1,500 行)

  • 全部 7 个模态框
  • 编辑状态指示器(created/updated/dropped 视觉标记)
  • 滚动位置保持
  • applyMetadataEdit+generateDiffDDL集成测试
  • 退出:与 Vue 实现验证功能完全对等

设计目标 5 强调"部分完成的迁移(如没有分区编辑器的表编辑器)本身就有价值",每阶段的退出标准即为可测试性保障。计划文档将上述阶段展开为 28 个具体任务(T1 安装依赖 → T28 最终验证),T28 的验证清单包括pnpm --dir frontend fixchecktype-checktest全部通过,且git diff frontend/src/components/SchemaEditorLite/确认 Vue 文件未被改动。

非目标与权衡:什么不做,为什么

设计文档明确划出了边界,这些边界是保证迁移质量的关键:

  • 不重写algorithm/spec.tsutils/types.ts(纯 TS、无 Vue 依赖);
  • 不迁移 Plan/Issue 页面本身(React 组件被嵌入,Vue 父页面通过桥接使用);
  • 不迁移 SQL 编辑器视图(TablesPanel、ViewsPanel、ExternalTablesPanel);
  • 不替换 ELK 布局引擎或 Monaco 集成(框架无关库);
  • 不引入新状态管理库(zustand、TanStack Query);
  • 迁移期间不改变 schema editor 行为、不添加新功能;
  • 不迁移所有 Vue 调用方——Vue 调用方在自己被迁移前继续使用 Vue 版;
  • SchemaDiagram 与 SchemaPane 不在本次迁移范围——它们是调用方不同的独立界面(ER 图查看器、SQL 编辑器侧边栏模式浏览器),应作为独立迁移任务。

行业基线部分给出的权衡同样值得注意:双框架并行会带来构建复杂度,且若两者管理同一 DOM 子树会产生 reconciliation 冲突;增量迁移意味着 Vue 版必须维护到所有调用方迁移完成为止,存在临时重复成本;DrawDB 式的分布式 context 对自包含编辑器很合适,但嵌入有外部状态(如 Pinia store)的大型应用时需要小心协调。

结语:从设计到落地的验证

这份设计文档不是停留在纸面的方案——仓库中frontend/src/modules/schema-editor/的完整实现与设计逐条对应:Context provider(context.tsx)、四子 hook(useTabs.ts、useEditStatus.ts、useSelection.tsuseScrollStatus.ts)、可伸缩双栏(SchemaEditorLite.tsx)、react-arborist 侧边树(AsideTree.tsx)、类型路由面板(EditorPanel.tsx)、模态框集(Modals)均已存在,react-arborist ^3.5.0react-resizable-panels ^4.11.0也已在 frontend/package.json 中。对于正在规划大规模前端框架迁移的团队,本方案在"状态局部化、无头组件、纯逻辑复用、渐进交付、Vue 调用方零回归"五个维度上提供了可直接参照的工程样板。

【免费下载链接】bytebaseDatabase governance built for humans and agents — controlling changes and access across every major database.项目地址: https://gitcode.com/GitHub_Trending/by/bytebase

创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考

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

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

立即咨询