第 2 章

组件模型:函数组件与类组件的本质差异

第2章:组件模型:函数组件与类组件的本质差异

React 16.8 引入 Hooks 后,函数组件和类组件之间的差异不再是 API 能力的差别,而是关于"值如何在时间中存在"的根本性语义差异。

本章核心问题:函数组件和类组件的核心差异到底是什么?为什么函数组件最终胜出? 读完本章你将理解


Level 1 · 你需要知道的(1-3年经验)

渲染的本质:函数调用 vs 实例方法

理解函数组件与类组件差异的起点,是理解 React 如何"渲染"一个组件。

对于类组件,React 会:

  1. 实例化这个类(new MyComponent(props)),创建一个对象实例
  2. 调用实例的 render() 方法获取 React 元素树
  3. 将实例保存在 Fiber 节点上,后续更新时复用同一个实例,只更新其 propsstate

对于函数组件,React 会:

  1. 直接调用这个函数(MyComponent(props)),函数返回 React 元素树
  2. 每次需要更新时,再次调用这个函数
  3. 没有实例,每次调用都是一个独立的函数调用

这意味着:函数组件的每次渲染是独立的函数调用,每次调用都有自己独立的作用域和闭包

闭包语义:函数组件捕获渲染时的值

让我们通过一个具体例子来理解这个差异。假设我们有一个计数器,点击按钮后延迟 3 秒显示当前值:

函数组件版本:

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

  function handleClick() {
    setTimeout(() => {
      alert(`You clicked ${count} times`);
    }, 3000);
  }

  return (
    <div>
      <button onClick={() => setCount(c => c + 1)}>+</button>
      <button onClick={handleClick}>Show after 3s</button>
      <p>Count: {count}</p>
    </div>
  );
}

类组件版本:

class Counter extends React.Component {
  state = { count: 0 };

  handleClick = () => {
    setTimeout(() => {
      alert(`You clicked ${this.state.count} times`);
    }, 3000);
  };

  render() {
    return (
      <div>
        <button onClick={() => this.setState(s => ({ count: s.count + 1 }))}>+</button>
        <button onClick={this.handleClick}>Show after 3s</button>
        <p>Count: {this.state.count}</p>
      </div>
    );
  }
}

现在执行以下操作:点击"+"按钮若干次,然后立刻点击"Show after 3s",在 3 秒内继续点击"+"。

这不是 bug,是设计。函数组件中的 handleClick 是在某次特定的渲染中创建的闭包,它捕获了那次渲染时 count 的值——这是一个具体的数字,不是对某个可变容器的引用。类组件中的 this.state.count 是对 this 这个可变实例的属性读取,this 始终指向同一个实例,所以总是读到最新值。

组件身份与 key

React 使用组件在树中的位置来决定是更新现有组件还是卸载并重新挂载。当一个组件在相同的位置渲染相同类型的组件时,React 复用现有实例(或 Fiber)并传入新 props。

key prop 是这个机制的显式控制开关。相同 key 意味着"这是同一个组件",不同 key 意味着"这是新的组件,应该销毁旧的"。

// 使用 key 强制重置
<UserProfile key={selectedId} userId={selectedId} />

selectedId 变化时,key 变化,React 会卸载旧组件、挂载新组件,所有内部状态都会被重置为初始值。这是一种干净、可预测的状态重置方式。


Level 2 · 它是怎么运行的(3-5年经验)

实例语义:类组件持有可变的 this

类组件的核心是 this——一个在整个组件生命周期内持续存在的可变对象。this.statethis.propsthis.setState 都是通过这个对象访问的。

这带来了一个根本性的问题:this 是共享的、可变的,而时间是线性流动的。当你在一个异步操作中读取 this.propsthis.state 时,你读到的不一定是触发该操作时的值,而是执行时的当前值。

// 类组件中捕获快照的手动方式
handleClick = () => {
  const count = this.state.count; // 手动快照
  setTimeout(() => {
    alert(`You clicked ${count} times`);
  }, 3000);
};

函数组件将这个"捕获快照"变成了默认行为——你无需做任何额外的事,每次渲染的数据自动是不可变的快照。

过时闭包:函数组件的陷阱

函数组件的闭包语义虽然在大多数情况下是正确的默认值,但在使用 Hooks 时,如果不理解这个语义,会踩入"过时闭包"(stale closure)的陷阱。

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

  useEffect(() => {
    const id = setInterval(() => {
      setCount(count + 1); // 陷阱:count 永远是 0
    }, 1000);
    return () => clearInterval(id);
  }, []); // 空依赖数组:effect 只运行一次

  return <div>Count: {count}</div>;
}

正确的解法:

方案一:使用函数式更新(推荐)

setCount(c => c + 1); // 使用更新函数,不依赖闭包中的 count

方案二:useRef 作为逃生舱

const countRef = useRef(count);
useEffect(() => { countRef.current = count; }, [count]);

useRef 返回的对象在组件整个生命周期内是同一个对象(类似类组件的 this),所以读取 .current 总能得到最新值。


Level 3 · 规范怎么定义的(资深)

为什么函数组件最终胜出

函数组件并不是因为"更简洁"或"代码量更少"而胜出。它们胜出是因为它们的语义与 React 的数据模型更加一致

React 的核心命题是:UI = f(state)。UI 是状态的纯函数。类组件通过 this 引入了一个贯穿时间的可变容器,使这个等式变得不纯粹——同样的 state 值,在不同时刻调用 render(),可能因为 this 上其他字段的变化而产生不同结果。

函数组件强制执行了这个等式:每次渲染就是用当前 props 和 state 调用函数,结果完全由输入决定。这使得组件更容易推理、更容易测试、更容易被编译器优化。

React 19 对组件模型的改进

React 19 引入了 Server Components 作为正式稳定特性,这对组件模型产生了影响。Server Components 是只在服务器上渲染的函数组件——它们没有状态,没有生命周期,但可以直接访问数据库和文件系统,结果作为 JSON 流传输到客户端。

// 这是一个 Server Component
async function UserList() {
  const users = await db.query('SELECT * FROM users');
  return (
    <ul>
      {users.map(user => <li key={user.id}>{user.name}</li>)}
    </ul>
  );
}

Server Components 彻底不可能是类组件——它们是异步函数,完全符合"函数组件 = 纯函数"的哲学的延伸。

此外,React 19 的 Compiler 进一步强化了函数组件的优势:编译器可以静态分析函数组件,自动识别哪些值在渲染间不会改变,并自动插入记忆化。类组件因为 this 的存在,其可变性难以静态分析,无法从编译器获益。


Level 4 · 边界与陷阱(所有人)

陷阱一:在类组件中使用异步回调读取 this.props

class ProfilePage extends React.Component {
  showMessage = () => {
    // 3 秒后读取的 this.props 可能已经变了(父组件传了新 props)
    setTimeout(() => alert(`Hello ${this.props.user}`), 3000);
  };
}

如果用户在 3 秒内切换了 profile 页面,alert 会显示新用户的名字而非触发时的用户。解法:在事件处理开始时将 this.props.user 存为局部变量。

陷阱二:误以为 useRef 能替代 useState 触发渲染

function Bad() {
  const countRef = useRef(0);
  function handleClick() {
    countRef.current++; // UI 不会更新!
  }
  return <p>{countRef.current}</p>;
}

useRef 的变更不触发重渲染。需要触发 UI 更新的数据必须用 useState

陷阱三:函数组件不等于无状态组件

在 Hooks 出现之前,"函数组件"几乎等同于"无状态组件"(stateless component)。但 React 16.8 之后,函数组件可以拥有完整的状态和生命周期能力。在面试或技术讨论中,仍然混用这两个概念是不准确的。函数组件不是暂时的流行趋势,而是 React 长期演进方向的必然选择。

本章评分
4.6  / 5  (95 评分)

💬 留言讨论