渐进迁移:按状态与资源职责拆解
迁移不是把componentDidMount替换为一个空依赖Effect,再把componentDidUpdate塞进另一个Effect。旧类的一个生命周期方法可能同时管理订阅、标题、输入草稿和统计;这些工作在函数组件里应按各自依赖组织。真正需要保持的是用户行为和资源责任,而不是方法数量或日志调用次数。
先给原实现固定合同
第二节Room的合同是:当前频道至多一个有效订阅,切频道撤销旧订阅,卸载没有存活资源。第四节的草稿合同则是:主题改变不丢草稿。这两条属于不同状态所有者。迁移时先保持Room的props接口及渲染位置,只换内部实现,父和错误边界可以继续是类组件。
下面通过浏览器事件完成一个完整的新实验,类和函数两组并排接收同一个频道事件。沿用前四章所学useEffect/useState。完整替换 src/App.jsx:
import { Component, useEffect, useState } from 'react'
function eventName(channel) { return `lesson:${channel}` }
function subscribe(channel, receive) {
const listener = event => receive(event.detail)
window.addEventListener(eventName(channel), listener)
return () => window.removeEventListener(eventName(channel), listener)
}
export class ClassFeed extends Component {
state = { text: '尚无消息' }
stop = null
start() {
this.stop = subscribe(this.props.channel, text => this.setState({ text }))
}
componentDidMount() { this.start() }
componentDidUpdate(previousProps) {
if (previousProps.channel !== this.props.channel) {
this.stop?.()
this.start()
}
}
componentWillUnmount() { this.stop?.() }
render() { return <p>类:{this.props.channel}/{this.state.text}</p> }
}
export function FunctionFeed({ channel }) {
const [text, setText] = useState('尚无消息')
useEffect(() => subscribe(channel, setText), [channel])
return <p>函数:{channel}/{text}</p>
}
export default function App() {
const [channel, setChannel] = useState('甲')
const [visible, setVisible] = useState(true)
function publish(target) {
window.dispatchEvent(new CustomEvent(eventName(target), { detail: `${target}消息` }))
}
return <main>
<button onClick={() => setChannel(c => c === '甲' ? '乙' : '甲')}>切频道</button>
<button onClick={() => setVisible(v => !v)}>切显示</button>
<button onClick={() => publish('甲')}>发甲</button>
<button onClick={() => publish('乙')}>发乙</button>
{visible && <><ClassFeed channel={channel} /><FunctionFeed channel={channel} /></>}
</main>
}先发甲,两组均显示甲消息。切到乙时两组暂时保留上一条文本,这是本例明确选择的“保留最后消息”合同,与第二节切频道清空不同;发甲不应更新,发乙才变乙消息。卸载后发布再显示,两组都重新从尚无消息开始。需要验证真正退订,而不只是UI消失,可在subscribe的添加与取消处临时记录channel,逐次配对;开发StrictMode的额外setup/cleanup也必须配对。
本例按一次提交及Effect完成后的独立用户操作比较最终可用订阅。类didUpdate在提交阶段换订阅,函数useEffect的时点不同;若生产者在提交期间或被动Effect执行前同步发消息,两者可能观察到不同结果。需要这类时序保证时应重新设计订阅与快照协议,不能把本例当成所有时间点都等价的证明。
一个资源由一个Effect闭包拥有
类的start与stop借助实例字段找到当前取消函数;函数版本让每次Effect捕获自己的channel和取消函数。channel变化时,React先退出旧同步,再开始新同步。空依赖数组会一直订阅初始频道,改名为useMount也不会修复缺少依赖的问题。
这里没有把两个生命周期名字映射成两个Effect:mount与有条件update都属于“订阅当前channel”,unmount则退出相同协议,因此合到一个Effect。若原类还根据title设置document.title,应另按title依赖处理,不能因为它们过去写在同一方法中就共用channel依赖。
类setState浅合并对象,useState替换值;将 {text, error} 原样迁为一个Hook状态时,单写 setState({text}) 会丢error。可拆成真正独立字段,也可用函数更新保留其他字段;有耦合状态转换则使用第三章reducer。实例的连接句柄放ref,显示给用户的数据放state,不把所有this字段一股脑塞进ref。
先适配边界,再替换内部
render props消费者可以先保留,内部改用自定义Hook后仍调用原children函数;HOC可以保留对外props,由薄函数包装器读取Context。这样调用者不必同时迁移。自定义Hook共享的是逻辑,不会自动共享原来类实例的state,更不会消除组件身份变化。
有些职责没有简单的逐行替代:错误边界仍可保留已经验证的类;提交前DOM快照需要专门重设计,不能直接搬到修改DOM之后的useLayoutEffect;返回新组件类型会重置状态,即使新旧都显示相同文字。避免在用户正在编辑时切换实现类型来“平滑迁移”,重新部署或明确重建边界应单独评估草稿保存。
练习:迁移暂停协议
给ClassFeed与FunctionFeed都添加 active prop,默认true。父增加暂停按钮。暂停时移除监听,暂停期间切频道不会重新订阅;恢复后只接受当前频道。两组保留最后文本,不因暂停清空。按“发甲→暂停→切乙→发甲和乙→恢复→发乙→卸载”比较两组UI,并检查监听添加与取消成对。