Skip to content

类组件维护篇练习答案 ​

下面的改动分别应用到对应正文实验,不将多个同名App拼在一起。普通题沿用现代React实验环境,版本题在自己的隔离目录运行。观察顺序也是合同的一部分:只看干净页面上的一次成功不足以证明切换和清理。

01|三个步长更新 ​

在第一节Counter类内加入以下方法,并在render的section内加入 <button onClick={this.addThreeSteps}>一次增加三步</button>。父App已将运行时step状态传给两个Counter。先保持页面步长2,再点击“切换步长(当前2)”改为3;不编辑源码,也不点击“重建左侧”,保持组件类型与key不变。

jsx
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} />。

jsx
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为:

jsx
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改为“说明草稿”。

jsx
<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为:

jsx
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。

jsx
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> }
}
jsx
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和其他初始化、卸载步骤:

js
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会改变实验自变量,也掩盖了需要修复的中间读取依赖。类改成函数不是修复批处理差异的充分条件,函数也应遵循状态快照和更新队列合同。

章节入口核对 ​

以下入口对应本章概览或综合练习,答案核对沿用本章各知识点的实现与日志。

用心记录每一步成长