HOC、复合组件、render prop

2021-02-26  本文已影响0人  Small_Song

上一篇文章中我们领略到了软件开发原则和设计思想的魅力,介绍了 KISS、DRY、SOLID、正交设计 等软件设计原则,也介绍了 AOP 编程思想,以及他的两种实现方式,装饰器和中间件。今天主要介绍部分思想在 React 开发中的实现。

HOC 高阶组件

上一篇已经说到,装饰器是一个非常有用的模式,他可是用来装饰一个类或者方法,通过不修改原有代码的方式,扩展类和方法的功能(符合 open-close 原则),在 React 中,同样也存在类似的思想,那就是 HOC(Higher-order component) 高阶组件,通过修饰 React 组件,以包装和增强组件的功能。

HOC 并不是 React API,是一种设计模式,类似装饰器。具体而言,HOC 是一个函数,他接受一个组件,返回一个新组件。

const EnhancedComponent = higherOrderComponent(WrappedComponent);       

在 React 中,许多设计思想、哲学承自函数式编程,要理解 HOC,则不得不说他在函数式编程中对应的概念:高阶函数,他能接受一个函数,然后返回一个函数。HOC 也属于函数式编程思想,被包裹的组件不会感知到高阶组件的存在,高阶组件会返回一个增强原有组件的组件。下面是一个很简单的例子:

const higherOrderComponent = WrappedComponent => {
  console.log("higherOrderComponent");
  return class extends React.Component {
    render() {
      return <WrappedComponent {...this.props} />;
    }
  };
};

属性代理(Props Proxy)

HOC 有两种实现方式,一种就是上面的这种,属性代理(Props Proxy)的形式,通过包裹 WrappedComponent,同时传入 this.props。这个时候其实可以做很多事情:

const Enhance = WrappedComponent =>
  class extends Component {
    constructor() {
      this.state = { data: null };
    }
    componentDidMount() {
      this.setState({ data: 'Hello' });
    }
    render() {
      return <WrappedComponent {...this.props} data={this.state.data} />;
    }
  };

const higherOrderComponent = WrappedComponent => {
  return class extends React.Component {
    render() {
      return (
        <div>
          // Do whatever you want to do
          <span>some tips</span>
          <WrappedComponent {...this.props} />
        </div>
      );
    }
  };
};

当然也可以通过 refs 引用组件实例,具体参考 React Higher Order Components in depth

反向继承(Inheritance Inversion)

第二种实现方式是 反向继承(Inheritance Inversion)

const higherOrderComponent = WrappedComponent =>
  class extends WrappedComponent {
    render() {
      return super.render();
    }
  };

在这里有一层反转,本来应该是外层去包裹 WrappedComponent,类似于上面的属性代理方式,这里却是继承 WrappedComponent,所以这里看起来有种反转的关系,因此这种模式叫做反向继承。

因为是继承 WrappedComponent,所以我们可以取到所有的生命周期方法,state 和其他 function。在真正渲染的时候时候是调用 super.render(),实际上这个时候可以实现渲染劫持。举一个简单的例子,权限认证:

export default function Authorized(WrappedComponent) {
  return class extends WrappedComponent {
    render() {
      if (!auth.isAuthenticated()) {
        return <Login />;
      }
      const elements = super.render();
      return elements;
    }
  };
}

此时,完全可以通过劫持 super.render(),做你想做的任何事情,比如外层包个 div,加个 style。

这里的 Authorized 有一种非常简单的使用方式。

@Authorized
class PageComponent extends React.Component {
  // ...
}

对,就是装饰器,这个地方你可能有点熟悉感了,React-router 中的 withRouter 也是这么使用的,实际上,withRouter 内部是使用 HOC 中的属性代理(Props Proxy)方式实现的,同样的还有 connect

connect(mapStateToProps, mapDispatchToProps)(Component)

不过 connect 的实现会更高级一点,他是一个高阶函数,一个返回高阶组件的高阶函数。到这里,强烈建议你了解一下函数式编程

这种方式同时体现了 AOP 面向切面编程思想,关注点分离,可以将功能代码(权限管理、日志、持久化)从主业务逻辑代码中分离出来。也适用于在软件开发后期去增强和扩展现有组件的功能。

HOC 的耦合性很低,灵活性较高,可以非常方便的自由组合,适合于处理复杂的业务。当然当需求很简单的时候,可以使用 Container 容器组件去自由组合。

Compound Components 复合组件

Think of compound components like the <select> and <option> elements in HTML. Apart they don't do too much, but together they allow you to create the complete experience .
— Kent C. Dodds.

复合组件包含一组组件的状态和行为,但是同时将渲染的细节部分暴露给外部用户。

举个简单例子,实现 Tabs 组件

class Tabs extends React.Component {
  state = {
    activeIndex: 1
  };
  selectTabIndex(activeIndex) {
    this.setState({ activeIndex });
  }
  renderTabs() {
    return this.props.data.map((tab, index) => {
      const isAtive = this.state.activeIndex === index;
      return (
        <div
          className={isAtive ? 'activeTab' : 'tab'}
          onClick={() => this.selectTabIndex(index)}
        > {tab.label} </div>
      );
    });
  }
  renderPanel() {
    return this.props.data.map((tab, index) => {
      const isAtive = this.state.activeIndex === index;
      return (
        <div className={isAtive ? 'activePanel' : 'panel'}>{tab.content}</div>
      );
    });
  }
  render() {
    return (
      <div>
        <div className="tabs">{this.renderTabs()}</div>
        <div className="panels">{this.renderPanel()}</div>
      </div>
    );
  }
}

// use Tabs
const tabData = [
  { label: 'Tab A', content: 'This is Tab A' },
  { label: 'Tab B', content: 'This is Tab B' }
];
return <Tabs data={tabData} />;

这是常规的实现方式,非常简单,但是他的内部实现有一个弊端,结构太固定了,不方便扩展,比如

需求是多变的,所以 Tabs 组件除了封装状态和选项卡切换、高亮等交互行为以外,还要将个性化的渲染需求暴露给用户

<Tabs value={this.state.value} selectTabIndex={this.selectTabIndex}>
  <div className="right">
    <TabsList>
      <Tab index={0}>
        Tab A <Notification>5</Notification>
      </Tab>
      <Tab index={1}>Tab B</Tab>
    </TabsList>
  </div>
  <TabsPanels>
    <Panel index={0}>This is Tab A</Panel>
    <Panel index={1}>
      <div className="redText">Some tips for Tab B</div>
      This is Tab B
    </Panel>
  </TabsPanels>
</Tabs>

如此甚好,可以自由控制 tabs 和 panels 的位置,也可以针对 tab 和 panel 实现各种自定义渲染,对应的实现:

import React from 'react';

const TabsContext = React.createContext();

const Tab = ({ index, children }) => (
  <TabsContext.Consumer>
    {context => (
      <div
        className={context.activeIndex === index ? 'activeTab' : 'tab'}
        onClick={event => context.selectTabIndex(index, event)}
      >
        {children}
      </div>
    )}
  </TabsContext.Consumer>
);
const TabsList = ({ children }) => <div className="tabslist">{children}</div>;

const Panel = ({ index, children }) => (
  <TabsContext.Consumer>
    {context => (
      <div className={context.activeIndex === index ? 'activePanel' : 'panel'}>
        {children}
      </div>
    )}
  </TabsContext.Consumer>
);
const TabsPanels = ({ children }) => (
  <TabsContext.Consumer>
    {context => <div className="tabspanels">{children}</div>}
  </TabsContext.Consumer>
);

class Tabs extends React.Component {
  render() {
    const initialValue = {
      activeIndex: this.props.value,
      selectTabIndex: this.props.selectTabIndex
    };
    return (
      <TabsContext.Provider value={initialValue}>
        {this.props.children}
      </TabsContext.Provider>
    );
  }
}

复合组件提供了一种机制,让我们灵活的组合 React 组件,而不用关注组件的类型。

在 React 中使用这种模式,我们可以实现细粒度的渲染控制,而不用通过 props 属性传递或者 state,或者其他变量控制。

复合组件包含一组状态和行为,但是仍然可将其可变部分的渲染控制转交给外部用户控制。

A way to give rendering back to the user.

上面的实现中,我们依赖于连接 Context API 来共享应用程序中组件之间的状态。下面我们将探讨如何使用 render props 来实现相同的目标。

将函数作为 children 传入和 render prop

这一章节翻译了 react-in-patterns,react-in-patterns 中有很多 React 的模式,建议 React 开发者学习一遍。

在过去的几个月里,React 社区开始转向一个有趣的方向。在下面的实例中,children 属性是一个 React 组件,因此一种新的模式越来越瘦欢迎,那就是 children 属性作为一个 JSX 表达式。我们从传递一个简单对象实例开始。

function UserName({ children }) {
  return (
    <div>
      <b>{ children.lastName }</b>,
      { children.firstName }
    </div>
  );
}

function App() {
  const user = {
    firstName: 'Krasimir',
    lastName: 'Tsonev'
  };
  return (
    <UserName>{ user }</UserName>
  );
}

这看起来有点奇怪,但实际上它确实很强大。例如,当父组件的有些信息没有必要传递给子组件时。下面以打印 TODOs 待办列表为例。App 组件拥有全部的数据,而且知道如何确定待办事项是否完成。TODOList 组件只是简单的封装所需的 HTML 标签。

function TodoList({ todos, children }) {
  return (
    <section className='main-section'>
      <ul className='todo-list'>{
        todos.map((todo, i) => (
          <li key={ i }>{ children(todo) }</li>
        ))
      }</ul>
    </section>
  );
}

function App() {
  const todos = [
    { label: 'Write tests', status: 'done' },
    { label: 'Sent report', status: 'progress' },
    { label: 'Answer emails', status: 'done' }
  ];
  const isCompleted = todo => todo.status === 'done';
  return (
    <TodoList todos={ todos }>
      {
        todo => isCompleted(todo) ?
          <b>{ todo.label }</b> : todo.label
      }
    </TodoList>
  );
}

注意观察 App 组件是如何不暴露数据结构的,TodoList 完全不知道 labelstatus 属性的存在。

还有另外一种模式 — render prop 模式,和这种模式基本一致,唯一的区别是使用 render prop 而不是 children 去渲染 TODO。(仔细体会一下上下两段代码,唯一的区别就是渲染 TODO 时,将 children 换成 render

function TodoList({ todos, render }) {
  return (
    <section className='main-section'>
      <ul className='todo-list'>{
        todos.map((todo, i) => (
          <li key={ i }>{ render(todo) }</li>
        ))
      }</ul>
    </section>
  );
}

return (
  <TodoList
    todos={ todos }
    render={
      todo => isCompleted(todo) ?
        <b>{ todo.label }</b> : todo.label
    } />
);

function as childrenrender prop 是我最近最喜欢的两种模式。他提供了一种灵活性,来帮助我们复用代码。同时也是一种抽象命令式代码的强有力方式。

class DataProvider extends React.Component {
  constructor(props) {
    super(props);

    this.state = { data: null };
    setTimeout(() => this.setState({ data: 'Hey there!' }), 5000);
  }
  render() {
    if (this.state.data === null) return null;
    return (
      <section>{ this.props.render(this.state.data) }</section>
    );
  }
}

DataProvider 在首次挂载时不渲染任何内容。5 秒后我们更新了组件的状态,并渲染出一个 <section><section> 的内容是由 render 属性提供的。可以想象一下这种类型的组件,可以从远程服务器获取数据,并且决定什么时候渲染。

<DataProvider render={ data => <p>The data is here!</p> } />

我们描述我们想要做的事,而不是如何去做。细节都封装在了 DataProvider 中。最近,我们在工作中使用了这种模式,我们使用 render prop 模式,控制某些界面限制只对具有 read:products 权限的用户开放。

<Authorize
  permissionsInclude={[ 'read:products' ]}
  render={ () => <ProductsList /> } />

这种声明式的方式相当优雅,不言自明。Authorize 会进行认证,检查当前用户是否具有权限。如果用户具有读取产品列表的权限,那么我们便渲染 ProductList 。(大家可以比对一下 render prop 和 HOC 实现 Authorize 的差异,其实都是 AOP 编程思想的体现)

总结

上一篇介绍了一些重要的软件开发原则和设计思想,这篇主要讲了部分思想在 React 中的实现。具体介绍了 HOC、复合组件、render prop,这些方案都可以非常方便的实现关注分离,极大的提高了组件的扩展性和复用性。

这便是 React 一个最大的优势,就是可组合性。就个人而言,我还不知道还有哪个框架能提供如此简单的创建组件和复用组件的能力。

但是这种写法与我们常见的写法向左。虽然具有诸多好处,但是我想强调一下:不要过于复杂,Keep it simple & stupid。

复合组件和 render prop 会将拆散的很零碎,以 Tabs 为例,我们本可以很简单的使用,只需要传一个 data 属性即可

<Tabs data={tabData} />

采用复合组件方案实现以后,调用时变得很麻烦,我们需要了解 Tabs 的诸多 API 接口,Tabs、TabsList、Tab、TabsPanels、Panel、index、value,而且有许多实现细节没有被封装到组件内部,而是在调用时编写。

这违背了迪米特法则,也叫做最少知识原则,通俗的讲,一个对象应该对其他对象应该有最少的了解,就像原始的 Tabs 一样,只需要传递 data 就很好。

这个时候我们需要做好权衡,组件是否真的需要使用这些模式来实现。

不仅是工作中,在生活中,各个地方,我们都需要保持平衡。Everything is a trade-off.

在系统设计时,我们应该使用合适的方案,不宜过度设计来炫耀,而是要务实,编写易于理解和维护的代码。

参考:https://zhuanlan.zhihu.com/p/58228892

上一篇下一篇

猜你喜欢

热点阅读