跳到主要内容
预计阅读 41 分钟

State与Props —— 让组件活起来

如果把组件比作一个演员,Props 就是导演给他的剧本台词,State 就是他自己的情绪记忆。剧本决定了他要说什么,情绪决定了他怎么表达。两者配合,一个”活”的角色才能立在舞台上。

📋 开篇自测:你已经知道多少?

  1. Props 和 State 的核心区别是什么?哪个可以被组件自身修改?
  2. 为什么连续调用两次 setCount(count + 1) 结果只加了 1,而不是 2?
  3. 子组件想把数据传给父组件,在 React 中应该怎么做?

一、Props —— 组件的”入参”

1.1 理解 Props

你一定写过函数:

function greet(name) {
  return `你好,${name}!`;
}

greet("小明"); // "你好,小明!"

name 是函数的参数,调用时从外部传入。React 组件的 Props 就是同样的道理——它是父组件传给子组件的数据通道。

// 定义一个 UserCard 组件,接收 props
function UserCard(props) {
  return (
    <div className="user-card">
      <h2>{props.name}</h2>
      <p>年龄:{props.age}</p>
      <p>职业:{props.job}</p>
    </div>
  );
}

// 父组件使用时传入数据
function App() {
  return (
    <div>
      <UserCard name="小明" age={25} job="前端工程师" />
      <UserCard name="小红" age={28} job="产品经理" />
    </div>
  );
}

同一个 UserCard 组件,传入不同的 Props,渲染出不同的内容。这就是组件复用的基础。

1.2 解构 Props

每次写 props.xxx 很繁琐,实际开发中我们几乎都用解构写法:

function UserCard({ name, age, job }) {
  return (
    <div className="user-card">
      <h2>{name}</h2>
      <p>年龄:{age}</p>
      <p>职业:{job}</p>
    </div>
  );
}

代码更简洁,而且一眼就能看出这个组件需要哪些数据。

1.3 默认值与 children

当某个 Props 没有传入时,可以设置默认值:

function Button({ text = "点击", type = "primary", children }) {
  return (
    <button className={`btn btn-${type}`}>
      {children || text}
    </button>
  );
}

// 三种使用方式
<Button />                          // 显示"点击"
<Button text="提交" type="danger" /> // 显示"提交",红色样式
<Button>自定义内容</Button>           // 显示"自定义内容"

children 是一个特殊的 Props,它代表组件标签之间包裹的内容。在上面的例子中,<Button>自定义内容</Button> 里的”自定义内容”就是 children

1.4 Props 的核心规则:只读

Props 是只读的。 子组件不能修改从父组件接收到的 Props。

function UserCard({ name }) {
  // 错误!不要这样做
  // name = "张三";

  return <h2>{name}</h2>;
}

为什么?因为 Props 代表的是父组件的意志。如果子组件能随意修改 Props,数据流就乱了——你不知道一个值到底是谁改的、什么时候改的。React 的单向数据流设计就是为了避免这种混乱。

🤔 想一想 Props 只读这个规则,和 JavaScript 中”纯函数”的概念有什么关系?纯函数的特征之一是不修改传入的参数。React 组件本质上就是一个接收 Props、返回 JSX 的纯函数。


二、Props 的类型检查

当项目规模变大、组件数量增多时,一个常见的问题是:传错了 Props 类型,但没有任何报错,直到运行时页面显示异常才发现。

2.1 TypeScript 方式(推荐)

在 TypeScript 项目中,用 interfacetype 定义 Props 类型是最佳实践:

interface UserCardProps {
  name: string;
  age: number;
  job: string;
  avatar?: string;   // 可选属性
  onFollow?: () => void;  // 可选的回调函数
}

function UserCard({ name, age, job, avatar, onFollow }: UserCardProps) {
  return (
    <div className="user-card">
      {avatar && <img src={avatar} alt={name} />}
      <h2>{name}</h2>
      <p>年龄:{age}岁</p>
      <p>职业:{job}</p>
      {onFollow && <button onClick={onFollow}>关注</button>}
    </div>
  );
}

这样做的好处:编辑器会在你写代码时实时提示哪些 Props 是必传的、类型是什么,传错了立刻红线报错,不用等到运行时才发现。

2.2 PropTypes 方式(了解即可)

在非 TypeScript 的项目中,React 提供了 prop-types 库做运行时类型检查:

import PropTypes from 'prop-types';

function UserCard({ name, age, job }) {
  return (
    <div>
      <h2>{name}</h2>
      <p>{age}岁 · {job}</p>
    </div>
  );
}

UserCard.propTypes = {
  name: PropTypes.string.isRequired,
  age: PropTypes.number.isRequired,
  job: PropTypes.string.isRequired,
};

PropTypes 只在开发环境下生效,生产构建会自动移除。但它的检查发生在运行时,不如 TypeScript 在编译时就拦截问题。新项目强烈建议直接使用 TypeScript。


三、State —— 组件的”记忆”

Props 是外部传入的,组件自身不能改。那如果组件需要记住一些”自己的东西”——比如用户点了几次按钮、输入框里写了什么、下拉菜单是展开还是收起——怎么办?

这就是 State 的用武之地。State 是组件的私有记忆,由组件自身管理,改变时会触发重新渲染。

3.1 没有 State 的困境

先看一个”错误示范”,理解为什么需要 State:

function Counter() {
  let count = 0;

  function handleClick() {
    count = count + 1;
    console.log(count); // 值确实变了:1, 2, 3...
  }

  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={handleClick}>+1</button>
    </div>
  );
}

点击按钮后,console.log 里的 count 确实在增加,但页面上始终显示 0。为什么?

因为普通的局部变量改变不会通知 React 去重新渲染。React 不知道这个变量变了,所以界面不会更新。这就像你在脑子里默默改了一个数字,但嘴巴没有说出来——外界当然看不到变化。

3.2 useState:正确的姿势

React 提供了 useState Hook 来声明状态变量:

import { useState } from 'react';

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

  function handleClick() {
    setCount(count + 1);
  }

  return (
    <div>
      <p>计数:{count}</p>
      <button onClick={handleClick}>+1</button>
    </div>
  );
}

useState(0) 做了两件事:

  1. 声明一个状态变量 count,初始值为 0
  2. 返回一个更新函数 setCount,调用它可以修改 count 的值并触发组件重新渲染

[count, setCount] 是数组解构语法——useState 返回一个长度为 2 的数组,第一项是当前值,第二项是更新函数。名字可以随意取,但约定俗成使用 [xxx, setXxx] 的命名方式。


四、useState 深入 —— 更新的学问

4.1 直接赋值更新

最简单的用法,直接传入新值:

const [name, setName] = useState("小明");
const [age, setAge] = useState(25);
const [todos, setTodos] = useState([]);

setName("小红");         // 字符串
setAge(26);              // 数字
setTodos(["吃饭", "睡觉"]); // 数组

4.2 函数式更新(重要)

当新的 State 需要基于旧的 State 计算时,应该使用函数式更新:

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

  function handleClick() {
    // 方式一:直接赋值(在某些场景下有问题)
    // setCount(count + 1);

    // 方式二:函数式更新(推荐)
    setCount(prev => prev + 1);
  }

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

为什么推荐函数式更新? 看一个经典的坑:

function handleClick() {
  setCount(count + 1);  // count 此时是 0
  setCount(count + 1);  // count 此时还是 0!
  setCount(count + 1);  // count 此时还是 0!
}
// 点击后 count 变成 1,不是 3

在同一次事件处理中,count 的值是固定的(闭包特性)。三次 setCount(0 + 1) 其实都在设置 count = 1

使用函数式更新就没有这个问题:

function handleClick() {
  setCount(prev => prev + 1);  // 0 → 1
  setCount(prev => prev + 1);  // 1 → 2
  setCount(prev => prev + 1);  // 2 → 3
}
// 点击后 count 正确变成 3

函数式更新的参数 prev 始终是最新的 State 值,不受闭包影响。

一条实用规则:只要新值依赖旧值,就用函数式更新。

4.3 更新对象和数组

State 可以是任何类型。但更新对象和数组时,必须创建新的引用,不能直接修改原对象:

const [user, setUser] = useState({ name: "小明", age: 25 });

// 错误:直接修改原对象,React 检测不到变化
// user.age = 26;
// setUser(user);

// 正确:创建新对象(展开运算符)
setUser({ ...user, age: 26 });

// 正确:使用函数式更新
setUser(prev => ({ ...prev, age: prev.age + 1 }));

数组同理:

const [items, setItems] = useState(["苹果", "香蕉"]);

// 添加元素
setItems(prev => [...prev, "橙子"]);

// 删除元素(过滤掉索引为 1 的)
setItems(prev => prev.filter((_, index) => index !== 1));

// 修改元素
setItems(prev => prev.map((item, index) =>
  index === 0 ? "大苹果" : item
));

React 通过比较引用(===)来判断 State 是否变化。如果你修改原对象再调用 setState,引用没有改变,React 就认为”没变”,不会重新渲染。

🤔 想一想 为什么 React 要求用”不可变”的方式更新 State?直接修改原对象不是更省内存吗?提示:想想 React 如何判断”需不需要重新渲染”——用 === 比较两个对象,如果是同一个引用,即使内部属性变了也会被认为”没变”。不可变更新虽然创建了新对象,但让”变没变”的判断变得又快又准。

4.4 惰性初始化

如果初始 State 需要经过计算得出,可以传入一个函数,避免每次渲染都重复计算:

// 每次渲染都会执行 computeExpensiveValue(),即使只用一次
const [data, setData] = useState(computeExpensiveValue());

// 只在首次渲染时执行一次
const [data, setData] = useState(() => computeExpensiveValue());

常见场景:从 localStorage 读取初始值。

const [theme, setTheme] = useState(() => {
  return localStorage.getItem('theme') || 'light';
});

五、State vs Props —— 什么时候用哪个?

State 和 Props 都是数据,但角色完全不同。用一张表对比:

维度PropsState
谁来提供父组件传入组件自身声明
可否修改只读,不可修改可以通过 setter 更新
触发渲染父组件重新传入新 Props 时调用 setState 时
类比函数的参数函数内部的局部变量
用途配置组件行为、传递数据记录交互状态、管理动态数据

判断原则:

  • 这个数据是从外部传进来的吗? → Props
  • 这个数据会因为用户操作而改变吗? → State
  • 这个数据能从已有的 Props 或 State 计算出来吗? → 都不用,直接在渲染时计算

举个例子:一个商品卡片组件。

function ProductCard({ name, price, discount }) {
  // name, price, discount 是外部传入的 → Props
  // 是否收藏是用户行为决定的 → State
  const [isFavorite, setIsFavorite] = useState(false);

  // 折后价可以从 Props 计算出来 → 不需要额外的 State
  const finalPrice = price * (1 - discount);

  return (
    <div>
      <h3>{name}</h3>
      <p>原价:¥{price} | 折后:¥{finalPrice.toFixed(2)}</p>
      <button onClick={() => setIsFavorite(prev => !prev)}>
        {isFavorite ? "❤️ 已收藏" : "🤍 收藏"}
      </button>
    </div>
  );
}

finalPrice 完全可以从 pricediscount 算出来,不需要单独存一个 State。能算出来的值,就不要存成 State——这是减少 bug 的一条重要原则。


六、单向数据流 —— React 的哲学

在 React 中,数据像瀑布一样从上往下流动:

App(数据源)
  ↓ props
  Header(展示标题)
  ↓ props
  TodoList(展示列表)
    ↓ props
    TodoItem(展示单条)

这就是单向数据流(One-Way Data Flow)

  • 父组件通过 Props 把数据传给子组件
  • 子组件不能反过来修改父组件的数据
  • 数据只沿一个方向流动:从上到下

为什么要这样设计?想象一个 10 层楼的组件树,如果任何一层都能随意修改任何一层的数据,出了 bug 你得从上到下、从左到右排查所有可能——就像一团纠缠在一起的毛线球。

单向数据流让数据的来源和去向一目了然。某个数据出了问题,只要沿着组件树往上找”谁传给我的”就能定位。


七、组件通信模式

单向数据流规定了数据从上往下流。但实际开发中,不可能所有通信都是父传子。React 提供了几种模式来解决不同方向的通信需求。

7.1 父传子:Props

这是最基本的模式,前面已经充分演示。数据和回调函数都可以通过 Props 传递。

function Parent() {
  const [message, setMessage] = useState("你好");
  return <Child text={message} />;
}

function Child({ text }) {
  return <p>{text}</p>;
}

7.2 子传父:回调函数

子组件不能直接修改父组件的数据,但父组件可以把一个”回调函数”通过 Props 传下去。子组件调用这个函数,就等于间接通知了父组件。

function Parent() {
  const [score, setScore] = useState(0);

  // 把"加分"的能力通过 Props 传给子组件
  return (
    <div>
      <p>总分:{score}</p>
      <Child onScore={(points) => setScore(prev => prev + points)} />
    </div>
  );
}

function Child({ onScore }) {
  return (
    <div>
      <button onClick={() => onScore(10)}>答对了 +10</button>
      <button onClick={() => onScore(-5)}>答错了 -5</button>
    </div>
  );
}

数据依然是从上到下流动(父组件的 score 通过 Props 传下去展示),操作指令从下到上传递(子组件通过调用 onScore 通知父组件)。这并没有违反单向数据流——回调函数只是在”请求”父组件更新自己的 State。

7.3 兄弟通信:状态提升

两个平级的兄弟组件需要共享数据怎么办?把共享的 State 提升到它们共同的父组件中管理。

function App() {
  // 状态提升:温度数据由父组件统一管理
  const [celsius, setCelsius] = useState(0);

  return (
    <div>
      <CelsiusInput
        value={celsius}
        onChange={(val) => setCelsius(val)}
      />
      <FahrenheitDisplay celsius={celsius} />
    </div>
  );
}

function CelsiusInput({ value, onChange }) {
  return (
    <label>
      摄氏温度:
      <input
        type="number"
        value={value}
        onChange={(e) => onChange(Number(e.target.value))}
      />
    </label>
  );
}

function FahrenheitDisplay({ celsius }) {
  const fahrenheit = (celsius * 9) / 5 + 32;
  return <p>华氏温度:{fahrenheit.toFixed(1)}°F</p>;
}

CelsiusInputFahrenheitDisplay 是兄弟组件。它们不能直接对话,但通过父组件 App 中转:输入组件改变父组件的 State,父组件把最新值传给展示组件。

7.4 受控组件与非受控组件

在上面的温度输入框示例中,<input>value 由 React 的 State 控制——用户输入触发 onChangeonChange 更新 State,State 变化导致 inputvalue 更新。这种模式叫做受控组件

与之对应的是非受控组件——不用 State 控制表单值,而是用 defaultValue 设置初始值,需要时通过 ref 读取 DOM 的当前值:

import { useRef } from 'react';

function UncontrolledForm() {
  const inputRef = useRef(null);

  function handleSubmit() {
    // 需要值的时候,直接从 DOM 读取
    alert('输入的内容:' + inputRef.current.value);
  }

  return (
    <div>
      <input ref={inputRef} defaultValue="初始值" />
      <button onClick={handleSubmit}>提交</button>
    </div>
  );
}

什么时候用哪个? 大部分场景用受控组件——因为 React State 中始终保存着最新值,方便做验证、联动、条件渲染。非受控组件适合不需要实时追踪值的场景(比如文件上传的 <input type="file">)。

🤔 想一想 受控组件中,如果你只设置了 value 但没有 onChange 处理函数,输入框会怎样?试试看:你会发现无论怎么打字,输入框的内容都不会变——因为 React 的 State 没有更新,value 始终是初始值。这就是”受控”的含义:控制权完全在 State 手中。


八、实战:搭建待办事项列表

把本章学到的所有知识串起来,构建一个完整的 TodoList 应用。

8.1 功能需求

  • 输入框添加新待办
  • 点击切换完成/未完成状态
  • 删除待办
  • 显示统计信息(总数、已完成数)

8.2 完整代码

import { useState } from 'react';

// ---- 子组件:单条待办 ----
function TodoItem({ todo, onToggle, onDelete }) {
  return (
    <li style={{
      display: 'flex',
      alignItems: 'center',
      gap: '8px',
      padding: '8px 0',
      borderBottom: '1px solid #eee',
    }}>
      <input
        type="checkbox"
        checked={todo.done}
        onChange={() => onToggle(todo.id)}
      />
      <span style={{
        flex: 1,
        textDecoration: todo.done ? 'line-through' : 'none',
        color: todo.done ? '#999' : '#333',
      }}>
        {todo.text}
      </span>
      <button
        onClick={() => onDelete(todo.id)}
        style={{
          background: 'none',
          border: '1px solid #ddd',
          borderRadius: '4px',
          padding: '2px 8px',
          cursor: 'pointer',
          color: '#e74c3c',
        }}
      >
        删除
      </button>
    </li>
  );
}

// ---- 子组件:统计栏 ----
function TodoStats({ total, doneCount }) {
  return (
    <div style={{
      padding: '12px 0',
      color: '#666',
      fontSize: '14px',
    }}>
      共 {total} 项,已完成 {doneCount} 项,未完成 {total - doneCount} 项
    </div>
  );
}

// ---- 子组件:输入栏 ----
function TodoInput({ onAdd }) {
  const [text, setText] = useState('');

  function handleSubmit(e) {
    e.preventDefault();
    const trimmed = text.trim();
    if (!trimmed) return;
    onAdd(trimmed);
    setText('');
  }

  return (
    <form onSubmit={handleSubmit} style={{
      display: 'flex',
      gap: '8px',
      marginBottom: '16px',
    }}>
      <input
        type="text"
        value={text}
        onChange={(e) => setText(e.target.value)}
        placeholder="添加新待办..."
        style={{
          flex: 1,
          padding: '8px 12px',
          border: '1px solid #ddd',
          borderRadius: '4px',
          fontSize: '16px',
        }}
      />
      <button type="submit" style={{
        padding: '8px 20px',
        background: '#3498db',
        color: '#fff',
        border: 'none',
        borderRadius: '4px',
        cursor: 'pointer',
        fontSize: '16px',
      }}>
        添加
      </button>
    </form>
  );
}

// ---- 主组件:TodoApp ----
function TodoApp() {
  const [todos, setTodos] = useState([
    { id: 1, text: '学习 React Props', done: true },
    { id: 2, text: '学习 React State', done: false },
    { id: 3, text: '完成 TodoList 实战', done: false },
  ]);

  // 添加待办(子传父:TodoInput 通过 onAdd 回调通知父组件)
  function handleAdd(text) {
    setTodos(prev => [
      ...prev,
      { id: Date.now(), text, done: false },
    ]);
  }

  // 切换完成状态
  function handleToggle(id) {
    setTodos(prev =>
      prev.map(todo =>
        todo.id === id ? { ...todo, done: !todo.done } : todo
      )
    );
  }

  // 删除待办
  function handleDelete(id) {
    setTodos(prev => prev.filter(todo => todo.id !== id));
  }

  // 统计数据:从 State 计算得出,不需要额外的 State
  const doneCount = todos.filter(todo => todo.done).length;

  return (
    <div style={{
      maxWidth: '500px',
      margin: '40px auto',
      padding: '20px',
      fontFamily: '-apple-system, sans-serif',
    }}>
      <h1 style={{ fontSize: '24px', marginBottom: '16px' }}>
        待办事项
      </h1>

      {/* 父传子:传入 onAdd 回调 */}
      <TodoInput onAdd={handleAdd} />

      {/* 父传子:传入数据和回调 */}
      <ul style={{ listStyle: 'none', padding: 0 }}>
        {todos.map(todo => (
          <TodoItem
            key={todo.id}
            todo={todo}
            onToggle={handleToggle}
            onDelete={handleDelete}
          />
        ))}
      </ul>

      {/* 父传子:传入计算得到的统计数据 */}
      <TodoStats total={todos.length} doneCount={doneCount} />
    </div>
  );
}

export default TodoApp;

8.3 知识点回顾

这个 TodoList 虽然不大,但涵盖了本章的所有核心知识:

Props 相关:

  • TodoItem 通过 Props 接收 todo 数据、onToggleonDelete 回调
  • TodoStats 通过 Props 接收计算好的统计数据
  • TodoInput 通过 Props 接收 onAdd 回调

State 相关:

  • TodoAppuseState 管理 todos 数组
  • TodoInputuseState 管理输入框文本(受控组件)
  • 所有 State 更新都使用函数式更新 + 不可变方式

组件通信:

  • 父传子:TodoAppTodoItem(传数据和回调)
  • 子传父:TodoInputTodoApp(通过 onAdd 回调传递新待办文本)
  • 兄弟通信:TodoInputTodoItem 通过父组件 TodoApp 的状态提升间接通信

派生数据:

  • doneCounttodos 计算得出,没有额外存成 State

📝 掌握度自测

  1. 以下关于 Props 的说法,哪一项是错误的?

    • A) Props 是从父组件传递给子组件的数据
    • B) 子组件可以通过 this.props.xxx = newValue 修改 Props
    • C) Props 可以传递函数
    • D) children 是一个特殊的 Props
  2. 以下代码点击按钮后,页面显示的数字是多少?

    const [count, setCount] = useState(0);
    function handleClick() {
      setCount(count + 5);
      setCount(count + 5);
    }
    • A) 0
    • B) 5
    • C) 10
    • D) 报错
  3. 子组件想通知父组件”用户点击了删除按钮”,正确的做法是:

    • A) 子组件直接修改父组件的 State
    • B) 父组件把一个回调函数通过 Props 传给子组件,子组件调用它
    • C) 子组件通过 window.postMessage 发送消息
    • D) 子组件使用 document.dispatchEvent 触发自定义事件
  4. 以下哪种更新对象 State 的方式是正确的?

    • A) user.name = "新名字"; setUser(user);
    • B) setUser({ ...user, name: "新名字" });
    • C) setUser(Object.assign(user, { name: "新名字" }));
    • D) setUser((prev) => { prev.name = "新名字"; return prev; });
  5. 关于受控组件,以下描述正确的是:

    • A) 受控组件的值由 DOM 自身管理
    • B) 受控组件使用 defaultValue 设置初始值
    • C) 受控组件的值由 React State 控制,通过 onChange 更新
    • D) 受控组件不需要事件处理函数

💡 自我评估

  • 答对5题:State 与 Props 已经融会贯通,可以自信地构建交互式组件了!
  • 答对3-4题:核心概念已经掌握,再回顾一下函数式更新和不可变更新的细节。
  • 答对0-2题:建议动手把 TodoList 从零敲一遍。理解这些概念最好的方式不是读,而是写代码、犯错误、修bug。

参考答案: 1-B, 2-B, 3-B, 4-B, 5-C

购买课程解锁全部内容

从组件到架构:12 章系统掌握现代 React

¥29.90