原文:https://dev.to/hi_iam_chris/implementing-named-slots-in-react-with-child-type-inspection-oh4(作者 @hi_iam_chris)
React 在设计上刻意保持精简。它为你提供了组件、props 和 children,而将结构化的组合模式留给你自己去实现。有一种模式在 React 中并非原生支持,但在设计系统和组件库中却屡见不鲜,那就是命名插槽(named slots):能够向父组件传递多个不同的内容块,并让父组件决定每个内容块渲染在哪里的能力。
如果你用过 Vue.js,你马上就能理解这一点。我说的就是 <slot name="..." /> 的功能。React 没有内置的等价物,但你可以通过子元素类型检查(child type inspection)来清晰地实现相同的功能。本文将详细介绍具体方法。
目标
假设你正在构建一个用户资料卡,类似 LinkedIn 或团队通讯录上的那种。它具有固定的布局:顶部的封面照片、头像和姓名区域,以及分为个人信息、工作经历和联系方式的主体部分。组件的使用者控制着每个部分填充的内容,以及可选的部分是否显示。
理想的调用方式如下所示:
<ProfileCard>
<ProfileHeader>
<Avatar src="/avatars/jane.jpg" />
<DisplayName>Jane Kowalski</DisplayName>
<Headline>Senior Product Designer · London</Headline>
</ProfileHeader>
<ProfileDetails>
<DetailItem label="Location">London, United Kingdom</DetailItem>
<DetailItem label="Industry">Technology</DetailItem>
<DetailItem label="Member since">March 2019</DetailItem>
</ProfileDetails>
<WorkExperience>
<ExperienceItem
company="Acme Corp"
role="Senior Product Designer"
period="2022 – present"
/>
<ExperienceItem
company="Bright Studio"
role="UX Designer"
period="2019 – 2022"
/>
</WorkExperience>
<ContactInfo>
<ContactItem type="email">jane@example.com</ContactItem>
<ContactItem type="linkedin">linkedin.com/in/janekowalski</ContactItem>
</ContactInfo>
</ProfileCard>
这段 JSX 读起来像一份文档。每个子组件都自我描述其角色。ProfileCard 可以在正确的位置渲染每个部分,有条件地包裹内容,并添加分隔符,而使用者无需知晓这些内部细节。但关键在于,<ProfileCard /> 内部如何处理已定义和未定义的内容。
核心机制
其技巧在于将 React.Children.toArray 与类型比较结合使用:
import React from "react";
import ProfileHeader from "./ProfileHeader";
import ProfileDetails from "./ProfileDetails";
import WorkExperience from "./WorkExperience";
import ContactInfo from "./ContactInfo";
const ProfileCard = ({ children }: { children: React.ReactNode }) => {
const arr = React.Children.toArray(children);
const header = arr.find(
(child) => React.isValidElement(child) && child.type === ProfileHeader
);
const details = arr.find(
(child) => React.isValidElement(child) && child.type === ProfileDetails
);
const experience = arr.find(
(child) => React.isValidElement(child) && child.type === WorkExperience
);
const contact = arr.find(
(child) => React.isValidElement(child) && child.type === ContactInfo
);
return (
<div className="profile-card">
<div className="cover-photo" />
{header && <div className="profile-header">{header}</div>}
<div className="profile-body">
{details}
{experience && (
<>
<Divider />
{experience}
</>
)}
{contact && (
<>
<Divider />
{contact}
</>
)}
</div>
</div>
);
};
为什么这能行
JavaScript 函数具有引用唯一性。当你写下 child.type === WorkExperience 时,你是在将该元素的类型与导入的函数引用进行比较。如果使用者传递了 <WorkExperience>,那么该元素的 .type 属性值就是 WorkExperience 函数本身,比较会成功。无需字符串匹配、无需魔法属性、无需注册步骤。
React.Children.toArray 会将 children 标准化为一个扁平数组,并处理像 fragments、null 和单个子元素这样的边界情况。React.isValidElement 则在你访问 .type 之前收窄了 TypeScript 类型,让编译器满意。
强制要求的插槽
由于这是一个运行时检查,你可以在必需的插槽缺失时抛出有意义的错误:
if (!header) {
throw new Error(
"ProfileCard requires a <ProfileHeader /> child. Check that you have included it."
);
}
这样在开发阶段就会立即报错,而不是静默地渲染出损坏的 UI。这相当于 Vue 插槽校验的 React 实现。
条件结构属于父组件
这种模式最强大的方面之一是父组件完全拥有布局决策权。考虑个人资料部分之间的分隔符。使用基于属性的 API,消费者需要自己管理这一点。通过命名插槽,ProfileCard 在内部处理它,然后一个部分要么带有分隔符出现,要么根本不出现,消费者无需考虑它。
同样的原则适用于 WorkExperience 内部。它检查自己的子元素并在条目之间添加分隔符:
const WorkExperience = ({ children }: { children: React.ReactNode }) => {
const arr = React.Children.toArray(children);
const items = arr.filter(
(child) => React.isValidElement(child) && child.type === ExperienceItem
);
return (
<section className="work-experience">
<h2 className="section-title">Experience</h2>
{items.map((item, index) => (
<React.Fragment key={index}>
{index > 0 && <Divider subtle />}
{item}
</React.Fragment>
))}
</section>
);
};
开发者省略一个 <ExperienceItem>,其分隔符就不会渲染,没有外部条件逻辑。同样的原则适用于间距、边框、包装器、ARIA 角色或组件需要管理的任何其他结构细节。
顺序无关性
与普通 children 渲染不同,开发者编写插槽的顺序无关紧要。无论开发者如何编写,ProfileCard 总是先渲染 header,然后是 details,再是 experience,最后是 contact。这是一个有意义的人体工程学改进,因为开发者专注于提供什么,而不是它最终在哪里。
嵌套插槽
这种模式组合起来很清晰,因为每个组件只负责自己的子元素。ProfileCard 检查其直接子元素。WorkExperience 独立检查自己的子元素。两者都不知道或不关心对方的内部结构。
这意味着你得到一个自然的组件层次结构,反映了 UI 结构:
<ProfileCard> {/* manages cover, header area, body dividers */}
<ProfileHeader> {/* manages avatar, name, headline layout */}
<Avatar src="..." />
<DisplayName>Jane Kowalski</DisplayName>
<Headline>Senior Product Designer</Headline>
</ProfileHeader>
<ProfileDetails> {/* manages label/value grid layout */}
<DetailItem label="Location">London</DetailItem>
<DetailItem label="Industry">Technology</DetailItem>
</ProfileDetails>
<WorkExperience> {/* manages entry separators */}
<ExperienceItem company="Acme Corp" role="Senior Designer" period="2022 – present" />
<ExperienceItem company="Bright Studio" role="UX Designer" period="2019 – 2022" />
</WorkExperience>
<ContactInfo> {/* manages icon rendering per contact type */}
<ContactItem type="email">jane@example.com</ContactItem>
</ContactInfo>
</ProfileCard>
替代方案
渲染属性
<ProfileCard
header={<ProfileHeader />}
details={<ProfileDetails />}
experience={<WorkExperience />}
contact={<ContactInfo />}
/>
通过属性接口显式且类型安全。当部分数量较少且固定时效果良好。当部分增多时变得复杂,并失去了 JSX 树的文档可读性。
Context API
你可以将子元素包装在 Context 提供者中,让每个插槽组件从中读取,这在插槽需要响应共享运行时状态(如 selected、expanded、loading)时很有用。但是,在 Next.js App Router 中,任何调用 useContext 的组件都必须是客户端组件。强制你的插槽组件成为客户端组件以接收结构配置会增加包大小,引入水合边界,并阻止这些组件直接访问服务器端数据。如果插槽组件纯粹是展示性的,子元素类型检查没有这些成本,并且在服务器和客户端上工作方式相同。
React.cloneElement
以前常用于事后向子元素注入属性。这现在被视为遗留做法,与记忆化配合不佳,React 团队不推荐使用。应避免。
何时采用此模式
当以下情况时,这种方法值得其复杂性:
- 一个组件具有三个或更多具有独立布局逻辑的命名内容区域
- 条件布局(分隔符、包装器、间距)取决于哪些插槽存在
- 你希望开发者编写可读的 JSX 而不是传递渲染函数或配置对象
- 插槽组件是服务器安全的。这意味着没有钩子、没有浏览器 API
对于只有一两个可选区域的更简单情况,使用 React.ReactNode 类型的命名属性通常就足够了,并且更容易遵循。
结论
子元素类型检查赋予 React 组件一个插槽系统,该系统具有顺序无关、可组合且服务端安全的特性,完全由 React 已有的原语构建。此模式将布局决策置于其应在之处(组件内部),同时将内容决策留在其应在之处(由开发者掌控)。对于像个人资料卡片、数据面板、复杂列表和仪表盘部件这类复杂的 UI 构建块而言,这是 React 生态系统中可用的最清晰的结构模式之一。
更多内容,你可以在 LinkedIn、GitHub 或 Instagram 上关注我。
原文:https://dev.to/hi_iam_chris/implementing-named-slots-in-react-with-child-type-inspection-oh4(作者 @hi_iam_chris)



