目 录CONTENT

文章目录

React(五)Props 组件通信的桥梁

React(五):Props 组件通信的桥梁

目录



第一部分:Props 核心概念 —— 快递包裹的启示


1.1 生活化类比:快递包裹

想象你在网上买了一件衣服。商家打包时:

  • 包裹上贴着快递单,上面写着:收货人(你)、商品名称(T恤)、颜色(白色)、尺码(L号)
  • 收到包裹后可以打开使用,但你不能修改快递单上的信息——那是商家发货时确定的
  • 如果商家发错了颜色,你不能自己在快递单上改,只能联系商家重新发货

React 中的 Props 就是这个快递包裹:

快递场景 React Props 场景
商家(发货方) 父组件(数据的源头,负责"打包"并传递)
快递单上的信息 Props 对象(装着所有配置数据的容器)
收货人(你) 子组件(接收数据的一方)
收到包裹后使用衣服 子组件用 props 渲染 UI
不能改快递单 Props 是只读的,子组件不能修改
联系商家重新发货 子组件通过回调函数通知父组件更新数据
// 就像商家(父组件)给你(子组件)寄了一件指定颜色和尺码的 T 恤
<ClothingItem
  name="纯棉T恤" // 相当于快递单上的"商品名称"
  color="白色" // 相当于快递单上的"颜色"
  size="L" // 相当于快递单上的"尺码"
/>

一句话总结Props(Properties 的缩写)是父组件传递给子组件的数据"包裹",子组件只能读取、使用,不能拆开修改里面的内容。 它是 React 单向数据流的物理载体。


1.2 Props 是什么?(白话版)

用最直白的话说:Props 就是组件函数的参数。

// 你写了一个普通函数,它接收参数
function sayHello(userName) {
  console.log("你好," + userName);
}
sayHello("小明"); // 输出:你好,小明

// React 函数组件就是这样的函数,只不过参数叫做 props
function SayHello(props) {
  // props 是一个对象,装着调用者传来的所有"参数"
  return <h1>你好,{props.userName}!</h1>;
}
// 使用时就像给函数传参一样:
<SayHello userName="小明" />  {/* 渲染结果:你好,小明!*/}
普通 JavaScript 函数 React 函数组件
function fn(a, b) function Comp(props)
fn(1, 2) 调用传参 <Comp a={1} b={2} />
参数在函数体内使用 props 在 JSX 中使用
参数是只读的局部变量 props 是只读的

1.3 Props 在 React 数据流中的位置

React 的核心设计原则是单向数据流

父组件(数据的拥有者)
    │
    │  通过 Props 向下传递
    ▼
子组件(数据的使用者)
    │
    │  通过 Props 继续向下传递
    ▼
孙子组件(数据的最终使用者)
  • 数据只能从父流向子,不能反过来(就像水往低处流)
  • 如果子组件需要"改变"父组件的数据,必须通过回调函数(父组件把一个函数作为 props 传给子组件,子组件调用这个函数"上报"变化)

口诀数据向下流,事件向上冒。Props 是单向高速公路,只能从上往下开。



第二部分:Props 的类型定义


在 React 中定义 Props 的类型,主要有两大流派:

  • TypeScript 类型注解:编译时检查,写在代码里,IDE 有智能提示(现代项目首选)
  • PropTypes 运行时校验:运行时检查,不阻止运行只打印警告,兼容纯 JS 项目

下面分别覆盖两种组件(函数组件、类组件)在 TS 下的写法,以及纯 JS 下 PropTypes 的完整用法。


2.1 函数组件 TypeScript 类型定义

2.1.1 基础类型 —— 最常用的场景

// 01-ts-basic.tsx —— 可直接复制运行
// ============================================================
// 第一步:用 interface 或 type 定义 Props 的"形状"(Shape)
//        interface:接口,可以理解为"一份合同",规定 props 必须长什么样
// ============================================================
interface UserCardProps {
  //   属性名      类型注解      含义
  name: string; // 姓名,字符串类型
  age: number; // 年龄,数字类型
  isVip: boolean; // 是否为 VIP,布尔类型
}

// ============================================================
// 第二步:在函数参数后标注类型
// ============================================================
function UserCard({ name, age, isVip }: UserCardProps) {
  // 解构取值的同时获得完整的类型提示
  return (
    <div style={{ border: "1px solid #ccc", padding: 16, borderRadius: 8 }}>
      <h2>{name}</h2>
      {/* 鼠标悬停在 name 上,IDE 会显示:string */}
      <p>年龄:{age} 岁</p>
      {/* 鼠标悬停在 age 上,IDE 会显示:number */}
      <p>身份:{isVip ? "VIP 会员" : "普通用户"}</p>
      {/* isVip 是 boolean,三元表达式类型安全 */}
    </div>
  );
}

// ============================================================
// 第三步:使用组件时传错类型 → TypeScript 直接标红报错
// ============================================================
function App() {
  return (
    <div>
      {/* ✅ 正确:类型全部匹配 */}
      <UserCard name="小明" age={18} isVip={true} />

      {/* ❌ 错误:age 传了字符串,TS 编译报错 */}
      {/* <UserCard name="小明" age="十八岁" isVip={true} /> */}

      {/* ❌ 错误:缺少必填属性 name,TS 编译报错 */}
      {/* <UserCard age={18} isVip={true} /> */}
    </div>
  );
}

export default App;

白话版interface 相当于你跟 TypeScript 签了一份合同——"我的组件需要三个东西:name(字符串)、age(数字)、isVip(布尔),缺一不可,类型不对也不行。" TypeScript 就像一个严格的合同审核员,传错就标红。


2.1.2 可选类型 —— 有些 Props 可以不传

// 02-ts-optional.tsx —— 可直接复制运行
// ============================================================
// 在属性名后加 ? 表示"这个属性可以不传"
// ============================================================
interface ButtonProps {
  text: string; // 必填:按钮上显示的文字
  size?: "small" | "medium" | "large"; // 可选:按钮尺寸,只能选这三者之一
  //  └── ? 表示"可以不传,传了就必须是这三个值之一"
  disabled?: boolean; // 可选:是否禁用,不传默认为 false
  onClick?: () => void; // 可选:点击回调函数
  //            └── void 表示"这个函数没有返回值"
}

function Button({
  text,
  size = "medium",
  disabled = false,
  onClick,
}: ButtonProps) {
  //                          └── 解构时给默认值(TypeScript 也能识别)
  // 根据 size 计算实际像素值
  const sizeMap = {
    small: { padding: "6px 12px", fontSize: 12 },
    medium: { padding: "10px 20px", fontSize: 16 },
    large: { padding: "14px 28px", fontSize: 20 },
  };

  return (
    <button
      style={{
        ...sizeMap[size], // size 永远有值,因为有默认值兜底
        cursor: disabled ? "not-allowed" : "pointer",
        opacity: disabled ? 0.5 : 1,
      }}
      disabled={disabled} // disabled 是 HTML button 的原生属性
      onClick={onClick} // onClick 可能为 undefined,但 button 的 onClick 接受 undefined
    >
      {text}
    </button>
  );
}

// ============================================================
// 使用演示:可选属性可传可不传
// ============================================================
function App() {
  return (
    <div style={{ display: "flex", gap: 10, padding: 20 }}>
      {/* ✅ 只传必填的 text,其余用默认值 */}
      <Button text="默认按钮" />

      {/* ✅ 传递部分可选属性 */}
      <Button text="大号按钮" size="large" />

      {/* ✅ 传递全部属性 */}
      <Button
        text="不可点击"
        size="small"
        disabled={true}
        onClick={() => alert("这个按钮不可点击")}
      />
    </div>
  );
}

export default App;

白话版? 就是"选填"标记。就像填表——姓名是必填(不带 ?),手机号是选填(带 ?)。选填项不写也没事,TypeScript 不会报警告。


2.1.3 联合类型 —— 限定可选值的范围

// 03-ts-union.tsx —— 可直接复制运行
// ============================================================
// 联合类型:用 | 分隔多个可能的类型
// 作用:比 string 更精确,防止传入意料之外的值
// ============================================================

// 定义状态类型 —— 只能是这三个字符串之一
type StatusType = "success" | "warning" | "error";
//    └── type 关键字:给类型起一个别名(类似给手机号存一个"联系人"名字)

// 定义按钮变体类型
type VariantType = "primary" | "danger" | "default" | "text";

interface StatusBadgeProps {
  status: StatusType; // 必填,值必须是 "success" | "warning" | "error"
  variant?: VariantType; // 可选,值必须是四个变体之一
  message: string; // 必填的提示文字
}

function StatusBadge({
  status,
  variant = "default",
  message,
}: StatusBadgeProps) {
  // 根据 status 精确匹配颜色(不需要 default 兜底,因为 TS 保证不会传错)
  const colorMap: Record<StatusType, string> = {
    // Record<Key类型, Value类型>:创建一个"键值对字典"的类型
    success: "#4CAF50", // 绿色
    warning: "#FF9800", // 橙色
    error: "#F44336", // 红色
  };

  return (
    <div
      style={{
        display: "inline-flex",
        alignItems: "center",
        gap: 8,
        padding: "8px 16px",
        backgroundColor: colorMap[status],
        color: "white",
        borderRadius: 6,
        fontSize: 14,
      }}
    >
      {/* 不同状态显示不同图标 */}
      <span>
        {status === "success" ? "✓" : status === "warning" ? "⚠" : "✕"}
      </span>
      <span>{message}</span>
      {variant !== "default" && (
        <span style={{ fontSize: 11, opacity: 0.8 }}>({variant})</span>
      )}
    </div>
  );
}

function App() {
  return (
    <div
      style={{ display: "flex", flexDirection: "column", gap: 10, padding: 20 }}
    >
      <StatusBadge status="success" message="数据保存成功" />
      <StatusBadge status="warning" message="磁盘空间不足" variant="danger" />
      <StatusBadge status="error" message="网络连接失败" variant="primary" />

      {/* ❌ 编译报错:"loading" 不在 StatusType 的范围内 */}
      {/* <StatusBadge status="loading" message="加载中..." /> */}
    </div>
  );
}

export default App;

白话版type StatusType = "success" | "warning" | "error" 意思是"StatusType 这个类型只能取这三个值,其他的都不行"。就像自助餐厅只有三种饮料(可乐、雪碧、芬达),你点不了橙汁——TypeScript 会直接拦住你。


2.1.4 复杂引用类型 —— 对象、数组、函数的类型定义

// 04-ts-complex.tsx —— 可直接复制运行
// ============================================================
// 实际开发中最常见的 props 类型:对象、数组、函数、children
// ============================================================

// ---------- 1. 定义嵌套对象的类型 ----------
interface Address {
  city: string; // 城市
  district: string; // 区
  detail?: string; // 详细地址(可选)
}

// ---------- 2. 定义数组元素的类型 ----------
interface Hobby {
  id: number; // 唯一ID
  name: string; // 爱好名称
  level: "beginner" | "intermediate" | "advanced"; // 熟练程度
}

// ---------- 3. 定义组件的 Props ----------
interface UserProfileProps {
  // ----- 对象类型 -----
  user: {
    name: string;
    age: number;
    address: Address; // 嵌套对象,引用上面定义的 Address 类型
  };

  // ----- 数组类型 -----
  hobbies: Hobby[]; // Hobby 类型的数组,等价于 Array<Hobby>

  // ----- 函数类型(回调) -----
  onFollow: (userId: number) => void;
  //         └── 参数列表        └── 返回值类型(void = 无返回值)
  onDelete: (userId: number, reason: string) => Promise<void>;
  //                                            └── Promise<void> = 返回一个 Promise

  // ----- children 类型 -----
  children?: React.ReactNode;
  //          └── ReactNode 是 React 中最宽泛的"可渲染内容"类型
  //             可以是 string、number、JSX 元素、数组、null、undefined 等
}

function UserProfile({
  user,
  hobbies,
  onFollow,
  onDelete,
  children,
}: UserProfileProps) {
  // 计算用户等级(根据年龄)
  const userLevel = user.age >= 60 ? "老年" : user.age >= 30 ? "中年" : "青年";

  return (
    <div
      style={{
        border: "1px solid #e0e0e0",
        borderRadius: 12,
        padding: 24,
        maxWidth: 400,
      }}
    >
      {/* 头部:用户基础信息 */}
      <div style={{ marginBottom: 16 }}>
        <h2 style={{ margin: 0 }}>{user.name}</h2>
        <p style={{ color: "#888", margin: "4px 0" }}>
          {user.age} 岁 · {userLevel} · {user.address.city}{" "}
          {user.address.district}
        </p>
        {user.address.detail && (
          <p style={{ color: "#aaa", fontSize: 13, margin: 0 }}>
            {user.address.detail}
          </p>
        )}
      </div>

      {/* 爱好列表 */}
      <div style={{ marginBottom: 16 }}>
        <h4 style={{ margin: "0 0 8px" }}>爱好标签:</h4>
        <div style={{ display: "flex", gap: 6, flexWrap: "wrap" }}>
          {hobbies.map((hobby) => (
            <span
              key={hobby.id} // key 必须在 map 的最外层元素上
              style={{
                padding: "4px 10px",
                borderRadius: 12,
                fontSize: 13,
                backgroundColor:
                  hobby.level === "advanced"
                    ? "#e8f5e9"
                    : hobby.level === "intermediate"
                      ? "#fff3e0"
                      : "#f3e5f5",
                color:
                  hobby.level === "advanced"
                    ? "#2e7d32"
                    : hobby.level === "intermediate"
                      ? "#e65100"
                      : "#6a1b9a",
              }}
            >
              {hobby.name}
            </span>
          ))}
        </div>
      </div>

      {/* children 插槽区域 */}
      {children && (
        <div
          style={{
            marginBottom: 16,
            padding: 12,
            backgroundColor: "#f9f9f9",
            borderRadius: 8,
          }}
        >
          {children}
        </div>
      )}

      {/* 操作按钮 */}
      <div style={{ display: "flex", gap: 8 }}>
        <button
          onClick={() => onFollow(1)}
          style={{ padding: "8px 20px", cursor: "pointer" }}
        >
          关注
        </button>
        <button
          onClick={() => onDelete(1, "不再感兴趣")}
          style={{
            padding: "8px 20px",
            cursor: "pointer",
            color: "#f44336",
            border: "1px solid #f44336",
            background: "transparent",
          }}
        >
          删除
        </button>
      </div>
    </div>
  );
}

function App() {
  // 模拟用户数据(实际开发中通常来自 API 请求)
  const sampleUser = {
    name: "小明",
    age: 25,
    address: {
      city: "北京",
      district: "朝阳区",
      detail: "望京SOHO T3 12层",
    },
  };

  const sampleHobbies: Hobby[] = [
    // 显式标注数组类型,获得智能提示
    { id: 1, name: "React 编程", level: "advanced" },
    { id: 2, name: "篮球", level: "intermediate" },
    { id: 3, name: "摄影", level: "beginner" },
  ];

  return (
    <UserProfile
      user={sampleUser}
      hobbies={sampleHobbies}
      onFollow={(id) => console.log("关注了用户", id)}
      onDelete={async (id, reason) => {
        // async 函数自动返回 Promise
        console.log("删除了用户", id, "原因:", reason);
      }}
    >
      {/* 这段 JSX 会通过 children prop 传给 UserProfile */}
      <p style={{ margin: 0, fontSize: 14, color: "#666" }}>
        💬 "热爱生活,热爱代码。"
      </p>
    </UserProfile>
  );
}

export default App;

白话版

  • React.ReactNode 是 React 里"什么都能渲染的东西"的统一名字,包括文字、数字、HTML标签、其他组件等。
  • () => void 表示"这个函数不需要返回任何东西,调它就是为了执行一个操作"。
  • () => Promise<void> 表示"这个函数做的是异步操作(比如发网络请求)"。

2.1.5 综合示例 —— 一个完整组件所需的全部 TS Props 技巧

// 05-ts-complete.tsx —— 可直接复制运行,覆盖所有常见 Props 类型场景
import React, { useState } from "react";

// ==================== 类型定义区 ====================

// 表格列配置的类型
interface Column {
  key: string; // 列的 key(对应数据中的字段名)
  title: string; // 列标题
  width?: number; // 列宽(可选)
  sortable?: boolean; // 是否可排序(可选)
  render?: (value: unknown, record: Record<string, unknown>) => React.ReactNode;
  //        └── unknown 表示"我不知道是什么类型,但比 any 安全"
  //        └── Record<string, unknown> = { [key: string]: unknown }
  //            即"键是字符串、值类型未知的对象"
}

// 分页配置的类型
interface Pagination {
  current: number; // 当前页码
  pageSize: number; // 每页条数
  total: number; // 总条数
  onChange: (page: number) => void; // 翻页回调
}

// 组件的 Props
interface DataTableProps {
  // --- 必填 ---
  columns: Column[]; // 列配置数组
  dataSource: Record<string, unknown>[]; // 数据源(对象数组)

  // --- 可选 ---
  rowKey?: string; // 行唯一标识的字段名,默认 "id"
  loading?: boolean; // 加载状态
  emptyText?: string; // 空数据提示文字
  pagination?: Pagination; // 分页配置(整个对象可选)
}

// ==================== 组件实现 ====================

function DataTable({
  columns,
  dataSource,
  rowKey = "id",
  loading = false,
  emptyText = "暂无数据",
  pagination,
}: DataTableProps) {
  // 当前排序状态
  const [sortKey, setSortKey] = useState<string | null>(null);
  //                              └── useState 的泛型:string | null 联合类型
  const [sortOrder, setSortOrder] = useState<"asc" | "desc">("asc");
  //                                       └── 泛型限定只能是这两个字符串

  // 排序逻辑
  const sortedData = [...dataSource].sort((a, b) => {
    if (!sortKey) return 0;
    const aVal = a[sortKey];
    const bVal = b[sortKey];
    if (typeof aVal === "number" && typeof bVal === "number") {
      return sortOrder === "asc" ? aVal - bVal : bVal - aVal;
    }
    return 0;
  });

  // 处理表头点击排序
  const handleSort = (key: string) => {
    if (sortKey === key) {
      // 同一列 → 切换正序/倒序
      setSortOrder((prev) => (prev === "asc" ? "desc" : "asc"));
    } else {
      // 不同列 → 重置为正序
      setSortKey(key);
      setSortOrder("asc");
    }
  };

  // ===== 加载态 =====
  if (loading) {
    return <div style={{ textAlign: "center", padding: 40 }}>加载中...</div>;
  }

  // ===== 空数据态 =====
  if (dataSource.length === 0) {
    return (
      <div style={{ textAlign: "center", padding: 40, color: "#999" }}>
        {emptyText}
      </div>
    );
  }

  // ===== 正常数据态 =====
  return (
    <div>
      <table style={{ width: "100%", borderCollapse: "collapse" }}>
        {/* 表头 */}
        <thead>
          <tr style={{ backgroundColor: "#f5f5f5" }}>
            {columns.map((col) => (
              <th
                key={col.key}
                style={{
                  padding: "12px 16px",
                  textAlign: "left",
                  borderBottom: "2px solid #e0e0e0",
                  cursor: col.sortable ? "pointer" : "default",
                  width: col.width,
                }}
                onClick={() => col.sortable && handleSort(col.key)}
              >
                {col.title}
                {col.sortable && sortKey === col.key && (
                  <span style={{ marginLeft: 4 }}>
                    {sortOrder === "asc" ? "↑" : "↓"}
                  </span>
                )}
              </th>
            ))}
          </tr>
        </thead>

        {/* 表体 */}
        <tbody>
          {sortedData.map((record) => (
            <tr
              key={String(record[rowKey])}
              style={{ borderBottom: "1px solid #f0f0f0" }}
            >
              {/* 将 rowKey 转为字符串,因为 key 需要 string | number */}
              {columns.map((col) => (
                <td key={col.key} style={{ padding: "12px 16px" }}>
                  {/* 有自定义 render 就用 render,没有就直接显示原始值 */}
                  {col.render
                    ? col.render(record[col.key], record)
                    : String(record[col.key] ?? "-")}
                  {/*                                └── ?? 是"空值合并运算符"
                         record[col.key] 为 null 或 undefined 时显示 "-" */}
                </td>
              ))}
            </tr>
          ))}
        </tbody>
      </table>

      {/* 分页 */}
      {pagination && (
        <div
          style={{
            display: "flex",
            justifyContent: "flex-end",
            padding: 16,
            gap: 8,
          }}
        >
          <span style={{ color: "#666" }}>
            共 {pagination.total} 条,第 {pagination.current}/
            {Math.ceil(pagination.total / pagination.pageSize)} 页
          </span>
          <button
            disabled={pagination.current <= 1}
            onClick={() => pagination.onChange(pagination.current - 1)}
          >
            上一页
          </button>
          <button
            disabled={
              pagination.current >=
              Math.ceil(pagination.total / pagination.pageSize)
            }
            onClick={() => pagination.onChange(pagination.current + 1)}
          >
            下一页
          </button>
        </div>
      )}
    </div>
  );
}

// ==================== 使用示例 ====================
function App() {
  const columns: Column[] = [
    { key: "name", title: "姓名", sortable: true },
    { key: "age", title: "年龄", width: 80, sortable: true },
    { key: "email", title: "邮箱" },
    {
      key: "status",
      title: "状态",
      // 自定义渲染:将原始值转换为带颜色的标签
      render: (value) => {
        // value 类型为 unknown,但你可以根据业务逻辑安全使用
        const status = value as string; // 类型断言:告诉 TS"我知道它是 string"
        const colorMap: Record<string, string> = {
          active: "#4CAF50",
          inactive: "#9E9E9E",
          pending: "#FF9800",
        };
        return (
          <span
            style={{
              padding: "2px 8px",
              borderRadius: 4,
              backgroundColor: colorMap[status] || "#e0e0e0",
              color: "white",
              fontSize: 13,
            }}
          >
            {status}
          </span>
        );
      },
    },
  ];

  const data: Record<string, unknown>[] = [
    {
      id: 1,
      name: "小明",
      age: 25,
      email: "xiaoming@example.com",
      status: "active",
    },
    {
      id: 2,
      name: "小红",
      age: 30,
      email: "xiaohong@example.com",
      status: "pending",
    },
    {
      id: 3,
      name: "小刚",
      age: 28,
      email: "xiaogang@example.com",
      status: "inactive",
    },
  ];

  const [page, setPage] = useState(1);

  return (
    <DataTable
      columns={columns}
      dataSource={data}
      loading={false}
      pagination={{
        current: page,
        pageSize: 2,
        total: 3,
        onChange: (p) => setPage(p),
      }}
    />
  );
}

export default App;

2.2 类组件 TypeScript 类型定义

虽然现在主流是函数组件,但维护老项目时仍会遇到类组件。以下是类组件 Props 的 TS 写法:

// 06-ts-class.tsx —— 类组件 TypeScript Props 定义
import React, { Component } from "react";

// ============================================================
// 第一步:定义 Props 和 State 的类型(和函数组件一样)
// ============================================================
interface ClassCardProps {
  title: string; // 必填:卡片标题
  content: string; // 必填:卡片内容
  variant?: "default" | "outlined" | "elevated"; // 可选:卡片样式变体
  onClose?: () => void; // 可选:关闭回调
}

// State(状态)也需要定义类型
interface ClassCardState {
  isVisible: boolean; // 是否可见
  clickCount: number; // 点击次数
}

// ============================================================
// 第二步:Component<Props类型, State类型>
//        泛型参数1 = Props 的类型
//        泛型参数2 = State 的类型
// ============================================================
class ClassCard extends Component<ClassCardProps, ClassCardState> {
  //                                   └── 两个泛型参数

  // ----- 构造函数:初始化 state -----
  constructor(props: ClassCardProps) {
    super(props); // 类组件中必须先调用 super(props)
    this.state = {
      isVisible: true,
      clickCount: 0,
    };
  }

  // ----- 事件处理方法(需要用箭头函数绑定 this 或手动 bind)-----
  handleClose = () => {
    // 箭头函数自动绑定 this,不需要 constructor 里 bind
    this.setState({ isVisible: false }); // 更新 state
    this.props.onClose?.(); // 调用父组件传来的回调
    //     └── ?. 是"可选链操作符":onClose 存在才调用,不存在就跳过
  };

  handleClick = () => {
    this.setState((prevState) => ({
      // 函数式更新,prevState 保证是最新值
      clickCount: prevState.clickCount + 1,
    }));
  };

  // ----- render 方法:必须实现,返回 JSX -----
  render() {
    // 从 props 和 state 中解构取值
    const { title, content, variant = "default" } = this.props;
    //    └── 类组件中 props 挂在 this 上,不是函数的参数
    const { isVisible, clickCount } = this.state;

    // 根据 variant 计算样式
    const variantStyle: Record<string, React.CSSProperties> = {
      // React.CSSProperties 是 style 属性的内置类型
      default: {
        border: "1px solid #ccc",
        boxShadow: "none",
      },
      outlined: {
        border: "2px solid #1976D2",
        boxShadow: "none",
      },
      elevated: {
        border: "none",
        boxShadow: "0 4px 12px rgba(0,0,0,0.15)",
      },
    };

    if (!isVisible) {
      // 不可见时返回 null(不渲染任何内容)
      return null;
    }

    return (
      <div
        style={{
          padding: 20,
          borderRadius: 8,
          maxWidth: 400,
          ...variantStyle[variant],
        }}
      >
        {/* 关闭按钮 */}
        {this.props.onClose && (
          // 如果有 onClose 回调才显示关闭按钮
          <button
            onClick={this.handleClose}
            style={{
              float: "right",
              cursor: "pointer",
              border: "none",
              background: "none",
            }}
          >
            ✕
          </button>
        )}

        <h3 style={{ margin: "0 0 8px" }}>{title}</h3>
        <p style={{ color: "#555", margin: 0 }}>{content}</p>

        {/* 计数器 */}
        <button onClick={this.handleClick} style={{ marginTop: 12 }}>
          点击次数:{clickCount}
        </button>
      </div>
    );
  }
}

// ============================================================
// 类组件一样可以设置 defaultProps
// ============================================================
// ClassCard.defaultProps = {
//   variant: "default",
// };

export default ClassCard;

白话版:类组件就像一个"模具车间"——constructor 是初始化设备(设置 state),render 是生产产品(返回 JSX),this.props 是外部送来的原材料,this.state 是车间内部的库存。


2.3 纯 JS 环境下的 PropTypes 校验

如果项目不使用 TypeScript,React 提供了 PropTypes 来做运行时的类型校验。它在开发环境中检测到类型不匹配时,会在浏览器控制台打印警告。

2.3.1 安装与导入

# React 19 + Vite 环境通常已内置,无需额外安装
# 如果没有,运行:
npm install prop-types
import PropTypes from "prop-types";

2.3.2 全部常用校验规则速查

// 07-proptypes-all.jsx —— PropTypes 全部常用校验规则一览
import PropTypes from "prop-types";

function DemoComponent({
  name,
  age,
  isAdmin,
  hobbies,
  address,
  onSave,
  status,
  scores,
  config,
  avatar,
  children,
}) {
  // 组件逻辑省略...
  return <div>{/* ... */}</div>;
}

// ============================================================
// 挂载 propTypes 对象 —— 定义每个 prop 的校验规则
// ============================================================
DemoComponent.propTypes = {
  // ---------- 基础类型 ----------
  name: PropTypes.string, // 字符串
  age: PropTypes.number, // 数字(包括整数和小数)
  isAdmin: PropTypes.bool, // 布尔值(true / false)

  // ---------- 引用类型 ----------
  hobbies: PropTypes.array, // 数组(不关心数组里是什么)
  address: PropTypes.object, // 对象(不关心对象里有什么属性)
  onSave: PropTypes.func, // 函数

  // ---------- React 专用类型 ----------
  avatar: PropTypes.element, // 一个 React 元素(如 <img />、<div />)
  children: PropTypes.node, // 可渲染的内容(文字、元素、数组、数字等,最宽泛)

  // ---------- 枚举类型(限定可选值范围) ----------
  status: PropTypes.oneOf(["active", "inactive", "pending"]),
  // 只能是这三个字符串之一,传其他值 → 警告

  // ---------- 数组元素类型校验 ----------
  scores: PropTypes.arrayOf(PropTypes.number),
  // 必须是数组,且数组里每个元素都是数字

  // ---------- 对象结构校验 ----------
  config: PropTypes.shape({
    // shape:对象必须包含指定的属性
    title: PropTypes.string.isRequired, // 嵌套属性也可以加 isRequired
    width: PropTypes.number,
    height: PropTypes.number,
  }),

  // ---------- 必填(加 .isRequired) ----------
  name: PropTypes.string.isRequired, // name 必须传,且必须是字符串
};

// ============================================================
// 设置默认值(可选属性没有传值时使用)
// ============================================================
DemoComponent.defaultProps = {
  isAdmin: false, // 不传时默认为 false
  hobbies: [], // 不传时默认为空数组
  status: "active", // 不传时默认为 "active"
};

export default DemoComponent;

2.3.3 全部 PropTypes 校验规则速查表

写法 含义 示例合格值 示例不合格值
PropTypes.string 字符串 "hello" 123true
PropTypes.number 数字 1233.14 "123"
PropTypes.bool 布尔值 truefalse 0"true"
PropTypes.array 数组 [1,2,3]["a","b"] {a:1}
PropTypes.object 对象 {a:1}new Date() null[1,2]
PropTypes.func 函数 () => {}function(){} "fn"
PropTypes.symbol Symbol 类型 Symbol("id") "symbol"
PropTypes.node 任何可渲染的内容 "hello"123<div/>[<p/>, <span/>]
PropTypes.element 一个 React 元素 <div/><MyComp/> "hello"
PropTypes.elementType 一个 React 组件类型(构造函数) MyComponent"div" <MyComponent/>
PropTypes.instanceOf(Date) 某个类的实例 new Date() {}
PropTypes.oneOf(["a", "b"]) 只能是列表中的某一项 "a""b" "c"
PropTypes.oneOfType([PropTypes.string, PropTypes.number]) 多种类型中的某一种 "hello"123 true
PropTypes.arrayOf(PropTypes.number) 由指定类型元素组成的数组 [1, 2, 3] ["1", "2"]
PropTypes.objectOf(PropTypes.number) 值都是指定类型的对象 {a: 1, b: 2} {a: "1"}
PropTypes.shape({name: PropTypes.string}) 对象必须符合指定的结构 {name: "小明", age: 18} {age: 18}
PropTypes.exact({name: PropTypes.string}) 对象必须精确匹配结构(不能有多余属性) {name: "小明"} {name:"小明",age:18}
.isRequired 该 prop 必填(可链式调用) 不传该 prop

白话版PropTypes 就像门卫检查出入证——每人进门前亮出证件(props),门卫按规则(propTypes)核对。证件类型不对或缺少必填项,门卫就在大喇叭(控制台)里喊一声"You 不对劲!"——但不拦你进门(不阻止运行),只在开发环境提醒。


2.4 两种校验方式的对比与选择建议

维度 TypeScript 类型注解 PropTypes 运行时校验
检查时机 编译时(写代码时就标红) 运行时(浏览器控制台打印警告)
IDE 支持 优秀的自动补全、悬停提示、跳转定义 基本无
能否阻止 Bug 能(编译不通过无法运行) 不能(只警告,不阻止运行)
学习成本 中(需要学习 TS 语法) 低(API 简单、直观)
灵活性 极高(泛型、联合类型、条件类型) 有限(仅支持预定义的校验规则)
适用场景 新项目、中大型项目、团队协作 纯 JS 老项目、简单原型、教学演示
维护性 强(类型即文档,重构有保障) 弱(容易过时、忘记同步更新)

推荐策略新项目一律用 TypeScript,老项目逐步迁移。 PropTypes 可以作为 TypeScript 的补充在纯 JS 代码中使用,但不应取代 TS。



第三部分:Props 的使用全流程


3.1 父组件传值:五种数据类型的传递

// 08-parent-to-child.tsx —— 可直接复制运行
// 演示父组件向子组件传递各种类型的数据
import React from "react";

// ==================== 子组件:展示所有类型的 props ====================
interface ShowcaseProps {
  // --- 基础类型 ---
  title: string; // 字符串
  count: number; // 数字
  isPublished: boolean; // 布尔

  // --- 引用类型 ---
  tags: string[]; // 字符串数组
  author: {
    // 对象
    name: string;
    avatar: string;
  };
  onSave: (title: string) => void; // 函数(回调)
}

function Showcase({
  title,
  count,
  isPublished,
  tags,
  author,
  onSave,
}: ShowcaseProps) {
  return (
    <div
      style={{
        border: "1px solid #ddd",
        borderRadius: 8,
        padding: 16,
        maxWidth: 400,
      }}
    >
      <h3>{title}</h3>
      <p>阅读量:{count}</p>
      <p>状态:{isPublished ? "已发布" : "草稿"}</p>

      {/* 数组的渲染 */}
      <div>
        标签:
        {tags.map((tag, index) => (
          <span
            key={index}
            style={{
              marginLeft: 4,
              padding: "2px 8px",
              background: "#e3f2fd",
              borderRadius: 4,
              fontSize: 12,
            }}
          >
            {tag}
          </span>
        ))}
      </div>

      {/* 对象属性的使用 */}
      <div style={{ marginTop: 8 }}>
        作者:
        <img
          src={author.avatar}
          alt={author.name}
          style={{
            width: 24,
            height: 24,
            borderRadius: "50%",
            verticalAlign: "middle",
          }}
        />
        <span style={{ marginLeft: 4 }}>{author.name}</span>
      </div>

      {/* 调用函数类型的 prop */}
      <button onClick={() => onSave(title)} style={{ marginTop: 8 }}>
        保存文章
      </button>
    </div>
  );
}

// ==================== 父组件:准备数据并传递 ====================
function App() {
  // 模拟从数据库或 API 获取的数据
  const articleData = {
    title: "React Props 完全指南",
    count: 1280,
    isPublished: true,
    tags: ["React", "TypeScript", "前端"],
    author: {
      name: "小明老师",
      avatar: "https://i.pravatar.cc/150?img=3",
    },
  };

  // 回调函数:处理子组件的"上报"
  const handleSave = (title: string) => {
    alert(`文章《${title}》已保存!`);
  };

  return (
    <div style={{ padding: 20 }}>
      {/* ===== 方式一:逐属性传递(最常见) ===== */}
      <Showcase
        title={articleData.title} // 字符串:直接传
        count={articleData.count} // 数字:必须用 {} 包裹
        isPublished={articleData.isPublished} // 布尔:必须用 {} 包裹
        tags={articleData.tags} // 数组:用 {} 包裹
        author={articleData.author} // 对象:用 {} 包裹
        onSave={handleSave} // 函数:用 {} 包裹(不要加 (),加括号就变成调用后传返回值了!)
      />

      <hr style={{ margin: "20px 0" }} />

      {/* ===== 方式二:展开运算符批量传递 ===== */}
      <Showcase {...articleData} onSave={handleSave} />
      {/*          └── ...articleData 等价于逐条写出每个属性 */}
      {/* 注意:onSave 不在 articleData 里,需要单独传 */}
    </div>
  );
}

export default App;

传值方式速查

数据类型 传递语法 错误写法 说明
字符串 name="小明" 唯一不需要 {} 的类型
数字 age={18} age="18"(变成字符串了!) 必须用 {},否则被当成字符串
布尔 isVip={true} isVip="true"(变成字符串了!) 必须用 {}
JS 表达式 count={a + b} {} 里可以写任何 JS 表达式
数组 list={[1, 2, 3]} list="[1,2,3]" 必须用 {},双花括号不是数组字面量
对象 user={{ name: "小明" }} 外层 {} 是 JSX,内层 {} 是对象
函数 onClick={handleClick} onClick={handleClick()} 不要加 (),加了就是调用后的返回值
展开运算符 <Comp {...obj} /> 把对象的所有属性展开为独立的 prop

3.2 子组件接收值:四种接收方式

// 09-receive-props.tsx —— 子组件接收 Props 的四种方式

// ===== 方式一:接收整个 props 对象(不推荐,但要知道) =====
function WelcomeV1(props) {
  // props 是一个对象,装着所有传入的属性
  return <h1>你好,{props.name}!</h1>;
  //                  └── 每次用都要写 props.xxx,很繁琐
}

// ===== 方式二:参数位置直接解构(推荐⭐) =====
function WelcomeV2({ name, age }) {
  // 在函数签名的参数位置就解构,相当于:
  // const { name, age } = props;
  return (
    <div>
      <h1>你好,{name}!</h1>
      <p>年龄:{age} 岁</p>
    </div>
  );
}

// ===== 方式三:解构 + 默认值(推荐⭐⭐) =====
function WelcomeV3({ name, age = 18, isVip = false }) {
  //                            └── 默认值:父组件不传时使用
  return (
    <div>
      <h1>你好,{name}!</h1>
      <p>年龄:{age} 岁</p>
      <p>{isVip ? "VIP 用户" : "普通用户"}</p>
    </div>
  );
}

// ===== 方式四:剩余参数收集(rest props) =====
function WelcomeV4({ name, ...restProps }) {
  // 解构出 name,剩余的所有属性打包进 restProps 对象
  console.log("剩余属性:", restProps);
  // 比如 <WelcomeV4 name="小明" age={18} city="北京" />
  // restProps = { age: 18, city: "北京" }

  return (
    <div>
      <h1>你好,{name}!</h1>
      {/* 把剩余属性直接展开传给原生 HTML 元素 */}
      <div {...restProps}>其他信息区域</div>
    </div>
  );
}

3.3 children prop:插槽式的组件组合

children 是 React 中最特殊、最常用的 prop。它不需要显式传递——写在组件标签之间的所有内容,自动成为 children

// 10-children-prop.tsx —— 可直接复制运行
// children 的四种经典用法

// ===== 用法一:基础容器组件 =====
interface CardProps {
  title: string; // 卡片标题
  children?: React.ReactNode; // 卡片内容(写在标签之间)
}

function Card({ title, children }: CardProps) {
  return (
    <div
      style={{
        border: "1px solid #e0e0e0",
        borderRadius: 12,
        overflow: "hidden",
      }}
    >
      {/* 标题栏 */}
      <div
        style={{
          padding: "12px 16px",
          backgroundColor: "#f5f5f5",
          fontWeight: "bold",
        }}
      >
        {title}
      </div>
      {/* 内容区 —— children 在这里渲染 */}
      <div style={{ padding: 16 }}>{children}</div>
    </div>
  );
}

// ===== 用法二:多插槽(多个 children 区域) =====
interface LayoutProps {
  header?: React.ReactNode; // 头部插槽
  sidebar?: React.ReactNode; // 侧边栏插槽
  children?: React.ReactNode; // 主内容区(默认 children)
  footer?: React.ReactNode; // 底部插槽
}

function Layout({ header, sidebar, children, footer }: LayoutProps) {
  return (
    <div
      style={{ display: "flex", flexDirection: "column", minHeight: "100vh" }}
    >
      {/* 头部 */}
      {header && (
        <header
          style={{ padding: 16, backgroundColor: "#1976D2", color: "white" }}
        >
          {header}
        </header>
      )}

      <div style={{ display: "flex", flex: 1 }}>
        {/* 侧边栏 */}
        {sidebar && (
          <aside
            style={{
              width: 200,
              padding: 16,
              backgroundColor: "#f5f5f5",
              borderRight: "1px solid #e0e0e0",
            }}
          >
            {sidebar}
          </aside>
        )}

        {/* 主内容 */}
        <main style={{ flex: 1, padding: 16 }}>{children}</main>
      </div>

      {/* 底部 */}
      {footer && (
        <footer
          style={{
            padding: 16,
            backgroundColor: "#f5f5f5",
            borderTop: "1px solid #e0e0e0",
            textAlign: "center",
          }}
        >
          {footer}
        </footer>
      )}
    </div>
  );
}

// ===== 用法三:对 children 进行遍历/克隆操作 =====
interface ButtonGroupProps {
  children: React.ReactNode;
  direction?: "row" | "column"; // 排列方向
}

function ButtonGroup({ children, direction = "row" }: ButtonGroupProps) {
  return (
    <div style={{ display: "flex", flexDirection: direction, gap: 8 }}>
      {/* React.Children.map:安全地遍历 children(即使只有一个子元素也能正确处理) */}
      {React.Children.map(children, (child, index) => {
        // 给每个子元素包裹一层间距 + 添加序号标记
        return (
          <div
            key={index}
            style={{ display: "inline-flex", alignItems: "center", gap: 4 }}
          >
            <span style={{ fontSize: 10, color: "#999" }}>{index + 1}</span>
            {child}
          </div>
        );
      })}
    </div>
  );
}

// ===== 用法四:children 作为函数(Render Props 模式,进阶用法) =====
interface ToggleProps {
  children: (isOn: boolean, toggle: () => void) => React.ReactNode;
  //         └── children 是一个函数!接收状态和操作方法,返回 UI
}

function Toggle({ children }: ToggleProps) {
  const [isOn, setIsOn] = React.useState(false);
  const toggle = () => setIsOn((prev) => !prev);

  // 调用 children 函数,把状态和控制权交给父组件
  return <>{children(isOn, toggle)}</>;
}

// ==================== 综合使用示例 ====================
function App() {
  return (
    <div style={{ padding: 20, maxWidth: 600, margin: "0 auto" }}>
      {/* 用法一:基础容器 */}
      <Card title="📌 用户协议">
        <p>欢迎使用本产品。请仔细阅读以下条款...</p>
        <p>使用本产品即表示您同意以上条款。</p>
      </Card>

      <div style={{ height: 20 }} />

      {/* 用法二:多插槽布局 */}
      <Layout
        header={<nav>网站 Logo | 导航1 | 导航2</nav>}
        sidebar={
          <ul>
            <li>菜单项 1</li>
            <li>菜单项 2</li>
            <li>菜单项 3</li>
          </ul>
        }
        footer="© 2026 我的网站 · 版权所有"
      >
        <h2>欢迎来到主内容区</h2>
        <p>这里是页面的主要内容。</p>
      </Layout>

      <div style={{ height: 20 }} />

      {/* 用法三:遍历 children */}
      <ButtonGroup>
        <button>按钮A</button>
        <button>按钮B</button>
        <button>按钮C</button>
      </ButtonGroup>

      <div style={{ height: 20 }} />

      {/* 用法四:children 作为函数 */}
      <Toggle>
        {(isOn, toggle) => (
          <div
            onClick={toggle}
            style={{
              display: "inline-block",
              padding: "8px 24px",
              borderRadius: 20,
              cursor: "pointer",
              backgroundColor: isOn ? "#4CAF50" : "#ccc",
              color: "white",
              userSelect: "none",
              transition: "background-color 0.3s",
            }}
          >
            {isOn ? "开" : "关"}
          </div>
        )}
      </Toggle>
    </div>
  );
}

export default App;

白话版children 就是"标签里的东西"。就像夹心饼干——<Card></Card> 是两片饼干,中间夹的 p 标签、按钮等就是 children(夹心)。


3.4 Props 解构赋值最佳实践

// 11-destructure-best-practice.tsx —— Props 解构的六大最佳实践

// ===== 实践一:参数位置解构 + 默认值(最推荐) =====
// ✅ 好处:一眼看出需要哪些 props,默认值就近声明
function UserInfo({
  name,
  age = 0, // 数字默认值
  isAdmin = false, // 布尔默认值
  hobbies = [], // 数组默认值(注意:不能用 null!)
  avatar = "https://i.pravatar.cc/150?img=1", // 字符串默认值
}) {
  return (
    <div>
      <img src={avatar} alt={name} />
      <h3>
        {name}({age} 岁)
      </h3>
    </div>
  );
}

// ===== 实践二:嵌套对象的深层解构 =====
// ✅ 适合需要取出嵌套对象中多个属性的场景
function AddressCard({
  user: {
    name,
    address: { city, district }, // 深层解构:直接拿到 city 和 district
  },
}: {
  user: {
    name: string;
    address: { city: string; district: string };
  };
}) {
  return (
    <div>
      <p>姓名:{name}</p>
      <p>
        地址:{city} {district}
      </p>
    </div>
  );
}

// ===== 实践三:重命名解构(alias) =====
// ✅ 适合 props 名与组件内部变量名冲突时
function FormField({
  name: fieldName, // 重命名:props 叫 name,组件里用 fieldName(避免和 HTML name 属性混淆)
  value: fieldValue, // 重命名:props 叫 value,组件里用 fieldValue
}) {
  return <input name={fieldName} defaultValue={fieldValue} />;
}

// ===== 实践四:剩余参数 rest props(传给原生元素时特别有用) =====
// ✅ 自己处理特定的 prop,剩余的透传给底层 HTML 元素
interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
  //                           └── 继承 HTML input 元素的所有原生属性类型
  label: string; // 额外增加的自定义 prop
}

function LabeledInput({ label, id, ...inputProps }: InputProps) {
  //                            └── 收集剩余所有属性(className, placeholder, value, onChange 等)
  const uniqueId = id || label; // 如果没有传 id,就用 label 作为 id
  return (
    <div style={{ marginBottom: 12 }}>
      <label htmlFor={uniqueId} style={{ display: "block", marginBottom: 4 }}>
        {label}
      </label>
      <input
        id={uniqueId}
        {...inputProps} // 把所有剩余属性展开传给原生 input
        style={{
          padding: "8px 12px",
          border: "1px solid #ccc",
          borderRadius: 4,
          width: "100%",
        }}
      />
    </div>
  );
}

// ===== 实践五:Props 解构 + TypeScript 类型 =====
interface ProductCardProps {
  product: {
    id: number;
    name: string;
    price: number;
    inStock: boolean;
  };
  onBuy: (productId: number) => void;
}

function ProductCard({
  product: { id, name, price, inStock },
  onBuy,
}: ProductCardProps) {
  //                     └── 解构 + 重命名同时进行
  return (
    <div>
      <h4>{name}</h4>
      <p>¥{price}</p>
      <button disabled={!inStock} onClick={() => onBuy(id)}>
        {inStock ? "立即购买" : "缺货"}
      </button>
    </div>
  );
}

// ===== 实践六:不要解构你不用的 props =====
// ❌ 坏习惯:解构了一大堆,只用了其中一两个
function BadExample({ name, age, city, hobby, job, ...rest }) {
  // 只有 name 被用了,其他的解构都是浪费
  return <h1>{name}</h1>;
}

// ✅ 好习惯:只解构你需要的
function GoodExample(props: { name: string }) {
  // 或者直接 { name }
  return <h1>{props.name}</h1>;
}

解构最佳实践速查表

场景 推荐写法 说明
基础解构 + 默认值 { name, age = 18 } 默认值就近声明,一目了然
深层嵌套解构 { user: { name, address: { city } } } 直接拿到深层属性
重命名 alias { name: userName } 避免变量名冲突
剩余参数(rest) { label, ...inputProps } 剩余的透传给原生元素
解构 + 类型注解 { name }: { name: string } 解构的同时标注类型
解构 + TS interface { name }: UserProps interface 定义在外,解构干净
不要过度解构 只解构你真正用到的属性 减少不必要的变量声明


第四部分:注意事项与常见误区


4.1 Props 只读性 —— 铁律不可破

Props 是 React 中绝对不能修改的数据。这个规则不是建议,而是硬性规定——React 内部依赖 props 的不可变性来保证数据流的可预测性。

为什么不能修改?

父组件的 state
      │
      │  传给子组件(通过 props)
      ▼
子组件的 props  ← 这个值是父组件"借"给子组件看的
      │
      │  如果子组件改了 props...
      ▼
父组件的 state 不会跟着变!数据不一致,Bug 的温床。

错误 vs 正确 对比代码

// 12-immutable-props.tsx —— Props 只读性:错误 vs 正确对比

// ===== 场景:子组件收到一个用户对象,想要显示大写的名字 =====

// ❌ 错误做法一:直接修改 props 对象的属性
function UserBad1({ user }: { user: { name: string } }) {
  user.name = user.name.toUpperCase(); // ❌ 直接改 props!
  // TypeScript 模式下此处会报编译错误
  return <h1>{user.name}</h1>;
}

// ❌ 错误做法二:试图重新给解构出的变量赋值
function UserBad2({ name }: { name: string }) {
  name = name.toUpperCase(); // ❌ 虽然 JS 允许,但这改了局部变量,不是改 props
  // 问题是:这会让代码阅读者以为 props 被修改了
  return <h1>{name}</h1>;
}

// ✅ 正确做法一:创建新的局部变量
function UserGood1({ user }: { user: { name: string } }) {
  const displayName = user.name.toUpperCase(); // ✅ 新变量,不动 props
  return <h1>{displayName}</h1>;
}

// ✅ 正确做法二:在 JSX 中直接计算(适合简单变换)
function UserGood2({ user }: { user: { name: string } }) {
  return <h1>{user.name.toUpperCase()}</h1>; // ✅ 只是读取,没有修改
}

// ✅ 正确做法三:用 useMemo 缓存计算结果(适合复杂计算)
function UserGood3({
  user,
}: {
  user: { firstName: string; lastName: string };
}) {
  const fullName = React.useMemo(
    () => `${user.lastName} ${user.firstName}`.toUpperCase(),
    //                    └── useMemo:只在依赖变化时重新计算
    [user.firstName, user.lastName], // 依赖数组
  );
  return <h1>{fullName}</h1>;
}

// ✅ 正确做法四:需要"修改"数据时,调用父组件传来的回调函数
function UserGood4({
  user,
  onUpdateName, // 父组件传来的回调函数
}: {
  user: { id: number; name: string };
  onUpdateName: (id: number, newName: string) => void;
}) {
  const handleRename = () => {
    const newName = prompt("请输入新名字:", user.name);
    if (newName && newName !== user.name) {
      onUpdateName(user.id, newName); // ✅ 通知父组件去改,自己不碰数据
    }
  };

  return (
    <div>
      <span>{user.name}</span>
      <button onClick={handleRename}>改名</button>
    </div>
  );
}

口诀Props 是父组件的数据副本,子组件只有"查看权",没有"修改权"。要改数据,请呼叫父组件(回调函数)。


4.2 父组件更新 → 子组件重渲染机制

这是 React 最核心的行为之一,理解它才能写出高性能的组件。

触发子组件重渲染的三种情况

// 13-rerender-mechanism.tsx —— 子组件重渲染触发机制
import React, { useState } from "react";

// ===== 子组件 =====
function Child({ name, age }: { name: string; age: number }) {
  console.log(`Child 渲染了:name=${name}, age=${age}`);
  // 每次渲染都会打印,方便观察
  return (
    <div>
      {name} - {age} 岁
    </div>
  );
}

// ===== 父组件 =====
function App() {
  const [count, setCount] = useState(0); // 父组件自己的 state
  const [userName, setUserName] = useState("小明"); // 传给子组件的 state

  return (
    <div>
      {/* 情况一:父组件自身的 state 变化 → 父组件重渲染 → 子组件也重渲染 */}
      <p>父组件计数器:{count}</p>
      <button onClick={() => setCount(count + 1)}>count +1</button>

      {/* 情况二:传给子组件的 props 变化 → 子组件重渲染 */}
      <p>用户名:{userName}</p>
      <button
        onClick={() => setUserName(userName === "小明" ? "小红" : "小明")}
      >
        切换用户
      </button>

      <hr />
      <Child name={userName} age={25} />

      <hr />

      {/* 情况三:父组件 state 变化,但子组件的 props 没变 → 子组件仍然会重渲染! */}
      {/* 这就是为什么需要 React.memo 来优化 */}
      <Child name="固定的名字" age={30} />
      {/* 点击 count+1 按钮时,这个 Child 也会重渲染,即使它的 props(name、age)完全没变! */}
    </div>
  );
}

export default App;

重渲染规则总结

触发条件 子组件是否重渲染 说明
父组件的 state 变化(如 setState) ✅ 会 父组件渲染 → 默认所有子组件都渲染
传给子组件的 props 值变化 ✅ 会 父组件用新值调用子组件
父组件 state 变化,但子组件 props 不变 ✅ 会(默认行为) 这是 React 默认行为,需用 React.memo 优化
子组件自己的 state 变化 ✅ 会 自身状态变化触发重渲染

如何避免不必要的重渲染

// 14-memo-optimization.tsx —— 使用 React.memo 阻止不必要的重渲染
import React, { useState } from "react";

// ===== 用 React.memo 包裹子组件 =====
// React.memo 的作用:如果 props 没变,就跳过重渲染(复用上次的结果)
const MemoizedChild = React.memo(function Child({ name }: { name: string }) {
  console.log("MemoizedChild 渲染:", name);
  // 只有 name 变化时才会打印
  return <div>姓名:{name}</div>;
});

// 也可以写成这样:
// function Child({ name }: { name: string }) { ... }
// const MemoizedChild = React.memo(Child);

function App() {
  const [count, setCount] = useState(0);
  const [name, setName] = useState("小明");

  return (
    <div>
      <p>count: {count}</p>
      <button onClick={() => setCount(count + 1)}>count +1</button>
      {/* 点击这个按钮 → 父组件重渲染 → 但 MemoizedChild 的 props(name)没变 → 不重渲染! */}

      <button onClick={() => setName(name === "小明" ? "小红" : "小明")}>
        切换名字
      </button>
      {/* 点击这个按钮 → name 变了 → MemoizedChild 重渲染 */}

      <hr />
      <MemoizedChild name={name} />
      {/* 另一个实例:name 固定不变 → 永远不重渲染 */}
      <MemoizedChild name="固定的名字" />
    </div>
  );
}

export default App;

白话版React.memo 就像一个精明的管家——每次父组件说"大家重新装修!",管家就会检查子组件的"装修材料清单"(props)有没有变。没变就不折腾,省时省力。


4.3 默认 Props 的正确设置方式

方式对比

// 15-default-props.tsx —— 默认 Props 的四种设置方式

// ===== 方式一:解构时给默认值(推荐⭐⭐⭐,最常用) =====
function Greeting({
  name,
  greeting = "你好",
}: {
  name: string;
  greeting?: string;
}) {
  //                            └── 直接在解构位置赋值
  return (
    <p>
      {greeting},{name}!
    </p>
  );
}

// ===== 方式二:函数体中用 || 或 ?? 设置默认值 =====
function GreetingV2({ name, greeting }: { name: string; greeting?: string }) {
  const finalGreeting = greeting ?? "你好";
  //                         └── ?? 空值合并:只有 null/undefined 时才用默认值
  return (
    <p>
      {finalGreeting},{name}!
    </p>
  );
}

// ===== 方式三:defaultProps 静态属性(老方式,兼容类组件) =====
function GreetingV3({ name, greeting }: { name: string; greeting?: string }) {
  return (
    <p>
      {greeting},{name}!
    </p>
  );
}
GreetingV3.defaultProps = {
  greeting: "你好", // 不传 greeting 时使用
};

// ===== 方式四:TypeScript 中的 defaultProps(推荐用方式一替代) =====
interface GreetingProps {
  name: string;
  greeting?: string;
}
function GreetingV4({ name, greeting = "你好" }: GreetingProps) {
  // 直接在解构时给默认值,TS 能正确推断 greeting 不是 undefined
  return (
    <p>
      {greeting},{name}!
    </p>
  );
}

推荐:一律使用方式一(解构时给默认值)。它是现代 React 的标准做法,TypeScript 完美支持,代码最简洁。


4.4 新手六大典型误区

误区一:Props 命名使用了 HTML 保留属性名却含义不同

// ❌ 错误:自定义的 className prop 和 HTML 的 className 属性混了
function Box({ className }) {
  // 你以为 className 是你自定义的逻辑分类,实际上它被当成了 CSS 类名
  return <div className={className}>内容</div>;
}

// 别人使用时:
<Box className="用户管理模块" />;
// → 渲染成 <div class="用户管理模块">  ← 这根本不是合法的 CSS 类名!

// ✅ 正确:自定义 prop 取一个不会冲突的名字
function Box({ category }) {
  // 用 category 表示"业务分类",className 留给 CSS 样式
  return (
    <div data-category={category}>
      {/* data-* 属性是 HTML 标准中专门用来存自定义数据的 */}
      内容
    </div>
  );
}

<Box category="用户管理模块" />;
// → 渲染成 <div data-category="用户管理模块"> ✅

容易踩坑的 prop 名classNamestyleidkeyrefchildren。这些在 React 中有特殊含义,不要用作自定义业务属性名。


误区二:类型不匹配 —— 字符串当数字传

// ❌ 错误:数字类型的 prop 传了字符串
function AgeDisplay({ age }: { age: number }) {
  return <p>年龄:{age + 1} 岁</p>;
  // age 是字符串 "18" → "18" + 1 = "181"(JS 的字符串拼接!)
}

// 错误调用:
<AgeDisplay age="18" />
//          └── 这就是传了字符串!"18" 不是 18

// ✅ 正确:数字必须用 {} 包裹
<AgeDisplay age={18} />
//          └── {18} → JS 表达式 → 数字 18 → 18 + 1 = 19 ✅

记忆口诀引号包起来 = 字符串,花括号包起来 = JS 表达式。数字、布尔、对象、数组、函数——统统放花括号!


误区三:过度传值 —— 把整个大对象一股脑传给子组件

// ❌ 错误:把整个用户对象(含几十个字段)传给只展示姓名的子组件
function UserNameDisplay({ user }: { user: User }) {
  // User 类型可能有 30+ 个字段(id, name, email, phone, address, ...)
  return <span>{user.name}</span>;
  // 只用了 user.name,但整个 user 对象都传进来了
}

// 问题:
// 1. 子组件依赖了整个 User 类型 → 耦合度高
// 2. user 中任何一个字段变化(如 user.lastLoginTime)→ 子组件都重渲染
// 3. 别人看代码不知道这个组件到底需要哪些数据

// ✅ 正确:只传真正需要的字段
function UserNameDisplay({ name }: { name: string }) {
  return <span>{name}</span>;
}

// 调用:
<UserNameDisplay name={user.name} />

原则组件需要什么就传什么,只传最小必要数据。 Props 越少,组件越通用,越容易维护。


误区四:props 和 state 概念混淆

// ❌ 错误:把 props 当成 state 用
function Counter({ initialCount }: { initialCount: number }) {
  // 想记录当前计数,于是想"更新" props
  initialCount = initialCount + 1; // ❌ 试图修改 props
  // 或者想用 props 驱动内部状态但每次都重置:
  const [count, setCount] = useState(initialCount);
  // 问题:父组件传不同的 initialCount 时,count 不会自动同步
  // 需要用 useEffect 监听 + key 重置,很麻烦
  return <p>{count}</p>;
}

// ✅ 正确:分清"外部配置"和"内部状态"
// 场景1:计数完全由父组件管理(受控模式)
function Counter({ count, onIncrement }: { count: number; onIncrement: () => void }) {
  return (
    <div>
      <span>{count}</span>
      <button onClick={onIncrement}>+1</button>
    </div>
  );
}

// 场景2:计数由子组件自己管理,但 props 提供一个初始值
function Counter({ initialCount = 0 }: { initialCount?: number }) {
  const [count, setCount] = useState(initialCount);
  return (
    <div>
      <span>{count}</span>
      <button onClick={() => setCount((c) => c + 1)}>+1</button>
    </div>
  );
}
Props(外部来的) State(自己管的)
来源 父组件传入 组件内部通过 useState 创建
可变性 只读,不能改 可读写(通过 setState)
作用 配置组件、传递数据 管理组件内部的变化
变化触发 父组件重渲染时可能变化 调用 setState 时变化
类比 遥控器的"频道按钮"(固定) 电视机"当前频道"(会变)

误区五:在子组件中不小心"修改"了引用类型的 props

// ❌ 错误:直接操作了 props 中的数组
function TodoList({ items, onUpdate }: { items: { text: string; done: boolean }[]; onUpdate: (items: any[]) => void }) {
  const handleMarkAllDone = () => {
    // ❌ 遍历并直接修改了 props 中的对象属性!
    items.forEach((item) => {
      item.done = true; // props 中对象的属性被改了!
    });
    onUpdate(items); // 引用没变,父组件可能检测不到
  };

  return (
    <div>
      <button onClick={handleMarkAllDone}>全部完成</button>
      {items.map((item, i) => (
        <div key={i}>{item.text}</div>
      ))}
    </div>
  );
}

// ✅ 正确:创建全新的数组和对象
function TodoListGood({ items }: { items: { text: string; done: boolean }[] }) {
  const handleMarkAllDone = () => {
    // ✅ 创建新数组,每个元素也是新对象
    const newItems = items.map((item) => ({
      ...item, // 浅拷贝原对象的所有属性
      done: true, // 覆盖 done 属性
    }));
    // 通过回调通知父组件,而不是直接改
    // onUpdate(newItems);
  };

  return (
    <div>
      <button onClick={handleMarkAllDone}>全部完成</button>
      {items.map((item, i) => (
        <div key={i}>{item.text}</div>
      ))}
    </div>
  );
}

核心原则:永远不要直接修改 props 中的对象属性或数组元素。需要"修改"时,创建全新的副本再通过回调通知父组件。


误区六:函数作为 props 传递时用了 () 调用

// ❌ 错误:传函数时加了括号 → 传的是函数的返回值,不是函数本身
function App() {
  const handleClick = () => alert("点击了!");

  return (
    <div>
      {/* ❌ 页面加载时就弹窗了!因为 handleClick() 在渲染时就被调用了 */}
      <button onClick={handleClick()}>点我</button>
      {/* 这个 onClick 收到的值是 undefined(handleClick 没有返回值) */}

      {/* ✅ 正确一:传函数引用(函数本身) */}
      <button onClick={handleClick}>点我</button>

      {/* ✅ 正确二:需要传参时,用箭头函数包裹 */}
      <button onClick={() => handleClick("参数1")}>点我带参</button>
    </div>
  );
}

// 记忆技巧:
// onClick={handleClick}      → 把函数本身交给 React,等点击时 React 调用它
// onClick={handleClick()}    → 在渲染时立即调用函数,把返回值交给 React(通常不是你想要的)
// onClick={() => handleClick("x")}  → 传一个箭头函数,等点击时才会执行 handleClick("x")

口诀传函数时不加括号,要传参就包箭头。(唯一的例外是在 useEffect 的清理函数中需要调用,但那不是 props。)


第五部分:实战练习


5.1 练习一:商品展示卡片组件

题目描述

创建一个名为 ProductCard 的组件,用于展示商品信息。要求如下:

  1. 基础展示:显示商品图片、名称、价格、库存状态
  2. 条件渲染:有折扣时显示原价(删除线)和折扣价,无折扣时只显示正常价格
  3. 交互功能:有"加入购物车"按钮,点击后调用父组件传入的回调函数
  4. 缺货处理:库存为 0 时按钮显示"缺货"且不可点击
  5. TypeScript 类型:为所有 props 定义完整的 TypeScript 类型

Props 清单

Prop 名 类型 必填 说明
name string 商品名称
image string 商品图片 URL
price number 商品价格(元)
discount number 折扣比例(0~1)
stock number 库存数量,默认 0
onAddToCart (productName: string) => void 加入购物车回调

点击查看参考答案
// exercise-01-answer.tsx —— 商品展示卡片 参考答案
import React from "react";

// ========== 第一步:定义 Props 类型 ==========
interface ProductCardProps {
  name: string; // 商品名称(必填)
  image: string; // 商品图片 URL(必填)
  price: number; // 商品价格(必填)
  discount?: number; // 折扣比例,0~1 之间(可选,如 0.8 表示 8 折)
  stock?: number; // 库存数量(可选,默认 0)
  onAddToCart?: (productName: string) => void; // 加入购物车回调(可选)
}

// ========== 第二步:实现组件 ==========
function ProductCard({
  name,
  image,
  price,
  discount, // 可选,可以不传
  stock = 0, // 默认值为 0
  onAddToCart, // 可选的回调函数
}: ProductCardProps) {
  // ----- 计算展示价格 -----
  const hasDiscount = discount !== undefined && discount > 0 && discount < 1;
  // 有折扣的条件:discount 存在 且 大于 0 且 小于 1

  const finalPrice = hasDiscount ? price * discount : price;
  // 折后价 = 原价 × 折扣(如 100 × 0.8 = 80)

  // ----- 缺货判断 -----
  const isOutOfStock = stock <= 0;
  // 库存小于等于 0 视为缺货

  // ----- 加入购物车处理 -----
  const handleAddToCart = () => {
    if (isOutOfStock) return; // 缺货时什么都不做(防御性编程)
    onAddToCart?.(name); // 可选链调用:onAddToCart 存在才调用
  };

  return (
    <div
      style={{
        width: 260,
        border: "1px solid #e0e0e0",
        borderRadius: 12,
        overflow: "hidden", // 让图片圆角生效
        boxShadow: "0 2px 8px rgba(0,0,0,0.08)",
        transition: "box-shadow 0.3s",
        backgroundColor: "#fff",
      }}
      // 鼠标悬停时阴影加深(用内联样式无法实现 hover,这里仅做静态展示)
    >
      {/* ===== 商品图片 ===== */}
      <div style={{ position: "relative" }}>
        <img
          src={image}
          alt={name} // alt 属性:图片加载失败时显示的文字,也用于无障碍
          style={{
            width: "100%", // 宽度撑满容器
            height: 180,
            objectFit: "cover", // 保持比例裁剪,填满固定高度
          }}
        />
        {/* 折扣标签:有折扣时显示 */}
        {hasDiscount && (
          <span
            style={{
              position: "absolute", // 绝对定位,相对于父容器
              top: 8,
              left: 8,
              padding: "4px 10px",
              backgroundColor: "#f44336",
              color: "white",
              borderRadius: 4,
              fontSize: 13,
              fontWeight: "bold",
            }}
          >
            {Math.round(discount * 10)}折
            {/* Math.round():四舍五入取整,0.85 → 9 折 */}
          </span>
        )}
        {/* 缺货遮罩 */}
        {isOutOfStock && (
          <div
            style={{
              position: "absolute",
              top: 0,
              left: 0,
              width: "100%",
              height: "100%",
              backgroundColor: "rgba(0,0,0,0.5)", // 半透明黑色遮罩
              display: "flex",
              alignItems: "center",
              justifyContent: "center",
              color: "white",
              fontSize: 18,
              fontWeight: "bold",
            }}
          >
            已售罄
          </div>
        )}
      </div>

      {/* ===== 商品信息 ===== */}
      <div style={{ padding: 16 }}>
        {/* 商品名称 */}
        <h3
          style={{
            margin: "0 0 8px",
            fontSize: 16,
            // 文字超出时显示省略号
            overflow: "hidden",
            textOverflow: "ellipsis",
            whiteSpace: "nowrap",
          }}
          title={name} // 鼠标悬停显示完整名称
        >
          {name}
        </h3>

        {/* 价格区域 */}
        <div
          style={{
            marginBottom: 12,
            display: "flex",
            alignItems: "baseline",
            gap: 8,
          }}
        >
          {/* 当前价格(折后价或原价) */}
          <span style={{ fontSize: 22, fontWeight: "bold", color: "#f44336" }}>
            ¥{finalPrice.toFixed(2)}
            {/* toFixed(2):保留两位小数,80 → "80.00" */}
          </span>
          {/* 有折扣时显示原价(带删除线) */}
          {hasDiscount && (
            <span
              style={{
                fontSize: 14,
                color: "#999",
                textDecoration: "line-through", // 删除线
              }}
            >
              ¥{price.toFixed(2)}
            </span>
          )}
        </div>

        {/* 库存信息 */}
        <p
          style={{
            margin: "0 0 12px",
            fontSize: 13,
            color: isOutOfStock ? "#f44336" : "#4CAF50",
          }}
        >
          {isOutOfStock ? "暂时缺货" : `库存:${stock} 件`}
        </p>

        {/* 加入购物车按钮 */}
        <button
          onClick={handleAddToCart}
          disabled={isOutOfStock} // 缺货时按钮不可点击
          style={{
            width: "100%", // 撑满容器
            padding: "10px 0",
            border: "none",
            borderRadius: 8,
            backgroundColor: isOutOfStock ? "#ccc" : "#1976D2",
            color: "white",
            fontSize: 15,
            fontWeight: "bold",
            cursor: isOutOfStock ? "not-allowed" : "pointer",
            transition: "background-color 0.2s",
          }}
        >
          {isOutOfStock ? "缺货" : "加入购物车"}
        </button>
      </div>
    </div>
  );
}

// ========== 第三步:使用示例 ==========
function ExerciseApp() {
  const handleAddToCart = (productName: string) => {
    alert(`已将《${productName}》加入购物车!`);
  };

  return (
    <div style={{ display: "flex", gap: 20, padding: 20, flexWrap: "wrap" }}>
      {/* 正常商品:有折扣、有库存 */}
      <ProductCard
        name="React 从入门到精通"
        image="https://picsum.photos/seed/reactbook/400/300"
        price={79.9}
        discount={0.7} // 7 折
        stock={50}
        onAddToCart={handleAddToCart}
      />

      {/* 无折扣商品 */}
      <ProductCard
        name="JavaScript 高级程序设计"
        image="https://picsum.photos/seed/jsbook/400/300"
        price={99.0}
        stock={10}
        onAddToCart={handleAddToCart}
      />

      {/* 缺货商品:不传 discount,stock 为 0 */}
      <ProductCard
        name="TypeScript 实战指南(缺货)"
        image="https://picsum.photos/seed/tsbook/400/300"
        price={69.0}
      />
    </div>
  );
}

export default ExerciseApp;

要点解析:

知识点 代码位置 说明
可选 props 默认值 stock = 0 解构时直接给默认值
条件渲染 {hasDiscount && ...} 有折扣才显示折扣标签和原价
可选链调用 onAddToCart?.(name) 回调函数可能存在也可能不存在
计算值 finalPrice 折后价 = 原价 × 折扣
禁用状态 disabled={isOutOfStock} 缺货时按钮不可点击
缺货视觉反馈 半透明遮罩 + "售罄" 让用户一眼看出该商品不可购买

5.2 练习二:可配置的评分组件

题目描述

创建一个名为 StarRating 的评分组件,可以用于商品评分、电影评分等场景。

  1. 星级展示:用 ★ 和 ☆ 展示评分(满分 5 星),已评分部分显示实心星,未评分显示空心星
  2. 只读模式:当不传 onChange 时,组件为只读模式(不可点击)
  3. 可交互模式:当传了 onChange 时,点击星星可以修改评分(半星可选支持)
  4. 自定义最大星级:通过 maxStars prop 控制最多几颗星(默认 5)
  5. 自定义颜色和大小:支持通过 props 自定义星星颜色和大小

Props 清单

Prop 名 类型 必填 默认值 说明
value number 当前评分(0 ~ maxStars)
maxStars number 5 最大星级数
size number 24 星星大小(px)
color string "#FFB400" 星星颜色
onChange (newValue: number) => void undefined 评分改变回调(只读不传)
readOnly boolean false 是否只读

点击查看参考答案
// exercise-02-answer.tsx —— 可配置评分组件 参考答案
import React, { useState } from "react";

// ========== 第一步:定义 Props 类型 ==========
interface StarRatingProps {
  value: number; // 当前评分(必填)
  maxStars?: number; // 最大星级数(可选,默认 5)
  size?: number; // 星星的像素大小(可选,默认 24)
  color?: string; // 星星的填充颜色(可选,默认金色)
  onChange?: (newValue: number) => void; // 评分改变时的回调(不传 = 只读模式)
  readOnly?: boolean; // 显式的只读标记(可选,默认 false)
}

// ========== 第二步:实现组件 ==========
function StarRating({
  value,
  maxStars = 5,
  size = 24,
  color = "#FFB400",
  onChange, // 可选,不传就是只读
  readOnly = false,
}: StarRatingProps) {
  // ----- 悬停预览状态(仅在可交互模式下使用) -----
  const [hoverValue, setHoverValue] = useState<number>(0);
  // 鼠标悬停时临时显示的星级,鼠标移开后清空

  // ----- 判断是否可交互 -----
  const isInteractive = !readOnly && onChange !== undefined;
  // 两个条件同时满足才能交互:(1) 不是只读模式 (2) 有 onChange 回调

  // ----- 实际显示的值:悬停时用悬停值,否则用实际值 -----
  const displayValue = hoverValue > 0 ? hoverValue : value;

  // ----- 点击星星 -----
  const handleClick = (starIndex: number) => {
    if (!isInteractive) return; // 不可交互时直接返回
    // starIndex 从 1 开始:点击第 1 颗星 → starIndex = 1

    // 如果点击的是当前已有的最高星级,则取消(归零)
    const newValue = starIndex === value ? 0 : starIndex;
    onChange?.(newValue); // 可选链调用
  };

  // ----- 鼠标进入星星(悬停预览) -----
  const handleMouseEnter = (starIndex: number) => {
    if (!isInteractive) return;
    setHoverValue(starIndex); // 记录当前悬停到第几颗星
  };

  // ----- 鼠标离开整个组件 -----
  const handleMouseLeave = () => {
    if (!isInteractive) return;
    setHoverValue(0); // 清空悬停值,恢复显示实际评分
  };

  // ===== 渲染所有星星 =====
  return (
    <div
      style={{
        display: "inline-flex", // 星星排成一行
        gap: 2, // 星星之间的间距
        cursor: isInteractive ? "pointer" : "default", // 可交互时鼠标变成手型
      }}
      onMouseLeave={handleMouseLeave} // 鼠标离开区域时清除悬停
      role="radiogroup" // 无障碍:告诉屏幕阅读器这是一组单选按钮
      aria-label={`评分:${value} / ${maxStars}`} // 无障碍标签
    >
      {/* 
        Array.from({ length: maxStars })  创建一个长度为 maxStars 的数组
        例如 maxStars = 5 → [undefined, undefined, undefined, undefined, undefined]
        然后 map 遍历生成每个星星
      */}
      {Array.from({ length: maxStars }).map((_, index) => {
        const starIndex = index + 1; // 星星编号从 1 开始(不是从 0)

        // ----- 判断当前星星的填充状态 -----
        const isFilled = starIndex <= Math.floor(displayValue);
        // Math.floor(3.5) = 3 → 前 3 颗星满星

        const isHalf =
          !isFilled &&
          starIndex === Math.ceil(displayValue) &&
          displayValue % 1 !== 0;
        // 半星条件:不是满星 + 是向上取整的那颗 + 实际值有小数部分
        // 例如 value = 3.5 → starIndex = 4 时满足:不是满星 + 等于 Math.ceil(3.5)=4 + 3.5%1=0.5≠0

        return (
          <span
            key={starIndex} // React 列表渲染必备的 key
            onClick={() => handleClick(starIndex)}
            onMouseEnter={() => handleMouseEnter(starIndex)}
            style={{
              fontSize: size, // 用字体大小控制星星尺寸
              lineHeight: 1,
              color: isFilled || isHalf ? color : "#e0e0e0", // 有颜色或灰色
              transition: "transform 0.1s, color 0.2s", // 过渡动画
              // 悬停时星星稍微放大
              ...(isInteractive
                ? {
                    transform:
                      hoverValue >= starIndex ? "scale(1.15)" : "scale(1)",
                  }
                : {}),
            }}
            aria-label={`${starIndex} 星`} // 无障碍标签
          >
            {isFilled ? "★" : isHalf ? "⯪" : "☆"}
            {/* 满星显示 ★(实心),半星显示 ⯪,空星显示 ☆(空心) */}

            {/* 
              补充:更精确的半星实现可以用两个 span 叠加:
              外层是空星(灰色),内层是半颗实星(用 overflow:hidden 裁剪宽度)
              这里用 Unicode 字符 ⯪ 简化实现
            */}
          </span>
        );
      })}

      {/* 评分数字(可选展示) */}
      <span
        style={{
          marginLeft: 8,
          fontSize: size * 0.7,
          color: "#666",
          lineHeight: `${size}px`,
        }}
      >
        {value.toFixed(1)}
        {/* toFixed(1):保留一位小数,3.5 → "3.5",4 → "4.0" */}
      </span>
    </div>
  );
}

// ========== 第三步:使用示例 ==========
function ExerciseApp() {
  // ----- 可交互评分的状态 -----
  const [movieRating, setMovieRating] = useState(4);
  const [productRating, setProductRating] = useState(3.5);

  return (
    <div style={{ padding: 20, maxWidth: 500, margin: "0 auto" }}>
      <h2>评分组件演示</h2>

      {/* 只读模式:不传 onChange */}
      <div style={{ marginBottom: 20 }}>
        <h4>1. 只读模式(不传 onChange)</h4>
        <p>《React 完全指南》评分:</p>
        <StarRating value={4.5} />
        {/* 不传 onChange → 不可点击,仅展示 */}
      </div>

      <hr />

      {/* 可交互模式:传了 onChange */}
      <div style={{ marginBottom: 20 }}>
        <h4>2. 交互模式(传 onChange)</h4>
        <p>电影评分(点击星星修改):</p>
        <StarRating
          value={movieRating}
          onChange={(newVal) => setMovieRating(newVal)}
          maxStars={10} // 10 星制!
          color="#E91E63" // 粉色星星
          size={28} // 更大的星星
        />
        <p style={{ marginTop: 4, fontSize: 13, color: "#888" }}>
          当前评分:{movieRating} / 10
        </p>
      </div>

      <hr />

      {/* 商品评分:5 星制,默认金色 */}
      <div style={{ marginBottom: 20 }}>
        <h4>3. 商品评分(5 星制,含半星)</h4>
        <StarRating
          value={productRating}
          onChange={(newVal) => setProductRating(newVal)}
        />
        <p style={{ marginTop: 4, fontSize: 13, color: "#888" }}>
          当前评分:{productRating} / 5
        </p>
      </div>

      <hr />

      {/* 显式只读模式 */}
      <div>
        <h4>4. 显式只读(readOnly={true})</h4>
        <StarRating value={3} readOnly={true} color="#9E9E9E" />
        <p style={{ marginTop: 4, fontSize: 13, color: "#888" }}>
          灰色 + 只读,适合展示历史评分
        </p>
      </div>
    </div>
  );
}

export default ExerciseApp;

要点解析:

知识点 代码位置 说明
可选 props 默认值 maxStars = 5size = 24 解构时直接给默认值
只读/交互双模式 isInteractive 判断 不传 onChange → 纯展示;传了 → 可点击
悬停预览 hoverValue + onMouseEnter 鼠标划过时临时显示高亮,离开后恢复
半星逻辑 isHalf 计算 通过取余运算判断小数部分
Array.from 技巧 Array.from({ length: maxStars }) 创建指定长度的数组用于 map 渲染
无障碍(a11y) role="radiogroup"aria-label 让屏幕阅读器可以理解评分组件的含义
可选链调用 onChange?.(newValue) 回调函数不存在时安全跳过
点击当前星取消评分 starIndex === value ? 0 : starIndex 点击已评分星 → 清空评分


全文总结:Props 是 React 组件通信的基石。它就像快递包裹上的快递单——父组件填写数据,子组件接收并使用,但不能修改。掌握 Props 的类型定义(TypeScript 接口 / PropTypes 校验)、传递与接收方式(解构、children)、以及核心规则(只读性、默认值、单向数据流),你就掌握了 React 中最重要的组件通信能力。

再配合文中指出的六大典型误区及其修正方案,你可以避免新手期 90% 以上的 Props 相关 Bug。现在,打开你的编辑器,动手写几个组件,让 Props 在组件之间流动起来吧!

下一个话题预告:当你掌握了 Props(外部数据)后,接下来要学习组件内部的数据管理——State(状态)与事件处理,让组件真正"活"起来。


版权声明:本教程所有代码示例均为教学目的编写,可自由使用、修改和分发。

0

评论区