🚀 快速安装
复制以下命令并运行,立即安装此 Skill:
npx skills add https://github.com/wshobson/agents --skill web-component-design
💡 提示:需要 Node.js 和 NPM
Web 组件设计
使用现代框架构建可重用、可维护的 UI 组件,并采用清晰的组合模式和样式方法。
何时使用此技能
- 设计可复用的组件库或设计系统
- 实现复杂的组件组合模式
- 选择和应用 CSS-in-JS 解决方案
- 构建可访问、响应式的 UI 组件
- 在代码库中创建一致的组件 API
- 将遗留组件重构为现代模式
- 实现复合组件或渲染属性(render props)
核心概念
1. 组件组合模式
复合组件:协同工作的相关组件
// 使用方法
<Select value={value} onChange={setValue}>
<Select.Trigger>选择选项</Select.Trigger>
<Select.Options>
<Select.Option value="a">选项 A</Select.Option>
<Select.Option value="b">选项 B</Select.Option>
</Select.Options>
</Select>
渲染属性(Render Props):将渲染委托给父组件
<DataFetcher url="/api/users">
{({ data, loading, error }) =>
loading ? <Spinner /> : <UserList users={data} />
}
</DataFetcher>
插槽 (Vue/Svelte):命名的内容注入点
<template>
<Card>
<template #header>标题</template>
<template #content>正文内容</template>
<template #footer><Button>操作</Button></template>
</Card>
</template>
2. CSS-in-JS 方法
| 解决方案 | 方式 | 最适合 |
|---|---|---|
| Tailwind CSS | 工具类 | 快速原型、设计系统 |
| CSS Modules | 作用域 CSS 文件 | 现有 CSS 项目、渐进式采用 |
| styled-components | 模板字符串 | React、动态样式 |
| Emotion | 对象/模板样式 | 灵活、支持 SSR |
| Vanilla Extract | 零运行时 | 性能关键的应用 |
3. 组件 API 设计
interface ButtonProps {
variant?: "primary" | "secondary" | "ghost"; // 变体类型
size?: "sm" | "md" | "lg"; // 尺寸大小
isLoading?: boolean; // 加载状态
isDisabled?: boolean; // 禁用状态
leftIcon?: React.ReactNode; // 左侧图标
rightIcon?: React.ReactNode; // 右侧图标
children: React.ReactNode; // 子内容
onClick?: () => void; // 点击事件
}
设计原则:
- 使用语义化的属性名(如
isLoading而非loading) - 提供合理的默认值
- 通过
children支持组合 - 允许通过
className或style覆盖样式
快速开始:使用 Tailwind 的 React 组件
import { forwardRef, type ComponentPropsWithoutRef } from "react";
import { cva, type VariantProps } from "class-variance-authority";
import { cn } from "@/lib/utils";
const buttonVariants = cva(
"inline-flex items-center justify-center rounded-md font-medium transition-colors focus-visible:outline-none focus-visible:ring-2 disabled:pointer-events-none disabled:opacity-50",
{
variants: {
variant: {
primary: "bg-blue-600 text-white hover:bg-blue-700", // 主要按钮
secondary: "bg-gray-100 text-gray-900 hover:bg-gray-200", // 次要按钮
ghost: "hover:bg-gray-100 hover:text-gray-900", // 幽灵按钮
},
size: {
sm: "h-8 px-3 text-sm", // 小尺寸
md: "h-10 px-4 text-sm", // 中尺寸
lg: "h-12 px-6 text-base", // 大尺寸
},
},
defaultVariants: {
variant: "primary", // 默认主要变体
size: "md", // 默认中尺寸
},
},
);
interface ButtonProps
extends
ComponentPropsWithoutRef<"button">,
VariantProps<typeof buttonVariants> {
isLoading?: boolean; // 加载状态
}
export const Button = forwardRef<HTMLButtonElement, ButtonProps>(
({ className, variant, size, isLoading, children, ...props }, ref) => (
<button
ref={ref}
className={cn(buttonVariants({ variant, size }), className)}
disabled={isLoading || props.disabled} <!-- 加载时禁用按钮 -->
{...props}
>
{isLoading && <Spinner className="mr-2 h-4 w-4" />} <!-- 加载指示器 -->
{children}
</button>
),
);
Button.displayName = "Button";
框架模式
React:复合组件
import { createContext, useContext, useState, type ReactNode } from "react";
interface AccordionContextValue {
openItems: Set<string>; // 当前打开的项目 ID 集合
toggle: (id: string) => void; // 切换项目开/关状态的函数
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordion() {
const context = useContext(AccordionContext);
if (!context) throw new Error("必须在 Accordion 组件内部使用");
return context;
}
export function Accordion({ children }: { children: ReactNode }) {
const [openItems, setOpenItems] = useState<Set<string>>(new Set());
const toggle = (id: string) => {
setOpenItems((prev) => {
const next = new Set(prev);
if (next.has(id)) {
next.delete(id); // 如果已打开,则关闭
} else {
next.add(id); // 如果已关闭,则打开
}
return next;
});
};
return (
<AccordionContext.Provider value={{ openItems, toggle }}>
<div className="divide-y">{children}</div>
</AccordionContext.Provider>
);
}
Accordion.Item = function AccordionItem({
id,
title,
children,
}: {
id: string; // 项目唯一标识
title: string; // 标题文本
children: ReactNode; // 内容区域
}) {
const { openItems, toggle } = useAccordion();
const isOpen = openItems.has(id); // 检查当前项目是否打开
return (
<div>
<button
onClick={() => toggle(id)}
className="w-full text-left py-3"
>
{title}
</button>
{isOpen && <div className="pb-3">{children}</div>} <!-- 只在打开时显示内容 -->
</div>
);
};
Vue 3:组合式函数
<script setup lang="ts">
import { ref, computed, provide, inject, type InjectionKey } from "vue";
// 定义上下文接口
interface TabsContext {
activeTab: Ref<string>; // 当前激活的标签 ID
setActive: (id: string) => void; // 设置激活标签的方法
}
// 创建注入键
const TabsKey: InjectionKey<TabsContext> = Symbol("tabs");
// 父组件:提供上下文
const activeTab = ref("tab-1");
provide(TabsKey, {
activeTab,
setActive: (id: string) => {
activeTab.value = id;
},
});
// 子组件:注入并使用上下文
const tabs = inject(TabsKey);
const props = defineProps<{ id: string }>();
const isActive = computed(() => tabs?.activeTab.value === props.id); // 判断当前标签是否激活
</script>
Svelte 5:Runes
<script lang="ts">
// 定义属性接口
interface Props {
variant?: 'primary' | 'secondary'; // 按钮变体
size?: 'sm' | 'md' | 'lg'; // 按钮尺寸
onclick?: () => void; // 点击事件
children: import('svelte').Snippet; // 内容片段(类似插槽)
}
// 使用 $props 声明属性,并设置默认值
let { variant = 'primary', size = 'md', onclick, children }: Props = $props();
// 使用 $derived 创建派生状态(类似计算属性)
const classes = $derived(
`btn btn-${variant} btn-${size}`
);
</script>
<button class={classes} {onclick}>
{@render children()} <!-- 渲染内容片段 -->
</button>
最佳实践
- 单一职责:每个组件只做好一件事
- 避免属性穿透:对深层嵌套的数据使用上下文(Context)
- 默认可访问:包含 ARIA 属性,支持键盘操作
- 受控与非受控:在适当时支持两种模式
- 转发 Refs:允许父组件访问 DOM 节点
- 记忆化:对开销大的渲染使用
React.memo、useMemo - 错误边界:包装可能失败的组件
常见问题
- 属性爆炸:属性过多 – 考虑使用组合代替
- 样式冲突:使用作用域样式或 CSS Modules
- 重渲染级联:使用 React DevTools 分析,合理使用记忆化
- 可访问性缺失:用屏幕阅读器和键盘导航测试
- 打包体积:通过摇树优化移除未使用的组件变体
📄 原始文档
完整文档(英文):
https://skills.sh/wshobson/agents/web-component-design
💡 提示:点击上方链接查看 skills.sh 原始英文文档,方便对照翻译。
声明:本站所有文章,如无特殊说明或标注,均为本站原创发布。任何个人或组织,在未征得本站同意时,禁止复制、盗用、采集、发布本站内容到任何网站、书籍等各类媒体平台。如若本站内容侵犯了原著者的合法权益,可联系我们进行处理。

评论(0)