前端面试宝典React篇05 如何设计 React 组件?

287 篇文章 96 订阅

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

破题

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

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

  • 将一个页面写成一个组件;

  • 一个组件包含两三千行的代码。

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

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

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

承题

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

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

  • 把只作展示、独立运行、不额外增加功能的组件,称为哑组件,或无状态组件,还有一种叫法是展示组件

  • 把处理业务逻辑与数据状态的组件称为有状态组件,或灵巧组件,灵巧组件一定包含至少一个灵巧组件或者展示组件。

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

图片1.png

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

入题

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

.
├── README.md
├── package.json
├── public
│   ├── favicon.ico
│   ├── index.html
│   ├── logo192.png
│   ├── logo512.png
│   ├── manifest.json
│   └── robots.txt
├── src
│   ├── App.css
│   ├── App.js
│   ├── App.test.js
│   ├── index.css
│   ├── index.js
│   ├── logo.svg
│   ├── reportWebVitals.js
│   └── setupTests.js
└── yarn.lock

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

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

展示组件

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

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

代理组件

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

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

<button type="button">

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

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

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

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

import { Button as AntdButton } from from 'antd'
const Button = props =>
  <AntdButton size="small" type="primary" {...props}>

export default Button

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

  • 如果当前组件库不能使用了,是否能实现业务上的无痛切换;

  • 如果需要批量修改基础组件的字段,如何解决?

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

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

样式组件

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

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

const Button = props => (
  <button type="button" className="btn btn-primary">
)

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

import classnames from "classnames";

const StyleButton = ({ className, primary, isHighLighted, …props }) => (
  <Button
    type=“button”
    className={classnames(“btn”, {
btn-primary: primary,
highLight: isHighLighted,
}, className)}
    {…props}
  />
);

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

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

布局组件

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

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

<Layout
  Top={<NavigationBar />}
  Content={<Article />}
  Bottom={<BottomBar />}
/>

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

class Layout extends React.Component {
  shouldComponentUpdate() {
    return false;
  }
  render() {
    <div>
      <div>{this.props.NavigationBar}</div>
      <div>{this.props.Article}</div>
      <div>{this.props.BottomBar}</div>
    </div>
  }
}

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

灵巧组件

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

容器组件

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

const CardList = ({ cards }) => (
  <div>
    {cards.map(card => (
      <CardLayout
        header={<Avatar url={card.avatarUrl} />}
        Content={<Card {...card} />}
      />
        {comment.body}-{comment.author}
    ))}
  </div>
);

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

class CardListContainer extends React.Component {
  state = { cards: [] }
 
  async componentDidMount() {
    const response = await fetch('/api/cards')
    this.setState({cards: response})
  }
 
  render() {
    return <CardList cards={this.state.cards} />
  }
}

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

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

高阶组件

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

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

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

抽取公共逻辑

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

一般的思路类似于:

const checkLogin = () => {
  return !!localStorage.getItem('token')
}
class CartPage extends React.Component {
   ...
}
class UserPage extends  React.Component {
  componentDidMount() {
    if(!checkLogin) {
      // 重定向跳转登录页面
    }
  }
  ...
}
class OrderPage extends  React.Component {
  componentDidMount() {
    if(!checkLogin) {
      // 重定向跳转登录页面
    }
  }
  ...
 }

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

const checkLogin = () => {
  return !!localStorage.getItem('token')
}
const checkLogin = (WrappedComponent) => {
          return (props) => {
              return checkLogin() ? <WrappedComponent {...props} /> : <LoginPage />;
          }
// 函数写法
class RawUserPage extends  React.Component {
  ...
}
const UserPage = checkLogin(RawUserPage)
// 装饰器写法
@checkLogin
class UserPage extends  React.Component {
  ...
}
@checkLogin
class OrderPage extends  React.Component {
  ...
}

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

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

const trackPageView = (pageName) = { 
   // 发送埋点信息请求
   ... 
}
const PV = (pageName) => {
  return (WrappedComponent) => {
    return class Wrap extends Component {
      componentDidMount() {
        trackPageView(pageName)
      }
  render() {
    <span class="hljs-keyword">return</span> (
      <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">WrappedComponent</span> {<span class="hljs-attr">...this.props</span>} /&gt;</span></span>
    );
  }
}

};
}
@PV(‘用户页面’)
class UserPage extends React.Component {

}
@PV(‘购物车页面’)
class CartPage extends React.Component {

}
@PV(‘订单页面’)
class OrderPage extends React.Component {

}

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

链式调用

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

// 函数调用方式
class RawUserPage extends React.Component {
  ...
}
const UserPage = checkLogin(PV('用户页面')(RawUserPage))
// 装饰器调用方式
@checkLogin
@PV('用户页面')
class UserPage extends  React.Component {
  ...
}

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

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

渲染劫持

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

 function withLoading(WrappedComponent) {
    return class extends WrappedComponent {
        render() {
            if(this.props.isLoading) {
                return <Loading />;
            } else {
                return super.render();
            }
        }
    };
}

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

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

缺陷

丢失静态函数

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

// UserPage.jsx
@PV('用户页面')
export default class UserPage extends  React.Component {
  static getUser() {
      ...
  } 
}
// page.js
import UserPage from './UserPage'
UserPage.checkLogin() // 调用失败,并不存在。

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

const PV = (pageName) => {
  return (WrappedComponent) => {
    class Wrap extends Component {
      componentDidMount() {
        trackPageView(pageName)
      }
  render() {
    <span class="hljs-keyword">return</span> (
      <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">WrappedComponent</span> {<span class="hljs-attr">...this.props</span>} /&gt;</span></span>
    );
  }
}
&nbsp;Wrap.getUser = WrappedComponent.getUser;
 <span class="hljs-keyword">return</span> Wrap;

};
}

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

import hoistNonReactStatics from 'hoist-non-react-statics';
const PV = (pageName) => {
  return (WrappedComponent) => {
    class Wrap extends Component {
      componentDidMount() {
        trackPageView(pageName)
      }
  render() {
    <span class="hljs-keyword">return</span> (
      <span class="xml"><span class="hljs-tag">&lt;<span class="hljs-name">WrappedComponent</span> {<span class="hljs-attr">...this.props</span>} /&gt;</span></span>
    );
  }
}
 hoistNonReactStatics(Wrap, WrappedComponent);
 <span class="hljs-keyword">return</span> Wrap;

};
}

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

refs 属性不能透传

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

function withLog(Component) {
  class LogProps extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('old props:', prevProps);
      console.log('new props:', this.props);
    }
    render() {
      const {forwardedRef, ...rest} = this.props;
      // 将自定义的 prop 属性 “forwardedRef” 定义为 ref
      return <Component ref={forwardedRef} {...rest} />;
    }
  }
  // 注意 React.forwardRef 回调的第二个参数 “ref”。
  // 我们可以将其作为常规 prop 属性传递给 LogProps,例如 “forwardedRef”
  // 然后它就可以被挂载到被 LogProps 包裹的子组件上。
  return React.forwardRef((props, ref) => {
    return <LogProps {...props} forwardedRef={ref} />;
  });
}

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

工程实践

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

src
├── components
│   ├── basic
│   ├── container
│   └── hoc
└── 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 进行组件的开发管理,提升项目的工程化管理能力。

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

图片2 (1).png

进阶

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

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

function withLoading(WrappedComponent) {
    return class extends WrappedComponent {
        render() {
            if(this.props.isLoading) {
                return <Loading />;
            } else {
                return super.render();
            }
        }
    };
}

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

总结

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

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

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

《大前端高薪训练营》


精选评论

**举:

高阶组件做渲染劫持,有大用,在 Saas 产品被实施交付给企业客户之后,往往需要在标准产品之上进行定制化开发,由于采用 SPA 的方式,前端代码被bundle了,客户有不能对源代码进行破坏性修改(不利于后续版本升级),因此基于 React 高阶函数的渲染劫持,可以推出无侵入的二次开发方案。即当组件检测到满足某个条件时,渲染定制化代码,否则渲染标准化代码。

**辉:

请教下老师,高阶组件实现的功能,在函数式组件中是否有类似的写法呢,或者说函数式组件的写法中是否有高阶组件呢

    讲师回复:

    可以的,这里篇幅有限不好展开说明,可以看这篇 《Higher Order Components in a React Hooks World》。

**者:

如何在渲染劫持中为原本的渲染结果添加新的样式:function">Wrapper(WrapperedComponent){“>return”>class">extends">WrapperedComponent{“>render(){”>const">element">super.render()“>//被冻结了,不可直接修改”>const">newElement">Object.assign({},“>element)”>const">newProps">Object.assign({},“>element.props)”>newProps.style{“>color:”>‘red’}“>newElement.props”>newProps">return">newElement}}}

*瑞:

super.render 对于函数组件有效吗

    讲师回复:

    函数组件没有 super.render,函数不是类哦

console_man:

老师太厉害了,有代码辅助说明,绝对的好文,谢谢老师。

*瑞:

shouldComponentUpdate 写死返回 false ,难道不会阻止子组件的重渲染吗?

    讲师回复:

    会阻止,这里的案例是要阻止父组件带来的影响。因为子组件会自行去拉取数据更新。

*盼:

思路真棒

  • 0
    点赞
  • 4
    收藏
    觉得还不错? 一键收藏
  • 打赏
    打赏
  • 0
    评论
Vue 和 React 是两种不同的 JavaScript 库,它们有不同的语法和设计理念,因此不能直接在 Vue 中使用 React 组件。但是,如果你想在 Vue 中使用 React 组件,可以考虑使用 Vue 的 `vue-reactive-renderer` 插件。 `vue-reactive-renderer` 是一个 Vue 插件,它可以将 React 组件包装为 Vue 组件,从而实现在 Vue 中使用 React 组件的功能。以下是使用 `vue-reactive-renderer` 在 Vue 中使用 React 组件的步骤: 1. 安装 `vue-reactive-renderer` 依赖:`npm install vue-reactive-renderer` 2. 在 Vue 组件中引入 `vue-reactive-renderer`: ```javascript import VueReactiveRenderer from 'vue-reactive-renderer' ``` 3. 创建一个 React 组件: ```jsx import React from 'react' const MyReactComponent = () => ( <div> <h1>Hello, React!</h1> </div> ) export default MyReactComponent ``` 4. 包装 React 组件为 Vue 组件: ```javascript const MyVueComponent = { components: { ReactComponent: VueReactiveRenderer(MyReactComponent) }, template: ` <div> <h1>Hello, Vue!</h1> <ReactComponent /> </div> ` } ``` 5. 在 Vue 中使用包装后的组件: ```html <template> <div> <MyVueComponent /> </div> </template> <script> import MyVueComponent from './MyVueComponent.vue' export default { components: { MyVueComponent } } </script> ``` 这样就可以在 Vue 中使用 React 组件了。需要注意的是,因为 Vue 和 React 是两种不同的框架,它们之间的交互可能会受到一些限制和影响,因此在使用过程中需要进行适当的测试和调整。

“相关推荐”对你有帮助么?

  • 非常没帮助
  • 没帮助
  • 一般
  • 有帮助
  • 非常有帮助
提交
评论
添加红包

请填写红包祝福语或标题

红包个数最小为10个

红包金额最低5元

当前余额3.43前往充值 >
需支付:10.00
成就一亿技术人!
领取后你会自动成为博主和红包主的粉丝 规则
hope_wisdom
发出的红包

打赏作者

办公模板库 素材蛙

你的鼓励将是我创作的最大动力

¥1 ¥2 ¥4 ¥6 ¥10 ¥20
扫码支付:¥1
获取中
扫码支付

您的余额不足,请更换扫码支付或充值

打赏作者

实付
使用余额支付
点击重新获取
扫码支付
钱包余额 0

抵扣说明:

1.余额是钱包充值的虚拟货币,按照1:1的比例进行支付金额的抵扣。
2.余额无法直接购买下载,可以购买VIP、付费专栏及课程。

余额充值