Files
cs-note/hhs/REACT/1. 基础篇/03-组件与 Props.md
T
2026-05-24 11:42:38 +08:00

15 KiB
Raw Blame History

tags, create time
tags create time
React
Components
Props
Frontend
2026-04-29 22:02

组件与 Props

概述

React 应用由一组可复用的组件构成。理解组件的拆分粒度、Props 的类型安全传递,以及组合优于继承的设计哲学,是写出高质量 React 代码的核心。本章从组件的基本形态讲起,深入到 Props 的校验与传递模式,最后介绍组件组合的高级技巧。

[!tip] 本节学习路线

  1. 函数组件 vs 类组件 → 为什么函数组件成为主流
  2. 组件拆分原则 → 如何找到合理的职责边界
  3. Props 定义与校验 → TypeScript 方式(推荐)和 PropTypes 方式
  4. Props 传递模式 → 基础透传、回调通信、children 插槽
  5. Composition 高级模式 → React.memo、forwardRef、受控组件
  6. Context 初探 → 轻量级跨层级通信

函数组件 vs 类组件

// ✅ 函数组件(当前推荐写法)
// 输入 props,输出 JSX —— 纯函数的直观映射
function Greeting({ name }: { name: string }) {
  return <h1>Hello, {name}</h1>;
}

// ⚠️ 类组件(遗留项目常见,新项目中应避免)
// this.props + render() —— 需要额外处理 this 绑定
class Greeting extends React.Component<{ name: string }> {
  render() {
    return <h1>Hello, {this.props.name}</h1>;
  }
}

为什么推荐函数组件?

对比维度 函数组件 + Hooks 类组件
心智模型 纯函数:输入 props → 输出 JSX 实例方法 + this 绑定
逻辑复用 Custom Hooks(声明式组合) HOC / Render Props(嵌套地狱)
类型推导 TS 自动推断完善 需要手动泛型参数
性能开销 无实例创建成本 new 实例 + bind overhead
Suspense 完全支持 不支持
Hooks 绑定 基于调用顺序 依赖 this 上下文

[!note] Hooks 绑定的本质 useState、useEffect 等 Hook 按调用顺序内部维护一个链表。这就是为什么 Hook 不能写在条件语句或循环中——顺序一旦改变,状态就会错配。

组件拆分原则

[!question] 思考:一个按钮算一个独立组件,还是把按钮和其外层容器一起写?

如果按钮只在这一处使用,合并更简洁;如果多个地方共用同一个按钮样式或交互逻辑,就应该拆出来。核心判断标准:这个组件是否有独立的业务语义?

好的拆分遵循 "单一职责" + "合理抽象"。下面用流程图展示不同层级的职责划分:

graph LR
    subgraph P ["Page Level — 页面组装层"]
        A["AboutPage<br/>拼接各功能模块"]
    end

    subgraph F ["Feature Level — 功能模块层"]
        B["UserProfile<br/>头像 + 昵称 + 设置入口"]
        C["Dashboard<br/>图表 + 数据表格"]
    end

    subgraph C2 ["Component Level — 通用 UI 层"]
        D["Button<br/>按钮变体 & 尺寸"]
        E["Modal<br/>弹窗外壳"]
    end

    subgraph PL ["Primitive Level — 原子层"]
        F2["IconButton<br/>图标按钮"]
        G["Avatar<br/>头像图片"]
    end

    A --> B
    A --> C
    B --> D
    B --> G
    C --> E
    D --> F2

判断是否该拆分的标准

  1. 可复用性 — 同一组 JSX 出现两次以上,考虑抽取为独立组件
  2. 可读性 — 单个文件超过 300 行、单个组件超过 80 行,应考虑拆分
  3. 测试粒度 — 难以单独测试的巨型组件应拆为小组件
  4. 业务语义 — 每个组件应对应一个清晰的业务概念
// ❌ 反例:巨型组件,混合了多个不相关的职责
function Dashboard() {
  // 200+ 行,混合了 Header、Sidebar、DataTables、Chart...
  // 改 Header 要遍历整文件,测试也要 mock 所有子模块
}

// ✅ 正例:拆分为功能块,每个组件职责单一
function Dashboard() {
  return (
    <>
      <DashboardHeader />     {/* 专注头部信息 */}
      <aside><Sidebar /></aside>         {/* 专注侧边导航 */}
      <main>
        <DataTable data={tasks} />       {/* 专注数据表格 */}
        <Chart data={stats} />           {/* 专注图表展示 */}
      </main>
    </>
  );
}

Props 定义与类型校验

TypeScript 接口方式(推荐)

interface ButtonProps {
  label: string;                   // 必填:按钮文字
  variant?: "primary" | "secondary" | "danger";  // 可选:视觉变体(联合类型约束)
  size?: "sm" | "md" | "lg";      // 可选:尺寸档位
  disabled?: boolean;              // 可选:禁用态
  onClick?: (e: React.MouseEvent<HTMLButtonElement>) => void;  // 可选:点击回调
  children?: React.ReactNode;      // 可选:内容插槽
}

// 方式1:解构赋值设默认值(适合简单默认值)
function Button({ label, variant = "primary", onClick }: ButtonProps) {
  return <button className={`btn btn-${variant}`} onClick={onClick}>{label}</button>;
}

// 方式2:全部字段显式声明默认值(适合需要覆盖全部可选字段时)
function Button({ label, variant = "primary", size = "md", disabled = false, children }: ButtonProps) {
  return <button className={`btn btn-${variant} btn-${size}`} disabled={disabled}>{children}</button>;
}

[!tip] 类型定义的位置建议

  • 小型组件:interface 与组件放在同一文件中
  • 大型组件库:type 导出到独立的 types.ts,便于跨文件引用和复用

PropTypes 方式(JS 项目或过渡期)

import PropTypes from 'prop-types';

Button.propTypes = {
  label: PropTypes.string.isRequired,          // 必填字符串
  variant: PropTypes.oneOf(["primary", "secondary"]),  // 枚举限制
  onClick: PropTypes.func,                     // 可选函数
};

[!warning] 不要混用两种校验方式 TS 提供编译时检查,PropTypes 是运行时兜底。选其一即可,新项目统一用 TS。混用会导致维护和调试成本翻倍。

Props 传递模式

基础传递

// 1. 直接传递字面量值
<Button label="提交" disabled />

// 2. 传递变量或表达式
<Button label={submitText} onClick={handleSubmit} />

展开透传(Spread Props)

// 当一层组件需要将自身 props 原样传给子组件时,避免逐个手写
const inputProps = { placeholder: "请输入", onChange: handleChange };
<Input {...inputProps} />  // ⚡ Spread Operator 将对象解包为独立的 props

// 局部覆盖:父 prop 优先级低于子 prop(后者覆盖前者)
<Input {...inputProps} autoFocus />  // autoFocus 额外传入,其余来自 inputProps

回调 Prop(子 → 父通信)

// 子组件通过回调将数据或事件通知父组件
<Form onSubmit={(data) => console.log("收到表单数据:", data)} />
// 💡 父组件将处理函数作为 prop 传入,子组件在合适时机调用它

Render Item Prop(列表渲染定制)

// 父组件告诉子组件:"每个 item 长什么样"
<List
  items={items}
  renderItem={(item) => <ListItem key={item.id} {...item} />}
/>
// 📌 比完整的 Render Props 模式更轻量的写法

Children 与 Composition

// Card 接收 children(任意 JSX 内容),灵活度远超固定 string prop
function Card({ children, header }: { children: React.ReactNode; header: string }) {
  return (
    <div className="card">
      <h3>{header}</h3>
      <div className="card-body">{children}</div>
    </div>
  );
}

// 使用示例:children 可以包含任意合法的 JSX
<Card header="用户信息">
  <p>姓名:张三</p>
  <p>年龄:25</p>
</Card>

// Fragment —— 不产生额外 DOM 节点,用于包裹兄弟元素
function ListItem({ text, icon }: { text: string; icon: React.ReactNode }) {
  return (
    <li>
      <>  {/* ⚡ Fragment 缩写语法 */}
        <span className="icon">{icon}</span>
        <span>{text}</span>
      </>
    </li>
  );
}

[!question] children 的类型为什么是 React.ReactNode 而不是 JSX.Element?

  • JSX.Element 只代表 React 元素(如 <div />、<Comp />)
  • React.ReactNode 范围更广:包含字符串、数字、Fragment、数组、甚至 null/undefined
  • 💡 如果用 JSX.Element,当子元素是纯文本或数组时会报错

React.memo —— 减少不必要的重渲染

// memo 对组件做了一层"浅比较"包装:只有 props 变化时才重新渲染
const UserItem = React.memo(function UserItem({ name, age }: { name: string; age: number }) {
  // 即使父组件其他 state 变化,只要 name 和 age 不变,就不会重渲染
  return <li>{name},{age} 岁</li>;
});

// 自定义比较函数(仅在需要深比较时使用,一般不需要)
const compare = (prev: UserItemProps, next: UserItemProps) => prev.name === next.name && prev.age === next.age;
const UserItemDeep = React.memo(UserItem, compare);

[!tip] memo 的性能权衡

  • 适用于频繁重渲染但 props 稳定的叶子组件
  • 过度使用会增加维护成本和内存开销——先用 Profile 工具定位瓶颈,再决定是否加 memo

forwardRef —— 访问子组件 DOM 节点

// 父组件需要通过 ref 操作子组件内部的 DOM(如聚焦输入框)
const FancyInput = forwardRef<HTMLInputElement, { placeholder: string }>(({ placeholder }, ref) => {
  return <input ref={ref} placeholder={placeholder} />;
});

// 使用方可以拿到原生 input 的 ref
function Form() {
  const inputRef = useRef<HTMLInputElement>(null);
  
  useEffect(() => {
    inputRef.current?.focus();  // 💡 挂载后自动聚焦
  }, []);

  return <FancyInput ref={inputRef} placeholder="请输入..." />;
}

[!important] forwardRef 的使用场景 不是所有组件都需要 forwardRef。优先通过 props 解决问题(可控组件模式)。只在以下场景才需要:

  • 需要暴露 DOM API(聚焦、测量、滚动等)
  • 封装底层 UI 库组件时需要透传 ref

受控组件模式(Controlled Components)

// 组件的内部 state 由外部 props 控制 —— 单一数据源原则
function ControlledInput({ value, onChange }: { value: string; onChange: (v: string) => void }) {
  return (
    <input
      value={value}            // 🔑 value 由外部决定
      onChange={(e) => onChange(e.target.value)}  // 🔑 变化时通知外部更新
      placeholder="输入内容"
    />
  );
}

// 父组件持有唯一真实状态
function SearchPage() {
  const [query, setQuery] = useState("");
  return (
    <>
      <ControlledInput value={query} onChange={setQuery} />
      <p>搜索关键词:{query || "(空)"}</p>
    </>
  );
}

[!note] 受控 vs 非受控

  • 受控:state 在父组件中管理,数据流向清晰(✅ 推荐大多数场景)
  • 非受控:state 在子组件内部管理,通过 ref 读取(⚡ 适合简单的表单快速实现)

Context 初探(轻量级全局状态)

Context 是 React 内置的跨组件树通信机制,适合传递那些"多处用到但不应逐层透传"的数据。

// 创建 Context,初始值通常设为占位值(实际值由 Provider 注入)
const ThemeContext = createContext<"light" | "dark">("light");

function App() {
  const [theme, setTheme] = useState<"light" | "dark">("light");
  
  return (
    <ThemeContext.Provider value={theme}>
      {/* 其下所有子组件(不限层级)都可以通过 useContext 消费 */}
      <Toggle />
      <Content />
    </ThemeContext.Provider>
  );
}

function Toggle() {
  const theme = useContext(ThemeContext);  // 无需中间层透传
  return <button onClick={() => setTheme(t => t === "light" ? "dark" : "light")}>切换主题</button>;
}

[!warning] Context 的性能陷阱

  • Provider value 如果是新对象,每次渲染都是不同的引用 → 所有消费者全部重渲染
  • ✅ 解决:用 useReducer 返回稳定的 {state, dispatch} 对象,保持引用一致
  • ✅ 解决:拆分 Context,避免一个大 Context 塞入过多数据

组件组合的进阶模式

Compound Components(复合组件)

当一个组件的子项之间存在隐式共享状态时,使用复合组件模式:

// 手风琴组件:Tab 之间共享 activeIndex,但父组件无需关心
function Accordion({ children }: { children: React.ReactNode }) {
  const [activeIndex, setActiveIndex] = useState<number | null>(null);
  
  return (
    <AccordionContext.Provider value={{ activeIndex, setActiveIndex }}>
      <div className="accordion">{children}</div>
    </AccordionContext.Provider>
  );
}

function AccordionTab({ title, children }: { title: string; children: React.ReactNode }) {
  const ctx = useContext(AccordionContext);
  // 💡 每个 Tab 自己从 Context 里拿状态,父组件不用逐一传递
  const isActive = ctx.activeIndex !== null;
  return (
    <div onClick={() => ctx.setActiveIndex(ctx.activeIndex)}>
      <h3>{title}</h3>
      {isActive && <div>{children}</div>}
    </div>
  );
}

// 对外暴露子组件属性,方便用户使用
Accordion.Tab = AccordionTab;

// 使用:状态在 Tabs 间共享,父组件只需包裹
<Accordion>
  <Accordion.Tab title="第一章">...</Accordion.Tab>
  <Accordion.Tab title="第二章">...</Accordion.Tab>
</Accordion>

[!question] 什么时候用 compound components? 当你的一组子组件需要彼此感知对方的状态,又不想让父组件来协调时。典型场景:Tabs/Accordion/SwitchGroup/Table。

Composition Flow

sequenceDiagram
    participant Parent as 父组件
    participant Child as 子组件
    participant DOM as DOM

    Parent->>Child: 传入 props + children(JSX 树)
    Note over Parent,Child: Props 描述配置,Children 描述内容
    Child->>DOM: 根据 props 渲染特定结构
    Child->>Child: 插入 children 到指定位置
    Note over Child: 组合 = 配置 + 内容的分离

[!tip] 组合 vs 继承 React 的设计哲学是 组合优于继承:

  • JS 的 class 继承会导致紧耦合和脆弱的基类依赖
  • React 组合让每个组件保持独立,行为通过 props/children/Callbacks 灵活拼装
  • 💡 如果想共享逻辑,优先考虑 Custom Hooks 而非继承

关联笔记

  • REACT/1. 基础篇/01-环境搭建与项目结构 — React 项目初始化与目录规范
  • REACT/1. 基础篇/02-JSX 语法 — JSX 表达式、事件绑定、条件渲染
  • REACT/1. 基础篇/04-State 与不可变性 — State 设计原则与更新模式
  • REACT/2. Hooks 篇/05-核心 Hooks — useState/useEffect/useContext/useRef 原理
  • REACT/2. Hooks 篇/07-自定义 Hooks — 逻辑复用与常见 Hook 封装
  • REACT/4. 进阶篇/11-组件通信模式 — Props Drill / Context / 状态管理库方案对比
  • REACT/3. 生态工具篇/09-状态管理 — Context API vs Zustand vs Redux Toolkit
  • REACT/5. 工程实践篇/15-性能优化 — React.memo / 虚拟列表 / Code Splitting