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

在第 01 讲中,我们知道了 React 通过组件化的方式,解决了工程实践中代码如何组织的问题,但它并没有指出组件之间应该按照什么样的方式去组合,本讲我们一起来探讨这个问题,即 “如何设计 React 组件”。

破题

“如何设计 React 组件?” 其实就是在考察你是否了解 React 组件的设计模式。

你有没有发现在实际的工程实践中,如果缺乏一个指导性的设计模式,而直接开发,代码往往会非常凌乱。常见的情况就是:

  • 将一个页面写成一个组件;
  • 一个组件包含两三千行的代码。

这些都没有明显的模块划分,缺乏组合的思想。所以如何将组件更好地组合,这是需要探讨的第一个问题。

在明确了 “如何组合” 这一核心主题后,我们需要思考的是,如何将核心主题以更好的形式展示出来,因为平铺直叙地罗列知识,那内容是非常干瘪的。而基于不同的业务场景,组件的组合形式是不一样的,所以如果结合丰富场景来展示 “如何组合” 的方式,可以让表述变得有血有肉,也显得你经验十足。

这里你就需要先搞清楚基于场景的设计分类了。

承题

通过以上的分析,我们可以得出 “如何设计 React 组件?” 这一题的答题套路是 “一个主题,多个场景”,即围绕“如何组合” 这一核心主题,通过列举场景的方式展现设计模式的分类及用途。

我们先来了解下 React 的组件有哪些分类,这里可以直接采用 React 社区中非常经典的分类模式:

  • 把只作展示、独立运行、不额外增加功能的组件,称为哑组件,或无状态组件,还有一种叫法是展示组件
  • 把处理业务逻辑与数据状态的组件称为有状态组件,或灵巧组件,灵巧组件一定包含至少一个灵巧组件或者展示组件。

从分类中可以看出展示组件的复用性更强,灵巧组件则更专注于业务本身。那么基于以上的思路,你可以整理出如下的知识导图:

05 | 如何设计 React 组件? - 图1

05 | 如何设计 React 组件? - 图2

接下来我将结合各个场景来为你展开讲解这些组件。

入题

无论是怎样的设计,始终是不能脱离工程实践进行探讨的。回到前端工程中来,如果使用 create-react-app 初始化项目,通常会有类似这样的目录结构:

  1. .
  2. ├── README.md
  3. ├── package.json
  4. ├── public
  5. ├── favicon.ico
  6. ├── index.html
  7. ├── logo192.png
  8. ├── logo512.png
  9. ├── manifest.json
  10. └── robots.txt
  11. ├── src
  12. ├── App.css
  13. ├── App.js
  14. ├── App.test.js
  15. ├── index.css
  16. ├── index.js
  17. ├── logo.svg
  18. ├── reportWebVitals.js
  19. └── setupTests.js
  20. └── yarn.lock

在源码目录,也就是 src 目录中,所有组件就像衣服散落在房间里一样堆在了一起,如果继续添置衣物,可以想象这个房间最后会变得有多乱。就像每件衣服总有它适用的场合,组件也有同样的分类。

我先带你从功能最薄弱的展示组件开始梳理,其次是展示组件中装饰作用的小物件。

展示组件

展示组件内部是没有状态管理的,正如其名,就像一个个 “装饰物” 一样,完全受制于外部的 props 控制。展示组件具有极强的通用性复用率也很高,往往与当前的前端工程关系相对薄弱,甚至可以做到跨项目级的复用。

我们先来看一下展示组件中最常用的代理组件。

代理组件

代理组件常用于封装常用属性减少重复代码。关于代理组件你应该不陌生,可能经常会写。

举一个最常见的例子,当需要定义一个按钮的时候,需要在按钮上添加 button 属性,代码如下:

当然在 React 中使用的时候,不可能每次都写这样一段代码,非常麻烦。常见的做法是封装

  1. const Button = props =>
  2. <button type="button" {...props}>

在开发中使用 Button 组件替代原生的 button,可以确保 type 保证一致。

在使用 Antd 开发时,你也会采用类似的设计模式,大致情况如下:

  1. import { Button as AntdButton } from from 'antd'
  2. const Button = props =>
  3. <AntdButton size="small" type="primary" {...props}>
  4. export default Button

虽然进行封装感觉是多此一举,但切断了外部组件库的强依赖特性。在大厂中引入外部组件库需要考虑两点:

  • 如果当前组件库不能使用了,是否能实现业务上的无痛切换;
  • 如果需要批量修改基础组件的字段,如何解决?

代理组件的设计模式很好地解决了上面两个问题。从业务上看,代理组件隔绝了 Antd,仅仅是一个组件 Props API 层的交互。这一层如若未来需要替换,是可以保证兼容、快速替换的,而不需要在原有的代码库中查找修改。其次,如果要修改基础组件的颜色、大小、间距,代理组件也可以相对优雅地解决,使得这些修改都内聚在当前的 Button 组件中,而非散落在其他地方。

基于展示组件的思想,可以封装类似的其他组件,比如样式组件。

样式组件

样式组件也是一种代理组件,只是又细分了处理样式领域,将当前的关注点分离到当前组件内。你是否还记得在第 02 讲中提到过 “关注点分离” 的概念,其中就说到“将代码分隔为不同部分,其中每一部分都会有自己的关注焦点”。

但在工程实践中,我们并不会因为一个按钮需要协商 className 而封装成一个组件,就像下面这样:

  1. const Button = props => (
  2. <button type="button" className="btn btn-primary">
  3. )

这并没有什么意义。真实工程项目的样式管理往往是复杂的,它更接近于下面这个例子:

  1. import classnames from "classnames";
  2. const StyleButton = ({ className, primary, isHighLighted, ...props }) => (
  3. <Button
  4. type="button"
  5. className={classnames("btn", {
  6. btn-primary: primary,
  7. highLight: isHighLighted,
  8. }, className)}
  9. {...props}
  10. />
  11. );

复杂的样式管理对于 Button 是没有意义的,如果直接使用 Button,在属性上修改,对工程代码而言就是编写大量的面条代码。而 StyleButton 的思路是将样式判断逻辑分离到自身上,面向未来改动的时候会更为友好。

接下来可以看下基于样式组件的优化设计。

布局组件

布局组件的基本设计与样式组件完全一样,但它基于自身特性做了一个小小的优化。

首先来看下它的基础使用案例,主要用于安放其他组件,类似于这样的用法:

  1. <Layout
  2. Top={<NavigationBar />}
  3. Content={<Article />}
  4. Bottom={<BottomBar />}
  5. />

布局本身是确定的,不需要根据外部状态的变化去修改内部组件。所以这也是一个可以减少渲染的优化点。(当然,这里的样式结构写得比较简单)

  1. class Layout extends React.Component {
  2. shouldComponentUpdate() {
  3. return false;
  4. }
  5. render() {
  6. <div>
  7. <div>{this.props.NavigationBar}</div>
  8. <div>{this.props.Article}</div>
  9. <div>{this.props.BottomBar}</div>
  10. </div>
  11. }
  12. }

由于布局组件无需更新,所以对应到第 3 讲中提到的生命周期,就可以通过写死shouldComponentUpdate 的返回值直接阻断渲染过程。对于大型前端工程,类似的小心思可以带来性能上的提升。当然,这也是基于代理组件更易于维护而带来的好处。

灵巧组件

由于灵巧组件面向业务,所以相对于展示组件来说,其功能更为丰富、复杂性更高,而复用度更低。展示组件专注于组件本身特性灵巧组件更专注于组合组件。那么最常见的案例则是容器组件。

容器组件

容器组件几乎没有复用性,它主要用在两个方面:拉取数据与组合组件。可以看这样一个例子:

  1. const CardList = ({ cards }) => (
  2. <div>
  3. {cards.map(card => (
  4. <CardLayout
  5. header={<Avatar url={card.avatarUrl} />}
  6. Content={<Card {...card} />}
  7. />
  8. {comment.body}-{comment.author}
  9. ))}
  10. </div>
  11. );

这是一个 CardList 组件,负责将 cards 数据渲染出来,接下来将获取网络数据。如下代码所示:

  1. class CardListContainer extends React.Component {
  2. state = { cards: [] }
  3. async componentDidMount() {
  4. const response = await fetch('/api/cards')
  5. this.setState({cards: response})
  6. }
  7. render() {
  8. return <CardList cards={this.state.cards} />
  9. }
  10. }

像这样切分代码后,你会发现容器组件内非常干净,没有冗余的样式与逻辑处理。你有没有发现这也是采取了关注点分离的策略?其实这一策略还可以直接应用到你的工作中。因为互联网人的工作常常是多线并行,如果想把事做得更漂亮,可以尝试把它切分成多个片段,让自己的关注点在短时间内更为集中,从而做到高效快速地处理。

回到组件的问题上来,那么对复用性更强的业务逻辑采用什么方式处理呢?

高阶组件

React 的官方文档将高阶组件称为 React 中复用组件逻辑的高级技术。高阶组件本身并不是 React API 的一部分,它是一种基于 React 的组合特性而形成的设计模式。简而言之,高阶组件的参数是组件,返回值为新组件的函数。

这样听起来有一些高阶函数的味儿了。那什么是高阶函数呢?如果一个函数可以接收另一个函数作为参数,且在执行后返回一个函数,这种函数就称为高阶函数。在 React 的社区生态中,有很多基于高阶函数设计的库,比如 reselector 就是其中之一。

思想一脉相承,React 团队在组件方向也汲取了同样的设计模式。源自高阶函数的高阶组件,可以同样优雅地抽取公共逻辑。

抽取公共逻辑

用一个常见的例子来说,就是登录态的判断。假设当前项目有订单页面、用户信息页面及购物车首页,那么对于订单页面与用户信息页面都需要检查当前是否已登录,如果没有登录,则应该跳转登录页。

一般的思路类似于:

  1. const checkLogin = () => {
  2. return !!localStorage.getItem('token')
  3. }
  4. class CartPage extends React.Component {
  5. ...
  6. }
  7. class UserPage extends React.Component {
  8. componentDidMount() {
  9. if(!checkLogin) {
  10. }
  11. }
  12. ...
  13. }
  14. class OrderPage extends React.Component {
  15. componentDidMount() {
  16. if(!checkLogin) {
  17. }
  18. }
  19. ...
  20. }

虽然已经抽取了一个函数,但还是需要在对应的页面添加登录态的判断逻辑。然而如果有高阶组件的话,情况会完全不同。

  1. const checkLogin = () => {
  2. return !!localStorage.getItem('token')
  3. }
  4. const checkLogin = (WrappedComponent) => {
  5. return (props) => {
  6. return checkLogin() ? <WrappedComponent {...props} /> : <LoginPage />;
  7. }
  8. class RawUserPage extends React.Component {
  9. ...
  10. }
  11. const UserPage = checkLogin(RawUserPage)
  12. @checkLogin
  13. class UserPage extends React.Component {
  14. ...
  15. }
  16. @checkLogin
  17. class OrderPage extends React.Component {
  18. ...
  19. }

从上面的例子中可以看出无论采用函数还是装饰器的写法,都使得重复代码量下降了一个维度。

还有一个非常经典的场景就是页面埋点统计。如果使用装饰器编写的话,大概是这样的:

  1. const trackPageView = (pageName) = {
  2. ...
  3. }
  4. const PV = (pageName) => {
  5. return (WrappedComponent) => {
  6. return class Wrap extends Component {
  7. componentDidMount() {
  8. trackPageView(pageName)
  9. }
  10. render() {
  11. return (
  12. <WrappedComponent {...this.props} />
  13. );
  14. }
  15. }
  16. };
  17. }
  18. @PV('用户页面')
  19. class UserPage extends React.Component {
  20. ...
  21. }
  22. @PV('购物车页面')
  23. class CartPage extends React.Component {
  24. ...
  25. }
  26. @PV('订单页面')
  27. class OrderPage extends React.Component {
  28. ..
  29. }

就连埋点这样的烦琐操作都变得优雅了起来。那我想同时使用 checkLogin 与 PV 怎么办呢?这里涉及到了一个新的概念,就是链式调用。

链式调用

由于高阶组件返回的是一个新的组件,所以链式调用是默认支持的。基于 checkLogin 与 PV 两个例子,链式使用是这样的:

  1. class RawUserPage extends React.Component {
  2. ...
  3. }
  4. const UserPage = checkLogin(PV('用户页面')(RawUserPage))
  5. @checkLogin
  6. @PV('用户页面')
  7. class UserPage extends React.Component {
  8. ...
  9. }

在链式调用后,装饰器会按照从外向内、从上往下的顺序进行执行。

除了抽取公用逻辑以外,还有一种修改渲染结果的方式,被称为渲染劫持。

渲染劫持

渲染劫持可以通过控制 render 函数修改输出内容,常见的场景是显示加载元素,如下情况所示:

  1. function withLoading(WrappedComponent) {
  2. return class extends WrappedComponent {
  3. render() {
  4. if(this.props.isLoading) {
  5. return <Loading />;
  6. } else {
  7. return super.render();
  8. }
  9. }
  10. };
  11. }

通过高阶函数中继承原组件的方式,劫持修改 render 函数,篡改返回修改,达到显示 Loading 的效果。

但高阶组件并非万能,它同样也有缺陷。

缺陷

丢失静态函数

由于被包裹了一层,所以静态函数在外层是无法获取的。如下面的案例中 getUser 是无法被调用的。

  1. @PV('用户页面')
  2. export default class UserPage extends React.Component {
  3. static getUser() {
  4. ...
  5. }
  6. }
  7. import UserPage from './UserPage'
  8. UserPage.checkLogin()

如果希望外界能够被调用,那么可以在 PV 函数中将静态函数复制出来。

  1. const PV = (pageName) => {
  2. return (WrappedComponent) => {
  3. class Wrap extends Component {
  4. componentDidMount() {
  5. trackPageView(pageName)
  6. }
  7. render() {
  8. return (
  9. <WrappedComponent {...this.props} />
  10. );
  11. }
  12. }
  13. Wrap.getUser = WrappedComponent.getUser;
  14. return Wrap;
  15. };
  16. }

这样做确实能解决静态函数在外部无法调用的问题,但一个类的静态函数可能会有很多,都需要一一手动复制么?其实也有更为简便的处理方案。社区中早就有了现成的工具,通过 hoist-non-react-statics 来处理,可以自动复制所有静态函数。如下代码所示。

  1. import hoistNonReactStatics from 'hoist-non-react-statics';
  2. const PV = (pageName) => {
  3. return (WrappedComponent) => {
  4. class Wrap extends Component {
  5. componentDidMount() {
  6. trackPageView(pageName)
  7. }
  8. render() {
  9. return (
  10. <WrappedComponent {...this.props} />
  11. );
  12. }
  13. }
  14. hoistNonReactStatics(Wrap, WrappedComponent);
  15. return Wrap;
  16. };
  17. }

虽然缺少官方的解决方案,但社区方案弥补了不足。除了静态函数的问题以外,还有 refs 属性不能透传的问题。

refs 属性不能透传

ref 属性由于被高阶组件包裹了一次,所以需要进行特殊处理才能获取。React 为我们提供了一个名为 React.forwardRef 的 API 来解决这一问题,以下是官方文档中的一个案例:

  1. function withLog(Component) {
  2. class LogProps extends React.Component {
  3. componentDidUpdate(prevProps) {
  4. console.log('old props:', prevProps);
  5. console.log('new props:', this.props);
  6. }
  7. render() {
  8. const {forwardedRef, ...rest} = this.props;
  9. return <Component ref={forwardedRef} {...rest} />;
  10. }
  11. }
  12. return React.forwardRef((props, ref) => {
  13. return <LogProps {...props} forwardedRef={ref} />;
  14. });
  15. }

这段代码读起来会有点儿头皮发麻,它正确的阅读顺序应该是从最底下的 React.forwardRef 部分开始,通过 forwardedRef 转发 ref 到 LogProps 内部。

工程实践

通过以上的梳理,接下来看一下如何在目录中给组件安排位置。

  1. src
  2. ├── components
  3. ├── basic
  4. ├── container
  5. └── hoc
  6. └── pages
  • 首先将最基本的展示组件放入 basic 目录中;
  • 然后将容器组件放入 container;
  • 高阶组件放入 hoc 中;
  • 将页面外层组件放在页面目录中;
  • 通过目录级别完成切分。

在开发中,针对 basic 组件,建议使用类似 Storybook 的工具进行组件管理。因为 Storybook 可以有组织地、高效地构建基础组件,有兴趣的话可以查阅下它的官网

答题

通过以上的归类分析,关于 React 组件设计,我们的脑海中就有比较清晰的认知了。

React 组件应从设计与工程实践两个方向进行探讨。

从设计上而言,社区主流分类的方案是展示组件与灵巧组件。

展示组件内部没有状态管理,仅仅用于最简单的展示表达。展示组件中最基础的一类组件称作代理组件。代理组件常用于封装常用属性、减少重复代码。很经典的场景就是引入 Antd 的 Button 时,你再自己封一层。如果未来需要替换掉 Antd 或者需要在所有的 Button 上添加一个属性,都会非常方便。基于代理组件的思想还可以继续分类,分为样式组件与布局组件两种,分别是将样式与布局内聚在自己组件内部。

灵巧组件由于面向业务,其功能更为丰富,复杂性更高,复用度低于展示组件。最经典的灵巧组件是容器组件。在开发中,我们经常会将网络请求与事件处理放在容器组件中进行。容器组件也为组合其他组件预留了一个恰当的空间。还有一类灵巧组件是高阶组件。高阶组件被 React 官方称为 React 中复用组件逻辑的高级技术,它常用于抽取公共业务逻辑或者提供某些公用能力。常用的场景包括检查登录态,或者为埋点提供封装,减少样板代码量。高阶组件可以组合完成链式调用,如果基于装饰器使用,就更为方便了。高阶组件中还有一个经典用法就是反向劫持,通过重写渲染函数的方式实现某些功能,比如场景的页面加载圈等。但高阶组件也有两个缺陷,第一个是静态方法不能被外部直接调用,需要通过向上层组件复制的方式调用,社区有提供解决方案,使用 hoist-non-react-statics 可以解决;第二个是 refs 不能透传,使用 React.forwardRef API 可以解决。

从工程实践而言,通过文件夹划分的方式切分代码。我初步常用的分割方式是将页面单独建立一个目录,将复用性略高的 components 建立一个目录,在下面分别建立 basic、container 和 hoc 三类。这样可以保证无法复用的业务逻辑代码尽量留在 Page 中,而可以抽象复用的部分放入 components 中。其中 basic 文件夹放展示组件,由于展示组件本身与业务关联性较低,所以可以使用 Storybook 进行组件的开发管理,提升项目的工程化管理能力。

还可以通过以下知识导图来检验你的学习成果,看是否能将每部分补充完整。

05 | 如何设计 React 组件? - 图3

进阶

“如何在渲染劫持中为原本的渲染结果添加新的样式?” 这个问题也经常被追问,其实并不难,但是有可能考察手写代码,所以这里我会做一些提示。

首先回滚上面的案例,在调用 super.render 的时候就可以拿到原本的渲染结果。

  1. function withLoading(WrappedComponent) {
  2. return class extends WrappedComponent {
  3. render() {
  4. if(this.props.isLoading) {
  5. return <Loading />;
  6. } else {
  7. return super.render();
  8. }
  9. }
  10. };
  11. }

那 super.render() 返回的是什么呢?你可以结合 JSX 一讲中的内容思考下。

总结

在本讲中主要对 React 组件的设计模式进行了梳理与回顾,并探讨了设计模式在工程实践中的作用。

在面试中面试官不仅希望听到设计模式有哪些,社区的推荐方式有哪些,更希望听到模式具体用在哪儿。如果你知道具体的场景,就会显得更有经验。设计模式并非有确定的标准答案,社区流行的分类方式也并非万能。如果你有自己的见解,在面试中与面试官进行探讨,也是非常值得鼓励的。

下一讲我将会介绍 React 中的一个关于 setState 的经典面试题:“setState 是同步更新还是异步更新”。


05 | 如何设计 React 组件? - 图4
《大前端高薪训练营》

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