挂载、更新与卸载:资源归谁所有
“挂载时订阅,卸载时取消”只覆盖了两端。如果组件一直存在,但它从杭州频道切换到了北京频道,旧订阅也必须退出。把生命周期当作一组按时间执行的方法名,容易忘记这个中间过程;从“当前props需要哪一个外部连接”出发,才知道更新阶段要比较什么。
一个不需要网络的频道
下面的事件通道用Map保存频道对应的回调集合。subscribe返回取消函数,这是本实验的资源协议;publish只向当前订阅者发送消息;count只用于观察。它们都是普通JavaScript,不是React API。完整替换 src/App.jsx:
import { Component, useState } from 'react'
export const bus = {
channels: new Map(),
subscribe(room, listener) {
let listeners = this.channels.get(room)
if (!listeners) this.channels.set(room, listeners = new Set())
listeners.add(listener)
return () => {
listeners.delete(listener)
if (!listeners.size) this.channels.delete(room)
}
},
publish(room, text) {
this.channels.get(room)?.forEach(listener => listener(text))
},
count(room) { return this.channels.get(room)?.size ?? 0 }
}
export class Room extends Component {
state = { message: '尚无消息' }
unsubscribe = null
connect() {
this.unsubscribe = bus.subscribe(this.props.room, message => {
this.setState({ message })
})
}
disconnect() {
this.unsubscribe?.()
this.unsubscribe = null
}
componentDidMount() { this.connect() }
componentDidUpdate(previousProps) {
if (previousProps.room !== this.props.room) {
this.disconnect()
this.connect()
this.setState({ message: '尚无消息' })
}
}
componentWillUnmount() { this.disconnect() }
render() {
return <p role="status">{this.props.room}:{this.state.message}</p>
}
}
export default function App() {
const [room, setRoom] = useState('杭州')
const [shown, setShown] = useState(true)
const [counts, setCounts] = useState('尚未检查')
return <main>
<button onClick={() => setRoom(r => r === '杭州' ? '北京' : '杭州')}>切换频道</button>
<button onClick={() => setShown(v => !v)}>显示或卸载</button>
<button onClick={() => bus.publish('杭州', '杭州消息')}>发布杭州</button>
<button onClick={() => bus.publish('北京', '北京消息')}>发布北京</button>
<button onClick={() => setCounts(`杭州${bus.count('杭州')},北京${bus.count('北京')}`)}>检查订阅</button>
<output>{counts}</output>
{shown && <Room room={room} />}
</main>
}首次稳定显示后检查,杭州1、北京0;切到北京再检查,杭州0、北京1。此时发布杭州不应修改北京的status,发布北京才显示北京消息。卸载后两边都是0;再次显示仍只有北京一个订阅。StrictMode开发检查可能出现mount→unmount→mount,因此应观察最终存活数,不要求connect日志只出现一次。
三个阶段各知道什么
componentDidMount在初次DOM提交后启动外部同步,render只描述UI。componentDidUpdate拿到previousProps/previousState,而this.props/this.state已是这次提交的值,因此可以识别连接参数变化。componentWillUnmount撤销当前资源;React不会替你取消普通事件总线、timer或socket。
上例在didUpdate里setState会导致一次后续更新,但不形成死循环:只有room变化才进入分支,后一次提交的previousProps.room已等于当前room。若删除条件直接setState,每次更新又生成下次更新,这才是循环。将message清空是这个实验的产品合同,不是每次prop变化都必须复制一份state;若整段局部状态都应重置,也可使用key更明确地重建实例。
disconnect先执行旧取消函数,再把句柄清空。取消函数捕获旧listeners集合,所以切换props后仍能准确退出旧频道。反过来,在卸载时根据“最新room”重新拼一个退订请求,未必能对应原来订阅的对象。这也是保存返回句柄优于猜测如何撤销的原因。
请求失败与晚到结果还要多一层合同
对fetch一类异步资源,可保存AbortController,并在切换/卸载abort;同时用请求序号或取消标记判断旧结果是否仍有写入资格。取消底层网络和阻止旧响应写state是两个责任;服务端已经接受请求时,abort不会回滚它的业务副作用。第四章的清理与竞态已给出可控制完成顺序,这一协议迁到类中仍然适用。
不要把资源创建放进constructor、render、getDerivedStateFromProps或UNSAFE_生命周期。渲染阶段可能重做或被舍弃,并不保证随后发生一次对应的提交和卸载。旧代码的componentWillReceiveProps里既同步state又发请求时,先拆开派生计算和连接责任,再决定保留哪段。
类可以声明 static defaultProps = { active: true },为调用方省略或传undefined的prop提供默认值;显式false仍保留。static字段属于组件类,不是每个实例的state,默认值不会创建第二个active数据源。React19仍支持类defaultProps;这与函数参数默认值是不同的写法。
练习:暂停也要撤销订阅
为Room增加布尔prop active,默认true。父组件增加暂停/恢复按钮。active为false时当前频道没有订阅,消息不会更新;在暂停期间切频道,恢复后只订阅新频道;卸载永远留下0个订阅。继续使用同一实例,不用key绕过更新逻辑。比较room与active两个连接输入,并验证“暂停→切频道→恢复→发布两边→卸载”的连续顺序。
查看改动与观察。下一节在更新阶段继续观察跳过渲染与提交前快照。