首页
/ Supabase Studio 组件编写规范:目录职责、文件组织模式与代码标准

Supabase Studio 组件编写规范:目录职责、文件组织模式与代码标准

2026-09-06 13:49:14作者:伍霜盼Ellen

Supabase Studio(apps/studio)是一个代码量庞大的管理控制台前端,其 components/ 目录下聚集了数千个 React 组件。apps/studio/components/README.md 是面向贡献者的组件编写指南,规定了组件应该放在哪个目录、复杂组件如何按文件拆分,以及新组件的基本代码模板。阅读本文后,你将掌握 Studio 组件的目录职责划分、"单文件 vs 文件夹 + 入口"两种组织模式,并结合真实源码示例理解命名、类型与测试约定,从而在 Studio 中写出符合团队规范的组件。

一、组件目录职责:先决定组件放在哪里

README 开篇给出了三条目录放置规则(见 components/README.md):

组件类型 放置位置 说明
声明页面整体结构和布局的组件 components/layouts/xxx 页面级框架
与某个具体界面(功能页)强耦合的组件 components/interfaces/xxx 跟随所属功能模块
打算跨多个页面复用的组件 components/ui/xxx 通用可复用

从仓库实际目录结构可以印证这三类划分是真实生效的:

  • components/layouts/ 下按功能命名了 AppLayoutDatabaseLayoutAuthLayoutBillingLayoutBranchLayout 等页面框架组件;
  • components/interfaces/ 下有 SQLEditorRealtimeStorageEdgeFunctionsSignInProjectCreation 等与具体控制台界面一一对应的功能模块;
  • components/ui/ 下则是 DataTableCodeEditorDatePickerErrorBoundaryInfiniteList 等可跨页面复用的组件。

README 还提到一条历史背景:团队正在把 components/to-be-cleaned/ 中的文件逐步迁移到上述对应目录。查看 components/to-be-cleaned/ 可以看到该目录目前只剩 Table.tsxListIcons.tsxProductEmptyState.tsx 三个文件——这既说明"渐进式清理"的约定长期在执行,也给出一个实操提示:新代码不应再放进该目录。

另外,components/ 下还存在 grid/(表格网格相关)与 ui-patterns/ 目录。关于后者的定位,apps/studio/AGENTS.md 给出了明确的导入分层约定:'ui' 是原语(primitives),'ui-patterns' 是组合好的模式(如 ConfirmationModal),@ui/* 别名则指向 monorepo 中的 packages/ui/src。也就是说,"跨页面复用"的组件在落地时还有一层粒度区分:基础原语进 ui,复合交互模式进 ui-patterns

二、组件文件组织:单文件与"文件夹 + 入口"两种模式

README 的第二部分定义了组件内部的文件组织原则(components/README.md):

  • 如果组件有与自身强耦合的常量和工具方法,就把它们放在组件旁边,并统一收进一个以 index.tsx 作为入口的文件夹;
  • 否则组件就是一个独立文件;
  • 原文给出的目标结构示例:
components/ui
- SampleComponentA
  - SampleComponentA.tsx
  - SampleComponentA.constants.ts
  - SampleComponentA.utils.ts
  - SampleComponentA.types.ts
  - index.ts
- SampleComponentB.tsx

这一模式在 Studio 源码中有大量真实落地。下面选取两个代表性例子对照说明。

例 1:文件夹 + 入口模式的 HelpPanel

components/ui/HelpPanel/ 目录包含 HelpPanel.tsx(组件本体)、HelpButton.tsxHelpSection.tsx(子组件)、HelpOptionsList.tsx,以及按约定拆出的配套文件:

  • HelpPanel.constants.ts:导出 HelpOptionId 类型与 HELP_OPTION_IDS 常量数组,并用 as const satisfies readonly HelpOptionId[] 保证常量集合与类型定义一致——这是常量文件与 types 联动的典型写法;
  • HelpPanel.utils.ts:存放纯函数逻辑,例如 getSupportLinkQueryParams 根据 project / org / routerRef 推导支持链接的查询参数;
  • HelpPanel.utils.test.ts:与 utils 同目录的单元测试,覆盖参数优先级(parent_project_ref 优先、其次回退 routerRef、最后 orgSlug)等各分支行为。

这个例子完整演示了"常量与工具方法紧耦合于组件时,就地拆分并配套单测"的约定。

例 2:index.tsx 入口模式的 DatePicker

components/ui/DatePicker/ 目录包含 index.tsxTimeSplitInput.tsxDatePicker.types.ts。其中:

  • DatePicker.types.ts 定义了 Time{ HH, mm, ss })、TimeType 等类型,供 TimeSplitInput.tsxindex.tsx 共同引用,避免类型散落在组件内部;
  • index.tsx 作为入口承载主组件 DatePicker:声明 DatePickerPropsonChangeto/from ISO 字符串、triggerButtonVarianthideTimeminDate 等),组合 ui 包的 Popover/Calendar 原语与本地 TimeSplitInput 子组件。

再看一个规模更大的 components/ui/DataTable/DataTable.constants.tsDataTable.types.tsDataTable.utils.tsDataTableColumnDataTableFiltersprovidershooks 等子目录并存,是"文件夹模式"在复杂组件上的自然扩展。

例 3:单文件组件 CopyButton

并非每个组件都需要文件夹。components/ui/CopyButton.tsx 就是一个独立的单文件组件,它仍然遵循了 README 隐含的质量要求:

type CopyButtonWithText = CopyButtonBaseProps & {
  text: string
  asyncText?: never
}

type CopyButtonWithAsyncText = CopyButtonBaseProps & {
  text?: never
  asyncText: () => Promise<string> | string
}

export type CopyButtonProps = (CopyButtonWithText | CopyButtonWithAsyncText) &
  ComponentProps<typeof Button>

这里用 never 字段构造了互斥的联合类型(传 textasyncText 二选一),并通过 ComponentProps<typeof Button> 透传底层按钮的全部属性。同时它使用具名导出 const CopyButton = forwardRef(...),与 README 模板中"use a named export, not a default export"的要求一致。

三、组件代码模板与命名约定

README 给出了新组件的标准模板(components/README.md):

// Declare the prop types of your component
interface ComponentAProps {
  sampleProp: string
}

// Name your component accordingly — use a named export, not a default export
export const ComponentA = ({ sampleProp }: ComponentAProps) => {
  return <div>ComponentA: {sampleProp}</div>
}

模板要点有三个:props 类型显式声明、组件与 props 接口一一对应、一律使用具名导出。"不用 default export"在 Studio 中不只是风格偏好,而是有 CI 兜底的硬性约束:AGENTS.md 的"Defaults that differ here"一节说明,ESLint 告警在 CI 中按数量"棘轮"(ratchet)收紧,新增的 any、未解析的 exhaustive-deps 告警乃至 default export 都会让构建失败;本地可用 pnpm --filter studio run lint:ratchet 预检。同理,knip 会在 CI 中拦截死文件与死依赖,意味着新建的组件文件必须被真实引用,否则会被视为 dead file 而挂掉门禁。

AGENTS.md 的"Code style"部分还有与组件编写直接相关的补充约定,写新组件时值得对照执行:

  • 命名:布尔 prop 与变量读起来像 is/has/can/should,且应直接从已有状态推导,不要把可推导的值镜像进 useState;prop 回调命名为 onX,组件内部处理函数命名为 handleX;自定义 hook 返回对象而非元组;
  • 获取状态渲染:用顶层 early return 或互斥守卫的扁平 && 链,禁止嵌套三目(AGENTS.md 中给出了 loading / error / empty / success 四态的标准写法);
  • 组件体量:约 200–300 行时应拆分;重复 JSX 抽成小组件、纯逻辑抽进 .utils.ts 函数并配单元测试、有状态的可复用逻辑抽成自定义 hook——这正是 HelpPanel 这类"文件夹模式"组件的由来;
  • 类型安全:避免 as 断言,外部数据用 zod 解析;多状态值用可辨识联合建模而非多个布尔标志位;
  • 子组件就近放置:sub-component 与父组件同目录,避免 barrel 再导出文件;移动或抽取代码到新的模块时,直接更新所有 import 指向新位置,不要在原文件里留 re-export shim。

最后一条与 README 的"文件夹 + index.tsx 入口"模式需要放在一起理解:README 描述的是新组件文件夹的正门入口(如 DatePicker 的 index.tsx),而 AGENTS.md 反对的是在重构搬迁后于旧位置残留的"兼容垫片"。两者结合的含义是:入口文件可以有且应当是新的、单一的事实来源。

四、实操流程:在 Studio 中落地一个新组件

综合上述规范,在 apps/studio 中新增一个组件的完整决策路径如下:

  1. 定位目录:页面框架 → components/layouts/;某功能页私有 → components/interfaces/<功能名>/;跨页面复用 → components/ui/(原语)或 ui-patterns(组合模式)。不要放入 to-be-cleaned/
  2. 选择组织形态:只有组件本体的,写单文件 .tsx(参考 CopyButton.tsx);有耦合常量/工具/类型的,建文件夹,按 Xxx.tsx + Xxx.constants.ts + Xxx.utils.ts + Xxx.types.ts + index.tsx 组织(参考 HelpPanelDatePicker)。
  3. 按模板编写:显式 props 接口、具名导出、布尔 prop 用 is/has/can/should 前缀、回调 prop 用 onX
  4. 控制体量与状态:接近 200–300 行或出现多个不相关 useState 时拆分;纯逻辑进 .utils.ts 并写 vitest 单测(Studio 使用 vitest + MSW,组件测试使用 tests/lib/ 中的 customRender + addAPIMock)。
  5. 遵守导入分层与门禁:从 'ui'/'ui-patterns'/'common' 引入既有能力,不要重复造 hook 前先检索 hooks/lib/packages/common;提交前跑 pnpm --filter studio run lint:ratchet 与 knip 检查,确认无新增 ESLLint 棘轮告警与死文件。

五、小结

apps/studio/components/README.md 虽然篇幅不长,但它把 Studio 前端组件体系的三个核心问题——"放哪、怎么拆、怎么写"——给出了明确答案:目录按页面框架 / 功能界面 / 通用复用来划分职责;文件按"强耦合就文件夹 + 入口,否则单文件"的原则组织;代码上坚持具名导出、显式 props 类型与就近拆分。配合 AGENTS.md 中关于命名、体量、状态管理与 CI 棘轮门禁的补充约定,并参考 HelpPanelDatePickerCopyButton 这些真实组件的落地方式,即可在 Supabase Studio 中产出与既有代码库一致、可直接通过质量门禁的组件代码。

登录后查看全文
热门项目推荐
相关项目推荐