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()组合useTabs、useEditStatus、useSelection、useScrollStatus四个子 hook); - 使用 Emittery 事件总线做组件间通信(共 6 种事件)。
这些模式在 React 生态中没有直接对应物。设计文档给出五条设计目标,每一项都有明确的可验证标准:
- 与 Vue 版功能对等:数据库/模式/表/视图/存储过程/函数编辑、列 CRUD(类型/默认值/外键单元格)、索引编辑、分区编辑、树导航右键菜单、Tab 管理、编辑状态跟踪、选择/rollout 对象跟踪、DDL 预览全部要有 React 对应物;
- Vue 调用方零回归:SchemaEditorDrawer、EditorView 等 Vue 父组件在迁移期间和迁移后保持原样工作,Vue 版组件及其导入不被修改或删除;
- 复用纯 TS 层、不 fork:
algorithm/、types.ts、spec.ts、utils/由 React 组件从原位置直接导入; - 遵循既有 React 模式:用 React Context 而非 zustand/redux,用
useVueState访问 store,用 shadcn 风格 UI 与@/react/components/ui/原语,不引入新的状态管理库; - 增量交付:每个阶段产出可用、可测试的组件。
架构总览:镜像 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.tsx、TabsContainer.tsx、context.tsx、useTabs.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-objects | onSelectedRolloutObjectsChangeprop(提升到父组件) |
rebuild-tree | rebuildTree()context 函数,组件直接调用 |
rebuild-edit-status | rebuildEditStatus(resets)context 函数 |
clear-tabs | clearTabs()context 函数(tabs hook 的一部分) |
refresh-preview | refreshPreview()context 函数 |
merge-metadata | mergeMetadata(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 映射是直接对应的:
- Vue
ref()→ ReactuseState() - Vue
computed()→ ReactuseMemo() - Vue
watch()→ ReactuseEffect()
以 useTabs.ts 为例,可看到实际实现比设计更精细:tabMap用useRef(new Map())保存、currentTabId用useState、tabList用useMemo派生;addTab先findTab复用已存在 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.15、max=0.4、default-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 与设计完全一致:PanelGroup的id="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按类型分发图标(DatabaseIcon、Table2、View、FileCode、FunctionSquare),并用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_TAB、CLOSE_TAB、SET_CURRENT_TAB、CLEAR_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.definition、procedure.definition、function.definition,变更即markEditStatus("updated"); - PreviewPane:只读 Monaco 展示
generateDiffDDL()输出,监听 context 的refreshPreview触发重新生成,hidePreview控制显隐。
模态框:映射到现有 React UI 原语
设计文档给出的模态框映射表:
| Vue Modal | React 组件 | UI 原语 |
|---|---|---|
| ActionConfirmModal | ActionConfirmDialog | AlertDialog |
| TableNameModal | TableNameDialog | Dialog |
| SchemaNameModal | SchemaNameDialog | Dialog |
| EditColumnForeignKeyModal | EditColumnForeignKeySheet | Sheet(wide) |
| ViewNameModal | ViewNameDialog | Dialog |
| FunctionNameModal | FunctionNameDialog | Dialog |
| ProcedureNameModal | ProcedureNameDialog | Dialog |
只有 EditColumnForeignKeyModal 值得用宽版 Sheet——它包含带表/列选择器的多字段表单;其余都是单字段对话框(名称输入 + 确认/取消)。落地实现全部位于frontend/src/modules/schema-editor/Modals/(TableNamePopover.tsx、SchemaNameDialog.tsx、EditColumnForeignKeySheet.tsx、ActionConfirmDialog.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— 从编辑操作重建 metadataalgorithm/apply.ts— 将变更应用到内存 metadatatypes.ts— EditTarget、TabContext、RolloutObject、EditStatus 类型spec.ts— 引擎功能支持谓词(engineSupportsEditIndexes、engineSupportsEditTablePartitions等)utils/columnDefaultValue.ts— 默认值占位逻辑utils/metadata.ts— metadata 转换辅助common.ts—generateDiffDDL()RPC 调用
这样 Vue 与 React 两个实现共享完全相同的 DDL 生成行为,避免双实现分叉。计划文档 T8 进一步说明接入方式:DiffMerge类只依赖 context 的markEditStatusByKey,所以 React 侧只需定义一个仅含该方法的最小DiffMergeContext接口并做类型断言,无需改动 Vue 算法文件。
公共 API 与 Bridge 桥接
React 版对外暴露与 Vue 版相同的接口:
- Props:
project、readonly、selectedRolloutObjects、targets、loading、hidePreview; - 回调:
onSelectedRolloutObjectsChange、onIsEditingChange; - 命令式句柄(
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) useTabs、useEditStatus、useSelection、useScrollStatus四个 hook- 带 react-resizable-panels 的 SchemaEditorLite 外壳
- 入口:确认
types.ts、spec.ts、utils/、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 fix、check、type-check、test全部通过,且git diff frontend/src/components/SchemaEditorLite/确认 Vue 文件未被改动。
非目标与权衡:什么不做,为什么
设计文档明确划出了边界,这些边界是保证迁移质量的关键:
- 不重写
algorithm/、spec.ts、utils/、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.ts、useScrollStatus.ts)、可伸缩双栏(SchemaEditorLite.tsx)、react-arborist 侧边树(AsideTree.tsx)、类型路由面板(EditorPanel.tsx)、模态框集(Modals)均已存在,react-arborist ^3.5.0与react-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),仅供参考