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

从类组件到 Hook 理解 React 的数据驱动渲染
这篇笔记记录一下我对 React 状态和页面渲染方式的思考。
刚开始学习 React 时,我知道一句很常见的话:
数据驱动视图简单来说,就是不要到处手动修改页面,而是先修改数据,再让 React 根据数据重新计算页面应该长什么样。
后来我又接触了类组件、函数组件和 Hook,产生了一个新的疑惑:
函数组件看起来应该是一个纯函数。但是 useState() 可以记住状态,useEffect() 还可以执行副作用。它们是不是破坏了函数组件原本简单、纯粹的特性?
再往下看以后,我发现它们并不冲突。
不过有些概念需要说得更准确一点。
先说结论
可以先记住几句话:
- React 组件负责根据输入计算 UI。
- 渲染过程应该保持纯净,不要在渲染时执行副作用。
- 状态不是普通局部变量,也不是真的保存在函数内部。
- React 会根据组件在渲染树中的位置,保存并关联状态。
useState()让函数组件读取 React 保存的状态,并申请下一次更新。useEffect()用来在渲染完成后,与 React 外部系统同步。- Hook 没有破坏数据驱动视图,它让函数组件也能使用状态和副作用管理能力。
如果继续研究 React 内部实现,还会看到 Fiber 节点和 Hook 链表。
但是写业务代码时,更适合先用官方文档里的说法理解:
状态由 React 保存,并关联到组件在渲染树中的位置。什么是数据驱动视图
假设有一个计数器。
直接操作 DOM 的思路可能是:
let count = 0;
const button = document.querySelector("button");
const text = document.querySelector("#count");
button?.addEventListener("click", () => {
count += 1;
if (text) {
text.textContent = String(count);
}
});这里需要自己维护两件事情:
数据 count
页面上的文字数据变化后,还要记得手动更新 DOM。
如果逻辑越来越复杂,很容易漏掉某个更新路径。
React 的写法是:
import { useState } from "react";
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((value) => value + 1)}>
当前数量:{count}
</button>
);
}这里更关注:
当 count 是某个值时,页面应该长什么样点击按钮后,我们不是手动找到 DOM 并修改文字,而是调用:
setCount((value) => value + 1);React 会重新执行组件,计算下一版 UI,再把必要的变化提交到 DOM。
这就是声明式渲染。
单向数据流为什么容易维护
React 里的数据通常从父组件流向子组件:
function UserPage() {
const [userName, setUserName] = useState("小明");
return (
<UserCard
userName={userName}
onRename={setUserName}
/>
);
}子组件通过 Props 接收数据:
type UserCardProps = {
userName: string;
onRename: (name: string) => void;
};
function UserCard({ userName, onRename }: UserCardProps) {
return (
<div>
<div>{userName}</div>
<button onClick={() => onRename("小红")}>
修改名字
</button>
</div>
);
}可以把关系理解成:
父组件保存状态
-> 通过 Props 把数据传给子组件
-> 子组件触发回调
-> 父组件更新状态
-> React 重新渲染数据来源比较清楚。
如果任何地方都能直接修改任意全局变量,页面出问题时会很难追踪:
到底是谁改了数据?
什么时候改的?
为什么这个组件重新渲染了?单向数据流并不是说应用里永远不能用全局状态,而是说状态变化应该有明确入口,页面应该由可追踪的数据推导出来。
渲染过程为什么要保持纯净
可以把组件渲染粗略理解成:
UI = render(props, state, context);相同的输入,应该得到相同的 JSX。
例如:
function Greeting({ name }: { name: string }) {
return <div>你好,{name}</div>;
}只要 name 一样,输出就一样。
下面这种写法就不适合放在渲染过程中:
let renderCount = 0;
function Greeting() {
renderCount += 1;
return <div>渲染次数:{renderCount}</div>;
}因为每次调用组件,都会修改外部变量。
同样,渲染时也不要直接做这些事情:
function UserPage() {
document.title = "用户页面";
localStorage.setItem("visited", "true");
fetch("/api/users");
return <div>用户页面</div>;
}React 可能多次执行渲染,也可能暂停、重新开始或者放弃某次渲染结果。
如果副作用直接写在渲染过程中,就可能重复执行,或者在页面还没真正提交时提前执行。
React 真正要求的是:
渲染过程保持纯净。副作用并不是完全禁止,而是应该放在合适的位置。
类组件为什么曾经很重要
React 早期的函数组件比较简单:
function Greeting({ name }: { name: string }) {
return <div>你好,{name}</div>;
}它主要负责根据 Props 返回 JSX。
如果组件需要保存状态、响应生命周期,就要使用类组件:
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>
);
}
}类实例很适合保存状态:
this.state也能通过生命周期方法处理副作用:
componentDidMount()
componentDidUpdate()
componentWillUnmount()所以类组件并不是错误设计。
它解决了当时函数组件缺少状态和生命周期能力的问题。
类组件为什么逐渐不再是主流
Hook 出现以后,函数组件也可以保存状态和处理副作用:
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>
);
}函数组件逐渐成为主流,不只是因为写法更短。
类组件还有一些实际维护问题。
同一个功能容易散落在多个生命周期中
例如订阅一个服务:
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 更容易把相关逻辑放在一起:
function ChatRoom({ roomId }: { roomId: string }) {
useEffect(() => {
connect(roomId);
return () => {
disconnect(roomId);
};
}, [roomId]);
return <div>聊天室:{roomId}</div>;
}复用状态逻辑比较麻烦
类组件时代常见的复用方式包括:
- 高阶组件。
- Render Props。
- Mixin 的历史方案。
这些方式能解决问题,但组件嵌套和数据来源可能变得复杂。
自定义 Hook 可以直接复用状态逻辑:
function useOnlineStatus() {
// 监听网络状态
}使用时:
const isOnline = useOnlineStatus();this 会增加理解成本
类组件里经常需要理解:
this指向。- 方法绑定。
- 构造函数。
- 生命周期之间的关系。
Hook 让组件更接近普通函数。
React 仍然支持类组件。旧项目也没有必要为了追新,把所有类组件一次性重写。
但是新代码通常更适合使用函数组件和 Hook。
函数组件重新执行,状态为什么没有丢失
看一个例子:
function Counter() {
const [count, setCount] = useState(0);
return (
<button onClick={() => setCount((value) => value + 1)}>
{count}
</button>
);
}每次状态更新后,Counter() 都会重新执行。
如果按普通函数理解:
const count = 0;每次执行都应该回到 0。
但是 useState(0) 不是一个普通局部变量。
初次渲染时,React 保存这份状态。
后续渲染时,React 找到同一个组件位置对应的状态,再把当前值交给组件。
可以先粗略理解成:
function Counter() {
const [count, setCount] = React.读取当前组件位置上的状态(0);
return <button>{count}</button>;
}这里不是 React 的真实源码,只是帮助理解。
状态到底保存在 Fiber 节点上吗
如果继续研究 React 内部实现,会接触 Fiber。
可以把 Fiber 粗略理解成 React 用来表示组件工作单元和树结构的内部节点。
函数组件调用的 Hook 状态,会被 React 记录到对应 Fiber 的 Hook 数据结构中。后续重新渲染时,React 会按照 Hook 调用顺序读取对应状态。
所以,说:
Hook 状态挂在 Fiber 节点上作为理解 React 内部实现的简化说法,方向是对的。
但是写业务代码时,不要过度依赖内部实现细节。
更稳定的理解方式是 React 官方文档里的表述:
状态由 React 保存,并和组件在渲染树中的位置关联。这个说法还能解释为什么下面两种情况会影响状态是否保留:
- 组件类型改变。
- 组件的
key改变。
Hook 为什么必须按固定顺序调用
下面的写法是不允许的:
function UserPage({ isLoggedIn }: { isLoggedIn: boolean }) {
if (isLoggedIn) {
const [userName, setUserName] = useState("");
}
const [theme, setTheme] = useState("light");
return <div>{theme}</div>;
}Hook 不能放在条件判断里。
一个简化的理解是:React 会按照调用顺序把每一个 Hook 和对应状态关联起来。
第一次渲染:
第一个 Hook -> userName
第二个 Hook -> theme第二次渲染时,如果 isLoggedIn 变成 false:
第一个 Hook -> themeReact 就无法正确判断状态属于谁。
所以 Hook 要写在组件顶层:
function UserPage({ isLoggedIn }: { isLoggedIn: boolean }) {
const [userName, setUserName] = useState("");
const [theme, setTheme] = useState("light");
return <div>{isLoggedIn ? userName : theme}</div>;
}这也是为什么 Hook 不是随便调用的普通工具函数。
useState 有没有破坏纯函数特性
表面上看:
const [count, setCount] = useState(0);好像从函数外部拿到了一份状态。
但是组件渲染时,并没有偷偷修改一个普通全局变量。
可以把 count 理解成当前渲染使用的一份状态快照。
function Counter() {
const [count, setCount] = useState(0);
return <div>{count}</div>;
}对于某一次渲染,只要 Props、State 和 Context 相同,组件应该计算出相同的 JSX。
调用:
setCount(1);也不是直接修改当前这一份 count。
它是在通知 React:
请安排一次新的渲染,并在新的渲染中使用更新后的状态。所以更准确的说法不是:
函数组件是严格意义上完全没有外部依赖的纯函数。而是:
函数组件的渲染过程应该是纯净、幂等的。
组件根据当前 Props、State 和 Context 计算 JSX。useEffect 有没有破坏纯函数特性
useEffect() 的存在,乍一看更像是主动引入了副作用:
useEffect(() => {
document.title = `当前数量:${count}`;
}, [count]);但它并没有在渲染过程中直接修改页面标题。
组件渲染时做的是:
声明一个 EffectReact 提交页面更新后,再执行 Effect 中的同步逻辑。
可以粗略理解成:
渲染阶段
-> 根据 Props、State、Context 计算 JSX
提交阶段
-> 把必要变化更新到 DOM
之后
-> 执行 useEffect 中声明的同步逻辑所以 useEffect() 不是为了证明函数组件完全没有副作用。
它的作用是把必须发生的副作用移出渲染过程,在合适的时间与外部系统同步。
Effect 是用来同步外部系统的
React 官方文档对 useEffect() 的描述很准确:
useEffect 是一个让组件与外部系统同步的 Hook。常见外部系统包括:
- 浏览器 DOM API。
- 网络连接。
- WebSocket。
- 定时器。
- 第三方地图或图表组件。
- 硬件设备。
- 浏览器存储。
例如:
useEffect(() => {
const connection = createConnection(roomId);
connection.connect();
return () => {
connection.disconnect();
};
}, [roomId]);这里是在同步聊天室连接。
roomId 改变时:
清理旧连接
-> 建立新连接组件卸载时:
清理当前连接不要把所有逻辑都放进 Effect
理解到 Effect 可以执行副作用以后,也容易走到另一个极端:
什么都写进 useEffect()。
例如:
function UserName({ firstName, lastName }: {
firstName: string;
lastName: string;
}) {
const [fullName, setFullName] = useState("");
useEffect(() => {
setFullName(`${firstName} ${lastName}`);
}, [firstName, lastName]);
return <div>{fullName}</div>;
}这里没有外部系统需要同步。
fullName 可以直接从 Props 推导:
function UserName({ firstName, lastName }: {
firstName: string;
lastName: string;
}) {
const fullName = `${firstName} ${lastName}`;
return <div>{fullName}</div>;
}这样更简单,也少一次额外渲染。
可以先问自己:
我是在根据已有数据计算结果,
还是在同步 React 外部的系统?如果只是计算结果,通常不需要 Effect。
事件处理函数也是副作用的合理位置
副作用不一定都要放进 useEffect()。
例如点击按钮后提交表单:
function SubmitButton() {
async function handleClick() {
await saveForm();
alert("保存成功");
}
return <button onClick={handleClick}>保存</button>;
}这是用户点击直接触发的行为,放在事件处理函数里更自然。
可以简单区分:
| 场景 | 更适合放在哪里 |
|---|---|
| 根据 Props 和 State 计算 JSX | 渲染过程 |
| 用户点击后提交表单 | 事件处理函数 |
| 组件出现后建立连接 | useEffect() |
| 某个状态改变后同步外部系统 | useEffect() |
| 根据已有数据拼接字符串 | 直接计算 |
Hook 这个名字怎么理解
“Hook” 可以理解成“钩子”。
函数组件本身只是一个函数:
function Counter() {
return <button>0</button>;
}调用:
useState()
useEffect()
useContext()相当于让这个函数组件接入 React 提供的能力:
- 接入状态。
- 接入 Context。
- 接入外部同步逻辑。
- 接入缓存和引用等能力。
把它理解成“从 Fiber 节点上把状态勾出来”,可以帮助理解内部实现。
不过在业务层面,更准确也更通用的理解是:
Hook 让函数组件接入 React 的状态和生命周期相关能力。状态、Ref 和外部变量的区别
有些值很容易混在一起。
State
const [count, setCount] = useState(0);特点:
- 由 React 保存。
- 更新后会触发重新渲染。
- 当前渲染拿到的是一份快照。
Ref
const timerRef = useRef<number | undefined>(undefined);特点:
- 由 React 保存。
- 可以跨渲染保留值。
- 修改
ref.current不会触发重新渲染。 - 适合保存 DOM 节点、定时器 ID 等不直接参与页面展示的值。
普通局部变量
let count = 0;特点:
- 每次渲染都会重新创建。
- 不适合保存跨渲染状态。
模块级外部变量
let count = 0;如果写在组件外部:
- 多个组件实例可能共享它。
- 修改后 React 不一定知道。
- 容易破坏局部推理和渲染纯度。
状态应该放在哪里,要看它是否参与渲染,以及生命周期属于谁。
类组件和函数组件的对比
可以用一张表总结:
| 对比项 | 类组件 | 函数组件和 Hook |
|---|---|---|
| 保存状态 | this.state | useState()、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 都是副作用。
更准确的原则是:
不要在渲染过程中执行副作用。优先放进事件处理函数。需要跟随渲染结果同步外部系统时,再使用 Effect。
2. 不要把业务理解建立在 Fiber 内部细节上
说 Hook 状态挂载在 Fiber 节点上,作为内部实现的简化理解没有问题。
但是 React 官方更稳定的表述是:
State 与组件在渲染树中的位置关联。Fiber 是 React 的实现方式。理解它有帮助,但业务代码不应该依赖它。
3. useEffect() 不是让副作用变成纯函数
Effect 仍然是在执行副作用。
它做的是把副作用移出渲染阶段,并让 React 能在依赖变化和组件卸载时正确安排执行与清理。
4. 函数组件不是严格数学意义上的纯函数
函数组件会读取 State 和 Context,也会声明 Effect。
更准确的要求是:
组件和 Hook 的渲染逻辑应该保持纯净、幂等,并避免修改非局部值。我最后想通的地方
React 的核心思路并没有因为 Hook 出现而改变。
仍然是:
当前 Props、State、Context
-> 计算下一版 UI
-> React 提交必要的 DOM 更新Hook 做的是让函数组件接入 React 保存的状态和外部同步能力。
useState
-> 读取当前渲染对应的状态快照
-> 请求下一次状态更新
useEffect
-> 声明需要在渲染提交后执行的外部同步逻辑
-> 依赖改变或组件卸载时清理旧逻辑所以 Hook 看起来像是给函数组件增加了外部状态和副作用,实际上没有破坏 React 的数据驱动思想。
它反而把边界分得更清楚:
渲染阶段只负责计算 UI
事件处理函数负责响应用户操作
Effect 负责同步外部系统
React 负责保存状态和安排更新理解这层关系以后,再看函数组件、类组件和 Hook,就不再只是记 API,而是能看出 React 为什么会逐渐形成现在这种写法。