news 2026/9/15 19:49:47

RomM 前端 v2 组件体系指南:三层组件模型、约定与工程实践

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
RomM 前端 v2 组件体系指南:三层组件模型、约定与工程实践

RomM 前端 v2 组件体系指南:三层组件模型、约定与工程实践

【免费下载链接】rommA beautiful, powerful, self-hosted ROM manager and player.项目地址: https://gitcode.com/GitHub_Trending/rom/romm

导读

RomM 是一个自托管的 ROM 管理器与游戏播放器,其前端正在经历一次从 v1 到 v2 的整体性架构迁移。本文聚焦于指导 v2 组件开发的核心规范文档(.claude/skills/frontend-v2-components/SKILL.md),系统讲解 v2 的三层组件模型(Primitive / Shared Composite / Feature Composite)、文件目录约定、SFC 结构、导入顺序、Storybook 要求以及必须规避的反模式,并辅以 frontend/src/v2/ 目录下的真实源码与测试作为佐证。读完本文,你将掌握在 RomM v2 前端中新增、修改组件的完整决策流程与编码标准,能够区分“什么组件该放哪一层”“什么时候该造一个新的 R* 原语”“如何写出符合规范的 SFC 与 Story”。

说明:本文依据当前仓库中.claude/skills/frontend-v2-components/SKILL.md(下称“组件宪法”)撰写,所有路径均以仓库根目录为基准;更宏观的前端架构参见 docs/FRONTEND_ARCHITECTURE.md。

v2 是什么:冻结的 v1 与受开关控制的 v2

组件宪法开篇即明确:v1 已冻结(frozen)。src/views/src/components/src/console/src/layouts/这些 v1 目录永远不要重构,它们将在最终一波迁移中被整体删除。所有新的前端工作都发生在frontend/src/v2/下,并且 v2 界面由后端用户的user.ui_settings.uiVersion字段控制启停——也就是说 v1 与 v2 会在共存期内并行存在,直到 v1 被移除。

v1 与 v2 共存时若不得不创建某个 store / composable / util 的 v2 分支,规范要求:给 v1 的导出打上@deprecated注解,并在注解中指向 v2 的替代品。这一点可以从 v2 目录结构中得到印证:frontend/src/v2/composables/下存在useGalleryFilterUrluseSocketEvent等与 v1 同名功能对应的新实现,而规范在“Known debt”一节中明确要求将 v1 store 的旧用法标记为@deprecated

另外一条贯穿全文的元规则:所有代码、注释、标识符、.md文件以及 commit/PR 消息的官方语言均为英语(Official language for all code, comments, identifiers,.md, and commit/PR messages: English)。

三层组件模型:先想清楚“放哪一层”

v2 组件被严格划分为三个层级,每个层级有独立的路径、前缀、依赖权限与 Story 要求:

层级路径前缀stores/services/router/emitter/i18nStory领域知识
Primitive(原语)frontend/src/v2/lib/R*(强制)禁止强制
Shared composite(共享复合)frontend/src/v2/components/shared/无前缀允许可选跨功能,无特定领域
Feature composite(功能复合)frontend/src/v2/components/<feature>/无前缀允许可选特定功能

从目录结构看,三个层级在实际仓库中都有充分落地:

  • Primitivefrontend/src/v2/lib/下按 Storybook 分类组织为primitives/RBtnRCardRChipRAlertRIconRProgressCircularRTag等 19 个)、forms/RFormRTextFieldRSelectRCheckboxRSwitchRDateFieldRComboboxFieldRDropzoneRRatingRSlider)、structural/RToolbarRTooltipRListRListItemRCollapsibleRExpandTransitionRVirtualScroller)、menus/RMenuRMenuItem)、overlays/RDialogRDrawerRCarousel)、data/RTable)与media/RBox3DRPlatformIcon)。
  • Shared compositefrontend/src/v2/components/shared/下的ConfirmDialog.vueGameCover.vuePageHeader.vuePasswordField.vueCoverArtPip.vueAssetStrip.vue等 30 个文件,均为跨功能复用、但可能用到 store 或 i18n 的组件。
  • Feature compositefrontend/src/v2/components/下按功能划分的目录,如Gallery/GameCard/GameDetails/Player/Settings/Dialogs/Collections/Platforms/等,组件依赖对应功能领域的状态与逻辑。

判定一个组件是否“原语”的三条标准

组件宪法给出了严格的三条判定标准,必须全部满足才算 Primitive:

  1. 不依赖stores、services、router 或 emitter;
  2. 不持有任何产品领域知识(ROM、Platform、Collection、User…)——文档给出的例子是:RAvatar是原语,UserAvatar不是;
  3. API 可以用不涉及功能名的方式描述——即 props/slots/events 都是通用的。

只要任一条不满足,就降级处理:若在多个功能间通用则归入shared composite,若只属于某个功能则归入feature composite。边缘情况(无法清晰归类)必须上报给用户决定,而不是自行拍板。另外,消费者数量(consumer count)永远不会把一个原语“降级”成复合组件——原语的判定只看上述三条,不看被谁使用。

Primitive 的边界(能做什么 / 不能做什么)

  • 可以使用:设计 token、其他原语、Vue/Vuetify 本身、通用 composables(useInput*useFocus*)。
  • 不能使用:Pinia stores、API services、emitterrouter(但可以把RouterLink作为 prop 接收)、直接使用i18n原语内部禁止出现$t()——所有文本必须通过 props 或 slots 传入。

这个约束在RBtn的实现中得到严格执行:frontend/src/v2/lib/primitives/RBtn/RBtn.vue中完全没有 store、service、emitter 或$t()的身影,其颜色、尺寸、图标、tooltip 全部以 props 形式暴露。

文件与目录约定:一原语一目录,复合组件扁平化

Primitive 的目录结构

每个原语独占一个目录,固定包含:

RFoo/ ├── RFoo.vue # 组件实现 ├── RFoo.stories.ts # Storybook story(强制) ├── index.ts # 出口(export default) └── types.ts # 可选:共享类型

以实际仓库为例,frontend/src/v2/lib/primitives/RBtn/下就有RBtn.vueRBtn.stories.tsindex.ts三个文件;RTable则额外带types.ts。目录名与组件名保持一致,前缀统一为R

Composite 的目录结构

  • 如果单个.vue文件就够用,就直接放扁平文件(如frontend/src/v2/components/shared/ConfirmDialog.vue);
  • 如果组件内部有子部件,则使用与 Primitive 相同的内部结构(文件夹 + 同名单文件),但不强制要求 story

Barrel(桶文件)规则

frontend/src/v2/lib/index.ts是唯一的原语 barrel,它 re-export 了lib/下全部原语(primitives/RAlertRBtnRChipforms/RTextFieldstructural/RVirtualScrolleroverlays/RDialogdata/RTablemedia/RPlatformIcon)。规则如下:

  • 每新增一个原语,必须同步更新 barrel
  • 复合组件不经过 barrel,按路径直接导入
  • 禁止为了缩短路径而写一个“只做 re-export”的单文件index.ts(no single-fileindex.tsthat just re-exports to shorten a path)。

该文件头部的注释也印证了设计意图:只有“被两个及以上功能依赖、且必须带 Storybook story”的通用组件才允许进入 barrel;GameCard这类依赖 stores 与useGameActions的功能复合组件,明确归属components/GameCard/,不进 barrel。

SFC 结构:一套固定的书写顺序与类型约束

脚本、模板、样式三段式

  • <script setup lang="ts">始终使用
  • 所有wrapper(包装组件)必须使用defineOptions({ inheritAttrs: false }),并与v-bind="$attrs"slot 透传配套使用——不绑定$attrs的话,透传的 attrs 会静默丢失;
  • Props 一律用defineProps<Props>()接口声明(interface),禁止运行时声明(never runtime declarations);Emits 用defineEmits<{...}>()类型化声明;带负载的 Slots 用defineSlots<{}>()声明;
  • 书写顺序固定为<script setup><template><style scoped>;非 scoped 的<style>(仅用于 teleport 覆盖场景)放在 scoped 块之后。

RBtn.vue为例:第 54 行defineOptions({ inheritAttrs: false })、第 56–103 行interface Props、第 105–130 行withDefaults(defineProps<Props>(), {...}),第 306–409 行<template>,第 411 行起<style scoped>,完全符合上述三段式规范。

Wrapper 契约

包装 Vuetify 组件的 wrapper 必须接受被包装组件的全部 props 与 slots。也就是说,如果你包一个v-dialog做成RDialog,那么v-dialog的所有 props/slots 都要能透传过去,用户不应感觉到“包了一层”带来的能力损失。RBtn的多态根节点(<component :is>)就是这个契约的变体:设置了to渲染成RouterLink、设置了href渲染成<a>、否则渲染成<button>,调用方无需区分三种用法。

导入顺序与路径别名:别名优先,杜绝相对路径

组件宪法给出了一份必须遵守的导入顺序模板(注意:模板注释中的编号 1–5 实际是文档示例的书写顺序,按以下分组执行):

// 1. External(外部依赖) // 2. v2 primitives(v2 原语,经 barrel) import { RBtn, RDialog } from "@v2/lib"; import { computed, ref } from "vue"; import type { SimpleRom } from "@/__generated__"; // 5. Canonical shared resources(权威共享资源:stores/services/locales/utils) import storeAuth from "@/stores/auth"; // 4. v2 feature siblings(v2 功能同级组件,按路径导入) import GameCard from "@/v2/components/GameCard.vue"; // 3. v2 composables / shared(v2 通用 composables 与共享组件) import { useCan } from "@/v2/composables/useCan";

别名约定:

  • @v2/lib—— 原语 barrel;
  • @/v2/...—— v2 下的其他一切;
  • @/...—— 权威共享资源(canonical shared resources)。

在别名可用时,永远不要使用相对路径../../foo)。这与 v1 时代@/* → ./src/*的别名体系一致(见 docs/FRONTEND_ARCHITECTURE.md 第 18 节),v2 在其之上新增了@v2快捷别名。

类型来源

  • v2 内部共享类型放在frontend/src/v2/types/(实际存在scan.ts);
  • 后端类型一律来自frontend/src/__generated__/(OpenAPI 代码生成产物),不使用src/types/(那是 v1 遗留目录)。

frontend/src/v2/utils/可以看到,大量纯逻辑模块(covers.tsromVerification.tssmartCollectionCriteria.tsrouteQuery.tstime.ts等)都配套了.test.ts单测文件,这正是“共享资源权威化”的一种落地形态。

Composables 与日志规范

Composables 约定

  • 命名以use前缀开头(如useCanuseGalleryFilterUrluseSocketEvent);
  • 每个 composable 从composables/useFoo/index.ts导出唯一一个具名导出
  • 参数与返回值必须完整类型化
  • 模块加载时不得产生副作用——所有初始化推迟到首次调用时进行(init on first call);
  • 允许在 v1 已有等价物的情况下创建 v2 专属 composable(Creating a v2-only composable when a v1 equivalent exists is allowed)。

实际仓库中frontend/src/v2/composables/下有 60+ 个 composable,涵盖输入(useInputModalityuseGamepaduseGridNavuseWrapGridNav)、画廊(useGalleryModeuseGallerySelectionInputuseGalleryVirtualItems)、播放器(usePlaySessionusePlayerHerousePlayerNav)、扫码/同步(useScanLifecycleuseRomSync)等,其中useSocketEventuseGalleryFilterUrl正是文档“Known debt”中计划进一步强化的对象。

Console 日志规范

  • console.error允许用于生产环境可见的错误;
  • console.log/console.warn不得随代码发布
  • console.debug:仅限开发环境,PR 提交前必须移除

Storybook:/lib原语的硬性要求

对于frontend/src/v2/lib/下的每个原语,Storybook 是强制项(mandatory):

  1. 每个原语至少一个 story,带 controls 控件
  2. 每个主题(theme)至少一个变体(at least one variant per theme);
  3. 新的交互式原语若需要手柄(gamepad)导航,必须附带play()交互测试;
  4. 被修改的原语:既有 story 必须仍能渲染、其交互测试仍能通过。

RBtn.stories.ts是一个绝佳的范本:它定义了DefaultPrimaryVariants(六种 variant 同屏)、SizeLadderDensityTonesWithIconsIconOnlyRoundedLoadingStateBlockDisabledBorderModifierAsLinkToolbarFormActionsSurfaceWithSlider等 17 个 story,并通过argTypes暴露了 variant/color/size/density/rounded/type/icon/loading/disabled/block/border/surface/prependIcon/appendIcon 全套 controls;LoadingState还内置了可点击的交互逻辑。仓库中RMenu.stories.tsVisualLanguage.stories.ts同样位于lib/根目录下。

测试分层:Vitest 与 Storybook play() 的分工

  • npm run test会同时运行Vitest每个/libstory 的play()(通过composeStories驱动);
  • 不要重复覆盖:纯逻辑交给 Vitest(如frontend/src/v2/utils/*.test.ts),组件交互交给 Storybookplay()

这与frontend/package.json中的脚本一致:"test": "vitest run""storybook": "storybook dev -p 6006"。原语目录中同时出现.stories.ts.test.ts的形态(如RDateFieldRSelect)正是这种分层覆盖的体现。

反模式清单:规则之外的红线

在 Premises 已规定的基础上,组件宪法额外列出 6 条反模式,其中第一条还带了一个真实教训:

  1. 不要改动共享 store 的 API 来迁就 v2 调用方的问题(应当修复调用方)——文档以 Gallery 为例:当时的教训是应该从 view 调用romsStore.reset(),而不是给 store 添加_fetchSeq字段;
  2. 不要内联做角色(role)检查——一律通过useCan(详见frontend-v2-patternsskill);
  3. 不要重新发明一个“表面”——dialog / menu / popover / card 都必须走各自的原语;特殊情况通过新增 prop解决,而不是另起炉灶做一个平行组件;
  4. 不要直接使用v-form——统一使用RForm
  5. 不要在 v2 内部加向后兼容 shim:删掉废弃代码;不留// removed注释;不留改名但未使用的导出;不留“只是调用新函数的 deprecated wrapper”;
  6. 不要写冗余测试、不要碰 v1、commit 时永远不要--no-verify

常常被误读的“允许”清单

  • 对共享 stores/services/utils 进行增量(additive)修改是允许的;
  • 创建 v2 专属 composable 是允许的;
  • frontend/src/__generated__/导入类型是允许的(它是权威后端类型来源,不属于“fork 共享资源”)。

已知技术债:后续聚焦方向

文档在结尾列出了 v2 后续的聚焦改进清单,这些既是“已知债”也是未来的开发热点,值得贡献者关注:

  • 虚拟化迁移RVirtualScroller(位于frontend/src/v2/lib/structural/)需要吸收GameGrid/LetterGroupedGrid,涉及Platform.vueSearch.vueCollection.vue的结构性重构;useLetterGroups需要改为基于索引(index-based)以支持 AlphaStrip 的滚动吸附(scroll-spy);
  • useGalleryFilterUrl:把galleryFilterstore 字段同步到 URL 查询参数,实现可收藏(bookmarkable)的链接;同时将 v1 store 的旧用法标记为@deprecated
  • Vue Router 滚动恢复:画廊滚动的是自定义容器(.r-v2-plat__scroll)而非 window,计划新增一个 Pinia 的routeFullPath → offsetTop映射并配 per-view hooks,与虚拟化迁移捆绑进行;
  • useSocketEventcomposable:提供类型化的 socket 订阅,并在 mount/unmount 时自动清理(目前消费方还在手动socket.on/off);
  • v1 下线时的收尾清单:将uiVersion移入UI_SETTINGS_KEYS;删除.r-v2-*作用域 class(token 上移到:root);简化useUISettings的同步;删除useGameAnimation;移除NotificationHost中的颜色字符串→tone 折叠逻辑;删除stores/users.ts里的 Vuetify rule 数组。

快速上手:在 v2 中新增一个组件的决策路径

综合上述全部规范,把“新增/修改 v2 组件”的决策流程浓缩如下:

  1. 先判断层级:不依赖 store/service/router/emitter/i18n、无领域知识、API 通用 →frontend/src/v2/lib/<name>/建 Primitive(强制前缀R,强制 story);否则按“跨功能 / 单功能”分别进components/shared/components/<feature>/
  2. 按约定建文件:Primitive 一目录一组件(.vue+.stories.ts+index.ts,可选的types.ts);更新frontend/src/v2/lib/index.tsbarrel;
  3. 写 SFC<script setup lang="ts">开头;wrapper 加defineOptions({ inheritAttrs: false })+v-bind="$attrs"+ slot 透传;Props/Emits/Slots 全部类型化声明;顺序 script → template → scoped style;
  4. 检查依赖边界:Primitive 内不出现$t()、store、service、emitter、router(RouterLink只能作为 prop);布局使用 Vuetify 工具类(d-flexpa-4align-center)而非裸 CSS;Vuetify 布局组件(v-rowv-colv-containerv-spacerv-appv-main)首次使用时懒包装成R*
  5. 写 Story:至少一个带 controls 的 story、每主题至少一个变体;交互式原语补play();跑npm run test确认 Vitest 与全部/libstory 的play()都通过;
  6. 验收红线:不碰 v1、不改共享 store API、不绕过useCan、不重新发明 dialog/menu 等表面、不用v-form、不留兼容 shim、不写冗余测试、commit 不跳过 verify。

遵循这套“组件宪法”,你产出的组件会自然融入 RomM v2 的设计语言,与既有原语(如RBtnRForm)保持一致的 API 风格、交互反馈与无障碍水准,也能在 v1 整体下线时平滑完成迁移。

延伸阅读

  • 组件宪法全文:.claude/skills/frontend-v2-components/SKILL.md
  • 前端整体架构:docs/FRONTEND_ARCHITECTURE.md
  • 原语 barrel:frontend/src/v2/lib/index.ts
  • 原语实现范例:frontend/src/v2/lib/primitives/RBtn/RBtn.vue 与 RBtn.stories.ts
  • 表单注册上下文范例:frontend/src/v2/lib/forms/RForm/RForm.vue
  • 共享复合组件:frontend/src/v2/components/shared/
  • 功能复合组件:frontend/src/v2/components/
  • v2 通用 composables:frontend/src/v2/composables/

【免费下载链接】rommA beautiful, powerful, self-hosted ROM manager and player.项目地址: https://gitcode.com/GitHub_Trending/rom/romm

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

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/15 19:49:42

微信小程序五子棋开发:从棋盘渲染到对局状态管理

简介&#xff1a;微信小程序双人五子棋项目实例&#xff0c;适合具备基础前端知识、想进阶小程序游戏开发的初学者与移动端爱好者。资源为完整可运行工程&#xff0c;解压后导入微信开发者工具即可直接体验双人对局。压缩包共10个文件&#xff0c;包含4个json配置文件&#xff…

作者头像 李华
网站建设 2026/9/15 19:47:46

湖南关键词优化排名推广避坑指南:新手建站不踩雷

湖南关键词优化排名推广避坑指南:新手建站不踩雷 不会代码想做网站?别急着找外包。很多湖南的中小企业老板或创业者,卡在第一步:想做个官网或商城,但不懂技术,怕被坑。这篇避坑指南,不讲虚的,只讲湖南本地做关键词优化排名推广时,新手最容易交智商税的地方。 一、 明确目标:别被“全站收录”忽悠…

作者头像 李华
网站建设 2026/9/15 19:45:31

用分数阶傅里叶变换(FRFT)实现chirp信号检测与参数估计

在雷达目标检测、水声通信、甚至是生物医学信号分析里&#xff0c;我经常碰到一类“频率随时间线性变化”的信号。这类信号叫chirp&#xff0c;也叫线性调频信号。直观说&#xff0c;它的瞬时频率是一条直线&#xff0c;要么往上扫、要么往下扫。问题在于&#xff0c;常规FFT一…

作者头像 李华