React命名插槽:利用子元素类型检查实现内容分发

原文: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 生态系统中可用的最清晰的结构模式之一。

更多内容,你可以在 LinkedInGitHubInstagram 上关注我。

原文:https://dev.to/hi_iam_chris/implementing-named-slots-in-react-with-child-type-inspection-oh4(作者 @hi_iam_chris)

发布评论
全部评论(0)