
Langfuse React 组件设计规范最小 Props 接口、显式状态与用 ESLint 规则机械化执行的组件准则【免费下载链接】langfuse Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuseLangfuse 的 web 应用沉淀了一套针对 React 组件的编写准则.agents/skills/react-component-guidelines/SKILL.md核心思想是组件是封装单元其接口由 props 定义因此 props 必须显式、无歧义组件不能泄漏实现细节也不能依赖它被放在哪里使用。这篇指南把这套准则逐条展开并结合仓库中配套实现的自定义 ESLint 插件packages/eslint-plugin/说明 Langfuse 如何把这些软规范变成 CI 阶段可强制检查的硬约束读完你可以掌握 Langfuse 前端组件 API 的设计方法以及如何在自己的项目中复用同样的规则化落地思路。设计目标组件是可隔离的封装单元原文档的开篇就给出了整份规范的判断依据组件之所以有用是因为它们充当封装单元从而促进组合composition。为此组件必须可以被隔离——既不泄漏实现细节也不依赖其放置位置或使用上下文。组件的接口由 props 定义因此 props 应尽可能设计得显式且无歧义。这意味着在 Langfuse 的前端代码web/src/中一个共享组件应该满足两个独立视角调用方视角只看 props 就能完整理解组件的行为和渲染结果不需要知道它内部用了哪些样式工具、state 结构或第三方库实现视角组件内部可以随时间演进只要 props 契约不变所有调用点都不受影响。后续各节的具体规则都是围绕接口显式性和实现可隔离性这两点展开的。最小接口Minimal Interface原文档对 props 接口给出了四条硬约束不要有未使用的 props——声明了就要消费不要写默认值除非它对调用方的人机工效有重大收益MAJOR benefit需要自行判断取舍避免可选 propsoptional props——可选性让调用方无法确定组件当前处于什么模式不要有互相冲突的 props——例如同时暴露onClick和onSelect让两个属性竞争同一行为的控制权。这套约束的效果是一个组件的 props 表就是它完整的行为说明书。以仓库中真实的控制器组件为例web/src/features/in-app-agent/components/dialog-controller.tsx的 props 只有children和dialog两个且都有明确的 render-prop 语义没有默认值、没有可选开关调用方拿到类型定义即可推断出组件的全部能力边界。组合Composition先回答它凭什么是一个组件原文档在 Composition 一节要求共享组件必须拥有有意义的展示presentation或复杂逻辑。代码成为组件而不是纯函数/hook必须有理由并给出了三级决策当逻辑大部分与 JSX 无关时用纯函数把琐碎的 JSX 留在各个调用点只有当确实需要 React 生命周期或 state 时才升级为hook需要输出 JSX 且需要被多个调用点复用展示才是组件。避免多态polymorphic组件文档进一步把避免多态组件拆成展示与行为两个维度展示维度一个组件应只拥有一个具体展示形态。同一个动作在不同上下文里有时是按钮、有时是图标就应保持为两个独立组件而不是用一个 mode prop 或 flag 在内部二选一行为维度一个组件应只拥有一个内聚的工作流。当某个 prop 会切换出根本不同的工作流时应拆成各自拥有行为的组件。文档给出的例子是优先使用专门的 create、update、delete 对话框控制器而不是一个靠mode属性改变整个交互的动作组件。只提取真正共享的部分文档还给出提取粒度的判据只提取跨调用点共享的内容。共享的是展示——复用表现型组件共享的只是状态或行为——封装为 wrapper/render-prop 组件让每个调用点自带自己的展示。仓库里DialogController正是后者的典型实现见下文 Overlay 一节它持有valuestate但渲染什么完全由传入的dialogrender-prop 决定。显式状态Explicit States用类型系统表达意图原文档对 TypeScript 类型写法提出了三条规则优先使用Pick而非Omit定义 props 类型因为显式列出我接受哪些属性比列出我排除哪些属性更明确展开spreadprops 时必须在组件类型定义中通过Pick排除掉那些被手动应用到元素上的属性。这样做是为了防止 props 展开与手动赋值发生静默冲突——如果一个属性既在...props里、又被显式写在元素上后写者会静默覆盖展开值产生难以排查的行为差异使用可辨识联合discriminated unions传达意图而不是依赖 nullability / optionality。第 3 条的关键目标是让不可能的状态在类型上不可表示。原文档举了一个直接对应用户界面场景的例子如果 props 里同时有loading和data字段就不应该存在loading为 true 且data非 null这样的状态。用联合类型可以把这种非法组合在编译期排除而不是靠运行时if兜底。这类显式状态的写法与 Langfuse 前端对类型即文档的整体取向一致props 类型本身就是组件契约调用方不需要读实现就能知道哪些状态组合是合法的。封装Encapsulation样式不外泄内部不导出原文档的 Encapsulation 两条规则不允许className/styleprops除非该组件本身是 headless 组件——即它自身不包含任何样式或布局逻辑内部实现不得导出——例如cvaclass-variance-authority生成的类名、内部 helper 函数都不应出现在模块导出面中。Langfuse 的组件大量使用cva定义变体例如web/src/components/design-system/下的Accordion、Alert、Avatar、Badge等组件均有cva(定义。这些 cva 类名是组件的内部实现一旦导出调用方就可能直接引用类名绕过组件 API封装即告破。因此规则要求变体类名留在组件内部对外只暴露语义化的 variant props。这条规则在仓库中同样有机械化执行packages/eslint-plugin/src/rules/no-style-props.ts实现的no-style-props规则会扫描组件的 props 类型定义禁止className、style及xxxClassName、xxxStyle这类带样式后缀的属性实现见 no-style-props.ts并在 web/eslint.config.mjs 中按 error 级别作用于src/**/*.{ts,tsx}测试与 e2e 文件除外配置注释明确说明组件 API 应暴露显式 variant 而不是 className仅 headless 组件可以通过文件级豁免保留这类 props。归属Ownership间距归父级null 渲染是坏味道margin 由父组件负责文档规定margin 应由父组件施加而不是写在组件内部子组件只负责自己内容与自身内容的内部间距padding。理由是可组合性——组件不知道自己在列表里、网格中还是单独放置把外边距写进组件会让组合行为变得不可预测配置注释中也引用了 mxstbr 关于 margin 的经典论述。这一条被no-margin-on-root-elements规则自动化no-margin-on-root-elements.ts它定位每个组件的根元素支持条件渲染、Fragment、memo/forwardRef包裹等复杂形态定位逻辑见 react-components.ts 中的createComponentRootElementVisitors检查根元素上的 Tailwind margin 工具类m/mt/mr等正则MARGIN_UTILITY_REm-0这类零值放行以及style对象中的 margin 相关 CSS 属性。规则支持classNameFunctions选项默认[cn, clsx]能穿透cn(...)/clsx(...)调用检查其字符串参数在 web/eslint.config.mjs 中以 warn 级别作用于src/components/**。组件不应返回 null / undefined文档指出返回null或undefined是坏实践多数情况下导致该状态的条件应该由父组件处理——通常可以通过贴近当前组件位置的 hook 或 HOC 来完成父级先判断条件再决定是否挂载这个组件。no-null-render规则no-null-render.ts把这条落实为 error 级别检查且豁免逻辑与文档的 headless 例外完全对应规则先收集组件所有 return 分支的渲染内容把渲染输出分为passthroughchildren/createPortal透传、presentation拥有具体 JSX、other三类如果一个组件的所有渲染分支都只做 headless 透传isHeadlessPassthrough判定则允许返回 null——这正是文档中headless 组件例外的自动化表达其余拥有真实标记的组件任何返回null/undefined/false/空 Fragment 的分支都会被报告报错信息直接复述了文档的建议在父级处理条件或抽成 hook/HOC 使本组件始终渲染。值得注意的是规则在 web/eslint.config.mjs 中的作用域设计对src/**生效但豁免src/pages/**Next.js 页面是路由组合根路由而非父组件决定是否挂载页面返回 null 属于页面级职责、测试文件与src/components/layouts/**并对存量违规采用文件级eslint-disable冻结、禁止新增——这是一个渐进式收紧的落地策略值得参考。Overlay用 Controller 组合触发器留在调用方这是原文档篇幅最重的一节Langfuse 对下拉菜单、气泡、对话框这类 overlay 的组合方式有明确约定用DropdownMenuController、PopoverController或DialogController来组合 overlay。触发器Trigger的展示形态保留在调用方不做抽象。需要额外行为时添加功能特定的 wrapper在其中处理权限、埋点、mutation 等应被共享的工作流行为Controller 组件必须位于它所触发的瞬时 overlay 之外。典型反例从一个下拉菜单项打开的 popover/dialog其 Controller 不能放在DropdownMenuContent里——因为下拉菜单关闭时 Content 会卸载连带把刚打开的 overlay 状态一起销毁消费 controller 暴露的 render-prop 控制项而不是复制它的形状。不要把传入的Trigger挪进子组件内部触发器的展示抽象应通过 ref 转发组件完成并保留显式 variantsTrigger asChild留在各个调用点在调用方用Trigger asChild包裹一个语义化的、能转发 ref 和注入 props 的子元素。文档给出的例子直接用DropdownMenuItem而不是在里面再嵌套一个Button。仓库中的DialogControllerdialog-controller.tsx完整演示了第 1、3 条的形态——它持有打开/关闭的 state通过两个 render-prop 把控制权和渲染分开export function DialogControllerT({ children, dialog, }: { children: (control: { open: (value: T) void }) ReactNode; dialog: (close: () void, value: T | null) ReactNode; }) { const [value, setValue] useStateT | null(null); const open useCallback((nextValue: T) { setValue(nextValue); }, []); const close useCallback(() { setValue(null); }, []); return ( {dialog(close, value)} {children({ open })} / ); }调用点通过children({ open })拿到打开函数、自行决定用什么控件按钮、菜单项等来触发——触发器展示天然留在调用方dialog(close, value)则由 Controller 直接渲染符合Controller 在 overlay 生命周期外持有状态的要求。DropdownMenuController、PopoverController在web/src/components/下的多个调用点如session/SessionTraceActionButtons.tsx、table/data-table-settings-popover.tsx中有实际引用遵循同一模式。第 2、3 条同样有 ESLint 层面的守护no-abstracted-overlay-trigger规则no-abstracted-overlay-trigger.ts维护了六组 Radix 系 overlay 家族Dialog、AlertDialog、DropdownMenu、Drawer、Popover、Sheet 各自的Trigger与Content组件名当一个组件的返回 JSX 中同时出现某个家族的 Trigger 与 Content 时即报错提示不要抽象{{overlay}}的触发器与内容把触发器保留在父树中通过 controller/render prop 暴露 overlay 行为——这与原文档trigger presentation should remain in the caller and not be abstracted是一一对应的自动化表达。确定性样式Deterministic Styling最后一节的规则是避免在 CSS 定义中出现互相冲突的样式名——即使 tailwind-merge 这类工具最终会消解冲突源码层面仍然应该用cva、条件表达式或查找表lookup tables把变体写显式。这条规则针对的典型坏味道是在同一个 cva 类字符串里同时出现px-2和px-4依赖合并工具的后者胜出来得到最终效果。它把哪个变体赢的知识藏进了工具链行为而不是显式的条件分支中。显式变体cva 的variant定义或条件三元让每个形态对应的类名组合在代码里一目了然也与前文避免用 flag 在多展示形态间二选一的多态禁令同向。规则如何被机械化仓库中的自定义 ESLint 插件上述准则并非停留在文档层。仓库维护了一个 monorepo 内共享的自定义 ESLint 插件 packages/eslint-plugin/规则注册表src/index.ts中包含与本文各节对应的实现规则对应准则小节实现文件no-style-propsEncapsulation禁 className/style propsrules/no-style-props.tsno-null-renderOwnership组件不得渲染空rules/no-null-render.tsno-margin-on-root-elementsOwnershipmargin 归父级rules/no-margin-on-root-elements.tsno-abstracted-overlay-triggerOverlaysTrigger 不抽象rules/no-abstracted-overlay-trigger.tsno-raw-font-weight/no-arbitrary-colorsDeterministic Styling设计令牌墙packages/eslint-plugin/src/rules/no-overlay-zindexOverlays禁止 z-index 逃逸rules/no-overlay-zindex.ts这些规则的公共基础是 packages/eslint-plugin/src/react-components.ts 中的三个 AST 访问器工厂createComponentRootElementVisitors定位组件的根 JSX 元素margin 规则依赖它createComponentReturnExpressionVisitors收集组件完整 return 边界并支持对局部 const 的解析null-render、overlay-trigger 规则依赖它createComponentPropTypeVisitors解析函数组件、类组件、FC/forwardRef等多种形态下的 props 类型声明no-style-props 规则依赖它。它们共同识别大写字母命名、返回 JSX 的函数/箭头函数并解包memo、forwardRef、类型断言等封装保证规则能覆盖仓库中实际出现的各种组件写法。在 web/eslint.config.mjs 中这些规则通过repo/*命名空间接入 flat config形成了分级作用域no-null-render、no-style-props全局 error 并精确豁免测试/页面/布局目录no-margin-on-root-elements在src/components/**下 warnno-overlay-zindex按 overlay-content任意 overlay 内容元素禁止高 z-index与 wrappersrc/components/ui/下九个 overlay 原语文件全量禁止两种模式分别配置。配置中的注释还记录了规则的演进历史与已知例外如存量违规用文件级eslint-disable冻结使规范 强制 豁免理由三者在同一个文件里可审计。这套准则文档 同名 ESLint 规则 带理由注释的分级配置的组合是 Langfuse 保持大规模前端代码web/src/下数百个功能模块组件 API 一致性的重要手段规范不仅告诉人怎么写还通过 lint 在每次提交时给出机器可读的判断与修正建议。快速核对清单按原文档脉络可以整理为落地时的检查清单props 是否最小无未使用属性、无无理由的默认值、无可选属性、无相互冲突的属性这个逻辑凭什么是组件与 JSX 无关就用纯函数需要 state/生命周期才用 hook共享展示才抽组件一个组件是否只有一种展示形态、一个内聚工作流mode类 flag 是否该拆成独立组件props 类型是否用Pick显式声明、spread 是否与手动属性冲突、状态组合是否用可辨识联合排除了非法分支是否意外导出了className/styleprops 或 cva 内部类名headless 组件除外margin 是否写在了组件内部而非父级组件是否存在返回 null 的分支且该条件能否上提到父级或用 hook/HOC 表达overlay 是否用*Controller组合、Trigger 是否留在调用方、Controller 是否位于瞬时 overlay 之外CSS 类名是否通过 cva/条件/查找表显式表达变体而非依赖 tailwind-merge 消解冲突。结合 SKILL.md 原文与 packages/eslint-plugin/ 的规则实现对照阅读可以完整复现 Langfuse 前端组件从设计准则到CI 强制的完整闭环。【免费下载链接】langfuse Open source AI engineering platform: LLM evals, observability, metrics, prompt management, playground, datasets. Integrates with OpenTelemetry, LangChain, OpenAI SDK, LiteLLM, and more. YC W23项目地址: https://gitcode.com/GitHub_Trending/la/langfuse创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考