React系列八:非父子组件通信

1,028

快来加入我们吧!

"小和山的菜鸟们",为前端开发者提供技术相关资讯以及系列基础文章。为更好的用户体验,请您移至我们官网小和山的菜鸟们 ( xhs-rookies.com/ ) 进行学习,及时获取最新文章。

"Code tailor" ,如果您对我们文章感兴趣、或是想提一些建议,微信关注 “小和山的菜鸟们” 公众号,与我们取的联系,您也可以在微信上观看我们的文章。每一个建议或是赞同都是对我们极大的鼓励!

前言

这节我们将介绍 React 中非父子组件的通信,上节我们说到父子组件间的通信可通过 props 和回调函数完成,但随着应用程序越来越大,使用 props 和回调函数的方式就变得非常繁琐了,那么非父子组件间的组件通信,有没有一种简单的方法呢?

本文会向你介绍以下内容:

  • 跨级组件间的通信
  • Context
  • 兄弟组件通信

跨级组件间的通信

Context

Context 提供了一个无需为每层组件手动添加 props,就能在组件树间进行数据传递的方法

Context 的使用场景

  • 对于有一些场景:比如一些数据需要在多个组件中进行共享(地区偏好、UI 主题、用户登录状态、用户信息等)。
  • 如果我们在顶层的 App 中定义这些信息,层层传递下去,对于一些中间层不需要数据的组件来说,这是一种冗余的操作。

image.png

如果层级更多的话,一层层传递是非常麻烦,并且代码是非常冗余的:

  • React 提供了一个 API:Context
  • Context 提供了一种在组件之间共享此类值的方式,而不必显式地通过组件树的逐层传递 props
  • Context 设计目的是为了共享那些对于一个组件树而言是“全局”的数据,例如当前认证的用户、主题或首选语言;

Context 相关的 API

React.createContext
const MyContext = React.createContext(defaultValue)

创建一个需要共享的 Context 对象:

  • 如果一个组件订阅了 Context,那么这个组件会从离自身最近的那个匹配的 Provider 中读取到当前的context 值;
  • 只有当组件所处的树中没有匹配到 Provider 时,其 defaultValue 参数才会生效。defaultValue 是组件在顶层查找过程中没有找到对应的Provider,那么就使用默认值

注意:undefined 传递给 Provider 的 value 时,消费组件的 defaultValue 不会生效。

Context.Provider
<MyContext.Provider value={/* 某个值 */}>

每个 Context 对象都会返回一个 Provider React 组件,它允许消费组件订阅 context 的变化:

  • Provider 接收一个 value 属性,传递给消费组件;
  • 一个 Provider 可以和多个消费组件有对应关系;
  • 多个 Provider 也可以嵌套使用,里层的会覆盖外层的数据;

当 Provider 的 value 值发生变化时,它内部的所有消费组件都会重新渲染;

Class.contextType
class MyClass extends React.Component {
  componentDidMount() {
    let value = this.context
    /* 在组件挂载完成后,使用 MyContext 组件的值来执行一些有副作用的操作 */
  }
  componentDidUpdate() {
    let value = this.context
    /* ... */
  }
  componentWillUnmount() {
    let value = this.context
    /* ... */
  }
  render() {
    let value = this.context
    /* 基于 MyContext 组件的值进行渲染 */
  }
}
MyClass.contextType = MyContext

挂载在 class 上的 contextType 属性会被重赋值为一个由 React.createContext() 创建的 Context 对象:

  • 这能让你使用 this.context 来消费最近 Context 上的那个值;
  • 你可以在任何生命周期中访问到它,包括 render 函数中;
Context.Consumer
<MyContext.Consumer>
  {value => /* 基于 context 值进行渲染*/}
</MyContext.Consumer>

这里,React 组件也可以订阅到 context 变更。这能让你在 函数式组件 中完成订阅 context

  • 这里需要 函数作为子元素(function as child)这种做法;
  • 这个函数接收当前的 context 值,返回一个 React 节点;

Context 使用

举个例子,在下面的代码中,我们通过一个 “theme” 属性手动调整一个按钮组件的样式:

class App extends React.Component {
  render() {
    return <Toolbar theme="dark" />
  }
}

function Toolbar(props) {
  // Toolbar 组件接受一个额外的“theme”属性,然后传递给 ThemedButton 组件。
  // 如果应用中每一个单独的按钮都需要知道 theme 的值,这会是件很麻烦的事,
  // 因为必须将这个值层层传递所有组件。
  return (
    <div>
      <ThemedButton theme={props.theme} />
    </div>
  )
}

class ThemedButton extends React.Component {
  render() {
    return <Button theme={this.props.theme} />
  }
}

使用 context, 我们可以避免通过中间元素传递 props

// Context 可以让我们无须明确地传遍每一个组件,就能将值深入传递进组件树。
// 为当前的 theme 创建一个 context(“light”为默认值)。
const ThemeContext = React.createContext('light')
class App extends React.Component {
  render() {
    // 使用一个 Provider 来将当前的 theme 传递给以下的组件树。
    // 无论多深,任何组件都能读取这个值。
    // 在这个例子中,我们将 “dark” 作为当前的值传递下去。
    return (
      <ThemeContext.Provider value="dark">
        <Toolbar />
      </ThemeContext.Provider>
    )
  }
}

// 中间的组件再也不必指明往下传递 theme 了。
function Toolbar() {
  return (
    <div>
      <ThemedButton />
    </div>
  )
}

class ThemedButton extends React.Component {
  // 指定 contextType 读取当前的 theme context。
  // React 会往上找到最近的 theme Provider,然后使用它的值。
  // 在这个例子中,当前的 theme 值为 “dark”。
  static contextType = ThemeContext
  render() {
    return <Button theme={this.context} />
  }
}

兄弟组件通信

兄弟组件即他们拥有共同的父组件!

而在讲兄弟组件之前我们先要讲到一个概念:状态提升

状态提升 :在 React 中,将多个组件中需要共享的 state 向上移动到它们的最近共同父组件中,便可实现共享 state。这就是所谓的 状态提升

简单例子

接下来通过一个例子帮助大家深刻理解:

我们将从一个名为 BoilingVerdict 的组件开始,它接受 celsius 温度作为一个 prop,并据此打印出该温度是否足以将水煮沸的结果。

function BoilingVerdict(props) {
  if (props.celsius >= 100) {
    return <p>The water would boil.</p>
  }
  return <p>The water would not boil.</p>
}

接下来, 我们创建一个名为 Calculator 的组件。它渲染一个用于输入温度的 <input>,并将其值保存在 this.state.temperature 中。

另外, 它根据当前输入值渲染 BoilingVerdict 组件。

class Calculator extends React.Component {
  constructor(props) {
    super(props);
    this.state = {temperature: ''};
  }

  handleChange(e) {
    this.setState({temperature: e.target.value});
  }

  render() {
    const temperature = this.state.temperature;
    return (
        <p>Enter temperature in Celsius:</p>
        <input
          value={temperature}
          onChange={e => this.handleChange(e)} />
        <BoilingVerdict
          celsius={parseFloat(temperature)} />
    );
  }
}

image.png

image.png

添加第二个输入框

现在的新需求是,在已有摄氏温度输入框的基础上,我们提供华氏度的输入框,并保持两个输入框的数据同步。

我们先从 Calculator 组件中抽离出 TemperatureInput 组件,然后为其添加一个新的 scale prop,它可以是 "c" 或是 "f":(代表摄氏温度和华氏温度)

const scaleNames = {
  c: 'Celsius',
  f: 'Fahrenheit',
}

class TemperatureInput extends React.Component {
  constructor(props) {
    super(props)
    this.handleChange = this.handleChange.bind(this)
    this.state = { temperature: '' }
  }

  handleChange(e) {
    this.setState({ temperature: e.target.value })
  }

  render() {
    const temperature = this.state.temperature
    const scale = this.props.scale
    return (
      <fieldset>
        <legend>Enter temperature in {scaleNames[scale]}:</legend>
        <input value={temperature} onChange={this.handleChange} />
      </fieldset>
    )
  }
}

我们现在可以修改 Calculator 组件让它渲染两个独立的温度输入框组件:

class Calculator extends React.Component {
  render() {
    return (
      <div>
        <TemperatureInput scale="c" />
        <TemperatureInput scale="f" />
      </div>
    )
  }
}

image.png

我们现在有了两个输入框,但当你在其中一个输入温度时,另一个并不会更新。这与我们的要求相矛盾:我们希望让它们保持同步。

另外,我们也不能通过 Calculator 组件展示 BoilingVerdict 组件的渲染结果。因为 Calculator 组件并不知道隐藏在 TemperatureInput 组件中的当前温度是多少。

状态提升

到目前为止, 两个 TemperatureInput 组件均在各自内部的 state 中相互独立地保存着各自的数据。

class TemperatureInput extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {temperature: ''};
  }

  handleChange(e) {
    this.setState({temperature: e.target.value});
  }

  render() {
    const temperature = this.state.temperature;
    // ...

然而,我们希望两个输入框内的数值彼此能够同步。当我们更新摄氏度输入框内的数值时,华氏度输入框内应当显示转换后的华氏温度,反之亦然。

React 中,将多个组件中需要共享的 state 向上移动到它们的最近共同父组件中,便可实现共享 state。这就是所谓的“状态提升”。接下来,我们将 TemperatureInput 组件中的 state 移动至 Calculator 组件中去。

如果 Calculator 组件拥有了共享的 state,它将成为两个温度输入框中当前温度的“数据源”。它能够使得两个温度输入框的数值彼此保持一致。由于两个 TemperatureInput 组件的 props 均来自共同的父组件 Calculator,因此两个输入框中的内容将始终保持一致。

让我们看看这是如何实现的。

**核心点在于:**父组件将状态改变函数作为 props 传递给子组件。

我们会把当前输入的 temperaturescale 保存在组件内部的 state 中。这个 state 就是从两个输入框组件中“提升”而来的,并且它将用作两个输入框组件的共同“数据源”。这是我们为了渲染两个输入框所需要的所有数据的最小表示。

由于两个输入框中的数值由同一个 state 计算而来,因此它们始终保持同步:

class Calculator extends React.Component {
  constructor(props) {
    super(props);
    this.handleCelsiusChange = this.handleCelsiusChange.bind(this);
    this.handleFahrenheitChange = this.handleFahrenheitChange.bind(this);
    this.state = {temperature: '', scale: 'c'};
  }

  handleCelsiusChange(temperature) {
    this.setState({scale: 'c', temperature});
  }

  handleFahrenheitChange(temperature) {
    this.setState({scale: 'f', temperature});
  }

  tryConvert(temperature, convert){
  	... //用来转化温度
  }

  render() {
    const scale = this.state.scale;
    const temperature = this.state.temperature;
    const celsius = scale === 'f' ? tryConvert(temperature, toCelsius) : temperature;    				const fahrenheit = scale === 'c' ? tryConvert(temperature, toFahrenheit) : temperature;
    return (
      <div>
        <TemperatureInput
          scale="c"
          temperature={celsius}
  				onTemperatureChange={this.handleCelsiusChange} />
        <TemperatureInput
          scale="f"
          temperature={fahrenheit}
					onTemperatureChange={this.handleFahrenheitChange} />
        <BoilingVerdict
          celsius={parseFloat(celsius)} />
      </div>
    );
  }
}

再让我们看下 TemperatureInput 组件如何变化。我们移除组件自身的 state,通过使用 this.props.temperature 替代 this.state.temperature 来读取温度数据。当我们想要响应数据改变时,我们需要调用 Calculator 组件提供的 this.props.onTemperatureChange(),而不再使用 this.setState()

class TemperatureInput extends React.Component {
  constructor(props) {
    super(props)
    this.handleChange = this.handleChange.bind(this)
  }

  handleChange(e) {
    this.props.onTemperatureChange(e.target.value)
  }

  render() {
    const temperature = this.props.temperature
    const scale = this.props.scale
    return (
      <fieldset>
        <legend>Enter temperature in {scaleNames[scale]}:</legend>
        <input value={temperature} onChange={this.handleChange} />
      </fieldset>
    )
  }
}

现在无论你编辑哪个输入框中的内容,Calculator 组件中的 this.state.temperaturethis.state.scale 均会被更新。其中一个输入框保留用户的输入并取值,另一个输入框始终基于这个值显示转换后的结果。

让我们来重新梳理一下当你对输入框内容进行编辑时会发生些什么:

  • React 会调用 DOM 中 <input>onChange 方法。在本实例中,它是 TemperatureInput 组件的 handleChange 方法。
  • TemperatureInput 组件中的 handleChange 方法会调用 this.props.onTemperatureChange(),并传入新输入的值作为参数。其 props 诸如 onTemperatureChange 之类,均由父组件 Calculator 提供。
  • 起初渲染时,用于摄氏度输入的子组件 TemperatureInput 中的 onTemperatureChange 方法与 Calculator 组件中的 handleCelsiusChange 方法相同,而,用于华氏度输入的子组件 TemperatureInput 中的 onTemperatureChange 方法与 Calculator 组件中的 handleFahrenheitChange 方法相同。因此,无论哪个输入框被编辑都会调用 Calculator 组件中对应的方法。
  • 在这些方法内部,Calculator 组件通过使用新的输入值与当前输入框对应的温度计量单位来调用 this.setState() 进而请求 React 重新渲染自己本身。
  • React 调用 Calculator 组件的 render 方法得到组件的 UI 呈现。温度转换在这时进行,两个输入框中的数值通过当前输入温度和其计量单位来重新计算获得。
  • React 使用 Calculator 组件提供的新 props 分别调用两个 TemperatureInput 子组件的 render 方法来获取子组件的 UI 呈现。
  • React 调用 BoilingVerdict 组件的 render 方法,并将摄氏温度值以组件 props 方式传入。
  • React DOM 根据输入值匹配水是否沸腾,并将结果更新至 DOM。我们刚刚编辑的输入框接收其当前值,另一个输入框内容更新为转换后的温度值。

得益于每次的更新都经历相同的步骤,两个输入框的内容才能始终保持同步。

Monitoring State in React DevTools

讲完了状态提升,让我们现在来看看它怎么运用到兄弟组件通信中来!

现在有这样一个场景

  • 绘制登录页面:输入用户名和密码
  • 点击登录

image.png

class Login extends React.Component {
		constructor(props) {
        super (props);
        this.state = {
            userName:"",
            password:""
        }
    }

  	handlerLogin(e){
      this.setState(e)
    }

    render(){
        return(
        	<div>
          	<UserNameInput onChange = {value => this.handlerLogin({username:value})}>
          	<PasswordInput onChange = {value => this.handlerLogin({password:value})}>
          </div>
        )
    }
}

class UserNameInput extends React.Component {
     handlerUserName(e){
       this.props.handlerLogin(e.target.value);
     }

  	render(){
      return (
      	<div>
        	<input onChange={e => this.handlerUserName(e)} placeholder="请输入用户名"/>
        </div>
      )
    }
}

class PasswordInput extends React.Component {
     handlerPassword(e){
       this.props.handlerLogin(e.target.value);
     }

    render(){
        return (
          <div>
            <input onChange={e => this.handlerUserName(e)} placeholder="请输入密码"/>
          </div>
        )
      }
}

其实这里的代码并没有写完,但我们可以看到的是我们已经可以在 App 组件中拿到用户名和密码了,接下来我们就可以在此去调用登录接口了。

下节预告

下节中我们将讲述使用 React 组件间通信的相关知识,组件化的内容将之前的实战案例进行改版,优化之前的实战方案。敬请期待!