类实例、this 与 setState 队列
函数组件每次调用会建立本次渲染的局部变量;类组件把当前 props 和 state 放在一个由 React 管理的实例上。两种方式描述的是同一棵组件树,都受类型、位置和 key 的身份规则约束。React 19 仍支持类组件;本篇先学会在类中维护行为,再讨论迁移,不能把看到 class 当成必须降级 React 的理由。
从一个能点击的实例开始
本章沿用全书环境,每个完整例子独立替换 src/App.jsx,保留 createRoot 与入口 StrictMode。类声明属于 JavaScript:extends Component 继承 React 的更新接口,render() 返回 JSX,this.props 是输入,this.state 是状态对象。若不熟悉class/extends/super,先读JavaScript类与继承。
下面有两个独立计数器。先只点左边,再改变左边的 key。观察“实例内状态”与“父组件数据”何时分别变化。
import { Component, useState } from 'react'
export class Counter extends Component {
state = { count: 0, note: '保留此字段' }
addOne = () => {
this.setState(previous => ({ count: previous.count + 1 }))
}
addThreeObjects = () => {
this.setState({ count: this.state.count + 1 })
this.setState({ count: this.state.count + 1 })
this.setState({ count: this.state.count + 1 })
}
addThreeFunctions = () => {
this.setState(previous => ({ count: previous.count + 1 }))
this.setState(previous => ({ count: previous.count + 1 }))
this.setState(previous => ({ count: previous.count + 1 }))
}
render() {
return <section aria-label={this.props.label}>
<h2>{this.props.label}</h2>
<output>{this.state.count}</output>
<p>{this.state.note}</p>
<button onClick={this.addOne}>加一</button>
<button onClick={this.addThreeObjects}>三个对象更新</button>
<button onClick={this.addThreeFunctions}>三个函数更新</button>
</section>
}
}
export default function App() {
const [revision, setRevision] = useState(0)
const [step, setStep] = useState(2)
return <main>
<button onClick={() => setRevision(n => n + 1)}>重建左侧</button>
<button onClick={() => setStep(value => value === 2 ? 3 : 2)}>切换步长(当前{step})</button>
<Counter key={revision} label="左侧" step={step} />
<Counter label="右侧" step={step} />
</main>
}初始两个output都是0。在左侧依次点击三个对象更新、三个函数更新,得到1、4;右侧仍为0。重建左侧后左侧回0。父组件重新渲染本身没有要求丢弃右侧实例,左侧key变化才建立了新身份。不要在函数组件里声明Counter类,否则每次父渲染都创建新类型,会把身份重置伪装成state更新失败。
队列里的对象与函数不是同一种请求
三个对象表达式都在当前事件里立刻求值。count为0时,入队的是 {count:1} 三次。处理队列时每个对象与当时状态做浅合并,结果仍是1;未提及的note保留。三个函数则等到处理队列时取得各自前面的待定状态,依次算出2、3、4。这和第二章函数组件队列的因果相通,但返回值的合并规则不同:类setState合并对象顶层字段,useState setter替换该状态值。
浅合并不意味着深合并。若state里有 {profile:{name:'Ada', city:'杭州'}},更新 {profile:{name:'Lin'}} 会替换整个profile,city丢失。应返回 {profile:{...previous.profile,name:'Lin'}}。同样不能修改 this.state.count++ 来请求更新;React没有收到合规更新,旧快照也被污染。
updater必须纯粹。不能在它里面发请求、埋点或修改外部数组,因为开发检查或渲染重试可能重新求值。需要在更新提交后操作,可以给setState第二个参数:在addOne里改为 this.setState(p => ({count:p.count+1}), () => console.log(this.state.count))。回调读取提交后的状态;它不是Promise,await this.setState(...) 没有“等提交”的合同。也不要靠调用后立刻读取this.state推断提交已完成。
this 为什么会丢失
上例 addOne = () => ... 是实例字段,箭头函数捕获该实例的this。普通方法 addOne() { ... } 不会自动绑定;传递 onClick={this.addOne} 只取出函数,未来调用不能依赖原接收者。可在constructor中先 super(props),再 this.addOne = this.addOne.bind(this)。两种写法都可维护,不必为了统一风格重写所有方法。
把上例addOne临时改成普通方法并点击,是观察this错误的反例;恢复箭头字段后继续。super(props) 建立父类初始化,必须先于constructor内使用this。state字段等价于实例初始化的一部分,但constructor/state初始化不能启动订阅或计时器,因为这个实例不一定真正提交到页面。
实例还可保存timer、连接句柄等不参与UI的字段。修改它们不会发起渲染,正如函数组件里改变ref.current不发起渲染。下一节将用它们记录资源的所有权。
updater还接收第二个参数props:(previous, props) => ({ count: previous.count + props.step })。它适合让下一状态同时取决于队列前一步和当前输入,而不是把props复制成另一份state。
练习:按父组件步长连续累加
给Counter增加 step prop,约定为1到5的整数,父组件已用state初始传2,按钮可在2与3之间切换。新增按钮“一次增加三步”,使用三个updater,每个读取updater的第二个参数props,保证从0到6、再到12,note不丢失。随后点击页面上的“切换步长”把prop变为3,下一次从12到21。不要通过编辑源码改常量来执行这一步:开发热更新可能重建类实例,造成计数重置;本题要验证同一类型、同一key下的运行时prop更新。不要把step复制进state,不修改原状态。