IntelliGit 前端入口与开发测试面板边界重构

本文为山东大学软件学院创新实训项目博客

IntelliGit 前端入口与开发测试面板边界重构

这次做的是 IntelliGit 前端入口层的一次边界整理。

在前面几轮重构中,项目已经完成了很多结构性工作:原来很大的 MainApp.tsx 被拆成了 app/layout/views/components/;原来的全局 useAppStore.ts 被拆成多个按状态所有权划分的 Zustand store;组件订阅方式也继续整理出了 selector 层、view model 层和 service workflow 层;后面 CSS 也从两个全局大文件迁移到了"全局基础样式 + CSS Modules"的结构。

这些工作完成以后,前端主干已经比最早清楚很多。但这次重新检查结构体检文档里的问题 5 时,我发现还有一个小入口没有真正收干净:App.tsx、正式主界面和 Sidecar 测试面板之间的关系。

最早的问题 5 是一个很直接的 React hooks 问题:旧的 App.tsx 先根据运行模式提前返回正式主界面,然后又在后面调用测试面板用的 hooks:

tsx 复制代码
if (mode !== 'test') {
  return <MainApp />
}

const [command, setCommand] = useState('')
const { loading, history, error, executeCommand, clearHistory } = useGitStore()

这会触发 React hooks 规则错误,因为 hooks 不能放在条件分支之后。表面看,这个问题只要把测试面板拆成单独组件就可以解决。

但我这次没有停在这个层面。

因为在当前代码里,条件 hooks 的 lint 错误其实已经没有了。App.tsx 已经可以根据 mode 渲染正式界面或测试面板,npm.cmd run lint 也不再报 hooks error。真正剩下的问题变得更细:测试面板虽然搬出去了,但它用的调试 store 还留在正式 store/ 目录里;正式入口虽然已经移动到了 app/MainApp.tsx,但根目录还保留了一个 MainApp.tsx 转发文件。

这说明问题 5 已经从"修一个 lint 错误"变成了"彻底划清正式应用入口和开发测试入口的边界"。

这篇博客就记录一下这次我是怎么把这个边界继续收紧的。


一、问题已经不是条件 hooks,而是入口边界不够硬

这次开始之前,当前的 App.tsx 其实已经很短:

tsx 复制代码
import MainApp from './MainApp'
import SidecarTestPanel from './dev/SidecarTestPanel'

function App(): React.JSX.Element {
  return window.electronAPI.mode === 'test' ? <SidecarTestPanel /> : <MainApp />
}

从 React hooks 规则来看,这已经没有问题。App.tsx 自己没有调用任何 hooks,只是根据 window.electronAPI.mode 做入口选择。

但是继续往下看,会发现它仍然引用了:

text 复制代码
src/renderer/src/MainApp.tsx

而这个文件的内容只有一行:

tsx 复制代码
export { default } from './app/MainApp'

这个转发文件是在前面拆 MainApp.tsx 时留下的兼容入口。它当时是合理的,因为大文件拆分不应该一次性改太多引用,保留兼容导出可以降低迁移风险。

但到了问题 5 这一步,这个兼容层反而变成了一个新的模糊点。因为现在正式主界面的真实位置已经是:

text 复制代码
src/renderer/src/app/MainApp.tsx

如果根目录继续保留 MainApp.tsx,后续维护者仍然可能围绕旧路径理解项目,甚至继续往根目录入口上加东西。这样一来,前面做过的 app 层装配边界就会变软。

所以这次第一件事,是把入口路径彻底改清楚:

tsx 复制代码
import MainApp from './app/MainApp'
import SidecarTestPanel from './dev/SidecarTestPanel'

然后删除根目录的:

text 复制代码
src/renderer/src/MainApp.tsx

这个改动很小,但含义很明确:

text 复制代码
App.tsx 是唯一入口分流文件。
app/MainApp.tsx 是正式主界面的真实入口。
根目录 MainApp.tsx 不再作为兼容转发存在。

也就是说,入口结构从"有一个历史兼容壳"变成了"路径本身表达职责"。


二、把 mode 从普通字符串改成明确协议

入口分流依赖的是:

tsx 复制代码
window.electronAPI.mode

原来的类型定义比较宽:

ts 复制代码
mode?: string

这在早期开发阶段没什么问题,因为只要能从 preload 把环境变量传到 Renderer,功能就能跑起来。但如果从结构角度看,这个类型太松了。

App.tsx 实际只关心两种模式:

text 复制代码
main
test

string 表示任何字符串都可以传进来,比如:

text 复制代码
dev
debug
sidecar
undefined
空字符串
拼错的 tset

这类输入虽然大多数时候会自然落到正式主界面,但它没有把入口协议说清楚。项目越往后走,入口模式越应该是一个明确的契约,而不是一个随意字符串。

所以我在共享类型里新增了:

ts 复制代码
export type ElectronMode = 'main' | 'test'

并把 ElectronAPI 改成:

ts 复制代码
export interface ElectronAPI {
  mode: ElectronMode
}

同时在 preload 层做归一化:

ts 复制代码
function resolveElectronMode(mode: string | undefined): ElectronMode {
  return mode === 'test' ? 'test' : 'main'
}

最后暴露给 Renderer:

ts 复制代码
mode: resolveElectronMode(process.env.ELECTRON_MODE)

这样处理以后,Renderer 不需要再理解环境变量细节。对 Renderer 来说,mode 永远只有两种明确值:

text 复制代码
main
test

这里的重点不是"类型变得好看",而是把 Electron preload 到 React Renderer 之间的小协议收紧了。入口层越小,越应该稳定,因为它是整个前端应用树的起点。


三、测试面板搬出去了,但测试状态还没搬干净

这次真正值得处理的,是 Sidecar 测试面板的状态归属。

当前正式业务状态已经拆成了多个 store:

text 复制代码
repositoryStore.ts
gitStatusStore.ts
diffStore.ts
historyStore.ts
uiStore.ts
operationStore.ts

这些 store 都是正式 Git 客户端界面的一部分。它们分别负责仓库列表、工作区状态、diff 状态、提交历史、UI 消息、操作 loading 等。

但是测试面板使用的 store 仍然放在:

text 复制代码
src/renderer/src/store/useGitStore.ts

它的职责和正式业务 store 完全不一样。它不是管理正式业务状态,而是记录开发测试面板里的原始命令执行历史:

ts 复制代码
export interface CommandRecord {
  id: number
  command: string
  payload?: Record<string, unknown>
  response: unknown
  timestamp: number
  success: boolean
}

更关键的是,它在 store 内直接调用了:

ts 复制代码
window.electronAPI.invokeGit(command, payload)

这对测试面板来说是合理的。因为测试面板的目的就是验证 Sidecar 原始通信链路,它需要允许输入任意 command 和 payload。

但这对正式 store/ 目录来说就不合理了。

在前面的状态层重构中,我们已经确定了一条规则:

text 复制代码
正式业务 Git 调用必须通过 api/gitClient.ts。
store 只保存状态所有权和局部 mutation。
不要在 store 里直接调用 window.electronAPI.invokeGit。

useGitStore.ts 虽然只是调试用,但它放在正式 store/ 里,就会让这个规则变得不纯粹。更麻烦的是,它还从正式 barrel export 暴露出去:

ts 复制代码
export { useGitStore } from './useGitStore'
export type { CommandRecord } from './useGitStore'

对应 selector 和 view model 也在正式目录中:

text 复制代码
src/renderer/src/store/selectors/gitCommandSelectors.ts
src/renderer/src/viewModels/useSidecarTestPanelModel.ts

这样一来,目录结构表达出来的是:

text 复制代码
Sidecar 测试面板状态是正式状态层的一部分。
Sidecar 测试面板 view model 是正式 viewModels 的一部分。

但这并不是我们想要的边界。

我的判断是:

text 复制代码
dev 工具可以有自己的状态。
dev 工具可以有自己的 raw client。
dev 工具不应该污染正式 store / viewModels / api 导出面。

所以这次真正的重构重点,是把 Sidecar 测试面板需要的东西都搬回它自己的目录。


四、让 SidecarTestPanel 自己拥有自己的小世界

这次我删除了正式层里的三个文件:

text 复制代码
src/renderer/src/store/useGitStore.ts
src/renderer/src/store/selectors/gitCommandSelectors.ts
src/renderer/src/viewModels/useSidecarTestPanelModel.ts

然后在测试面板目录下新增:

text 复制代码
src/renderer/src/dev/SidecarTestPanel/sidecarTestClient.ts
src/renderer/src/dev/SidecarTestPanel/sidecarCommandStore.ts
src/renderer/src/dev/SidecarTestPanel/sidecarCommandSelectors.ts
src/renderer/src/dev/SidecarTestPanel/useSidecarTestPanelModel.ts

新的结构是:

text 复制代码
SidecarTestPanel/
  index.tsx
  SidecarTestPanel.module.css
  sidecarTestClient.ts
  sidecarCommandStore.ts
  sidecarCommandSelectors.ts
  useSidecarTestPanelModel.ts

这样做以后,测试面板变成了一个完整的 dev-only 功能岛。

sidecarTestClient.ts 只做一件事:保留原始 Sidecar 调用能力。

ts 复制代码
import type { SidecarResponse } from '../../../../shared/types'

export function invokeRawSidecarCommand(
  command: string,
  payload?: Record<string, unknown>
): Promise<SidecarResponse> {
  return window.electronAPI.invokeGit(command, payload)
}

这里我没有强行复用正式的 api/gitClient.ts

原因是正式 api/gitClient.ts 已经基于 Git command map 做了强类型封装,适合正式业务使用:

ts 复制代码
invokeGit<K extends GitCommandName>(
  command: K,
  ...args: GitCommandArgs<K>
): Promise<GitCommandResult<K>>

但测试面板的价值恰恰在于它可以输入任意 command 和 payload,用来验证 Sidecar 通信链路。它不应该被正式 command map 限制。

所以这里的边界不是"所有东西都必须走同一个 API",而是:

text 复制代码
正式业务走 typed gitClient。
开发测试面板走 dev raw client。
raw client 不能从 dev 目录泄漏到正式业务。

sidecarCommandStore.ts 则只负责测试面板自己的状态:

ts 复制代码
export interface SidecarCommandStoreState {
  loading: boolean
  history: SidecarCommandRecord[]
  error: string | null
  executeCommand: (command: string, payload?: Record<string, unknown>) => Promise<void>
  clearHistory: () => void
}

这个 store 不再从正式 store/index.ts 导出,也不再放在正式 store/ 目录里。它的所有权很清楚:只属于 SidecarTestPanel

然后测试面板自己的 view model 也搬到同目录:

ts 复制代码
export function useSidecarTestPanelModel(): SidecarTestPanelModel {
  const loading = useSidecarCommandStore(selectSidecarCommandLoading)
  const history = useSidecarCommandStore(selectSidecarCommandHistory)
  const error = useSidecarCommandStore(selectSidecarCommandError)
  const executeCommand = useSidecarCommandStore(selectExecuteSidecarCommand)
  const clearHistory = useSidecarCommandStore(selectClearSidecarCommandHistory)

  return {
    loading,
    history,
    error,
    executeCommand,
    clearHistory
  }
}

最后 SidecarTestPanel/index.tsx 的 import 也从:

tsx 复制代码
import { useSidecarTestPanelModel } from '../../viewModels'

改成:

tsx 复制代码
import { useSidecarTestPanelModel } from './useSidecarTestPanelModel'

这个改动表达的意思很明确:测试面板不再消费正式 view model 层,它使用自己的本地 view model。


五、清理正式导出面

文件移动只是第一步,真正重要的是把导出面也清掉。

这次我修改了:

text 复制代码
src/renderer/src/store/index.ts
src/renderer/src/store/selectors/index.ts
src/renderer/src/viewModels/index.ts

删除这些导出:

ts 复制代码
export { useGitStore } from './useGitStore'
export type { CommandRecord } from './useGitStore'
export * from './gitCommandSelectors'
export * from './useSidecarTestPanelModel'

这样做以后,正式业务代码不能再通过统一入口拿到测试面板的 store、selector 或 view model。

这个细节很重要。

有时候文件已经移动了,但 barrel export 没有清理,结果其他模块还是可以很方便地 import 到旧概念。这样边界看起来变了,实际依赖面没有变。

所以这次我更关注"对外暴露了什么"。一个模块是否属于正式架构,不只看它放在哪里,也要看它是否从正式入口导出。

清理后的正式状态层只导出正式 store:

text 复制代码
repositoryStore
gitStatusStore
diffStore
historyStore
uiStore
operationStore

正式 selector 层也只导出正式业务 selector。

测试面板相关状态完全留在:

text 复制代码
src/renderer/src/dev/SidecarTestPanel/

六、用脚本把边界固定下来

只靠约定是不够的,因为项目继续迭代时,很容易因为一时方便把边界又打穿。

比如后面某个人可能会重新创建:

text 复制代码
src/renderer/src/MainApp.tsx

然后写:

tsx 复制代码
export { default } from './app/MainApp'

看起来没什么问题,但它会重新打开旧入口路径。

又比如正式业务里某个 service 想快速测试一个 Git 命令,直接写:

ts 复制代码
window.electronAPI.invokeGit('staging.status')

这也能跑,但它绕过了 api/gitClient.ts 和 Git command map,正式业务层又重新知道了 Sidecar 原始通信细节。

所以这次我补强了:

text 复制代码
scripts/check-renderer-boundaries.mjs

新增了三类检查。

第一,禁止恢复根目录 MainApp.tsx

text 复制代码
src/renderer/src/MainApp.tsx

如果这个文件再次出现,边界检查会失败。

第二,禁止正式代码随意 import dev/

text 复制代码
only App.tsx may import dev-only renderer modules

这里保留了一个例外:App.tsx 可以 import dev/SidecarTestPanel,因为它是唯一的运行模式分流入口。除此之外,正式 app/layout/views/components/store/services/api/ 都不应该依赖 dev 模块。

第三,限制 raw invokeGit 的出现位置:

text 复制代码
raw invokeGit is only allowed in api/gitClient.ts
and dev/SidecarTestPanel/sidecarTestClient.ts

也就是说,正式业务只能通过:

text 复制代码
src/renderer/src/api/gitClient.ts

调试面板只能通过:

text 复制代码
src/renderer/src/dev/SidecarTestPanel/sidecarTestClient.ts

直接调用 window.electronAPI.invokeGit。其他位置出现 raw 调用,脚本会报错。

这个脚本的意义在于:把"这次重构的判断"变成"后续每次 lint 都会检查的规则"。


七、同步文档,避免后面的人按旧记忆写代码

结构改完以后,我同步更新了几份文档。

新增:

text 复制代码
src/renderer/src/dev/README.md

这个文档说明 dev/ 是开发和调试入口目录。它可以拥有局部测试状态和 raw protocol client,但只能服务调试 UI,不得被正式业务层消费。

更新:

text 复制代码
src/renderer/src/app/README.md
src/renderer/src/store/README.md
docs/project-rules.md

其中 app/README.md 里明确写了:

text 复制代码
src/renderer/src/App.tsx 是唯一的渲染入口分流文件。
正式主界面的真实落点必须保持在 app/MainApp.tsx。
不要恢复根目录 MainApp.tsx 转发文件。

store/README.md 里明确写了:

text 复制代码
开发测试工具的状态不得放入正式 store/。
Sidecar 原始命令测试面板的 store、selector、raw client
都内聚在 dev/SidecarTestPanel/。

project-rules.md 里则把项目级规则同步调整为:

text 复制代码
正式 UI 通过正式 viewModels 消费正式 store。
dev 调试入口不得 import 正式 store。
dev 可以在自己的面板目录内维护局部测试 store、selector 和 view model。

这一步看起来像文档工作,但对长期维护很重要。因为结构边界不是一次改完就永远安全,后续每个人都要能从文档里看到当前真实结构,而不是继续按照旧的 walkthrough 记忆写代码。


八、验证结果

这次改完以后,我跑了三类检查:

bash 复制代码
npm.cmd run lint
npm.cmd run typecheck
npm.cmd run build

结果是:

text 复制代码
lint 通过
typecheck 通过
build 通过

其中 lint 会继续执行:

text 复制代码
check:renderer-boundaries
check:renderer-styles

这两个边界检查也都通过了。

当前 lint 还有 63 个 Prettier warning,位置主要在:

text 复制代码
scripts/build-sidecar.mjs
src/main/core/SidecarManager.ts
src/main/index.ts
src/main/ipc/configHandlers.ts
src/main/ipc/gitHandlers.ts

这些 warning 是已有 main / build 脚本格式问题,不是这次入口边界重构引入的新错误。因为这次任务集中在问题 5,我没有顺手格式化这些无关文件,避免把一次结构提交扩大成格式化提交。


九、这次重构后的结构

现在前端入口结构变成:

text 复制代码
src/renderer/src/App.tsx
  -> app/MainApp
  -> dev/SidecarTestPanel

src/renderer/src/app/
  -> MainApp.tsx
  -> AppProviders.tsx
  -> appTheme.ts
  -> useThemeMode.ts
  -> useAutoRefresh.ts

src/renderer/src/dev/
  -> README.md
  -> SidecarTestPanel/
       index.tsx
       SidecarTestPanel.module.css
       sidecarTestClient.ts
       sidecarCommandStore.ts
       sidecarCommandSelectors.ts
       useSidecarTestPanelModel.ts

正式 store 目录里不再有:

text 复制代码
useGitStore.ts
gitCommandSelectors.ts

正式 viewModels 目录里不再有:

text 复制代码
useSidecarTestPanelModel.ts

根目录也不再有:

text 复制代码
src/renderer/src/MainApp.tsx

这样一来,结构表达出的含义就很清楚:

text 复制代码
正式入口属于 app。
开发测试入口属于 dev。
正式状态属于 store。
调试状态属于调试面板自己。
正式 Git 调用走 typed client。
原始 Sidecar 调用只存在于 dev raw client。

十、这次重构的收获

这次问题 5 看起来比前几轮重构小很多。它不像拆 MainApp.tsx 那样移动大量组件,也不像拆 useAppStore.ts 那样重排很多业务流程。

但它处理的是一个很容易被忽略的问题:临时开发工具和正式业务代码之间的边界。

在一个项目早期,测试面板、调试 store、raw IPC 调用往往都是必要的。没有这些东西,很多底层通信和 Sidecar 行为很难快速验证。问题不在于它们存在,而在于它们不能一直待在正式架构的核心层里。

这次重构以后,Sidecar 测试面板仍然保留,而且能力没有被削弱。它仍然可以输入任意 command 和 payload,仍然可以直接验证原始通信链路。但它的位置变了:它不再伪装成正式 store 的一部分,也不再从正式 viewModels 导出。

我觉得这是项目结构逐渐成熟时很重要的一步:

text 复制代码
不是删除开发工具,
而是给开发工具一个清楚的位置。

正式代码和调试工具都可以存在,但它们应该通过目录、导出、类型和检查脚本表达出不同身份。这样后续继续扩展功能时,项目才不会因为早期留下的小方便,慢慢重新长成一个边界模糊的大文件集合。

相关推荐
量子炒饭大师5 小时前
【优化算法】滑动窗口的「义体化」重构 ——【滑动窗口】何为滑动窗口?滑动窗口算法的核心目的是什么?
c++·算法·重构·优化算法·双指针·滑动窗口
广州灵眸科技有限公司5 小时前
瑞芯微(EASY EAI)RV1126B 千兆以太网电路
服务器·前端·人工智能·python·深度学习
梦想的旅途25 小时前
基于 RPA 自动化技术的外部群主动消息推送实现指南
前端·自动化·rpa
jiayong235 小时前
前端面试题库 - React框架篇
前端·javascript·react.js
ttwuai5 小时前
XYGo Admin 国际化实战:Vue3 中后台多语言方案详解
前端·javascript·vue.js·vue
IT_陈寒5 小时前
React状态更新后视图不刷新?我差点以为是灵异事件
前端·人工智能·后端
Csvn5 小时前
JS 技巧:设计模式(下)- 策略、装饰器、代理
前端
一颗小青松5 小时前
uniapp 集成友盟并且上传页面路径
前端·vue.js·uni-app
周淳APP5 小时前
微前端核心沙箱机制深度解析:从iframe到乾坤沙箱
前端·学习·iframe·微前端·qiankun·前端架构