React 设计模式

(一)前言

这里提到的设计模式并不是编程通用的设计模式,如常说的单例模式、抽象工厂模式、建造者模式、工厂模式、原型模式等。而是在设计 React 组件时的一些解决方案与技巧,包括以下几种:
(1) 容器与展示组件
(2) 高阶组件
(3) render props
(4) context 模式
(5) 组合组件
(6) 继承

当然概念部分,大家能根据名字猜出,但是我还是要为每种,单独给出demo详细说下。
设计模式本身是因为产品需求,解决特定的物业场景,并非完全需要按照这样,所以个人开发中建议形成自己的开发模式。

(二)容器与展示组件

这可能是目前应用最广泛,也是最简单的,大家最推崇的设计方案。其实大概意思就是,将组件分为两种。
一个为容器组件,负责与外部数据进行交互,比如处理业务逻辑(连接redux),
一个为展示组件,只通过props传递业务逻辑,state控制内部交互,不包含任何业务逻辑,也不与外部数据源(连接redux)进行沟通,现在我来举个例子。

需求: 当我们点击按钮后,会去接口拉去数据,并将返回的数据渲染到视图,当然这种需求,简单可以将全部视图写在一起,但是我们需要使用容器和展示组件的方式来组织代码,那么代码如下

// 容器组件 textContainer.js
import React from 'react';
import Text from './text';

class TextContainer extends React.Component {
  state = {
    text: '',
  };

  getData = () => {
    // 模拟异步请求
    setTimeout(() => {
      this.setState({ text: '测试数据' });
    }, 1000);
  };

  render() {
    const {
      state: {
        text,
      },
    } = this;
    return (
      
); } } export default TextContainer; // 展示组件 text.js import React from 'react'; import PropTypes from 'prop-types'; class Text extends React.PureComponent { render() { const { props: { text, onClick, }, } = this; return (
接口返回的数据: {text}
); } } // 这里我建议,不是必须传入的参数,尽量不使用isRequired验证 // 比如input value 肯定是必须的 但是如果不做受控组件,那么回调onChange就不是必须 Text.defaultProps = { text: '', onClick: () => null, }; Text.propTypes = { text: PropTypes.string, onClick: PropTypes.func, }; export default Text;

从上面代码我们看出,我们采用软件设计原则中的“责任分离”, 即让一个模块只负责责任尽量单一

容器展示组件这个模式所解决的问题在于,当我们切换数据获取方式时,只需在容器组件修改相应逻辑即可,展示组件无需做改动。展示组件可完全不变,展示组件有了更高的可复用性。

但该模式的缺点也在于将一个组件分成了两部分,增加了代码跳转的成本。并不是说组件包含从外部获取数据,就要将其拆成容器组件与展示组件。

(三)高阶组件

1.概念:
高阶组件不是组件,是 增强函数,可以输入一个元组件,返回出一个新的增强组件;
高阶组件的主要作用是 代码复用,操作状态和参数;

2. 分类(主要两个。一个属性代理,一个反向继承)

  1. 属性代理 : 返回出一个组件,它基于被包裹组件进行功能增强

这里有个获取display name的函数,下面新增的高阶组件都会调用这个方法,就不再次书写了

// 获取display name
function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

1.1 默认参数: 可以为组件包裹一层默认参数;

export function ProxyHoc(Component) {
  const NewComponent = (props) => {
    const newProps = {
      name: '高阶组件增加的属性',
      age: 1,
    };
    return ;
  };
  return NewComponent;
}

1.2 提取状态: 可以通过 props 将被包裹组件中的 state 依赖外层,例如用于转换受控组件:
现在我们来实现一个简单表单高阶组件,不包含表单验证。

// withForm.js
import React from 'react';
import hoistNonReactStatic from 'hoist-non-react-statics';
import { getDisplayName } from './utils';

export default function WithForm(Component) {
  class Enhance extends React.Component {
    static displayName = `WithForm(${getDisplayName(Component)})`;

    state = {
      form: {},
    };

    onChange = key => (e) => {
      const { form } = this.state;
      form[key] = e.target.value;
      this.setState({
        form,
      });
    };

    handleSubmit = () => this.state.form;

    getField = fieldName => ({
      onChange: this.onChange(fieldName),
    });

    render() {
      const newProps = {
        ...this.props,
        getForm: this.handleSubmit,
        setFormItem: this.getField,
      };

      return ();
    }
  }
  // 自动拷贝所有非 React 的静态方法
  hoistNonReactStatic(Enhance, Component);
  return Enhance;
}

我们需要使用这个表单控件时候,那么代码如下

import React from 'react';

// components
import HocForm from './components/hoc/hocForm';

// style
import './app.css';

class App extends React.Component {
  render() {
    const {
      props: { setFormItem, getForm },
    } = this;
    return (
      
表单数据: {JSON.stringify(getForm())}
); } } export default HocForm(App);

我们可以通过props,设置表单需要的key,最后调用getForm得到内部表单数据。

1.3 包裹组件: 可以为被包裹元素进行一层包装,

import React from 'react';
import hoistNonReactStatic from 'hoist-non-react-statics';
import { getDisplayName } from './utils';

export default function WithMask(Component) {
  class Enhance extends React.Component {
    static displayName = `WithMask(${getDisplayName(Component)})`;

    render() {
      return (
        
); } } // 自动拷贝所有非 React 的静态方法 hoistNonReactStatic(Enhance, Component); return Enhance; }

这里给mask封装来一层背景包裹组件。

  1. 反向继承 : 返回出一个组件,继承于被包裹组件,常用于以下操作:
export default function WithHocSimple(Component) {
  return class extends Component {
    render() {
      return super.render();
    }
  };
}

反向继承其实就是 一个函数接受一个 Component 组件作为参数传入,并返回一个继承了该传入 Component 组件的类,且在该类的 render() 方法中返回 super.render() 方法。

会发现其属性代理和反向继承的实现有些类似的地方,都是返回一个继承了某个父类的子类,只不过属性代理中继承的是 React.Component,反向继承中继承的是传入的组件 Component

反向继承可以用来做什么:

操作 state
渲染劫持(Render Highjacking)

2.1 操作state

高阶组件中可以读取、编辑和删除 WrappedComponent 组件实例中的 state。甚至可以增加更多的 state 项,但是 非常不建议这么做 因为这可能会导致 state 难以维护及管理。

import React from 'react';

export default function WithHocLoading(Component) {
  return class extends Component {
    render() {
      return (
        

Debugger Component Logging...

state:

{JSON.stringify(this.state, null, 4)}

props:

{JSON.stringify(this.props, null, 4)}
{super.render()}
); } }; }

在这个例子中利用高阶函数中可以读取 state 和 props 的特性,对 Component 组件做了额外元素的嵌套,把 Component 组件的 state 和 props 都打印了出来,

2.2 渲染劫持

我们来实现一个通过修改由 render() 输出的 React 元素树

import React from 'react';

export default function WithTree(Component) {
  return class extends Component {
    render() {
      const tree = super.render();
      const newProps = {};
      if (tree && tree.type === 'div') {
        newProps.className = 'App add-view';
      }
      const props = {
        ...tree.props,
        ...newProps,
      };
      const newTree = React.cloneElement(tree, props, tree.props.children);
      return newTree;
    }
  };
}

模式所解决的问题
同样的逻辑我们总不能重复写多次。高阶组件起到了抽离共通逻辑的作用。同时高阶组件的嵌套使用使得代码复用更加灵活了。

注意以下几点

  1. 包装显示名称以便轻松调试(定义displayName)
  2. 不要在 render 方法中使用 HOC(会导致每次都重新渲染整个HOC,而且之前的状态会丢失)
  3. 务必复制静态方法(hoist-non-react-statics库解决)
  4. Refs 不会被传递(使用React.forwardRef解决)

(四)render props
概念:
指一种在 React 组件之间使用一个值为函数的prop来共享代码的简单技术。同高阶组件一样,render props的引入也是为了解决复用业务逻辑。

需求:
我们实现一个判断性别的组件,提供给购物车和商品列表组件使用,将共用性别判断逻辑抽离出来。

// providerGender.js
import React from 'react';
import PropTypes from 'prop-types';

const ProviderGender = (props) => {
  // 判断是否是女性用户
  const isWoman = Math.random() > 0.1;
  if (isWoman) {
    const allProps = { add: '高阶组件增加的属性', ...props };
    return props.children(allProps);
  }
  return 
女士专用,男士无权浏览
; }; ProviderGender.defaultProps = { children: () => null, }; ProviderGender.propTypes = { children: PropTypes.func, }; export default ProviderGender;

然后我们分别定义商品列表组件

// list.js
import React from 'react';
import PropTypes from 'prop-types';

const List = ({ add, name }) => (
  
{`${name}:`} {add}
); List.defaultProps = { name: '', add: '', }; List.propTypes = { name: PropTypes.string, add: PropTypes.string, }; export default List;

现在我们来该如何使用两者组合

import React from 'react';

// components
// render props
import ProviderGender from './components/renderProps/providerGender';
import List from './demo/shoppingPropsDemo';

// style
import './app.css';

class App extends React.Component {
  state = {
    name: '商品名称列表',
  };

  onToggleList = () => {
    this.setState(state => ({
      ...state,
      name: state.name === '商品名称列表' ? '购物车列表' : '商品名称列表',
    }));
  };

  render() {
    const {
      state: { name },
    } = this;
    return (
      
{props => }
); } } export default App;

上面代码中可以看出,属性代理的方案高阶组件也能实现,但是 render props 却有更加强大的一个点就是,因为可以在render中,所以能自定义给list组件,添加新的props,而不需要修改ProviderGender组件,这是高阶不能在render中调用,所不具备的最大优势。

当然,render props 的使用可以不局限在利用 children,组件任意的 prop 属性都可以达到相同效果,比如我们用 renderChildren 这个 prop 实现上面相同的效果。

比如我们将children改为renderChildren函数。

// providerGender.js
import React from 'react';
import PropTypes from 'prop-types';

const ProviderGender = (props) => {
  // 判断是否是女性用户
  const isWoman = Math.random() > 0.1;
  if (isWoman) {
    const allProps = { add: '高阶组件增加的属性', ...props };
    return props.renderChildren(allProps);
  }
  return 
女士专用,男士无权浏览
; }; ProviderGender.defaultProps = { renderChildren: () => null, }; ProviderGender.propTypes = { renderChildren: PropTypes.func, }; export default ProviderGender; // app.js class App extends React.Component { render() { const { state: { name }, } = this; renturn (
} />
) } }

注意事项:

将 Render Props 与 React.PureComponent 一起使用时要小心!如果你在 Provider 属性中创建函数,那么使用 render props 会抵消使用React.PureComponent 带来的优势。因为浅比较 props 的时候总会得到 false,并且在这种情况下每一个 render 对于 render props 将会生成一个新的值。

比如上面例子就是在每次render,重新生成一个新的函数作为 的 prop。那么解决方案就是,将匿名函数,提到外层组件的方法

class App extends React.Component {
  state = {
    name: '商品名称列表',
  };

  onToggleList = () => {
    this.setState(state => ({
      ...state,
      name: state.name === '商品名称列表' ? '购物车列表' : '商品名称列表',
    }));
  };

  renderList = (props) => {
    const {
      state: { name },
    } = this;
    return (
      
    );
  };

  render() {
    return (
      
{this.renderList}
); } }

(五)Context模式
概念:
React 的 Context 接口提供了一个无需为每层组件手动添加 props ,就能在组件树间进行数据传递的方法。

// themeProvider.js
import React from 'react';
import PropTypes from 'prop-types';

const ThemeContext = React.createContext();

const ThemeProvider = ThemeContext.Provider;

export const ThemeConsumer = ThemeContext.Consumer;

const Context = ({ value, children }) => (
  
{children}
); Context.defaultProps = { value: {}, }; Context.propTypes = { value: PropTypes.objectOf(PropTypes.any), children: PropTypes.node.isRequired, }; export default Context; // consumer.js import React from 'react'; // import PropTypes from 'prop-types'; import { ThemeConsumer } from '../components/context/themeProvider'; const Consumer = () => ( { theme => (

内容区域

) }
); Consumer.defaultProps = {}; Consumer.propTypes = {}; export default Consumer;

在app.js中调用为

import React from 'react';

// context
import ThemeProvider from './components/context/themeProvider';
import Consumer from './demo/consumer';

// style
import './app.css';

class App extends React.Component {
  render() {
    return (
      
        
); } } export default App;

我们看到,consumer.js中并直接传递props,通过ThemeContext.Consumer直接读取到context中的theme。

优势:
Context 主要应用场景在于很多不同层级的组件需要访问同样一些的数据,可以跨组件访问一样的数据。

(五)组合组件模式

借用组合组件,使用者只需要传递子组件,子组件所需要的props在父组件会封装好,引用子组件的时候就没必要传递所有props了。

组合组件核心的两个方法是React.Children.mapReact.cloneElement。React.Children.map 用来遍历获得组件的子元素。React.cloneElement 则用来复制元素,这个函数第一个参数就是被复制的元素,第二个参数可以增加新产生元素的 props ,我们就是利用这个函数,把想要的 props 传入子元素。

现在我们来实现一个antd的tabs组件,我们先看他如何使用的

import { Tabs } from 'antd';

const { TabPane } = Tabs;

function callback(key) {
  console.log(key);
}

ReactDOM.render(
  
    
      Content of Tab Pane 1
    
    
      Content of Tab Pane 2
    
    
      Content of Tab Pane 3
    
  ,
  mountNode,
);

首先我们实现tabs

import React from 'react';
import PropTypes from 'prop-types';

class Tabs extends React.Component {
  state = {
    activeIndex: 0,
  };

  onClickItem = (index) => {
    this.setState({ activeIndex: index });
    this.props.onChange(index);
  };

  renderHeaderItem = () => {
    const {
      state: { activeIndex },
      props: { children },
    } = this;
    return React.Children.map(children, (childElem, index) => {
      if (!childElem.type) return null;
      const active = activeIndex === index;
      return (
        
this.onClickItem(index)} > {childElem.props.tab}
); }); }; render() { const { state: { activeIndex }, props: { children }, } = this; return (
{this.renderHeaderItem()}
{ React.Children.map(children, (childElem, index) => { if (!(childElem.type && activeIndex === index)) return null; return React.cloneElement(childElem, { activeIndex, index, }); }) }
); } } Tabs.defaultProps = { onChange: () => null, }; Tabs.propTypes = { children: PropTypes.node.isRequired, onChange: PropTypes.func, }; export default Tabs;

然后实现tabPane.js

import React from 'react';
import PropTypes from 'prop-types';

const TabPane = ({ children }) => (
  
{children}
); TabPane.defaultProps = { // activeIndex: 0, // index: 0, children: '', }; TabPane.propTypes = { // activeIndex: PropTypes.number, // index: PropTypes.number, children: PropTypes.node, }; export default TabPane;

在app.js中调用为

import React from 'react';

// tabs
import Tabs from './components/tabs/tabs';
import TabPane from './components/tabs/tabPane';

// style
import './app.css';

class App extends React.Component {
  render() {
    return (
      
内容1
内容2
内容3
); } } export default App;

模式所解决的问题

组合组件设计模式一般应用在一些共享组件上。如 select 和 option , Tab 和TabItem 等,通过组合组件,使用者只需要传递子组件,子组件所需要的 props 在父组件会封装好,引用子组件的时候就没必要传递所有 props 了。我们可以在共享的组件中运用这种模式,简化组件使用者的调用方式,antd 当中你就能看到许多组合组件的使用。

(六)继承模式

我们最后来谈谈OOP的继承模式。

如果组件定义为class组件,那么我们当然可以使用继承的模式来实现组件的复用。我们通过一个基类来实现一些通用的逻辑,然后再通过继承分别实现两个子类。

// base.js
import React from 'react';

class Base extends React.PureComponent {
  getHeader = () => null;

  render() {
    return (
      
{this.getHeader()}
这里是通用逻辑
); } } export default Base; // demo.js import React from 'react'; // import PropTypes from 'prop-types'; // components import Base from '../components/oop/base'; class Demo1 extends Base { getHeader = () => demo1; } class Demo2 extends Base { getHeader = () => Demo2; } export { Demo1, Demo2 };

从上面代码可以看出,可以将通用逻辑封装在基本类中,通过继承,实现方法,达到复用。

缺点:

  1. 父类的属性和方法,子类是无条件继承的。也就是说,不管子类愿意不愿意,都必须继承父类所有的属性和方法,这样就不够灵活了。
  2. js中class并不直接支持多继承。

这两个缺点使得继承相对于组合组件缺少了灵活性以及可扩展性。

(七)结语

通过上面例子我们总结常见的模式,但是请记住,组合优于继承!组件的复用请第一时间想到使用组合而非继承。

你可能感兴趣的:(React)