Skip to content

类中的 Context、render props 与 HOC ​

维护组件库时常会遇到 withTheme(Editor) 或 <Counter>{value => ...}</Counter>。这些写法不是某个React版本专属能力:前者是返回组件的普通函数,后者是把渲染决策作为函数prop传入。先找出谁拥有state、谁提供依赖、谁决定UI,再改动包装层,比把所有包装都改写成Hook更稳妥。

同一份状态,两种消费界面 ​

完整替换 src/App.jsx。ThemeContext由父管理;CounterModel拥有自己的计数;Editor拥有草稿。它们不是同一份state,只是通过组合相遇。

jsx
import { Component, createContext, useState } from 'react'

export const ThemeContext = createContext('浅色')

export class ThemeLabel extends Component {
  static contextType = ThemeContext
  render() { return <p>当前主题:{this.context}</p> }
}

export class CounterModel extends Component {
  state = { count: 0 }
  increment = () => this.setState(p => ({ count: p.count + 1 }))
  render() {
    return this.props.children({ count: this.state.count, increment: this.increment })
  }
}

export function withTheme(Wrapped) {
  class Themed extends Component {
    static contextType = ThemeContext
    render() { return <Wrapped {...this.props} theme={this.context} /> }
  }
  Themed.displayName = `withTheme(${Wrapped.displayName || Wrapped.name || 'Component'})`
  return Themed
}

export class Editor extends Component {
  state = { draft: '' }
  render() {
    return <section>
      <p>{this.props.theme}:{this.props.title}</p>
      <label>草稿<input value={this.state.draft}
        onChange={event => this.setState({ draft: event.target.value })} /></label>
    </section>
  }
}
const ThemedEditor = withTheme(Editor)

export default function App() {
  const [theme, setTheme] = useState('浅色')
  return <ThemeContext.Provider value={theme}>
    <button onClick={() => setTheme(t => t === '浅色' ? '深色' : '浅色')}>切换主题</button>
    <ThemeLabel />
    <CounterModel>{({ count, increment }) =>
      <button onClick={increment}>计数{count}</button>
    }</CounterModel>
    <ThemedEditor title="编辑器" />
  </ThemeContext.Provider>
}

输入草稿,再点击计数与主题切换。计数递增,主题变化,草稿仍保留。主题不是CounterModel的状态,父也没有直接操作Editor的draft;稳定组件身份使这些状态可以独立变化。

Context入口与render prop的调用时机 ​

static contextType声明本类读取哪一个Context,React把最近Provider的value提供给this.context。defaultValue只在没有对应Provider时使用;Provider显式给undefined并不会退回defaultValue。这里只使用一个Context;需要多个时可以组合Consumer,或由薄函数包装器调用多个useContext后传props。不能在类方法里调用Hook。

本篇使用 <ThemeContext.Provider>,在17/18/19均适用;React19还支持直接把Context用作Provider。历史 contextTypes/getChildContext 属于另一套旧协议,不能把末尾有没有s当作同义拼写。19已经移除旧协议,升级前需找到提供者和消费者一起改造。

CounterModel的children不是立即渲染的元素,而是函数。render调用它并传 {count,increment},父决定把同一份能力画成按钮、文本或其他UI。如果把 this.props.children(...) 写成直接 {this.props.children},React不会替你猜测应该传什么参数。函数是在渲染中执行,必须保持纯粹;不要在这个回调里发请求或调用Hook,把真正的组件单独声明再返回JSX。

HOC改变输入,不修改被包组件 ​

withTheme返回的新类负责读取环境并注入theme,Editor只知道它收到theme prop。{...this.props} 保留title等正常输入;后面的theme覆盖同名调用方prop,这是明确的注入优先级。若顺序反过来,调用方就能覆盖主题。包装契约应明确保留哪些props,不能无意吞掉onSave等事件。

将 const ThemedEditor = withTheme(Editor) 移进App函数,再输入草稿并切主题:每次App执行都会创建新组件类型,草稿可能因重建消失。问题不是“类state不稳定”,而是包装器的定义位置改变了类型身份。恢复模块顶层定义后再继续。

HOC也不是透明到没有边界:原类上的业务静态字段不会自动变成包装类字段;React19中传给类包装器的ref仍指向包装器实例,不会因为展开props就指向内层。需要命令接口时,应设计显式的innerRef或由函数适配器传递有限能力,并沿用第四章ref合同,不以“props都转发了”声称ref也已转发。

练习:把能力换一种呈现 ​

用同一个CounterModel增加第二个独立实例:它的children返回一段“当前值N”和一个“增加第二组”按钮。点击第一组不能改变第二组。给ThemedEditor传入 title="说明草稿",验证包装器保留title;切主题后draft仍保持。说明为什么“复用CounterModel”没有共享两组state。

查看答案。下一节学习错误隔离与恢复。

用心记录每一步成长