类组件维护篇练习答案
下面的改动分别应用到对应正文实验,不将多个同名App拼在一起。普通题沿用现代React实验环境,版本题在自己的隔离目录运行。观察顺序也是合同的一部分:只看干净页面上的一次成功不足以证明切换和清理。
01|三个步长更新
在第一节Counter类内加入以下方法,并在render的section内加入 <button onClick={this.addThreeSteps}>一次增加三步</button>。父App已将运行时step状态传给两个Counter。先保持页面步长2,再点击“切换步长(当前2)”改为3;不编辑源码,也不点击“重建左侧”,保持组件类型与key不变。
addThreeSteps = () => {
this.setState((previous, props) => ({ count: previous.count + props.step }))
this.setState((previous, props) => ({ count: previous.count + props.step }))
this.setState((previous, props) => ({ count: previous.count + props.step }))
}从0开始连续两次得到6、12;step变3后再点得到21。note保持“保留此字段”。每个函数读取队列前一步的状态,props是React处理该更新时提供的输入,没有把父prop另存为容易过时的state。若写成三个 {count:this.state.count+this.props.step},初始事件三项都算出2,第一轮只能到2。
02|暂停、切换与恢复
保留第二节bus,替换Room类,并给App增加 const [active, setActive] = useState(true),按钮 <button onClick={() => setActive(v => !v)}>暂停或恢复</button>,把原Room使用处改成 <Room room={room} active={active} />。
export class Room extends Component {
static defaultProps = { active: true }
state = { message: '尚无消息' }
unsubscribe = null
connect() {
if (this.props.active) {
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 || previousProps.active !== this.props.active) {
this.disconnect()
this.connect()
if (previousProps.room !== this.props.room) this.setState({ message: '尚无消息' })
}
}
componentWillUnmount() { this.disconnect() }
render() { return <p role="status">{this.props.room}:{this.state.message}</p> }
}依次检查:首次杭州1/北京0;暂停后0/0;暂停中切北京仍0/0;恢复后0/1;发杭州无变化,发北京显示北京消息;卸载后0/0。defaultProps只为缺省active提供true,显式false不会被覆盖。若只比较room,暂停不会退订;若connect不检查active,暂停时切频道会偷偷重新订阅。这两个错误需要分别检查。
03|只改变compact也要更新
在第一组实验的App状态增加 compact: false,增加按钮 <button onClick={() => this.setState(p => ({compact: !p.compact}))}>切换摘要</button>,并传 <Profile person={this.state.person} compact={this.state.compact} />。替换Profile为:
export class Profile extends PureComponent {
render() {
return <p data-testid="profile">{this.props.compact ? '简' : '完整'}:{this.props.person.name}</p>
}
}初始完整:Ada;只点切换摘要后简:Ada;再切回完整:Ada。person保持同一个对象,compact这个原始值发生变化已足以打破浅相等。若只按person决定shouldComponentUpdate,就遗漏了实际输出依赖compact。原地修改person仍是错误,不因新增一个会触发更新的字段就变安全。
第二组snapshot实验从B切回A,输出“方案B → 方案A”。快照来自旧DOM,提交后读取新DOM;父trace变化导致的额外更新不会再生成快照,因为value未变。
04|两次复用不是共享state
在第四节App中原CounterModel后追加下列块,保持ThemedEditor定义在模块顶层,并把调用处title改为“说明草稿”。
<CounterModel>{({ count, increment }) => <section>
<p>当前值{count}</p>
<button onClick={increment}>增加第二组</button>
</section>}</CounterModel>第一组连点两次显示计数2,第二组仍当前值0;第二组点一次显示当前值1,第一组仍2。每个CounterModel节点各有实例;共享的是类定义与更新逻辑。ThemedEditor显示“浅色:说明草稿”;输入草稿后切主题,显示“深色:说明草稿”且输入保留。漏传props会丢title,在render内创建HOC会破坏草稿身份,这两项并不是同一种故障。
05|两张独立错误卡
保留第五节Boundary和Card,在Boundary fallback按钮内容改用 {this.props.repairLabel},替换App为:
export default function App() {
const [cards, setCards] = useState({
甲: { broken: false, revision: 0 },
乙: { broken: false, revision: 0 }
})
const [draft, setDraft] = useState('')
function breakCard(id) {
setCards(old => ({ ...old, [id]: { ...old[id], broken: true } }))
}
function repair(id) {
setCards(old => ({ ...old, [id]: { broken: false, revision: old[id].revision + 1 } }))
}
return <main>
<label>边界外草稿<input value={draft} onChange={e => setDraft(e.target.value)} /></label>
{['甲', '乙'].map(id => <section key={id} aria-label={`${id}区域`}>
<button onClick={() => breakCard(id)}>破坏{id}</button>
<Boundary resetKey={cards[id].revision} onRepair={() => repair(id)} repairLabel={`修复${id}`}>
<Card broken={cards[id].broken} />
</Boundary>
</section>)}
</main>
}初始两份卡片正文。破坏甲后甲区域有alert和“修复甲”,乙仍有正文;修复甲后恢复两份正文,外部草稿保持。按id复制对应状态,未改乙的broken/revision;同名Card定义可复用,但每个Boundary的错误state独立。若只清掉边界错误、不修broken,甲会再次进入fallback。
06|按同一资源协议迁移
在第六节ClassFeed中增加 static defaultProps = { active: true };start与didUpdate替换如下,其他方法保持。函数组件也替换为下面完整定义。App增加active状态和切换按钮,并将active同时传给两组Feed。
export class ClassFeed extends Component {
static defaultProps = { active: true }
state = { text: '尚无消息' }
stop = null
start() {
if (this.props.active) {
this.stop = subscribe(this.props.channel, text => this.setState({ text }))
}
}
componentDidMount() { this.start() }
componentDidUpdate(previousProps) {
if (previousProps.channel !== this.props.channel || previousProps.active !== this.props.active) {
this.stop?.()
this.stop = null
this.start()
}
}
componentWillUnmount() { this.stop?.(); this.stop = null }
render() { return <p>类:{this.props.channel}/{this.state.text}</p> }
}export function FunctionFeed({ channel, active = true }) {
const [text, setText] = useState('尚无消息')
useEffect(() => {
if (!active) return
return subscribe(channel, setText)
}, [channel, active])
return <p>函数:{channel}/{text}</p>
}两组先收到甲消息。暂停并切乙后发消息,两组仍保留甲消息;恢复后发乙,两组显示乙消息。取消函数只管理监听,不清空最后文本。active和channel共同决定订阅,因此都参与类的比较或Effect依赖;若函数只依赖channel,单独暂停不会触发清理。
这里的可选返回值undefined表示本轮没有创建资源;恢复后的那一轮才返回自己的取消函数。类版本必须清空旧stop以明确“当前未持有资源”,函数版本由每轮Effect闭包区分所有权。StrictMode重复检查后,最终每组最多一个有效监听。
07|相同终点,不同中间提交
在版本实验中用以下代码替换setTimeout回调里的两次更新及日志,保留外围resolve和其他初始化、卸载步骤:
instance.setState(p => ({ count: p.count + 1 }))
const between = instance.state.count
instance.setState(p => ({ count: p.count + 1 }), () => {
console.log(JSON.stringify({ version: React.version, modern, between, trace, text: host.textContent }))
resolve()
})17 legacy与18 legacy:between为1,trace为 [[1,false],[2,false]],text为2/false。18 modern与19 modern:between为0,trace为 [[2,false]],text同为2/false。两次updater不依赖中途读取,因此终点都正确;旧代码若把between发送到外部系统,升级新根后外部行为仍可能改变。
这个题目要求的是原样版本与入口对照,不是强制每次提交。加入flushSync会改变实验自变量,也掩盖了需要修复的中间读取依赖。类改成函数不是修复批处理差异的充分条件,函数也应遵循状态快照和更新队列合同。
章节入口核对
以下入口对应本章概览或综合练习,答案核对沿用本章各知识点的实现与日志。