目 录CONTENT

文章目录

React(三)组件

React 基础版(三):组件


目录



第一部分:组件是什么?—— 乐高积木的启示


1.1 一个生活化的类比

打开一盒乐高积木,里面有长方块、正方块、车轮、窗户、门。每一块积木都有自己固定的形状和功能。单独看只是一个小零件,但把它们拼在一起,就能搭出城堡、飞船或汽车。

更神奇的是 —— 同一块积木可以在多个场景反复使用。一块红色长方块,今天做墙壁,明天做船底,后天做车底盘。你不需要重新造这块积木,直接从盒子里拿出来就行。

React 组件和乐高积木的设计理念一模一样:

乐高积木 React 组件
一块有固定形状的积木 一个有固定功能的 UI 模块
积木的尺寸和颜色 组件的 props(配置参数)
同一块积木反复使用 同一个组件在多个页面复用
多块积木拼在一起 多个组件嵌套组合成页面
换一块积木不影响其他 修改一个组件不影响其他组件
// 就像从乐高盒子里拿出"窗户"和"门"两块积木
<Window color="blue" size="medium" />
<Door color="brown" width="double" />

// 拼在一起就组成了一面墙
<div className="house-wall">
  <Window color="blue" size="medium" />
  <Door color="brown" width="double" />
</div>

一句话总结组件是 UI 的基本构建块。每个组件是一个独立、可复用的"积木块",有自己的外观(JSX)和行为(逻辑),可以像搭积木一样拼成完整页面。


1.2 组件的三大核心特性

特性一:复用性(Reusability)—— 一次定义,到处使用

就像刻了一枚"审批通过"的印章,之后任何人拿着它就能盖章,不需要每次手写。

// 定义一次 Button 组件(刻一枚"印章")
function Button({ text, type }) {
  return <button className={`btn btn-${type}`}>{text}</button>;
}

// 到处使用(到处盖章)
function App() {
  return (
    <div>
      <Button text="保存" type="primary" /> {/* 蓝色主按钮 */}
      <Button text="取消" type="default" /> {/* 灰色默认按钮 */}
      <Button text="删除" type="danger" /> {/* 红色危险按钮 */}
    </div>
  );
}

复用的好处:改一处 Button 样式,所有页面自动生效;新增按钮类型只改一处。

特性二:独立性(Independence)—— 内部变化不影响外部

每个组件像一间带隔音墙的录音棚。A 棚里唱歌跑调,B 棚完全听不到。

function Counter() {
  const [count, setCount] = useState(0); // Counter 自己的"私有财产"
  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

// 页面上放三个 Counter,它们是三个独立实例
function App() {
  return (
    <div>
      <Counter /> {/* 这个 count 是自己的 */}
      <Counter /> {/* 这个也是自己的,互不影响 */}
      <Counter /> {/* 同上 */}
    </div>
  );
}
// 点击第一个按钮,只有第一个数字会变 —— 这就是"独立性"

特性三:可组合性(Composability)—— 小积木拼出大城堡

像写论文:词语 → 句子 → 段落 → 完整章节

// 第一层:原子组件(词语)
function Avatar({ src }) {
  return <img src={src} />;
}
function UserName({ name }) {
  return <span>{name}</span>;
}

// 第二层:组合组件(句子)
function UserCard({ user }) {
  return (
    <div>
      <Avatar src={user.avatar} />
      <UserName name={user.name} />
    </div>
  );
}

// 第三层:页面组件(章节)
function UserListPage() {
  const users = [
    { id: 1, name: "小明" },
    { id: 2, name: "小红" },
  ];
  return (
    <div>
      {users.map((user) => (
        <UserCard key={user.id} user={user} />
      ))}
    </div>
  );
}

React 核心理念组合优于继承。不需要通过继承来扩展功能,而是将小组件组合成更大的组件。


1.3 函数组件 vs 类组件 —— 为什么现在都用函数组件?

React 历史上有两种组件写法。这里是直观对比:

类组件(旧方式) 函数组件(现代推荐)
类比 手工作坊(复杂的模具和流程) 3D 打印机(数字模型一键打印)
状态管理 this.state + this.setState useState 一行搞定
生命周期 多个方法(componentDidMount 等) useEffect 统一处理
代码量 多(constructor、render、this 绑定) 少(一个函数即可)
学习门槛 需要理解 this 绑定 会写 JS 函数就能上手
Hooks 支持 不支持 全面支持
// ❌ 类组件(了解即可,不要求掌握)
class Welcome extends React.Component {
  constructor(props) {
    super(props);
    this.state = { count: 0 };
    this.handleClick = this.handleClick.bind(this); // 必须手动绑定 this!
  }
  handleClick() {
    this.setState({ count: this.state.count + 1 });
  }
  render() {
    return (
      <div>
        <h1>你好,{this.props.name}</h1>
        <p>计数:{this.state.count}</p>
        <button onClick={this.handleClick}>+1</button>
      </div>
    );
  }
}

// ✅ 函数组件(现代推荐写法)
function Welcome({ name }) {
  const [count, setCount] = useState(0); // 一行搞定状态
  return (
    <div>
      <h1>你好,{name}</h1>
      <p>计数:{count}</p>
      <button onClick={() => setCount(count + 1)}>+1</button>
    </div>
  );
}

本书约定:后续全部使用函数组件 + Hooks。遇到老项目中的类组件,能读懂即可。



第二部分:组件的定义 —— 从零搭建一个组件


2.1 最简组件:Hello, World

// 第1行:导入 React(React 17+ 新 JSX 转换可省略,保留最安全)
import React from "react";

// 第2-12行:定义一个函数组件
//        ┌── function 关键字
//        │      ┌── 组件名(首字母必须大写!)
//        │      │
function HelloWorld() {
  //    │
  //    └── 函数体:在这里写组件的逻辑

  // return 后面是 JSX —— 描述组件"长什么样"
  return (
    <div>
      {/* JSX 中的注释要这样写 */}
      <h1>Hello, World!</h1>
      <p>这是我的第一个 React 组件</p>
    </div>
  );
}

// 最后一行:导出组件,让其他文件可以 import 使用
export default HelloWorld;

逐行拆解:

代码 作用 必须遵守的规则
import React from "react" 导入 React 核心库 使用 JSX 的文件都需要(React 17+ 可省略)
function HelloWorld() 声明函数组件 首字母必须大写,否则被当成普通 HTML 标签
return (...) 返回 UI 描述(JSX) 多行 JSX 必须用 () 包裹,否则 return 后换行会变成空返回
<div>...</div> 组件的 HTML 结构 JSX 必须有且仅有一个根节点
{/* 注释 */} JSX 中的注释 不能用 ///* */(在 JSX 里会被当成文本渲染)
export default 默认导出 每个文件通常只导出一个默认组件

使用这个组件

import HelloWorld from "./components/HelloWorld"; // 引入

function App() {
  return (
    <div>
      <HelloWorld /> {/* 像使用 HTML 标签一样用! */}
      <HelloWorld /> {/* 可以用多次 */}
    </div>
  );
}

2.2 Props:给组件传入"配置项"

类比:外卖订单

你点外卖时要告诉商家:口味(香辣/原味)、数量(1份/2份)、加不加可乐。这些"配置项"就是 props

同一个组件 + 不同的 props = 不同的渲染结果 —— 这就是组件复用的核心。

基础用法

// props 是一个对象,装着调用者传来的所有数据
function Welcome(props) {
  // 比如 <Welcome name="小明" visitCount={1} />
  // props = { name: "小明", visitCount: 1 }

  return (
    <div>
      <h1>你好,{props.name}!</h1>
      <p>欢迎你第 {props.visitCount} 次访问。</p>
    </div>
  );
}

// 使用
function App() {
  return (
    <div>
      <Welcome name="小明" visitCount={1} />
      <Welcome name="小红" visitCount={5} />
      <Welcome name="小刚" visitCount={99} />
    </div>
  );
}
// 渲染结果:
// 你好,小明!欢迎你第 1 次访问。
// 你好,小红!欢迎你第 5 次访问。
// 你好,小刚!欢迎你第 99 次访问。

进阶:用解构语法简化

// 写法一(基础):用 props 对象
function Welcome(props) {
  return <h1>你好,{props.name}!</h1>;
}

// 写法二(推荐):在参数位置直接解构
function Welcome({ name, visitCount }) {
  //            └── 相当于 const { name, visitCount } = props;
  return (
    <div>
      <h1>你好,{name}!</h1>
      <p>第 {visitCount} 次访问</p>
    </div>
  );
}

Props 的铁律:只读!不能修改!

function Welcome({ name }) {
  // ❌ 绝对不能这样!props 是只读的!
  // name = "李四";

  // ✅ 如果需要转换,存到新变量
  const upperName = name.toUpperCase();
  return <h1>你好,{upperName}!</h1>;
}

口诀Props 是数据的单行道,只能从父组件流向子组件,不能逆行向上。


2.3 PropTypes:给 Props 加"门卫"

JS 是动态类型语言 —— 你可以给 name 传字符串、数字、甚至函数,JS 都不会报错。但类型错误是 Bug 的第一大来源。

PropTypes 就像门卫:你告诉它"name 必须是字符串",有人传数字时它会在控制台发出警告。

import React from "react";
import PropTypes from "prop-types"; // React 19 + Vite 自带,无需额外安装

// 第一步:定义组件
function UserCard({ name, age, isAdmin, hobbies, onDelete }) {
  return (
    <div>
      <h2>{name}</h2>
      <p>年龄:{age} 岁</p>
      <p>身份:{isAdmin ? "管理员" : "普通用户"}</p>
      <p>爱好:{hobbies.join("、")}</p>
      <button onClick={onDelete}>删除</button>
    </div>
  );
}

// 第二步:挂载 propTypes —— 告诉"门卫"规则
UserCard.propTypes = {
  name: PropTypes.string.isRequired, // 字符串 + 必填
  age: PropTypes.number.isRequired, // 数字 + 必填
  isAdmin: PropTypes.bool, // 布尔值,可选
  hobbies: PropTypes.array, // 数组,可选
  onDelete: PropTypes.func, // 函数,可选
};

// 第三步:设置默认值
UserCard.defaultProps = {
  isAdmin: false,
  hobbies: [],
};

export default UserCard;
// 传错类型时,控制台会警告(不阻止运行,只在开发环境提示)
<UserCard name={123} age="十八岁" />
// Warning: Failed prop type: Invalid prop `name` of type `number`
//          supplied to `UserCard`, expected `string`.

PropTypes 常用类型速查:

写法 含义
PropTypes.string 字符串
PropTypes.number 数字
PropTypes.bool 布尔值
PropTypes.array 数组
PropTypes.object 对象
PropTypes.func 函数
PropTypes.node 可渲染的内容
PropTypes.element 一个 React 元素
PropTypes.oneOf(["a","b"]) 只能是列表中的某一项
PropTypes.arrayOf(PropTypes.number) 由数字组成的数组
PropTypes.shape({ name: ... }) 对象包含特定结构的属性
.isRequired 该 prop 必填

2.4 useState:让组件拥有"记忆"

到目前组件还是"静态"的——传入什么就渲染什么,不会自己变化。真实应用中组件需要"活"起来:点击按钮计数器 +1,输入框打字显示内容,切换开关改变主题。

这种会随用户操作而变化的数据,在 React 中叫做 state(状态)

类比:白板上的便利贴

白板上贴着一张便利贴,上面写了数字 0

  • 错误做法:直接把便利贴上的 0 擦掉写成 1 —— 白板不知道变化了
  • 正确做法:按"更新"按钮(调用 setState),React 换一张写有 1 的新便利贴贴上去,并刷新白板显示

基本语法

import React, { useState } from "react"; // 第一步:导入 useState

function Counter() {
  // 第二步:调用 useState
  // ┌── 状态变量名           ┌── 初始值(可以是数字、字符串、对象、数组)
  // │                         │
  // │            ┌── 更新函数(命名惯例 = set + 变量名首字母大写)
  // │            │
  const [count, setCount] = useState(0);
  //    │        │               │
  //    │        │               └── useState(初始值):返回 [当前值, 更新函数]
  //    │        └── 第二个元素:更新状态的函数,调用它才会触发重新渲染
  //    └── 第一个元素:当前的状态值

  return (
    <div>
      <p>你点击了 {count} 次</p>
      {/* 第三步:通过 setXxx 更新状态 */}
      <button onClick={() => setCount(count + 1)}>点我 +1</button>
      <button onClick={() => setCount(0)}>归零</button>
    </div>
  );
}
// 每次点击按钮 → setCount(新值) → React 重新渲染 → 页面数字更新

逐行详解

const [count, setCount] = useState(0);
//     └─────────────────┘ 这叫"数组解构赋值"
//     useState(0) 返回 [0, function]
//       count = 返回数组的第0个元素(当前值)
//       setCount = 返回数组的第1个元素(更新函数)
//     等价于:
//       const stateArray = useState(0);
//       const count = stateArray[0];
//       const setCount = stateArray[1];

// ❌ 不能直接改 count
// count = count + 1;       这样写不会触发重新渲染!

// ✅ 必须通过 setCount
// setCount(count + 1);     正确做法

三个关键规则

规则一:只能在函数组件或自定义 Hook 中调用

// ❌ 错误:在普通函数中使用
function normalFunction() {
  const [value, setValue] = useState(0); // 报错!
}

// ✅ 正确:在组件函数中使用
function MyComponent() {
  const [value, setValue] = useState(0); // 没问题
  return <div>{value}</div>;
}

规则二:必须在函数体顶层调用,不能在条件/循环中

// ❌ 错误:useState 在 if 里面
function Bad({ isAdmin }) {
  if (isAdmin) {
    const [data, setData] = useState(null); // 报错!
  }
}

// ❌ 错误:useState 在循环里
function Bad() {
  for (let i = 0; i < 5; i++) {
    const [val, setVal] = useState(i); // 报错!
  }
}

// ✅ 正确:始终在顶层
function Good({ isAdmin }) {
  const [data, setData] = useState(null); // 第1个Hook,始终在这里

  useEffect(() => {
    if (isAdmin) fetchData().then(setData); // 条件逻辑放 Hook 里面
  }, [isAdmin]);

  return <div>...</div>;
}

为什么? React 用"编号"管理状态:第1个 useState 是 #1,第2个是 #2……如果某个 useState 有时存在有时不存在(因为在 if 里),编号就乱了,React 不知道哪个状态对应哪个数据。

规则三:setState 是"异步"的 —— 不要立刻读刚更新的值

function Demo() {
  const [count, setCount] = useState(0);

  const handleClick = () => {
    setCount(count + 1);
    console.log(count); // ❌ 打印的还是 0!不是 1!
    // setCount 只是"安排"了更新,更新不会立刻发生
  };

  // ✅ 如果需要基于上一次的值更新,用函数式更新
  const handleClickSafe = () => {
    setCount((prevCount) => {
      //  prevCount 是 React 保证的最新值
      return prevCount + 1;
    });
  };

  return <button onClick={handleClick}>count = {count}</button>;
}

处理对象和数组 —— 不可变更新

function UserProfile() {
  const [user, setUser] = useState({
    name: "小明",
    age: 18,
    hobbies: ["篮球", "编程"],
  });

  const handleBirthday = () => {
    // ❌ 错误:直接改原对象(引用地址没变,React 检测不到)
    // user.age = user.age + 1; setUser(user);

    // ✅ 正确:创建新对象
    setUser({ ...user, age: user.age + 1 });
  };

  const addHobby = (hobby) => {
    // ❌ 错误:user.hobbies.push(hobby); setUser(user);

    // ✅ 正确:创建新数组
    setUser({ ...user, hobbies: [...user.hobbies, hobby] });
  };

  return (
    <div>
      <p>姓名:{user.name}</p>
      <p>年龄:{user.age} 岁</p>
      <p>爱好:{user.hobbies.join("、")}</p>
      <button onClick={handleBirthday}>过生日 +1岁</button>
    </div>
  );
}

核心原则:React 通过 === 引用比较判断状态是否变化。直接修改对象属性不改变引用,React 认为"没变",跳过渲染。必须创建新对象/新数组


2.5 useEffect:让组件学会"做事"

什么是副作用?

纯渲染:接收 props 和 state,返回 JSX。同样的输入永远得到同样的输出。

副作用:超越了"纯渲染"的操作:

  • 网络请求(从服务器获取数据)
  • 操作 DOM(手动改页面元素)
  • 定时器(setTimeoutsetInterval
  • 事件订阅(监听窗口大小、键盘按键)
  • 读写浏览器存储(localStorage

类比:智能管家

useEffect 就像一个智能管家:

  • 你进房间(组件挂载)→ 管家开灯、拉窗帘、调空调
  • 你出房间(组件卸载)→ 管家关灯、关空调、锁门
  • 房间东西变了(依赖项变化)→ 管家重新调整环境

基本语法

import React, { useState, useEffect } from "react";

function Timer() {
  const [seconds, setSeconds] = useState(0);

  useEffect(() => {
    //     参数1:副作用函数 —— 组件渲染后执行
    //     参数2:依赖数组   —— 控制何时重新执行(下面这个是空数组)

    console.log("定时器已启动");
    const timerId = setInterval(() => {
      setSeconds((prev) => prev + 1);
    }, 1000);

    // 返回一个"清理函数" —— 组件卸载时 React 自动调用
    return () => {
      console.log("定时器已清除");
      clearInterval(timerId); // 清除定时器,防止内存泄漏
    };
  }, []);
  //  └── 空数组 = "只在组件首次挂载时执行一次"

  return <p>你在这个页面停留了 {seconds} 秒</p>;
}

依赖数组的三种情况(核心!)

// 情况一:不传依赖数组 —— 每次渲染都执行(⚠️ 容易死循环)
useEffect(() => {
  console.log("每次渲染都执行");
});

// 情况二:传空数组 [] —— 只在首次挂载时执行一次
useEffect(() => {
  console.log("只执行一次");
  // 适合:初始化请求、事件绑定
}, []);

// 情况三:传有值的数组 [a, b] —— 首次 + 依赖项变化时执行
useEffect(() => {
  document.title = `你点击了 ${count} 次`;
}, [count]); // count 变化 → 重新执行

完整示例:网络请求

function UserList() {
  const [users, setUsers] = useState([]);
  const [loading, setLoading] = useState(true);
  const [error, setError] = useState(null);

  useEffect(() => {
    async function fetchUsers() {
      try {
        setLoading(true);
        const response = await fetch(
          "https://jsonplaceholder.typicode.com/users",
        );
        if (!response.ok) throw new Error("网络请求失败");
        const data = await response.json();
        setUsers(data);
        setError(null);
      } catch (err) {
        setError(err.message);
      } finally {
        setLoading(false);
      }
    }
    fetchUsers();
  }, []); // 只在首次挂载时执行

  if (loading) return <div>加载中...</div>;
  if (error) return <div>出错了:{error}</div>;

  return (
    <div>
      <h2>用户列表(来自网络请求)</h2>
      <ul>
        {users.map((user) => (
          <li key={user.id}>
            {user.name} — {user.email}
          </li>
        ))}
      </ul>
    </div>
  );
}

2.6 完整示例:待办事项组件

下面是一个融合了 props + useState + useEffect + PropTypes 的完整组件:

import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";

// ==================== TodoItem 组件 ====================
// 作用:渲染单条待办事项,支持勾选完成、双击编辑、删除
// props:
//   id        - 事项的唯一标识
//   text      - 事项的文字内容
//   completed - 是否已完成
//   onToggle  - 勾选/取消勾选时的回调
//   onDelete  - 删除时的回调
//   onEdit    - 编辑完成时的回调
// ======================================================

function TodoItem({ id, text, completed, onToggle, onDelete, onEdit }) {
  // 状态1:是否处于编辑模式
  const [isEditing, setIsEditing] = useState(false);

  // 状态2:编辑时临时保存的文字
  const [editText, setEditText] = useState(text);

  // 副作用:当外部 text 变化时,同步更新编辑文字
  useEffect(() => {
    setEditText(text);
  }, [text]);

  // 保存编辑
  const handleSave = () => {
    const trimmed = editText.trim();
    if (trimmed === "") {
      onDelete(id); // 空文字 → 删除
    } else if (trimmed !== text) {
      onEdit(id, trimmed); // 有变化 → 通知父组件
    }
    setIsEditing(false);
  };

  // 取消编辑
  const handleCancel = () => {
    setEditText(text); // 恢复原文字
    setIsEditing(false);
  };

  // 键盘:回车保存,Esc 取消
  const handleKeyDown = (e) => {
    if (e.key === "Enter") handleSave();
    if (e.key === "Escape") handleCancel();
  };

  return (
    <div
      style={{
        display: "flex",
        alignItems: "center",
        gap: 8,
        padding: 8,
        borderBottom: "1px solid #eee",
        backgroundColor: completed ? "#f0f0f0" : "transparent",
      }}
    >
      {/* 完成状态复选框 */}
      <input
        type="checkbox"
        checked={completed}
        onChange={() => onToggle(id)}
      />

      {/* 编辑模式 / 普通模式切换 */}
      {isEditing ? (
        <>
          <input
            type="text"
            value={editText}
            onChange={(e) => setEditText(e.target.value)}
            onKeyDown={handleKeyDown}
            style={{ flex: 1 }}
            autoFocus
          />
          <button onClick={handleSave}>保存</button>
          <button onClick={handleCancel}>取消</button>
        </>
      ) : (
        <>
          <span
            style={{
              flex: 1,
              textDecoration: completed ? "line-through" : "none",
              cursor: "pointer",
            }}
            onDoubleClick={() => setIsEditing(true)}
          >
            {text}
          </span>
          <button onClick={() => onDelete(id)} style={{ color: "red" }}>
            删除
          </button>
        </>
      )}
    </div>
  );
}

// PropTypes 类型校验
TodoItem.propTypes = {
  id: PropTypes.number.isRequired,
  text: PropTypes.string.isRequired,
  completed: PropTypes.bool,
  onToggle: PropTypes.func.isRequired,
  onDelete: PropTypes.func.isRequired,
  onEdit: PropTypes.func,
};

// 默认值
TodoItem.defaultProps = {
  completed: false,
};

export default TodoItem;


第三部分:组件的使用 —— 完整操作流程


3.1 组件的引入与导出

导入自己写的组件(相对路径)

import Header from "./components/Header"; // 从子文件夹导入
import Sidebar from "./Sidebar"; // 从当前目录导入
import Button from "../common/Button"; // 从上级目录导入

导入第三方库(包名路径)

import React, { useState, useEffect } from "react";
import PropTypes from "prop-types";

默认导出 vs 命名导出

// ===== 定义文件:MyComponents.jsx =====

export default function Header() {
  // 默认导出 —— 一个文件只有一个
  return <header>网站头部</header>;
}

export function Footer() {
  // 命名导出 —— 可以有多个
  return <footer>网站底部</footer>;
}

export function Sidebar() {
  return <aside>侧边栏</aside>;
}

// ===== 使用文件:App.jsx =====

import Header from "./MyComponents"; // 默认导出:不需要花括号
import { Footer, Sidebar } from "./MyComponents"; // 命名导出:必须花括号 + 精确匹配名字
import Header, { Footer, Sidebar } from "./MyComponents"; // 同时导入

3.2 Props 的三种传值方式

方式一:基础类型(字符串、数字、布尔)

<UserCard
  name="小明" // ✅ 字符串:直接用引号
  age={18} // ✅ 数字:必须用 {},写成 age="18" 就是字符串了!
  isVip={true} // ✅ 布尔:必须用 {}
  score={99.5} // ✅ 小数:也是数字类型,用 {}
/>

方式二:对象类型

function App() {
  const userInfo = {
    name: "小红",
    age: 22,
    address: { city: "北京", district: "朝阳区" },
  };

  return (
    <div>
      {/* 传入整个对象 */}
      <ProfileCard user={userInfo} />

      {/* 展开运算符:把对象属性展开为独立 props */}
      <ProfileCard {...userInfo} />
      {/* 等价于 <ProfileCard name="小红" age={22} address={{city:"北京",...}} /> */}

      {/* 直接写对象字面量 */}
      <Table config={{ columns: 3, bordered: true }} />
    </div>
  );
}

方式三:函数类型 —— 子组件"向上通知"父组件的标准方式

// ===== 父组件 =====
function App() {
  const handleDelete = (userId) => {
    console.log("要删除的用户 ID:", userId);
    // 执行删除逻辑...
  };

  return <UserList onDelete={handleDelete} />; // 把函数作为 props 传入
}

// ===== 子组件 =====
function UserList({ onDelete }) {
  return (
    <ul>
      <li>
        小明
        <button onClick={() => onDelete(1)}>删除</button>
        {/* 子组件不直接改数据,而是"打电话通知"父组件 */}
      </li>
    </ul>
  );
}

关键理解:React 数据流是单向的(父→子),子组件不能直接改父组件的数据。但子组件可以调用父组件传来的回调函数来"上报"变化,让父组件自己改。


3.3 组件的嵌套组合

children:特殊的"插槽" prop

// 定义容器组件 —— children 自动包含被包裹的内容
function Card({ title, children }) {
  return (
    <div style={{ border: "1px solid #ccc", borderRadius: 8, padding: 16 }}>
      <h2>{title}</h2>
      <div>{children}</div> {/* 渲染被包裹的内容 */}
    </div>
  );
}

// 使用
function App() {
  return (
    <div>
      <Card title="个人信息">
        <p>姓名:小明</p>
        <p>年龄:18 岁</p>
        <button>编辑资料</button>
      </Card>

      <Card title="最新动态">
        <ul>
          <li>小明发布了一篇文章</li>
          <li>小红点了一个赞</li>
        </ul>
      </Card>
    </div>
  );
}

3.4 实战案例一:个人信息卡片

效果:可复用的用户信息卡片,展示头像、姓名、年龄和简介。不同 props 展示不同用户。

import React from "react";
import PropTypes from "prop-types";

function UserCard({ avatar, name, age, bio }) {
  return (
    <div
      style={{
        width: 300,
        border: "1px solid #e0e0e0",
        borderRadius: 12,
        padding: 20,
        textAlign: "center",
        boxShadow: "0 2px 8px rgba(0,0,0,0.1)",
        margin: 10,
      }}
    >
      <img
        src={avatar}
        alt={`${name} 的头像`}
        style={{
          width: 80,
          height: 80,
          borderRadius: "50%",
          objectFit: "cover",
        }}
      />
      <h3 style={{ margin: "12px 0 4px" }}>{name}</h3>
      <p style={{ color: "#888", fontSize: 14 }}>{age} 岁</p>
      {/* 有 bio 才显示,没有就不渲染 */}
      {bio && (
        <p style={{ marginTop: 12, fontSize: 14, color: "#555" }}>{bio}</p>
      )}
    </div>
  );
}

UserCard.propTypes = {
  avatar: PropTypes.string.isRequired,
  name: PropTypes.string.isRequired,
  age: PropTypes.number.isRequired,
  bio: PropTypes.string, // 可选
};

export default UserCard;
// 使用示例
import UserCard from "./components/UserCard";

function App() {
  return (
    <div style={{ display: "flex", flexWrap: "wrap" }}>
      <UserCard
        avatar="https://i.pravatar.cc/150?img=1"
        name="小明"
        age={18}
        bio="热爱编程的00后,喜欢篮球和吉他"
      />
      <UserCard
        avatar="https://i.pravatar.cc/150?img=5"
        name="小红"
        age={22}
        bio="设计师一枚,对一切美的事物感兴趣"
      />
      <UserCard
        avatar="https://i.pravatar.cc/150?img=3"
        name="小刚"
        age={27}
        // 故意不传 bio —— 简介区域不会显示
      />
    </div>
  );
}
要点 说明
props 解构 { avatar, name, age, bio } 直接从参数取出
条件渲染 {bio && <p>...</p>} —— 有 bio 才显示
PropTypes bio 没加 isRequired,不传也不会警告
组件复用 同一个 UserCard,三组不同 props,三种不同卡片

3.5 实战案例二:点赞计数器

效果:带点赞/踩和重置的计数器 + 操作日志。展示 useState 核心用法。

import React, { useState } from "react";

function LikeCounter() {
  const [count, setCount] = useState(0); // 状态1:点赞数
  const [logs, setLogs] = useState([]); // 状态2:操作日志

  const handleLike = () => {
    setCount((prev) => prev + 1); // 函数式更新
    setLogs((prev) => [...prev, "赞 +1"]); // 不可变追加
  };

  const handleDislike = () => {
    setCount((prev) => prev - 1);
    setLogs((prev) => [...prev, "踩 -1"]);
  };

  const handleReset = () => {
    setCount(0);
    setLogs([]);
  };

  return (
    <div
      style={{
        width: 300,
        padding: 20,
        border: "1px solid #ddd",
        borderRadius: 12,
        textAlign: "center",
      }}
    >
      <div style={{ fontSize: 48, fontWeight: "bold", margin: "16px 0" }}>
        {count}
      </div>
      <p style={{ color: "#888" }}>
        {count > 0 ? "正向" : count < 0 ? "负向" : "当前为零"}
      </p>

      <div style={{ display: "flex", gap: 8, justifyContent: "center" }}>
        <button onClick={handleLike}>赞 +1</button>
        <button onClick={handleDislike}>踩 -1</button>
        <button onClick={handleReset} style={{ color: "#999" }}>
          重置
        </button>
      </div>

      {logs.length > 0 && (
        <div
          style={{
            marginTop: 16,
            textAlign: "left",
            fontSize: 13,
            color: "#666",
          }}
        >
          <p style={{ fontWeight: "bold" }}>操作日志:</p>
          {logs.map((log, i) => (
            <div key={i}>
              {i + 1}. {log}
            </div>
          ))}
        </div>
      )}
    </div>
  );
}

export default LikeCounter;
要点 说明
多个 useState 一个组件可以有多个状态,各自独立管理
函数式更新 setCount((prev) => prev + 1) 基于最新值更新
不可变数组 [...prev, "赞 +1"] 创建新数组而非 push
React 自动批处理 同时调多个 setState,React 会合并为一次渲染

3.6 实战案例三:搜索过滤列表

效果:输入关键词实时过滤商品列表。综合运用 useState + useEffect + 受控输入。

import React, { useState, useEffect } from "react";

const ALL_PRODUCTS = [
  { id: 1, name: "MacBook Pro", category: "电脑", price: 12999 },
  { id: 2, name: "iPhone 15", category: "手机", price: 6999 },
  { id: 3, name: "AirPods Pro", category: "耳机", price: 1899 },
  { id: 4, name: "iPad Air", category: "平板", price: 4799 },
  { id: 5, name: "Apple Watch", category: "手表", price: 2999 },
  { id: 6, name: "Mac Mini", category: "电脑", price: 4499 },
  { id: 7, name: "iPhone SE", category: "手机", price: 3499 },
  { id: 8, name: "Magic Keyboard", category: "配件", price: 899 },
];

function ProductSearch() {
  const [searchTerm, setSearchTerm] = useState(""); // 搜索词
  const [filtered, setFiltered] = useState(ALL_PRODUCTS); // 筛选结果

  // 搜索词变化 → 重新筛选
  useEffect(() => {
    const term = searchTerm.toLowerCase().trim();
    if (term === "") {
      setFiltered(ALL_PRODUCTS); // 空搜索 → 显示全部
    } else {
      setFiltered(
        ALL_PRODUCTS.filter(
          (p) =>
            p.name.toLowerCase().includes(term) ||
            p.category.toLowerCase().includes(term),
        ),
      );
    }
  }, [searchTerm]);

  const formatPrice = (price) => "¥" + price.toLocaleString("zh-CN");

  return (
    <div style={{ maxWidth: 500, margin: "0 auto", padding: 20 }}>
      <h2>商品搜索</h2>

      <input
        type="text"
        placeholder="输入商品名称或类别..."
        value={searchTerm}
        onChange={(e) => setSearchTerm(e.target.value)}
        style={{
          width: "100%",
          padding: "10px 12px",
          fontSize: 16,
          border: "1px solid #ccc",
          borderRadius: 8,
          marginBottom: 16,
          boxSizing: "border-box",
        }}
      />

      <p style={{ color: "#888", fontSize: 14 }}>
        共 {filtered.length} 件商品
        {searchTerm && `(搜索:"${searchTerm}")`}
      </p>

      {filtered.length === 0 ? (
        <p style={{ textAlign: "center", color: "#999", padding: "40px 0" }}>
          没有找到匹配的商品
        </p>
      ) : (
        filtered.map((product) => (
          <div
            key={product.id}
            style={{
              display: "flex",
              justifyContent: "space-between",
              padding: "12px 16px",
              borderBottom: "1px solid #f0f0f0",
            }}
          >
            <div>
              <span style={{ fontWeight: "bold" }}>{product.name}</span>
              <span
                style={{
                  marginLeft: 8,
                  fontSize: 12,
                  color: "#fff",
                  background: "#1890ff",
                  padding: "2px 6px",
                  borderRadius: 4,
                }}
              >
                {product.category}
              </span>
            </div>
            <span style={{ color: "#ff4d4f", fontWeight: "bold" }}>
              {formatPrice(product.price)}
            </span>
          </div>
        ))
      )}
    </div>
  );
}

export default ProductSearch;
要点 说明
受控组件 value={searchTerm} + onChange 实现输入框与状态双向同步
useEffect 依赖 [searchTerm] —— 搜索词变了才重新筛选
filter 不修改原数组 ALL_PRODUCTS.filter(...) 返回新数组
空列表友好提示 filtered.length === 0 时显示提示文字
key 用唯一 ID key={product.id} 而非 index


第四部分:注意事项 —— 10 个高频错误


错误 1:组件名首字母没有大写

// ❌ 错误:首字母小写 → React 把它当普通 HTML 标签
function myButton() {
  return <button>点击</button>;
}
// 使用:<myButton />  →  不会渲染!

// ✅ 正确:首字母必须大写
function MyButton() {
  return <button>点击</button>;
}

原因:React 靠首字母大小写区分"组件"和"HTML 标签"。小写 = 原生标签,大写 = 你的组件。


错误 2:JSX 返回多个并列元素(没有唯一根节点)

// ❌ 错误:两个并列元素
function App() {
  return (
    <h1>标题</h1>
    <p>段落</p>
  );
}
// 报错:Adjacent JSX elements must be wrapped in an enclosing tag.

// ✅ 正确:用 <div> 或 <>...</> 包裹
function App() {
  return (
    <>
      <h1>标题</h1>
      <p>段落</p>
    </>
  );
}

原因:JSX 本质是 React.createElement() 调用,return 只能返回一个表达式。


错误 3:多行 JSX 的 return 后面没加括号

// ❌ 错误:return 后换行 → JS 自动插入分号 → return;(什么都没返回)
function App() {
  return;
  <div>
    <h1>标题</h1>
  </div>;
}
// 结果:页面一片空白!

// ✅ 正确:return 后紧跟 (
function App() {
  return (
    <div>
      <h1>标题</h1>
    </div>
  );
}

原因:JS 的自动分号插入(ASI)机制会在 return 后换行时补分号,变成 return;


错误 4:直接修改 props

// ❌ 错误:直接改 props
function Greeting({ name }) {
  name = name.toUpperCase(); // 试图修改 props!
  return <h1>你好,{name}</h1>;
}

// ✅ 正确:存到新变量
function Greeting({ name }) {
  const upperName = name.toUpperCase(); // 新变量,不动 props
  return <h1>你好,{upperName}</h1>;
}

原因:React 数据流是单向的。修改 props 会破坏数据流,导致不可预测的 Bug。


错误 5:useState / useEffect 在条件或循环中调用

// ❌ 错误:Hook 在 if 语句里
function Bad({ isAdmin }) {
  if (isAdmin) {
    const [data, setData] = useState(null); // 报错!
  }
}

// ✅ 正确:Hook 始终在顶层
function Good({ isAdmin }) {
  const [data, setData] = useState(null); // 始终在这里

  useEffect(() => {
    if (isAdmin) fetchData().then(setData); // 条件逻辑放里面
  }, [isAdmin]);

  return <div>...</div>;
}

原因:React 依赖 Hook 的调用顺序来管理状态。顺序乱了,状态就对应错了。


错误 6:直接修改 state(push、直接赋值属性)

// ❌ 错误:直接 push(引用没变,React 检测不到)
function BadList() {
  const [items, setItems] = useState(["苹果", "香蕉"]);

  const addItem = () => {
    items.push("橘子"); // 改了原数组!
    setItems(items); // 引用地址没变 → React 认为没变化 → 不渲染!
  };
  // 点击按钮 → 页面没有变化!
}

// ✅ 正确:创建新数组
function GoodList() {
  const [items, setItems] = useState(["苹果", "香蕉"]);

  const addItem = () => {
    setItems([...items, "橘子"]); // 新数组,新引用 → React 检测到变化
  };
  // 点击按钮 → 页面出现"橘子" ✅
}

原因:React 用 === 比较来判断状态是否变化。push 不改变引用地址。


错误 7:useEffect 忘了写依赖数组

// ❌ 错误:没有依赖数组 → 每次渲染都执行
function Bad() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(count);
  }); // ← 没有 []!每次渲染都执行!
  // 如果在里面 setCount,会死循环!

  return <button onClick={() => setCount(count + 1)}>+1</button>;
}

// ✅ 正确:加上依赖数组
function Good() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(count);
  }, [count]); // ← 有依赖数组,只在 count 变化时执行

  return <button onClick={() => setCount(count + 1)}>+1</button>;
}

原因:不传依赖数组 = "每次渲染都执行",容易造成死循环。


错误 8:useEffect 中没清理副作用(事件、定时器泄漏)

// ❌ 错误:加了事件监听,没移除 → 内存泄漏
function Bad() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);
    // ⚠️ 没有返回清理函数!旧的监听器一直在!
  }, [width]);

  return <div>{width}px</div>;
}

// ✅ 正确:返回清理函数
function Good() {
  const [width, setWidth] = useState(window.innerWidth);

  useEffect(() => {
    const handleResize = () => setWidth(window.innerWidth);
    window.addEventListener("resize", handleResize);

    return () => {
      window.removeEventListener("resize", handleResize); // 清理!
    };
  }, []); // 空数组:只绑定一次

  return <div>{width}px</div>;
}

原因:事件、定时器等像"只管开灯不管关灯",不清理会导致内存泄漏和重复执行。


错误 9:在组件函数体中(渲染阶段)执行副作用

// ❌ 错误:在函数体中直接发请求 → 每次渲染都发 → 死循环
function Bad({ userId }) {
  fetch(`/api/user/${userId}`) // ⚠️ 渲染阶段执行副作用
    .then((res) => res.json())
    .then((data) => {
      /* setState → 触发渲染 → 又 fetch → 死循环 */
    });

  localStorage.setItem("lastVisit", Date.now()); // ⚠️ 也是副作用

  return <div>{userId}</div>;
}

// ✅ 正确:副作用全放 useEffect
function Good({ userId }) {
  const [userData, setUserData] = useState(null);

  useEffect(() => {
    fetch(`/api/user/${userId}`)
      .then((res) => res.json())
      .then(setUserData);
    localStorage.setItem("lastVisit", Date.now());
  }, [userId]);

  return <div>{userId}</div>;
}

原因:渲染阶段应该是纯函数——只返回 JSX,不产生副作用。副作用必须放 useEffect


错误 10:useEffect 依赖数组"撒谎"(遗漏依赖)

// ❌ 错误:effect 里用了 count,但依赖数组是空的(闭包陷阱)
function Bad() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`当前计数:${count}`); // 用了 count
  }, []); // ⚠️ 依赖数组"撒谎"了 —— count 永远是闭包里的旧值 0!

  return <button onClick={() => setCount(count + 1)}>+1</button>;
}

// ✅ 正确:诚实声明所有依赖
function Good() {
  const [count, setCount] = useState(0);

  useEffect(() => {
    console.log(`当前计数:${count}`);
  }, [count]); // ✅ 诚实声明:我依赖 count

  return <button onClick={() => setCount(count + 1)}>+1</button>;
}

原因:依赖数组是给 React 的"诚信声明"。漏写依赖会导致 effect 里的值永远是旧的(闭包陷阱)。


十大错误速查总表

# 错误现象 核心原因 正确做法
1 组件名首字母小写 React 无法区分组件和 HTML 标签 首字母必须大写
2 JSX 返回多个并列元素 必须有唯一根节点 <div><>...</> 包裹
3 多行 return 不用括号 ASI 自动插入分号 return ( 开头
4 直接修改 props 破坏单向数据流 复制到新变量
5 Hook 放在 if/for 里 破坏 Hook 调用顺序 Hook 始终在函数体顶层
6 直接 push/修改 state 引用没变,React 检测不到 创建新对象/数组(展开运算符)
7 useEffect 不写依赖数组 每次渲染都执行,容易死循环 按需写 [][dep1, dep2]
8 useEffect 不返回清理函数 事件/定时器泄漏 return () => { cleanup }
9 函数体中执行副作用 渲染阶段应是纯函数 副作用全放 useEffect
10 依赖数组遗漏实际使用的变量 闭包陷阱,拿到的永远是旧值 诚实声明所有依赖


第五部分:配套练习


5.1 课堂练习:构建"心愿单"组件

请创建一个名为 WishList 的函数组件,满足以下要求:

  1. 初始数据:初始化 3 个示例心愿(如"学一门乐器"、"去西藏旅行"、"每天阅读 30 分钟")
  2. 添加心愿:输入框 + "添加"按钮,输入文字后加入列表。空文字不添加(alert 提示)
  3. 完成标记:每个心愿前有复选框,勾选后显示删除线效果
  4. 删除功能:已完成的心愿旁出现"删除"按钮,点击移除
  5. 统计信息:底部显示"共 X 个心愿,已完成 Y 个"

提示

  • 心愿数据结构:{ id: number, text: string, completed: boolean }
  • 新心愿 id 可用 Date.now() 生成
  • ... 展开运算符更新数组,不用 push

扩展挑战(选做)

  • 增加"一键清空"按钮
  • localStorage 持久化,刷新页面后数据不丢失

5.2 随堂自测(3 道选择题)

第 1 题:关于 props,以下说法正确的是?

A. 子组件可以直接修改 props 来实现数据回传
B. props 是只读的,但可以通过调用父组件传入的回调函数来"通知"父组件更新数据
C. props 只能传递字符串
D. props 和 state 是完全一样的东西

点击查看答案

正确答案:B

  • A 错:props 只读(见错误 4)
  • B 对:这是 React 单向数据流的标准模式
  • C 错:props 可传任何 JS 类型
  • D 错:props 是外部传入(只读),state 是内部管理(可更新)

第 2 题:以下代码的 useEffect 何时执行?

function UserPage({ userId }) {
  const [user, setUser] = useState(null);
  useEffect(() => {
    fetchUser(userId).then(setUser);
  }, [userId]);
  return <div>{user?.name}</div>;
}

A. 只在首次渲染时执行一次
B. 每次渲染都执行
C. 首次渲染时执行,且 userId 变化时重新执行
D. 组件卸载时执行

点击查看答案

正确答案:C

  • 依赖数组是 [userId],所以首次挂载后执行,之后每次 userId 变化时重新执行
  • A 对应 [],B 对应不传数组,D 对应清理函数

第 3 题:关于 useState,哪一项是错误的?

A. useState 返回 [当前值, 更新函数]
B. 调用 setCount(count + 1) 后,count 会立刻变成新值
C. 基于旧值更新时推荐 setCount((prev) => prev + 1)
D. 一个组件可以多次调用 useState

点击查看答案

正确答案:B

  • B 错误setCount 是异步的,调用后立即读 count 拿到的还是旧值(见 2.4 规则三)
  • A 对:正是数组解构的写法
  • C 对:函数式更新避免闭包陷阱
  • D 对:可以有多个独立的状态


学习建议:学完本文后,打开你的 React 项目,亲手创建 3~5 个组件练手。重点体验"props 从上传下"和"state 管理内部变化"两种数据流动方式的区别。

遇到问题时,先对照第四部分的十大错误速查表 —— 你的问题大概率就在其中。

当你能够熟练地拆分组件、理解 props 与 state 的区别、正确使用 useEffect 处理副作用时,恭喜你 —— 你已经掌握了 React 最核心的能力。

0

评论区