Skip to content

07如何面向组件跨层级通信?

本讲我们一起来探讨"React 如何面向组件跨层级通信",这个问题在面试中应该如何回答。

破题

"React 如何面向组件跨层级通信"当面试官提出这个问题时,其实是在试探你是否有经手大型前端项目的经验。"跨层级通信"是所有现代前端框架都会遇到的一个场景,并且设计大型前端项目中的组件通信,对于开发人员来说非常具有考验。如何让不同的组件在通信中保持一致性、排除副作用,几乎是所有状态管理框架的开发者都在思考的问题。但这里我们暂时先不讨论这个问题,具体的讲解放在第 15 讲。

回到本讲的问题上来,我将类似的问题统称为"列举题"。如果你还有印象的话,应该会记得在第 05 讲我们也讲解过类似的问题,解题思路是"一个基本,多个场景",即先确定主题,再根据场景列举。

所以该讲我们还是通过结合实践、丰富场景的方式,来表述面向组件跨层级通信的各个分类。

承题

由于 React 是一个组件化框架,那么基于组件树的位置分布,组件与组件之间的关系,大致可分为 4 种。

  • 父与子:父组件包裹子组件,父组件向子组件传递数据。

  • 子与父:子组件存在于父组件之中,子组件需要向父组件传递数据。

  • 兄弟:两个组件并列存在于父组件中,需要金属数据进行相互传递。

  • 无直接关系:两个组件并没有直接的关联关系,处在一棵树中相距甚远的位置,但需要共享、传递数据。

基于以上的分类思路,本讲初步的知识导图就有了:

接下来就可以结合不同的布局关系进行一一论述。

入手

在具体讲解之前,我需要提醒你一下,每种通信方式一定都有它实际的意义以及具体的业务场景对应,而不只是代码的呈现。所以在回答问题时,一定要结合实际的场景才有说服力,才能让面试官信服。

父与子

父与子的通信是最常见的场景,React 开发的每个组件都在使用这样的设计模式。每个组件都会在父级被使用,再传入 Props,完成信息的传递。这样的交互方式尽管不起眼,容易让人忽略,但正是最经典的设计。

Props

那就让我们看看 Props,这个最常用、也最容易忽略的通信方式。就像下面这样的场景:

  • 在初始化时展示默认文案;

  • 初始化以后通过网络请求拉取文案数据;

  • 通过 Props 传递 state 的文案数据,来更新按钮中的文案。

js
const Button = ({ text }) => {
    <button type="button">{text}</button>
}
class HomePage extends React.Component {
   state = {
      text: "默认文案"
   }

   asyc componentDidMount() {
     const response = await fetch('/api/buttonText')
     this.setState({
       text: response.buttoText
     })
   }

    render() {
        const {
          text
        } = this.state
        return (
            <Button text={text} />
        )
    }
}

这样的通信方式非常适用于第 05 讲中提到的展示组件。在这段示例代码中,HomePage 是一个容器组件,而 Button 是一个展示组件。在这样一个设计结构中,这种通信方式就非常常见。

子与父

子与父的通信主要依赖回调函数。

回调函数

回调函数在 JavaScript 中称为 callback。React 在设计中沿用了 JavaScript 的经典设计,允许函数作为参数赋值给子组件。最基础的用法就像下面的例子一样,通过包装传递 text 的值。

java
class Input  extends React.Component {
   handleChanged = (e) => {
     this.onChangeText(e.target.text)
   }
 
   render() {
     return <input onChange={handleTextChanged} />
   }

}
class HomePage extends React.Component {
   handleTextChanged = (text) => {
     console.log(text)
   }

    render() {
        return (
            <Input onChangeText={this.handleTextChanged} />
        )
    }
}

回调函数不仅仅用于传递值,它还可以用在渲染中,父组件根据返回的结果,决定子组件该渲染什么。比如在 React Router 中,我们常常会这样使用它:

js
<Route path="/hello" render={() => <h1>Hello Everyone</h1>} />

这里的回调函数没用具体的参数,所以我们可以看接下来的案例:

js
class FetchPosts extends React.Component {
  state = {
      loading: true,
      data: []
  }

  async componentDidMount() {
    const response = await fetch('/api/posts')
    this.setState({
      data: response.data,
      loading: false,
    })
  }
  render() {
    if (this.state.loading) {
      return <Loading />
    }
    return this.props.renderPosts(this.state.data)
  }
}
class HomePage extends React.Component {
  render() {
    return (
    <FetchPosts
      renderPosts={posts => (
        <ul>
          {posts.map(post => (
            <li key={post.id}>
              <h2>{post.title}</h2>
              <p>{post.description}</p>
            </li>
          ))}
        </ul>
      )}
    />)
  }
}

采用这样的策略可以使子组件专注业务逻辑 ,而父组件专注渲染结果

实例函数

需要注意的是,实例函数是一种不被推荐的使用方式。这种通信方式常见于 React 流行初期,那时有很多组件都通过封装 jQuery 插件生成。最常见的一种情况是在 Modal 中使用这种方式。如下代码所示:

java
import React from 'react'
class HomePage extends React.Component {
   modalRef = React.createRef()
   showModal = () ={
     this.modalRef.show()
   }

   hideModal = () => {
     this.modalRef.hide()
   }

    render() {
        const {
          text
        } = this.state
        return (
            <>
              <Button onClick={this.showModal}>展示 Modal </Button>
              <Button onClick={this.hideModal}>隐藏 Modal </Button>
              <Modal ref={modalRef} />
            </>
          />
        )
    }

但这种方式并不符合 React 的设计理念,如果你使用过 Antd 的 Modal 组件,你可能会有印象,Antd 将 Modal 显隐的控制放在 visible 参数上,直接通过参数控制。如果你有幸在工作中看到类似的代码,那么这个项目一定有些年头了。

兄弟

兄弟组件之间的通信,往往依赖共同的父组件进行中转。可以一起看看下面的案例:

js
class Input extends React.Component {
   handleChanged = (e) => {
     this.onChangeText(e.target.text)
   }
 
   render() {
     return <input onChange={handleTextChanged} />
   }

}
const StaticText = ({ children }) => {
  return (
    <P>{children}</p>
  )
}
class HomePage extends React.Component {

   state = {
     text: '默认文案'
   }
   handleTextChanged = (text) => {
     this.setState({
       text,
     })
   }

    render() {
        return (
            <>
              <Input onChangeText={this.handleTextChanged} />
              <StaticText>this.state.text</StaticText> 
            </>
        )
    }
}

在案例中,StaticText 组件需要显示的内容来自输入框输入的值,那么通过父组件的 state 进行收集、中转、赋值给 StaticText,就完成了以上的通信。

这种模式主要负责在容器组件中协调各组件

无直接关系

无直接关系就是两个组件的直接关联性并不大,它们身处于多层级的嵌套关系中,既不是父子关系,也不相邻,并且相对遥远,但仍然需要共享数据,完成通信。那具体怎样做呢?我们先从 React 提供的通信方案 Context 说起。

Context

Context 第一个最常见的用途就是做 i18n,也就是常说的国际化语言包。我们一起来看下这个案例:

java
import  { createContext } from 'react';
const I18nContext = createContext({
  translate: () => '',
  getLocale: () => {},
  setLocale: () => {},
});
export default I18nContext;

首先使用 React.createContext 创建 Context 的初始状态。这里包含三个函数。

  • translate,用于翻译指定的键值。

  • getLocale,获取当前的语言包。

  • setLocale,设置当前的语言包。

为了代码简洁性,这里包裹了 I18nProvider,提供了一个组件。如下代码所示:

js
import React, { useState } from 'react';
import I18nContext from './I18nContext';
class I18nProvider extends React.Component {
  state = {
      locale: '',
  }

  render() {
     const i18n =  {
        translate: key => this.props.languages[locale][key],
        getLocale: () => this.state.locale,
        setLocale: locale => this.setState({
          loacal,
        })
     }
     return (
      <I18nContext.Provider value={i18n}>
        {this.props.children}
      </I18nContext.Provider>
    )
  }
}
export default I18nProvider;

如果需要共享 Context 的数据,就需要针对每一个组件包装一次消费者,会带来很多无意义的重复代码。这里你可以用我们在第 05 讲讲到的高阶函数来减少它。如以下代码就是通过高阶函数封装消费者的逻辑来减少重复代码的。

js
import React from 'react';
import I18nContext from './I18nContext';
const withI18n = wrappedComponent => {
    return (props) => (
      <I18nContext.Consumer>
        {i18n => <WrappedComponent {...i18n} {...props} />}
      </I18nContext.Consumer>
    )
};
export default withI18n;

准备工作就绪以后,就需要在最顶层注入 Provider。就像下面第 12 行代码所写的那样。

js
import React from 'react';
import ReactDOM from 'react-dom';
import App from './App';
import { I18nProvider } from './i18n';
const locales = [ 'en-US', 'zh-CN' ];
const languages = {
  'en-US': require('./locales/en-US'),
  'zh-CN': require('./locales/zh-CN'),
}
ReactDOM.render(
  <I18nProvider locales={locales} languages={languages}>
    <App />
  </I18nProvider>,
  document.getElementById('root')
);

接下来就是使用 Context 实现国际化的效果。Title 组件中显示 title 标题的内容,而在 Footer 组件通过 setLocale 函数修改当前显示的语言。

js
const Title = withI18n(
  ({ translate }) => { 
    return ( <div>{translate('title')}</div> )
  }
)
const Footer = withI18n(
  ({ setLocale }) => { 
    return ( <Button onClick=(() => {
      setLocale('zh-CN')
    }) /> )
  }
)

这是一个标准的实现方案,接下来看一个不太推荐的方案。

全局变量与事件

全局变量,顾名思义就是放在 Window 上的变量。但值得注意的是修改 Window 上的变量并不会引起 React 组件重新渲染。(具体有哪些因素会造成 React 重新渲染,你可以回顾一下第 03 讲内容)

所以在使用场景上,全局变量更推荐用于暂存临时数据。比如在 CallPage 页面点击了按钮之后,需要收集一个 callId,然后在 ReportPage 上报这个 callId。如下代码所示:

js
class CallPage extends React.Component { 
    render() {
        return <Button onClick={() => {
              window.callId = this.props.callId
        }} />
}
class ReportPage extends React.Component {

    render() {
        return <Button onClick={() => {
              fetch('/api/report', { id: window.callId })
        }} />
    }
}

如果在这里使用 Context,会显得有点重,但是只依靠 Window 做值的暂存就会简单很多。那为什么不太推荐这个方案呢?因为它跳出了设计模式,用偷懒换取了快捷,在后续的维护中,如果业务需求发生变更,比如需要在某处显示 callId,在 callId 变化后,就要重新渲染新的 callId。那么 Window 的劣势就暴露无遗了。所以这是一个让人可以暂时忘记架构设计,尽情偷懒的方案,但请不要忘记,技术债迟早是要还的。就像兰尼斯特家的家训------有债必偿。

除了全局变量以外,还有一种方案是全局事件。如下代码所示:

java
class CallPage extends React.Component {
    dispatchEvent = () => {
        document.dispatchEvent(new CustomEvent('callEvent', {
          detail: {
             callId: this.props.callId
          }
        }))
    }
    render() {
        return <Button onClick={this.dispatchEvent} />
}
class ReportPage extends React.Component {
    state = {
      callId: null,
    }

    changeCallId = (e) => {
      this.setState({
        callId: e.detail.callId
      })
    } 

    componentDidMount() {
        document.addEventListener('callEvent', this.changeCallId)
    }
    componentWillUnmount() {
        document.removeEventListener('callEvent', this.changeCallId)
    }

    render() {
        return <Button onClick={() => {
              fetch('/api/report', { id: this.state.callId })
        }} />
    }
}

粗看代码,事件的方式让我们可以修改 state 的值,所以可以重新渲染组件。但不要忘记,事件的绑定往往会在组件加载时放入,如果 CallPage 与 ReportPage 不是同时存在于页面上,那么这个方案又不适用了。

状态管理框架

状态管理框架提供了非常丰富的解决方案,常见的有 Flux、Redux 及 Mobx,甚至在一定程度上约束了项目的代码结构。因为这些内容庞杂,所以将会在下一讲中详细介绍。引入第三方的状态管理框架主要困难点在于学习成本相对较高,且整个工程的开发思路也将随着框架的引入而改变。

答题

综合以上的分析,我们可以答题了。

在跨层级通信中,主要分为一层或多层的情况。

如果只有一层,那么按照 React 的树形结构进行分类的话,主要有以下三种情况:父组件向子组件通信,子组件向父组件通信以及平级的兄弟组件间互相通信。

在父与子的情况下,因为 React 的设计实际上就是传递 Props 即可。那么场景体现在容器组件与展示组件之间,通过 Props 传递 state,让展示组件受控。

在子与父的情况下,有两种方式,分别是回调函数与实例函数。回调函数,比如输入框向父级组件返回输入内容,按钮向父级组件传递点击事件等。实例函数的情况有些特别,主要是在父组件中通过 React 的 ref API 获取子组件的实例,然后是通过实例调用子组件的实例函数。这种方式在过去常见于 Modal 框的显示与隐藏。这样的代码风格有着明显的 jQuery 时代特征,在现在的 React 社区中已经很少见了,因为流行的做法是希望组件的所有能力都可以通过 Props 控制。

多层级间的数据通信,有两种情况。第一种是一个容器中包含了多层子组件,需要最底部的子组件与顶部组件进行通信。在这种情况下,如果不断透传 Props 或回调函数,不仅代码层级太深,后续也很不好维护。第二种是两个组件不相关,在整个 React 的组件树的两侧,完全不相交。那么基于多层级间的通信一般有三个方案。

第一个是使用 React 的 Context API,最常见的用途是做语言包国际化。

第二个是使用全局变量与事件。全局变量通过在 Windows 上挂载新对象的方式实现,这种方式一般用于临时存储值,这种值用于计算或者上报,缺点是渲染显示时容易引发错误。全局事件就是使用 document 的自定义事件,因为绑定事件的操作一般会放在组件的 componentDidMount 中,所以一般要求两个组件都已经在页面中加载显示,这就导致了一定的时序依赖。如果加载时机存在差异,那么很有可能导致两者都没能对应响应事件。

第三个是使用状态管理框架,比如 Flux、Redux 及 Mobx。优点是由于引入了状态管理,使得项目的开发模式与代码结构得以约束,缺点是学习成本相对较高。

还可以梳理出一个完整的知识框架。

总结

在本讲中,我结合开发实践中的常用案例,为你讲解了组件的跨层级通信方案与具体的适用场景。但本题的答案并不是唯一的,并且每一个方案不是只能解决讲到的问题,它们都有更为广泛的适用场景。我希望你可以在日常工作中积极地寻找对应的场景,只有与自己工作相关的场景才更有记忆点,在面试中才能让面试官耳目一新。当然我们的目标不只是拿下面试官,更是从难题中获得成长。

前文中提到了状态管理框架内容十分庞杂,所以在下一讲中,将会把状态管理框架中流行的方案一一详细讲解。

[

](https://shenceyun.lagou.com/t/mka)

《大前端高薪训练营》

对标阿里 P7 技术需求 + 每月大厂内推,6 个月助你斩获名企高薪 Offer。点击链接,快来领取!