使用 Compound Pattern 和 Composition 创建灵活的组件
Source: Dev.to
问题:僵硬的做法
想象一下你需要创建一个手风琴(Accordion)。初学者的做法通常把所有配置都集中在一起:
// ❌ O jeito rígido (Componente Monolítico)
这里的问题是配置(data)与 UI 混在一起。如果设计师决定第二项的标题需要红色,你就必须在代码中加入更多条件属性,弄得很乱。
解决方案:复合组件 + 可访问性
Compound Component(复合组件)模式允许组件一起工作,隐式共享状态,而 Composition(组合)让开发者自行决定在何处渲染每个部分。
此外,在创建自定义组件时,我们有责任使其可访问。没有 ARIA 属性(aria-expanded、aria-controls)的手风琴对依赖屏幕阅读器的用户是不可见的。
我们希望达到以下效果(DX - 开发者体验):
// ✅ O jeito flexível e acessível
<div>
<h2>O que é React?</h2>
<p>Uma biblioteca JavaScript para criar interfaces...</p>
</div>
<div>
<h3>Acessibilidade</h3>
<button>Saiba mais</button>
</div>
动手实现:实现模式
我们将使用 React Context、Hooks 和 useId 来构建,以确保可访问性。
1. 创建主上下文
首先,Accordion 的全局状态。
import React, { createContext, useContext, useState, ReactNode, useId } from "react";
type AccordionContextType = {
openItem: string | null;
toggleItem: (value: string) => void;
};
const AccordionContext = createContext<AccordionContextType | undefined>(undefined);
const useAccordion = () => {
const context = useContext(AccordionContext);
if (!context) {
throw new Error("Os componentes do Accordion devem estar dentro de ");
}
return context;
};
2. 父组件(Root)
interface AccordionProps {
children: ReactNode;
}
const AccordionRoot = ({ children }: AccordionProps) => {
const [openItem, setOpenItem] = useState<string | null>(null);
const toggleItem = (value: string) => {
setOpenItem(prev => (prev === value ? null : value));
};
return (
<AccordionContext.Provider value={{ openItem, toggleItem }}>
{children}
</AccordionContext.Provider>
);
};
3. 智能子组件
Item(ID 管理器)
// Contexto para passar IDs e Valor para os filhos (Trigger e Content)
const ItemContext = createContext<any>(undefined);
const AccordionItem = ({
value,
children,
}: {
value: string;
children: ReactNode;
}) => {
const id = useId(); // Gera um ID único para este item
const triggerId = `accordion-trigger-${id}`;
const contentId = `accordion-content-${id}`;
return (
<ItemContext.Provider value={{ value, triggerId, contentId }}>
{children}
</ItemContext.Provider>
);
};
触发器(Trigger)
const AccordionTrigger = ({ children }: { children: ReactNode }) => {
const { openItem, toggleItem } = useAccordion();
const itemContext = useContext(ItemContext);
if (!itemContext) throw new Error("Trigger deve estar dentro de um Item");
const { value, triggerId, contentId } = itemContext;
const isOpen = openItem === value;
return (
<button
id={triggerId}
aria-controls={contentId}
aria-expanded={isOpen}
onClick={() => toggleItem(value)}
className="flex justify-between w-full p-4 font-medium text-left hover:bg-gray-50 transition focus:outline-none focus:ring-2 focus:ring-blue-500"
>
{children}
{isOpen ? "➖" : "➕"}
</button>
);
};
内容(Content)
const AccordionContent = ({ children }: { children: ReactNode }) => {
const { openItem } = useAccordion();
const itemContext = useContext(ItemContext);
if (!itemContext) throw new Error("Content deve estar dentro de um Item");
const { value, triggerId, contentId } = itemContext;
const isOpen = openItem === value;
if (!isOpen) return null;
return (
<div
id={contentId}
role="region"
aria-labelledby={triggerId}
className="p-4"
>
{children}
</div>
);
};
4. 完成组合
export const Accordion = Object.assign(AccordionRoot, {
Item: AccordionItem,
Trigger: AccordionTrigger,
Content: AccordionContent,
});
为什么可访问性很重要?
当我们创建自定义组件(“手工打造”)时,会失去 HTML 原生的语义(如 <details> 和 <summary> 标签)。通过添加 aria-expanded 和 aria-controls,我们确保使用键盘或屏幕阅读器的用户能够获得与视觉用户相同的体验。一个好看的组件如果不能被所有人使用,就是不完整的组件。
但是 <details> 和 <summary> 呢?
你可能会问:“如果 HTML5 已经有 <details> 和 <summary> 标签,为什么还要写这么多代码?”
使用原生标签始终是被鼓励的,因为它们开箱即用地保证了可访问性和 SEO。然而,在以下情况下,需要使用 React 控制的手风琴(如上所示):
- 唯一打开 – 我们希望打开一个项时,其他项自动关闭。使用
<details>实现这一点需要拦截事件并手动操作open属性,这又回到了状态管理。 - 流畅动画 – 在纯 CSS 中为原生
<details>的关闭动画仍然很复杂(因为height: auto的过渡)。使用 React 我们可以控制渲染并以可预测的方式应用 CSS 动画或动画库。