Rocket.Chat 前端组件开发指南:Fuselage 组件化规范、分层原则与最佳实践
【免费下载链接】Rocket.ChatThe Secure CommsOS™ for mission-critical operations项目地址: https://gitcode.com/GitHub_Trending/ro/Rocket.Chat
本文基于 Rocket.Chat 仓库的 docs/frontend/building-components.md 官方工程文档,系统讲解 Rocket.Chat 前端(Meteor Web App + Fuselage 设计系统)的组件开发方法论。内容覆盖Application(应用级)组件与 Fuselage 库组件的边界划分、Simple/Complex/Logical/Visual 四类组件的开发规范、Storybook-first 工作流、单元测试与 CSS 变量定制策略,并以仓库中真实的视频会议 UI 组件族(ui-video-conf)作为贯穿全文的源码佐证。读完你能够为 Rocket.Chat 及其衍生应用写出分层清晰、可复用、易维护、可直接沉淀进 Fuselage 库的 React 组件。
组件是什么:可复用的最小 UI 单元
组件(Component)是一段可复用的代码,代表单一 UI 元素。组件随复杂度与类型而不同,并存在于两个层级之一:
- Application 组件(应用级):仅对某个具体的 Rocket.Chat 应用可见,不具备跨应用复用能力。例如只服务于 apps/meteor 客户端内部某个视图逻辑的临时性组件。
- Fuselage 库组件(库级):可在 Rocket.Chat 全系应用中复用,是官方推荐的首选形态。Fuselage(
@rocket.chat/fuselage)是 Rocket.Chat 的 React 组件库与设计系统,其组件通过统一的设计令牌(design tokens)和BoxAPI 驱动。
组件规则矩阵:什么样的组件该放在哪一层
官方用一个二维矩阵定义层级归属,判断依据是“组件是否既有视觉(Visual)属性,又包含业务逻辑(Logical)属性”:
| 组合 | Fuselage 层 | Application 层 |
|---|---|---|
| Simple & Visual | ✅ | ❌ |
| Complex & Visual | ✅ | ✅ |
| Simple & Logical | ❌ | ❌ |
| Complex & Logical | ❌ | ✅ |
解读这张矩阵:
- Simple & Visual(如按钮、输入框)必须放 Fuselage,因为它们是全产品通用的原子元素;
- Complex & Visual(如 Modal、表格)Fuselage 与应用层都可承载——先以应用内复杂组件形态出现,成熟后上移到 Fuselage;
- Simple & Logical属于反模式(逻辑不该包在一个简单视觉原子里),两层都不允许;
- Complex & Logical(如携带业务状态、房间上下文的视频会议弹窗)只应存在于 Application 层,因为它与具体业务域强耦合。
Simple 组件(简单组件)
简单组件指按钮、文本输入框这类原子级 UI 元素。以下是构建 Simple 组件的硬性规范。
用“变体”表达差异,而不是用“样式”
组件 API 中应优先暴露表达变体(variation)语义的 prop,而不是让调用方直接传样式值。与其写color="blue",不如写variation="primary"——前者把样式细节泄漏给使用方,后者表达“这是主按钮”这一稳定语义,可读性与一致性都更好。
拒绝硬编码与魔法数字
不要用像素值去推挤一个本可用语义化 prop 表达的尺寸:
{/* ✅ 推荐 */} <Button small square> <Icon name='circle-arrow-down' size='x24' /> </Button>{/* ❌ 不推荐:把布局细节硬编码进使用方 */} <Button height='50px' width='50px' square> <Icon name='circle-arrow-down' size='x24' /> </Button>正确写法中,small是 Fuselage 预定义的尺寸变体,size='x24'是图标令牌尺寸。仓库中真实组件也遵循同一原则——例如 VideoConfController.tsx 内部直接把small、active、secondary等语义 prop 透传给 Fuselage 的IconButton,由库去解析成具体视觉,调用方从不接触像素。
用 CSS 变量做定制,而不是写任意值
需要默认值兜底时,优先通过 CSS 变量 / 设计令牌派生,避免散落任意数值。官方示例使用theme()读取令牌:
$modal-margin: theme('modal-margin', auto); .rcx-modal { position: static; display: flex; width: 100%; max-height: 100%; margin: $modal-margin; }CSS 变量机制让主题切换、暗色模式与品牌定制(如 Rocket.Chat 的样式层)不必改动组件源码。
在 Storybook 中记录所有变体
每一个可公开的组合变体都应在 Storybook 中展示,并对不直观的选项配文字说明。仓库中 VideoConfController.stories.tsx 与 VideoConfMessage.stories.tsx 就是范例——后者用CallingDM、CallEndedDM、CallOngoing、Loading、NoAvatars等故事覆盖了“呼叫中/已结束/进行中/加载/无头像”全部视觉状态。
为所有行为写单元测试
Simple 组件的行为(打开、关闭、点击外部收起等)必须用测试固化。官方给出菜单组件的完整测试示例:
describe('[Menu Component]', () => { it('should renders without crashing', () => { render(<Simple {...Simple.args} />); }); it('should open options when click', async () => { const { getByTestId } = render(<Simple {...Simple.args} />); const button = getByTestId('menu'); userEvent.click(button); expect(await screen.findByText('Make Admin')).toBeInTheDocument(); }); it('should have no options when click twice', async () => { const { getByTestId } = render(<Simple {...Simple.args} />); const button = getByTestId('menu'); await userEvent.click(button); await userEvent.click(button); expect(screen.queryByText('Make Admin')).toBeNull(); }); it('should have no options when click on menu and then elsewhere', async () => { const { getByTestId } = render(<Simple {...Simple.args} />); const button = getByTestId('menu'); await userEvent.click(button); await userEvent.click(document.body); expect(screen.queryByText('Make Admin')).toBeNull(); }); });仓库内组件测试采用同样套路并更进一步:@testing-library/react渲染每一个 Storybook 故事,同时用jest-axe做无障碍(a11y)审计。见 VideoConfController.spec.tsx 与 VideoConfPopup.spec.tsx,快照存放在对应__snapshots__目录。
避免“Boxed”组件:Simple 直接用 HTML 标签
Box本质上是原型期“万能通配符”,适用于 Simple 或 Complex 组件的快速搭建。但最终的 Simple 组件应直接基于 HTML 标签构造(如<button>、<input>),以获得正确的原生语义、可访问性与最小的运行时开销,而不是层层包Box。
Complex 组件(复杂组件)
复杂组件由多个简单组件组合而成,用于承载 Modal、表格等较复杂的 UI 结构。
只做视觉,不掺逻辑
Complex 组件只关心界面呈现,设计上预留好接入逻辑的接口:
export const Default = () => ( <Modal> <ModalHeader> <ModalHeaderText> <ModalTitle>Modal Header</ModalTitle> </ModalHeaderText> <ModalClose /> </ModalHeader> <ModalContent>Modal Body</ModalContent> <ModalFooter> <ModalFooterControllers> <Button>Cancel</Button> <Button primary onClick={action('click')}> Submit </Button> </ModalFooterControllers> </ModalFooter> </Modal> );为清晰而拆分
把大组件拆成可理解、边界清晰的逻辑片段。仓库中的 VideoConfMessage 目录 是教科书式范例:一个复合消息块被拆成VideoConfMessage、Row、Content、Icon、Text、Footer、FooterText、Action(s)、Button、UserStack、Skeleton十余个子模块,每个文件只承担单一职责。
Storybook-first:先界面后逻辑
组件开发应从 Storybook 故事起步,先把 UI 从逻辑中剥离出来单独打磨:
export const CallingDM: ComponentStory<typeof VideoConfMessage> = () => ( <VideoConfMessage> <VideoConfMessageRow> <VideoConfMessageIcon variant='incoming' /> <VideoConfMessageText>Calling...</VideoConfMessageText> </VideoConfMessageRow> <VideoConfMessageFooter> <VideoConfMessageAction primary>Join</VideoConfMessageAction> <VideoConfMessageFooterText>Waiting for answer</VideoConfMessageFooterText> </VideoConfMessageFooter> </VideoConfMessage> ); export const CallEndedDM: ComponentStory<typeof VideoConfMessage> = () => ( <VideoConfMessage> <VideoConfMessageRow> <VideoConfMessageIcon /> <VideoConfMessageText>Call ended</VideoConfMessageText> </VideoConfMessageRow> <VideoConfMessageFooter> <VideoConfMessageAction>Call Back</VideoConfMessageAction> <VideoConfMessageFooterText>Call was not answered</VideoConfMessageFooterText> </VideoConfMessageFooter> </VideoConfMessage> );在 VideoConfMessage.stories.tsx 中可以看到该模式的现代写法(StoryObj+ 消息装饰器),并且所有状态都先在 Storybook 中被预览与评审。
子组件必须保持作用域封闭
子组件只能出现在其“父级组合”允许的上下文中,严禁被散落到任意布局里。把<VideoConfMessageAction>直接丢进<form>、脱离VideoConfMessage结构是错误用法:
// ❌ 错误:VideoConfMessageAction 脱离了它的组合作用域 export const MyComponent: ComponentStory<typeof VideoConfMessage> = () => ( <Box display='flex'> <form> <VideoConfMessageAction>Call ended</VideoConfMessageAction> </form> </Box> );// ✅ 正确:Action 仍然嵌套在 VideoConfMessage / Footer 的既有结构中 export const MyComponent: ComponentStory<typeof VideoConfMessage> = () => ( <Box display='flex'> <form> <VideoConfMessage> <VideoConfMessageFooter> <VideoConfMessageAction>Call ended</VideoConfMessageAction> </VideoConfMessageFooter> </VideoConfMessage> </form> </Box> );HTML 元素、Box 及其 props 应被封装
访问底层 HTML 属性只能经由组件自有的封装 API,绝不能在使用方裸写 HTML 标签 / Box props 拼界面:
// ❌ 错误:在组件内部直接散落 div 与魔法样式 export const VideoConfMessage = () => ( <Box mbs='x4' maxWidth='345px' borderWidth={2} borderColor='neutral-200' borderRadius='x4'> <Box p='x16' display='flex' alignItems='center'> <Icon name='link' /> <div>My Text</div> </Box> </Box> );// ✅ 正确:通过 AllHTMLAttributes 派生受控的 props,再统一交给 Box export type VideoConfMessageProps = Omit<AllHTMLAttributes<HTMLDivElement>, 'is'>; const VideoConfMessage = (props: VideoConfMessageProps) => ( <Box mbs='x4' maxWidth='345px' borderWidth={2} borderColor='neutral-200' borderRadius='x4' {...props} /> );真实实现正是如此:VideoConfMessage.tsx 用Omit<AllHTMLAttributes<HTMLDivElement>, 'is'>收窄对外 props,内部通过 Box 的令牌式 API(marginBlockStart={4}、borderRadius='x4'、backgroundColor='surface-light'等)描述外观,行为可预测、调试路径单一。
用 Hooks 辅助逻辑
复杂组件自身的状态与交互可下沉到 Hook 中作为“辅助器”(helper)暴露。官方以useVideoConfControllers为例,管理弹窗控制器的麦克风/摄像头开关状态:
export const useVideoConfControllers = ( initialPreferences: controllersConfigProps = { mic: true, cam: false }, ): { controllersConfig: controllersConfigProps; handleToggleMic: () => void; handleToggleCam: () => void } => { const [controllersConfig, setControllersConfig] = useState(initialPreferences); const handleToggleMic = useCallback((): void => { setControllersConfig((prevState) => ({ ...prevState, mic: !prevState.mic })); }, []); const handleToggleCam = useCallback((): void => { setControllersConfig((prevState) => ({ ...prevState, cam: !prevState.cam })); }, []); return { controllersConfig, handleToggleMic, handleToggleCam, }; };const { controllersConfig } = useVideoConfControllers(); return ( <VideoConfPopup> <VideoConfPopupHeader> <VideoConfPopupTitle text={t('Calling')} counter /> <VideoConfPopupControllers> <VideoConfController active={controllersConfig.cam} title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')} icon={controllersConfig.cam ? 'video' : 'video-off'} disabled /> <VideoConfController active={controllersConfig.mic} title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')} icon={controllersConfig.mic ? 'mic' : 'mic-off'} disabled /> </VideoConfPopupControllers> </VideoConfPopupHeader> </VideoConfPopup> );该 Hook 已按此规范落地于 packages/ui-video-conf/src/hooks/useVideoConfControllers.ts,默认值正是{ mic: true, cam: false },并通过useCallback保证handleToggleMic/handleToggleCam引用稳定,便于下游useEffect依赖。
理解组件并界定范围:何时升级到 Fuselage 库
新组件通常源自产品设计团队的需求。前端工程师有责任评估该组件的“真实必要性”:新建组件投入巨大,应主动与产品经理、设计师协作;并建议优先用应用级 Complex 组件作为 MVP 验证概念与用户流程,验证成功后再推进到新的 Fuselage 级组件。
如何判断组件是否应进入 Fuselage 库?官方以VerticalBar(侧边栏容器)为例:它最初只是单个应用里的 Complex 组件,如今已演进到 Fuselage 层,因为它同时服务于 Rocket.Chat、Cloud Portal 等多个应用。这一案例说明组件会从“具体问题的解决方案”成长为“更广场景的通用工具”——复用性跨出单一应用边界,正是升级到 Fuselage 的判别信号。
Logical 组件(逻辑组件)
逻辑组件承载应用业务状态与行为,通常与视觉组件解耦后编排而成。
用子组件组合出统一的逻辑复杂组件
利用子组件编排成一个语义完整、逻辑统一的复杂组件。例如视频会议场景中的“去电弹窗”(Outgoing Popup):
const OutgoingPopup = ({ room, onClose, id }: OutgoingPopupProps): ReactElement => { const t = useTranslation(); const videoConfPreferences = useVideoConfPreferences(); const { controllersConfig } = useVideoConfControllers(); return ( <VideoConfPopup> <VideoConfPopupHeader> <VideoConfPopupTitle text={t('Calling')} counter /> <VideoConfPopupControllers> <VideoConfController active={controllersConfig.cam} title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')} icon={controllersConfig.cam ? 'video' : 'video-off'} disabled /> <VideoConfController active={controllersConfig.mic} title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')} icon={controllersConfig.mic ? 'mic' : 'mic-off'} disabled /> </VideoConfPopupControllers> </VideoConfPopupHeader> </VideoConfPopup> ); };这段示例与仓库中 Meteor 客户端的 OutgoingPopup.tsx 几乎一一对应——useVideoConfControllers(videoConfPreferences)用真实用户偏好初始化,useVideoConfCapabilities()动态决定是否渲染摄像头/麦克风控制器,文案经useTranslation()国际化。
通过变体提供自定义能力
让使用方通过预定义的变体/选项而非自由样式来定制组件外观与行为。控制器按钮就是一个典型变体驱动的组件:
<VideoConfController active={controllersConfig.mic} title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')} icon={controllersConfig.mic ? 'mic' : 'mic-off'} disabled />实现侧把变体归一后透传给底层IconButton(secondary={secondary || active || disabled}的合取即是一种变体推导逻辑):
const VideoConfController = ({ icon, active, secondary, disabled, small = true, ...props }: VideoConfControllerProps): ReactElement => { const id = useUniqueId(); return ( <IconButton small={small} icon={icon} id={id} info={active} disabled={disabled} secondary={secondary || active || disabled} {...props} /> ); };可对照源码 VideoConfController.tsx:active同时驱动info(高亮提示)与secondary视觉,调用方只需声明“是否激活/禁用”,无需关心具体配色。
避免直接样式(inline styles)
不要对组件直接施加样式,保持样式与逻辑的分离。下面的写法把width='50px'/height='50px'这类布局塞进使用层,是反面教材:
// ❌ 避免:在使用方直接对组件施加尺寸样式 <VideoConfPopup> <VideoConfPopupHeader> <VideoConfPopupTitle text={t('Calling')} counter /> <VideoConfPopupControllers> <Box display='flex' alignItems='center'> <VideoConfController width='50px' height='50px' active={controllersConfig.cam} title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')} icon={controllersConfig.cam ? 'video' : 'video-off'} disabled /> <VideoConfController active={controllersConfig.mic} title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')} icon={controllersConfig.mic ? 'mic' : 'mic-off'} disabled /> </Box> </VideoConfPopupControllers> </VideoConfPopupHeader> </VideoConfPopup>不要在 JS 文件中写 CSS 样式
将逻辑与样式彻底分离,避免 CSS-in-JS 风格的散落内联样式。自定义样式应写入外部 CSS 文件:
/* styles.css */ .customClass { border: 1px solid black; padding: 1.5rem; }再在组件中导入并应用类名:
import './styles.css'; return ( <VideoConfPopup> <VideoConfPopupHeader> <VideoConfPopupTitle text={t('Calling')} counter /> <VideoConfPopupControllers> <VideoConfController className='customClass' active={controllersConfig.cam} title={controllersConfig.cam ? t('Cam_on') : t('Cam_off')} icon={controllersConfig.cam ? 'video' : 'video-off'} disabled /> <VideoConfController active={controllersConfig.mic} title={controllersConfig.mic ? t('Mic_on') : t('Mic_off')} icon={controllersConfig.mic ? 'mic' : 'mic-off'} disabled /> </VideoConfPopupControllers> </VideoConfPopupHeader> </VideoConfPopup> );值得说明的是,组件内的尺寸、间距等布局问题应优先通过 Fuselage 变体与设计令牌解决,外部 CSS 主要用于调用方特有的少量覆写场景。
用组件状态驱动分支渲染
利用“接收中 / 呼叫中 / 空闲”等状态,条件渲染不同的逻辑复杂组件,使结构清晰可维护:
if (isReceiving) { return <IncomingPopup room={room} id={id} position={position} onClose={onClose} onMute={handleMute} onConfirm={handleConfirm} />; } if (isCalling) { return <OutgoingPopup room={room} id={id} onClose={onClose} />; } return <StartCallPopup loading={starting} room={room} id={id} onClose={dismissOutgoing} onConfirm={handleStartCall} />;每个状态渲染对应的 Complex 组件。仓库中有两处印证:上层调度组件 VideoConfPopups.tsx 依据incomingCalls与isRinging/isCalling决定是否挂载弹窗 Portal 并循环渲染每个来电;而 TimedVideoConfPopup.tsx 内部同样用if分支在IncomingPopup与StartCallPopup之间切换,正是“state-driven composition”的应用层范本。
Visual 组件(视觉组件):组件化的收束
视觉组件只负责一个 UI 元素的“外观”——定义其样式、布局及其他视觉属性。遵循上文 Fuselage 组件化规范的价值在于获得模块化、可复用、可维护的 UI 组件体系:
- 通过封装逻辑(Hook 辅助 + 子组件组合)让界面保持纯净;
- 通过避免直接样式与禁止 JS 内联 CSS,保持关注点分离;
- 通过API 驱动的变体定制(而非使用方散写像素)建立稳定契约;
- 让方案从“具体应用的特例”平滑演进为“跨应用复用的通用能力”(如
VerticalBar的成长路径)。
这些实践共同产出一致的行为与用户体验,也让 Rocket.Chat 与 Fuselage 生态能在统一的设计语言上高效协作。想进一步掌握相关约定,可继续阅读仓库内 前端组件构建规范、TypeScript 编码约定 与 i18n 指南。
【免费下载链接】Rocket.ChatThe Secure CommsOS™ for mission-critical operations项目地址: https://gitcode.com/GitHub_Trending/ro/Rocket.Chat
创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考