前端面试宝典之 React 篇 - 某一线大厂资深前端工程师 - 拉勾教育
本讲我们一起来探讨 “React 如何面向组件跨层级通信”,这个问题在面试中应该如何回答。
破题
“React 如何面向组件跨层级通信” 当面试官提出这个问题时,其实是在试探你是否有经手大型前端项目的经验。“跨层级通信” 是所有现代前端框架都会遇到的一个场景,并且设计大型前端项目中的组件通信,对于开发人员来说非常具有考验。如何让不同的组件在通信中保持一致性、排除副作用,几乎是所有状态管理框架的开发者都在思考的问题。但这里我们暂时先不讨论这个问题,具体的讲解放在第 15 讲。
回到本讲的问题上来,我将类似的问题统称为 “列举题”。如果你还有印象的话,应该会记得在第 05 讲我们也讲解过类似的问题,解题思路是 “一个基本,多个场景”,即先确定主题,再根据场景列举。
所以该讲我们还是通过结合实践、丰富场景的方式,来表述面向组件跨层级通信的各个分类。
承题
由于 React 是一个组件化框架,那么基于组件树的位置分布,组件与组件之间的关系,大致可分为 4 种。
- 父与子:父组件包裹子组件,父组件向子组件传递数据。
- 子与父:子组件存在于父组件之中,子组件需要向父组件传递数据。
- 兄弟:两个组件并列存在于父组件中,需要金属数据进行相互传递。
- 无直接关系:两个组件并没有直接的关联关系,处在一棵树中相距甚远的位置,但需要共享、传递数据。
基于以上的分类思路,本讲初步的知识导图就有了:
接下来就可以结合不同的布局关系进行一一论述。
入手
在具体讲解之前,我需要提醒你一下,每种通信方式一定都有它实际的意义,以及具体的业务场景对应,而不只是代码的呈现。所以在回答问题时,一定要结合实际的场景才有说服力,才能让面试官信服。
父与子
父与子的通信是最常见的场景,React 开发的每个组件都在使用这样的设计模式。每个组件都会在父级被使用,再传入 Props,完成信息的传递。这样的交互方式尽管不起眼,容易让人忽略,但正是最经典的设计。
Props
那就让我们看看 Props,这个最常用、也最容易忽略的通信方式。就像下面这样的场景:
- 在初始化时展示默认文案;
- 初始化以后通过网络请求拉取文案数据;
- 通过 Props 传递 state 的文案数据,来更新按钮中的文案。
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 的值。
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 中,我们常常会这样使用它:
<Route path="/hello" render={() => <h1>Hello Everyone</h1>} />
这里的回调函数没用具体的参数,所以我们可以看接下来的案例:
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 中使用这种方式。如下代码所示:
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 参数上,直接通过参数控制。如果你有幸在工作中看到类似的代码,那么这个项目一定有些年头了。
兄弟
兄弟组件之间的通信,往往依赖共同的父组件进行中转。可以一起看看下面的案例:
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,也就是常说的国际化语言包。我们一起来看下这个案例:
import { createContext } from 'react';
const I18nContext = createContext({
translate: () => '',
getLocale: () => {},
setLocale: () => {},
});
export default I18nContext;
首先使用 React.createContext 创建 Context 的初始状态。这里包含三个函数。
- translate,用于翻译指定的键值。
- getLocale,获取当前的语言包。
- setLocale,设置当前的语言包。
为了代码简洁性,这里包裹了 I18nProvider,提供了一个组件。如下代码所示:
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 讲讲到的高阶函数来减少它。如以下代码就是通过高阶函数封装消费者的逻辑来减少重复代码的。
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 行代码所写的那样。
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 函数修改当前显示的语言。
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。如下代码所示:
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 的劣势就暴露无遗了。所以这是一个让人可以暂时忘记架构设计,尽情偷懒的方案,但请不要忘记,技术债迟早是要还的。就像兰尼斯特家的家训——有债必偿。
除了全局变量以外,还有一种方案是全局事件。如下代码所示:
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。优点是由于引入了状态管理,使得项目的开发模式与代码结构得以约束,缺点是学习成本相对较高。
还可以梳理出一个完整的知识框架。
总结
在本讲中,我结合开发实践中的常用案例,为你讲解了组件的跨层级通信方案与具体的适用场景。但本题的答案并不是唯一的,并且每一个方案不是只能解决讲到的问题,它们都有更为广泛的适用场景。我希望你可以在日常工作中积极地寻找对应的场景,只有与自己工作相关的场景才更有记忆点,在面试中才能让面试官耳目一新。当然我们的目标不只是拿下面试官,更是从难题中获得成长。
前文中提到了状态管理框架内容十分庞杂,所以在下一讲中,将会把状态管理框架中流行的方案一一详细讲解。
《大前端高薪训练营》
对标阿里 P7 技术需求 + 每月大厂内推,6 个月助你斩获名企高薪 Offer。点击链接,快来领取!