前端面试宝典之 React 篇 - 某一线大厂资深前端工程师 - 拉勾教育

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

破题

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

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

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

承题

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

  • 父与子:父组件包裹子组件,父组件向子组件传递数据。
  • 子与父:子组件存在于父组件之中,子组件需要向父组件传递数据。
  • 兄弟:两个组件并列存在于父组件中,需要金属数据进行相互传递。
  • 无直接关系:两个组件并没有直接的关联关系,处在一棵树中相距甚远的位置,但需要共享、传递数据。

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

07 | 如何面向组件跨层级通信? - 图1

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

入手

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

父与子

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

Props

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

  • 在初始化时展示默认文案;
  • 初始化以后通过网络请求拉取文案数据;
  • 通过 Props 传递 state 的文案数据,来更新按钮中的文案。
  1. const Button = ({ text }) => {
  2. <button type="button">{text}</button>
  3. }
  4. class HomePage extends React.Component {
  5. state = {
  6. text: "默认文案"
  7. }
  8. asyc componentDidMount() {
  9. const response = await fetch('/api/buttonText')
  10. this.setState({
  11. text: response.buttoText
  12. })
  13. }
  14. render() {
  15. const {
  16. text
  17. } = this.state
  18. return (
  19. <Button text={text} />
  20. )
  21. }
  22. }

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

子与父

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

回调函数

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

  1. class Input extends React.Component {
  2. handleChanged = (e) => {
  3. this.onChangeText(e.target.text)
  4. }
  5. render() {
  6. return <input onChange={handleTextChanged} />
  7. }
  8. }
  9. class HomePage extends React.Component {
  10. handleTextChanged = (text) => {
  11. console.log(text)
  12. }
  13. render() {
  14. return (
  15. <Input onChangeText={this.handleTextChanged} />
  16. )
  17. }
  18. }

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

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

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

  1. class FetchPosts extends React.Component {
  2. state = {
  3. loading: true,
  4. data: []
  5. }
  6. async componentDidMount() {
  7. const response = await fetch('/api/posts')
  8. this.setState({
  9. data: response.data,
  10. loading: false,
  11. })
  12. }
  13. render() {
  14. if (this.state.loading) {
  15. return <Loading />
  16. }
  17. return this.props.renderPosts(this.state.data)
  18. }
  19. }
  20. class HomePage extends React.Component {
  21. render() {
  22. return (
  23. <FetchPosts
  24. renderPosts={posts => (
  25. <ul>
  26. {posts.map(post => (
  27. <li key={post.id}>
  28. <h2>{post.title}</h2>
  29. <p>{post.description}</p>
  30. </li>
  31. ))}
  32. </ul>
  33. )}
  34. />)
  35. }
  36. }

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

实例函数

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

  1. import React from 'react'
  2. class HomePage extends React.Component {
  3. modalRef = React.createRef()
  4. showModal = () ={
  5. this.modalRef.show()
  6. }
  7. hideModal = () => {
  8. this.modalRef.hide()
  9. }
  10. render() {
  11. const {
  12. text
  13. } = this.state
  14. return (
  15. <>
  16. <Button onClick={this.showModal}>展示 Modal </Button>
  17. <Button onClick={this.hideModal}>隐藏 Modal </Button>
  18. <Modal ref={modalRef} />
  19. </>
  20. />
  21. )
  22. }

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

兄弟

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

  1. class Input extends React.Component {
  2. handleChanged = (e) => {
  3. this.onChangeText(e.target.text)
  4. }
  5. render() {
  6. return <input onChange={handleTextChanged} />
  7. }
  8. }
  9. const StaticText = ({ children }) => {
  10. return (
  11. <P>{children}</p>
  12. )
  13. }
  14. class HomePage extends React.Component {
  15. state = {
  16. text: '默认文案'
  17. }
  18. handleTextChanged = (text) => {
  19. this.setState({
  20. text,
  21. })
  22. }
  23. render() {
  24. return (
  25. <>
  26. <Input onChangeText={this.handleTextChanged} />
  27. <StaticText>this.state.text</StaticText>
  28. </>
  29. )
  30. }
  31. }

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

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

无直接关系

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

Context

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

  1. import { createContext } from 'react';
  2. const I18nContext = createContext({
  3. translate: () => '',
  4. getLocale: () => {},
  5. setLocale: () => {},
  6. });
  7. export default I18nContext;

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

  • translate,用于翻译指定的键值。
  • getLocale,获取当前的语言包。
  • setLocale,设置当前的语言包。

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

  1. import React, { useState } from 'react';
  2. import I18nContext from './I18nContext';
  3. class I18nProvider extends React.Component {
  4. state = {
  5. locale: '',
  6. }
  7. render() {
  8. const i18n = {
  9. translate: key => this.props.languages[locale][key],
  10. getLocale: () => this.state.locale,
  11. setLocale: locale => this.setState({
  12. loacal,
  13. })
  14. }
  15. return (
  16. <I18nContext.Provider value={i18n}>
  17. {this.props.children}
  18. </I18nContext.Provider>
  19. )
  20. }
  21. }
  22. export default I18nProvider;

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

  1. import React from 'react';
  2. import I18nContext from './I18nContext';
  3. const withI18n = wrappedComponent => {
  4. return (props) => (
  5. <I18nContext.Consumer>
  6. {i18n => <WrappedComponent {...i18n} {...props} />}
  7. </I18nContext.Consumer>
  8. )
  9. };
  10. export default withI18n;

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

  1. import React from 'react';
  2. import ReactDOM from 'react-dom';
  3. import App from './App';
  4. import { I18nProvider } from './i18n';
  5. const locales = [ 'en-US', 'zh-CN' ];
  6. const languages = {
  7. 'en-US': require('./locales/en-US'),
  8. 'zh-CN': require('./locales/zh-CN'),
  9. }
  10. ReactDOM.render(
  11. <I18nProvider locales={locales} languages={languages}>
  12. <App />
  13. </I18nProvider>,
  14. document.getElementById('root')
  15. );

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

  1. const Title = withI18n(
  2. ({ translate }) => {
  3. return ( <div>{translate('title')}</div> )
  4. }
  5. )
  6. const Footer = withI18n(
  7. ({ setLocale }) => {
  8. return ( <Button onClick=(() => {
  9. setLocale('zh-CN')
  10. }) /> )
  11. }
  12. )

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

全局变量与事件

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

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

  1. class CallPage extends React.Component {
  2. render() {
  3. return <Button onClick={() => {
  4. window.callId = this.props.callId
  5. }} />
  6. }
  7. class ReportPage extends React.Component {
  8. render() {
  9. return <Button onClick={() => {
  10. fetch('/api/report', { id: window.callId })
  11. }} />
  12. }
  13. }

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

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

  1. class CallPage extends React.Component {
  2. dispatchEvent = () => {
  3. document.dispatchEvent(new CustomEvent('callEvent', {
  4. detail: {
  5. callId: this.props.callId
  6. }
  7. }))
  8. }
  9. render() {
  10. return <Button onClick={this.dispatchEvent} />
  11. }
  12. class ReportPage extends React.Component {
  13. state = {
  14. callId: null,
  15. }
  16. changeCallId = (e) => {
  17. this.setState({
  18. callId: e.detail.callId
  19. })
  20. }
  21. componentDidMount() {
  22. document.addEventListener('callEvent', this.changeCallId)
  23. }
  24. componentWillUnmount() {
  25. document.removeEventListener('callEvent', this.changeCallId)
  26. }
  27. render() {
  28. return <Button onClick={() => {
  29. fetch('/api/report', { id: this.state.callId })
  30. }} />
  31. }
  32. }

粗看代码,事件的方式让我们可以修改 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。优点是由于引入了状态管理,使得项目的开发模式与代码结构得以约束,缺点是学习成本相对较高。

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

07 | 如何面向组件跨层级通信? - 图2

总结

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

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

07 | 如何面向组件跨层级通信? - 图3

《大前端高薪训练营》

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