web-component-design
Compare original and translation side by side
🇺🇸
Original
English🇨🇳
Translation
ChineseWeb Component Design
Web组件设计
Build reusable, maintainable UI components using modern frameworks with clean composition patterns and styling approaches.
使用现代框架,通过清晰的组合模式和样式方法构建可复用、可维护的UI组件。
When to Use This Skill
何时使用该技能
- Designing reusable component libraries or design systems
- Implementing complex component composition patterns
- Choosing and applying CSS-in-JS solutions
- Building accessible, responsive UI components
- Creating consistent component APIs across a codebase
- Refactoring legacy components into modern patterns
- Implementing compound components or render props
- 设计可复用组件库或设计系统
- 实现复杂的组件组合模式
- 选择并应用CSS-in-JS解决方案
- 构建可访问、响应式的UI组件
- 在代码库中创建一致的组件API
- 将遗留组件重构为现代模式
- 实现复合组件或渲染道具(Render Props)
Core Concepts
核心概念
1. Component Composition Patterns
1. 组件组合模式
Compound Components: Related components that work together
tsx
// Usage
<Select value={value} onChange={setValue}>
<Select.Trigger>Choose option</Select.Trigger>
<Select.Options>
<Select.Option value="a">Option A</Select.Option>
<Select.Option value="b">Option B</Select.Option>
</Select.Options>
</Select>Render Props: Delegate rendering to parent
tsx
<DataFetcher url="/api/users">
{({ data, loading, error }) =>
loading ? <Spinner /> : <UserList users={data} />
}
</DataFetcher>Slots (Vue/Svelte): Named content injection points
vue
<template>
<Card>
<template #header>Title</template>
<template #content>Body text</template>
<template #footer><Button>Action</Button></template>
</Card>
</template>复合组件:协同工作的相关组件
tsx
// Usage
<Select value={value} onChange={setValue}>
<Select.Trigger>Choose option</Select.Trigger>
<Select.Options>
<Select.Option value="a">Option A</Select.Option>
<Select.Option value="b">Option B</Select.Option>
</Select.Options>
</Select>渲染道具(Render Props):将渲染逻辑委托给父组件
tsx
<DataFetcher url="/api/users">
{({ data, loading, error }) =>
loading ? <Spinner /> : <UserList users={data} />
}
</DataFetcher>插槽(Slots,Vue/Svelte):命名内容注入点
vue
<template>
<Card>
<template #header>Title</template>
<template #content>Body text</template>
<template #footer><Button>Action</Button></template>
</Card>
</template>2. CSS-in-JS Approaches
2. CSS-in-JS方法
| Solution | Approach | Best For |
|---|---|---|
| Tailwind CSS | Utility classes | Rapid prototyping, design systems |
| CSS Modules | Scoped CSS files | Existing CSS, gradual adoption |
| styled-components | Template literals | React, dynamic styling |
| Emotion | Object/template styles | Flexible, SSR-friendly |
| Vanilla Extract | Zero-runtime | Performance-critical apps |
| 解决方案 | 实现方式 | 最佳适用场景 |
|---|---|---|
| Tailwind CSS | 工具类 | 快速原型开发、设计系统 |
| CSS Modules | 作用域CSS文件 | 现有CSS项目、逐步迁移 |
| styled-components | 模板字符串 | React、动态样式 |
| Emotion | 对象/模板样式 | 灵活性高、支持SSR |
| Vanilla Extract | 零运行时 | 性能敏感型应用 |
3. Component API Design
3. 组件API设计
tsx
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;
}Principles:
- Use semantic prop names (vs
isLoading)loading - Provide sensible defaults
- Support composition via
children - Allow style overrides via or
classNamestyle
tsx
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
Quick Start: React Component with Tailwind
快速上手:基于Tailwind的React组件
tsx
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";tsx
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";Framework Patterns
框架特定模式
React: Compound Components
React:复合组件
tsx
import { createContext, useContext, useState, type ReactNode } from "react";
interface AccordionContextValue {
openItems: Set<string>;
toggle: (id: string) => void;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordion() {
const context = useContext(AccordionContext);
if (!context) throw new Error("Must be used within 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);
next.has(id) ? next.delete(id) : 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>
);
};tsx
import { createContext, useContext, useState, type ReactNode } from "react";
interface AccordionContextValue {
openItems: Set<string>;
toggle: (id: string) => void;
}
const AccordionContext = createContext<AccordionContextValue | null>(null);
function useAccordion() {
const context = useContext(AccordionContext);
if (!context) throw new Error("Must be used within 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);
next.has(id) ? next.delete(id) : 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: Composables
Vue 3:组合式API
vue
<script setup lang="ts">
import { ref, computed, provide, inject, type InjectionKey } from "vue";
interface TabsContext {
activeTab: Ref<string>;
setActive: (id: string) => void;
}
const TabsKey: InjectionKey<TabsContext> = Symbol("tabs");
// Parent component
const activeTab = ref("tab-1");
provide(TabsKey, {
activeTab,
setActive: (id: string) => {
activeTab.value = id;
},
});
// Child component usage
const tabs = inject(TabsKey);
const isActive = computed(() => tabs?.activeTab.value === props.id);
</script>vue
<script setup lang="ts">
import { ref, computed, provide, inject, type InjectionKey } from "vue";
interface TabsContext {
activeTab: Ref<string>;
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 isActive = computed(() => tabs?.activeTab.value === props.id);
</script>Svelte 5: Runes
Svelte 5:Runes
svelte
<script lang="ts">
interface Props {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
onclick?: () => void;
children: import('svelte').Snippet;
}
let { variant = 'primary', size = 'md', onclick, children }: Props = $props();
const classes = $derived(
`btn btn-${variant} btn-${size}`
);
</script>
<button class={classes} {onclick}>
{@render children()}
</button>svelte
<script lang="ts">
interface Props {
variant?: 'primary' | 'secondary';
size?: 'sm' | 'md' | 'lg';
onclick?: () => void;
children: import('svelte').Snippet;
}
let { variant = 'primary', size = 'md', onclick, children }: Props = $props();
const classes = $derived(
`btn btn-${variant} btn-${size}`
);
</script>
<button class={classes} {onclick}>
{@render children()}
</button>Best Practices
最佳实践
- Single Responsibility: Each component does one thing well
- Prop Drilling Prevention: Use context for deeply nested data
- Accessible by Default: Include ARIA attributes, keyboard support
- Controlled vs Uncontrolled: Support both patterns when appropriate
- Forward Refs: Allow parent access to DOM nodes
- Memoization: Use ,
React.memofor expensive rendersuseMemo - Error Boundaries: Wrap components that may fail
- 单一职责:每个组件只做好一件事
- 避免属性透传:使用上下文传递深层嵌套数据
- 默认支持可访问性:包含ARIA属性、键盘导航支持
- 受控与非受控组件:在合适的场景下同时支持两种模式
- 转发Ref:允许父组件访问DOM节点
- 记忆化:对昂贵的渲染使用、
React.memouseMemo - 错误边界:包裹可能出错的组件
Common Issues
常见问题
- Prop Explosion: Too many props - consider composition instead
- Style Conflicts: Use scoped styles or CSS Modules
- Re-render Cascades: Profile with React DevTools, memo appropriately
- Accessibility Gaps: Test with screen readers and keyboard navigation
- Bundle Size: Tree-shake unused component variants
- 属性爆炸:属性过多时,考虑使用组件组合替代
- 样式冲突:使用作用域样式或CSS Modules
- 渲染级联:使用React DevTools分析性能,合理使用记忆化
- 可访问性缺口:使用屏幕阅读器和键盘导航进行测试
- 包体积过大:摇树优化未使用的组件变体