React

从类组件到 Hook 理解 React 的数据驱动渲染

从类组件到 Hook 理解 React 的数据驱动渲染 这篇笔记记录一下我对 React 状态和页面渲染方式的思考。 刚开始学习 React 时,我知道一句很常见的话: 简单来说,就是不要到处手动修改页面,而是先修改数据,再让 React 根据数据重新计算页面应该长什么样。 后来我又接触了类组件、函数组件和 Hook,

2026年6月12日20 分钟阅读
从类组件到 Hook 理解 React 的数据驱动渲染

从类组件到 Hook 理解 React 的数据驱动渲染

这篇笔记记录一下我对 React 状态和页面渲染方式的思考。

刚开始学习 React 时,我知道一句很常见的话:

text
数据驱动视图

简单来说,就是不要到处手动修改页面,而是先修改数据,再让 React 根据数据重新计算页面应该长什么样。

后来我又接触了类组件、函数组件和 Hook,产生了一个新的疑惑:

函数组件看起来应该是一个纯函数。但是 useState() 可以记住状态,useEffect() 还可以执行副作用。它们是不是破坏了函数组件原本简单、纯粹的特性?

再往下看以后,我发现它们并不冲突。

不过有些概念需要说得更准确一点。

先说结论

可以先记住几句话:

  1. React 组件负责根据输入计算 UI。
  2. 渲染过程应该保持纯净,不要在渲染时执行副作用。
  3. 状态不是普通局部变量,也不是真的保存在函数内部。
  4. React 会根据组件在渲染树中的位置,保存并关联状态。
  5. useState() 让函数组件读取 React 保存的状态,并申请下一次更新。
  6. useEffect() 用来在渲染完成后,与 React 外部系统同步。
  7. Hook 没有破坏数据驱动视图,它让函数组件也能使用状态和副作用管理能力。

如果继续研究 React 内部实现,还会看到 Fiber 节点和 Hook 链表。

但是写业务代码时,更适合先用官方文档里的说法理解:

text
状态由 React 保存,并关联到组件在渲染树中的位置。

什么是数据驱动视图

假设有一个计数器。

直接操作 DOM 的思路可能是:

ts
let count = 0;

const button = document.querySelector("button");
const text = document.querySelector("#count");

button?.addEventListener("click", () => {
  count += 1;

  if (text) {
    text.textContent = String(count);
  }
});

这里需要自己维护两件事情:

text
数据 count
页面上的文字

数据变化后,还要记得手动更新 DOM。

如果逻辑越来越复杂,很容易漏掉某个更新路径。

React 的写法是:

tsx
import { useState } from "react";

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

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      当前数量:{count}
    </button>
  );
}

这里更关注:

text
当 count 是某个值时,页面应该长什么样

点击按钮后,我们不是手动找到 DOM 并修改文字,而是调用:

ts
setCount((value) => value + 1);

React 会重新执行组件,计算下一版 UI,再把必要的变化提交到 DOM。

这就是声明式渲染。

单向数据流为什么容易维护

React 里的数据通常从父组件流向子组件:

tsx
function UserPage() {
  const [userName, setUserName] = useState("小明");

  return (
    <UserCard
      userName={userName}
      onRename={setUserName}
    />
  );
}

子组件通过 Props 接收数据:

tsx
type UserCardProps = {
  userName: string;
  onRename: (name: string) => void;
};

function UserCard({ userName, onRename }: UserCardProps) {
  return (
    <div>
      <div>{userName}</div>
      <button onClick={() => onRename("小红")}>
        修改名字
      </button>
    </div>
  );
}

可以把关系理解成:

text
父组件保存状态
  -> 通过 Props 把数据传给子组件
  -> 子组件触发回调
  -> 父组件更新状态
  -> React 重新渲染

数据来源比较清楚。

如果任何地方都能直接修改任意全局变量,页面出问题时会很难追踪:

text
到底是谁改了数据?
什么时候改的?
为什么这个组件重新渲染了?

单向数据流并不是说应用里永远不能用全局状态,而是说状态变化应该有明确入口,页面应该由可追踪的数据推导出来。

渲染过程为什么要保持纯净

可以把组件渲染粗略理解成:

ts
UI = render(props, state, context);

相同的输入,应该得到相同的 JSX。

例如:

tsx
function Greeting({ name }: { name: string }) {
  return <div>你好,{name}</div>;
}

只要 name 一样,输出就一样。

下面这种写法就不适合放在渲染过程中:

tsx
let renderCount = 0;

function Greeting() {
  renderCount += 1;

  return <div>渲染次数:{renderCount}</div>;
}

因为每次调用组件,都会修改外部变量。

同样,渲染时也不要直接做这些事情:

tsx
function UserPage() {
  document.title = "用户页面";
  localStorage.setItem("visited", "true");
  fetch("/api/users");

  return <div>用户页面</div>;
}

React 可能多次执行渲染,也可能暂停、重新开始或者放弃某次渲染结果。

如果副作用直接写在渲染过程中,就可能重复执行,或者在页面还没真正提交时提前执行。

React 真正要求的是:

text
渲染过程保持纯净。

副作用并不是完全禁止,而是应该放在合适的位置。

类组件为什么曾经很重要

React 早期的函数组件比较简单:

tsx
function Greeting({ name }: { name: string }) {
  return <div>你好,{name}</div>;
}

它主要负责根据 Props 返回 JSX。

如果组件需要保存状态、响应生命周期,就要使用类组件:

tsx
import { Component } from "react";

type CounterState = {
  count: number;
};

export class Counter extends Component<object, CounterState> {
  state: CounterState = {
    count: 0,
  };

  render() {
    return (
      <button
        onClick={() => {
          this.setState(({ count }) => ({
            count: count + 1,
          }));
        }}
      >
        当前数量:{this.state.count}
      </button>
    );
  }
}

类实例很适合保存状态:

ts
this.state

也能通过生命周期方法处理副作用:

ts
componentDidMount()
componentDidUpdate()
componentWillUnmount()

所以类组件并不是错误设计。

它解决了当时函数组件缺少状态和生命周期能力的问题。

类组件为什么逐渐不再是主流

Hook 出现以后,函数组件也可以保存状态和处理副作用:

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

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

  useEffect(() => {
    document.title = `当前数量:${count}`;
  }, [count]);

  return (
    <button onClick={() => setCount((value) => value + 1)}>
      当前数量:{count}
    </button>
  );
}

函数组件逐渐成为主流,不只是因为写法更短。

类组件还有一些实际维护问题。

同一个功能容易散落在多个生命周期中

例如订阅一个服务:

tsx
class ChatRoom extends Component {
  componentDidMount() {
    connect(this.props.roomId);
  }

  componentDidUpdate(previousProps) {
    if (previousProps.roomId !== this.props.roomId) {
      disconnect(previousProps.roomId);
      connect(this.props.roomId);
    }
  }

  componentWillUnmount() {
    disconnect(this.props.roomId);
  }
}

同一个“连接聊天室”的功能,被拆到了三个生命周期方法中。

Hook 更容易把相关逻辑放在一起:

tsx
function ChatRoom({ roomId }: { roomId: string }) {
  useEffect(() => {
    connect(roomId);

    return () => {
      disconnect(roomId);
    };
  }, [roomId]);

  return <div>聊天室:{roomId}</div>;
}

复用状态逻辑比较麻烦

类组件时代常见的复用方式包括:

  • 高阶组件。
  • Render Props。
  • Mixin 的历史方案。

这些方式能解决问题,但组件嵌套和数据来源可能变得复杂。

自定义 Hook 可以直接复用状态逻辑:

ts
function useOnlineStatus() {
  // 监听网络状态
}

使用时:

tsx
const isOnline = useOnlineStatus();

this 会增加理解成本

类组件里经常需要理解:

  • this 指向。
  • 方法绑定。
  • 构造函数。
  • 生命周期之间的关系。

Hook 让组件更接近普通函数。

React 仍然支持类组件。旧项目也没有必要为了追新,把所有类组件一次性重写。

但是新代码通常更适合使用函数组件和 Hook。

函数组件重新执行,状态为什么没有丢失

看一个例子:

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

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

每次状态更新后,Counter() 都会重新执行。

如果按普通函数理解:

ts
const count = 0;

每次执行都应该回到 0

但是 useState(0) 不是一个普通局部变量。

初次渲染时,React 保存这份状态。

后续渲染时,React 找到同一个组件位置对应的状态,再把当前值交给组件。

可以先粗略理解成:

ts
function Counter() {
  const [count, setCount] = React.读取当前组件位置上的状态(0);

  return <button>{count}</button>;
}

这里不是 React 的真实源码,只是帮助理解。

状态到底保存在 Fiber 节点上吗

如果继续研究 React 内部实现,会接触 Fiber。

可以把 Fiber 粗略理解成 React 用来表示组件工作单元和树结构的内部节点。

函数组件调用的 Hook 状态,会被 React 记录到对应 Fiber 的 Hook 数据结构中。后续重新渲染时,React 会按照 Hook 调用顺序读取对应状态。

所以,说:

text
Hook 状态挂在 Fiber 节点上

作为理解 React 内部实现的简化说法,方向是对的。

但是写业务代码时,不要过度依赖内部实现细节。

更稳定的理解方式是 React 官方文档里的表述:

text
状态由 React 保存,并和组件在渲染树中的位置关联。

这个说法还能解释为什么下面两种情况会影响状态是否保留:

  • 组件类型改变。
  • 组件的 key 改变。

Hook 为什么必须按固定顺序调用

下面的写法是不允许的:

tsx
function UserPage({ isLoggedIn }: { isLoggedIn: boolean }) {
  if (isLoggedIn) {
    const [userName, setUserName] = useState("");
  }

  const [theme, setTheme] = useState("light");

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

Hook 不能放在条件判断里。

一个简化的理解是:React 会按照调用顺序把每一个 Hook 和对应状态关联起来。

第一次渲染:

text
第一个 Hook -> userName
第二个 Hook -> theme

第二次渲染时,如果 isLoggedIn 变成 false

text
第一个 Hook -> theme

React 就无法正确判断状态属于谁。

所以 Hook 要写在组件顶层:

tsx
function UserPage({ isLoggedIn }: { isLoggedIn: boolean }) {
  const [userName, setUserName] = useState("");
  const [theme, setTheme] = useState("light");

  return <div>{isLoggedIn ? userName : theme}</div>;
}

这也是为什么 Hook 不是随便调用的普通工具函数。

useState 有没有破坏纯函数特性

表面上看:

tsx
const [count, setCount] = useState(0);

好像从函数外部拿到了一份状态。

但是组件渲染时,并没有偷偷修改一个普通全局变量。

可以把 count 理解成当前渲染使用的一份状态快照。

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

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

对于某一次渲染,只要 Props、State 和 Context 相同,组件应该计算出相同的 JSX。

调用:

ts
setCount(1);

也不是直接修改当前这一份 count

它是在通知 React:

text
请安排一次新的渲染,并在新的渲染中使用更新后的状态。

所以更准确的说法不是:

text
函数组件是严格意义上完全没有外部依赖的纯函数。

而是:

text
函数组件的渲染过程应该是纯净、幂等的。
组件根据当前 Props、State 和 Context 计算 JSX。

useEffect 有没有破坏纯函数特性

useEffect() 的存在,乍一看更像是主动引入了副作用:

tsx
useEffect(() => {
  document.title = `当前数量:${count}`;
}, [count]);

但它并没有在渲染过程中直接修改页面标题。

组件渲染时做的是:

text
声明一个 Effect

React 提交页面更新后,再执行 Effect 中的同步逻辑。

可以粗略理解成:

text
渲染阶段
  -> 根据 Props、State、Context 计算 JSX

提交阶段
  -> 把必要变化更新到 DOM

之后
  -> 执行 useEffect 中声明的同步逻辑

所以 useEffect() 不是为了证明函数组件完全没有副作用。

它的作用是把必须发生的副作用移出渲染过程,在合适的时间与外部系统同步。

Effect 是用来同步外部系统的

React 官方文档对 useEffect() 的描述很准确:

text
useEffect 是一个让组件与外部系统同步的 Hook。

常见外部系统包括:

  • 浏览器 DOM API。
  • 网络连接。
  • WebSocket。
  • 定时器。
  • 第三方地图或图表组件。
  • 硬件设备。
  • 浏览器存储。

例如:

tsx
useEffect(() => {
  const connection = createConnection(roomId);
  connection.connect();

  return () => {
    connection.disconnect();
  };
}, [roomId]);

这里是在同步聊天室连接。

roomId 改变时:

text
清理旧连接
  -> 建立新连接

组件卸载时:

text
清理当前连接

不要把所有逻辑都放进 Effect

理解到 Effect 可以执行副作用以后,也容易走到另一个极端:

什么都写进 useEffect()

例如:

tsx
function UserName({ firstName, lastName }: {
  firstName: string;
  lastName: string;
}) {
  const [fullName, setFullName] = useState("");

  useEffect(() => {
    setFullName(`${firstName} ${lastName}`);
  }, [firstName, lastName]);

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

这里没有外部系统需要同步。

fullName 可以直接从 Props 推导:

tsx
function UserName({ firstName, lastName }: {
  firstName: string;
  lastName: string;
}) {
  const fullName = `${firstName} ${lastName}`;

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

这样更简单,也少一次额外渲染。

可以先问自己:

text
我是在根据已有数据计算结果,
还是在同步 React 外部的系统?

如果只是计算结果,通常不需要 Effect。

事件处理函数也是副作用的合理位置

副作用不一定都要放进 useEffect()

例如点击按钮后提交表单:

tsx
function SubmitButton() {
  async function handleClick() {
    await saveForm();
    alert("保存成功");
  }

  return <button onClick={handleClick}>保存</button>;
}

这是用户点击直接触发的行为,放在事件处理函数里更自然。

可以简单区分:

场景更适合放在哪里
根据 Props 和 State 计算 JSX渲染过程
用户点击后提交表单事件处理函数
组件出现后建立连接useEffect()
某个状态改变后同步外部系统useEffect()
根据已有数据拼接字符串直接计算

Hook 这个名字怎么理解

“Hook” 可以理解成“钩子”。

函数组件本身只是一个函数:

tsx
function Counter() {
  return <button>0</button>;
}

调用:

tsx
useState()
useEffect()
useContext()

相当于让这个函数组件接入 React 提供的能力:

  • 接入状态。
  • 接入 Context。
  • 接入外部同步逻辑。
  • 接入缓存和引用等能力。

把它理解成“从 Fiber 节点上把状态勾出来”,可以帮助理解内部实现。

不过在业务层面,更准确也更通用的理解是:

text
Hook 让函数组件接入 React 的状态和生命周期相关能力。

状态、Ref 和外部变量的区别

有些值很容易混在一起。

State

tsx
const [count, setCount] = useState(0);

特点:

  • 由 React 保存。
  • 更新后会触发重新渲染。
  • 当前渲染拿到的是一份快照。

Ref

tsx
const timerRef = useRef<number | undefined>(undefined);

特点:

  • 由 React 保存。
  • 可以跨渲染保留值。
  • 修改 ref.current 不会触发重新渲染。
  • 适合保存 DOM 节点、定时器 ID 等不直接参与页面展示的值。

普通局部变量

tsx
let count = 0;

特点:

  • 每次渲染都会重新创建。
  • 不适合保存跨渲染状态。

模块级外部变量

tsx
let count = 0;

如果写在组件外部:

  • 多个组件实例可能共享它。
  • 修改后 React 不一定知道。
  • 容易破坏局部推理和渲染纯度。

状态应该放在哪里,要看它是否参与渲染,以及生命周期属于谁。

类组件和函数组件的对比

可以用一张表总结:

对比项类组件函数组件和 Hook
保存状态this.stateuseState()useReducer()
更新状态this.setState()State Setter、dispatch()
处理副作用生命周期方法useEffect() 等 Hook
复用状态逻辑高阶组件、Render Props 等自定义 Hook
常见理解成本this、绑定、生命周期拆分Hook 调用顺序、依赖数组
当前新项目常用选择仍可使用通常优先使用

函数组件并不是在所有方面都天然更简单。

Hook 也有自己的学习成本:

  • 为什么不能放在条件判断里。
  • 为什么 Effect 有依赖数组。
  • 为什么开发环境里 Effect 可能执行两次。
  • 为什么闭包里可能拿到旧状态。

但是掌握这些规则以后,函数组件更容易把相关逻辑组织在一起,也更容易抽取自定义 Hook。

对我原先理解的修正

我原先的理解里,有一些方向是对的:

  • React 强调数据驱动视图。
  • 单向数据流有利于梳理状态变化。
  • 渲染过程不应该被随意的外部副作用影响。
  • Hook 状态和组件对应的 Fiber 工作单元有关。
  • useEffect() 的信息也会被 React 记录,并在合适的阶段执行。

有几处可以说得更准确。

1. 不是完全不要引入副作用

应用一定会有副作用。

点击按钮、请求接口、操作 DOM、连接 WebSocket 都是副作用。

更准确的原则是:

text
不要在渲染过程中执行副作用。

优先放进事件处理函数。需要跟随渲染结果同步外部系统时,再使用 Effect。

2. 不要把业务理解建立在 Fiber 内部细节上

说 Hook 状态挂载在 Fiber 节点上,作为内部实现的简化理解没有问题。

但是 React 官方更稳定的表述是:

text
State 与组件在渲染树中的位置关联。

Fiber 是 React 的实现方式。理解它有帮助,但业务代码不应该依赖它。

3. useEffect() 不是让副作用变成纯函数

Effect 仍然是在执行副作用。

它做的是把副作用移出渲染阶段,并让 React 能在依赖变化和组件卸载时正确安排执行与清理。

4. 函数组件不是严格数学意义上的纯函数

函数组件会读取 State 和 Context,也会声明 Effect。

更准确的要求是:

text
组件和 Hook 的渲染逻辑应该保持纯净、幂等,并避免修改非局部值。

我最后想通的地方

React 的核心思路并没有因为 Hook 出现而改变。

仍然是:

text
当前 Props、State、Context
  -> 计算下一版 UI
  -> React 提交必要的 DOM 更新

Hook 做的是让函数组件接入 React 保存的状态和外部同步能力。

text
useState
  -> 读取当前渲染对应的状态快照
  -> 请求下一次状态更新

useEffect
  -> 声明需要在渲染提交后执行的外部同步逻辑
  -> 依赖改变或组件卸载时清理旧逻辑

所以 Hook 看起来像是给函数组件增加了外部状态和副作用,实际上没有破坏 React 的数据驱动思想。

它反而把边界分得更清楚:

text
渲染阶段只负责计算 UI
事件处理函数负责响应用户操作
Effect 负责同步外部系统
React 负责保存状态和安排更新

理解这层关系以后,再看函数组件、类组件和 Hook,就不再只是记 API,而是能看出 React 为什么会逐渐形成现在这种写法。