1

前言

最近在学习React的封装,虽然日常的开发中也有用到HOC或者Render Props,但从继承到组合,静态构建到动态渲染,都是似懂非懂,索性花时间系统性的整理,如有错误,请轻喷~~

例子

以下是React官方的一个例子,我会采用不同的封装方法来尝试代码复用,例子地址

组件在 React 是主要的代码复用单元,但如何共享状态或一个组件的行为封装到其他需要相同状态的组件中并不是很明了
例如,下面的组件在 web 应用追踪鼠标位置:

class MouseTracker extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        <h1>Move the mouse around!</h1>
        <p>The current mouse position is ({this.state.x}, {this.state.y})</p>
      </div>
    );
  }
}

随着鼠标在屏幕上移动,在一个 <p> 的组件上显示它的 (x, y) 坐标。

现在的问题是:我们如何在另一个组件中重用行为?换句话说,若另一组件需要知道鼠标位置,我们能否封装这一行为以让能够容易在组件间共享?

由于组件是 React 中最基础的代码重用单元,现在尝试重构一部分代码能够在<Mouse> 组件中封装我们需要在其他地方的行为。

// The <Mouse> component encapsulates the behavior we need...
class Mouse extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>

        {/* ...but how do we render something other than a <p>? */}
        <p>The current mouse position is ({this.state.x}, {this.state.y})</p>
      </div>
    );
  }
}

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <Mouse />
      </div>
    );
  }
}

现在 <Mouse> 组件封装了所有关于监听 mousemove 事件和存储鼠标 (x, y) 位置的行为,但其仍不失真正的可重用。

例如,假设我们现在有一个在屏幕上跟随鼠标渲染一张猫的图片的 <Cat> 组件。我们可能使用 <Cat mouse={{ x, y }} prop 来告诉组件鼠标的坐标以让它知道图片应该在屏幕哪个位置。

首先,你可能会像这样,尝试在 <Mouse> 的内部的渲染方法 渲染 <Cat> 组件:

class Cat extends React.Component {
  render() {
    const mouse = this.props.mouse
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
    );
  }
}

class MouseWithCat extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>

        {/*
          We could just swap out the <p> for a <Cat> here ... but then
          we would need to create a separate <MouseWithSomethingElse>
          component every time we need to use it, so <MouseWithCat>
          isn't really reusable yet.
        */}
        <Cat mouse={this.state} />
      </div>
    );
  }
}

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <MouseWithCat />
      </div>
    );
  }
}

这一方法对我们的具体用例来说能够生效,但我们却没法实现真正的将行为封装成可重用的方式的目标。现在,每次我们在不同的用例中想要使用鼠标的位置,我们就不得不创建一个新的针对那一用例渲染不同内容的组件 (如另一个关键的 <MouseWithCat>)

Mixin

Mixin概念

React Mixin将通用共享的方法包装成Mixins方法,然后注入各个组件实现,事实上已经是不被官方推荐使用了,但仍然可以学习一下,了解其为什么被遗弃,先从API看起。
React Mixin只能通过React.createClass()使用, 如下:

var mixinDefaultProps = {}
var ExampleComponent = React.createClass({
    mixins: [mixinDefaultProps],
    render: function(){}
});

Mixin实现

// 封装的Mixin
const mouseMixin = {
  getInitialState() {
    return {
      x: 0,
      y: 0
    }
  },
  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    })
  }
}

const Mouse = createReactClass({
  mixins: [mouseMixin],
  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        <p>The current mouse position is ({this.state.x}, {this.state.y})</p>
      </div>
    )
  }
})

const Cat = createReactClass({
  mixins: [mouseMixin],
  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        <img src="/cat.jpg" style={{ position: 'absolute', left: this.state.x, top: this.state.y }} alt="" />
      </div>
    )
  }
})

Mixin的问题

然而,为什么Mixin会被不推荐使用?归纳起来就是以下三点

1. Mixin引入了隐式依赖关系 如:

你可能会写一个有状态的组件,然后你的同事可能会添加一个读取这个状态的mixin。在几个月内,您可能需要将该状态移至父组件,以便与兄弟组件共享。你会记得更新mixin来读取道具吗?如果现在其他组件也使用这个mixin呢?

2. Mixin导致名称冲突 如:

你在该Mixin定义了getSomeName, 另外一个Mixin又定义了同样的名称getSomeName, 造成了冲突。

3. Mixin导致复杂的滚雪球

随着时间和业务的增长, 你对Mixin的修改越来越多, 到最后会变成一个难以维护的Mixin。

4. 拥抱ES6,ES6的class不支持Mixin

HOC

HOC概念

高阶组件(HOC)是react中的高级技术,用来重用组件逻辑。但高阶组件本身并不是React API。它只是一种模式,这种模式是由react自身的组合性质必然产生的,是React社区发展中产生的一种模式
高阶组件的名称是从高阶函数来的, 如果了解过函数式编程, 就会知道高阶函数就是一个入参是函数,返回也是函数的函数,那么高阶组件顾名思义,就是一个入参是组件,返回也是组件的函数,如:

const EnhancedComponent = higherOrderComponent(WrappedComponent);

HOC实现

高阶组件在社区中, 有两种使用方式, 分别是:

其中 W (WrappedComponent) 指被包裹的 React.Component,E (EnhancedComponent) 指返回类型为 React.Component 的新的 HOC。
  • Props Proxy: HOC 对传给 WrappedComponent W 的 porps 进行操作。
  • Inheritance Inversion: HOC 继承 WrappedComponent W。

依然是使用之前的例子, 先从比较普通使用的Props Proxy看起:

class Mouse extends React.Component {
  render() {
    const { x, y } = this.props.mouse
    return (
      <p>The current mouse position is ({x}, {y})</p>
    )
  }
}

class Cat extends React.Component {
  render() {
    const { x, y } = this.props.mouse
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: x, top: y }} alt="" />
    )
  }
}


const MouseHoc = (MouseComponent) => {
  return class extends React.Component {
    constructor(props) {
      super(props)
      this.handleMouseMove = this.handleMouseMove.bind(this)
      this.state = { x: 0, y: 0 }
    }

    handleMouseMove(event) {
      this.setState({
        x: event.clientX,
        y: event.clientY
      })
    }
    render() {
      return (
        <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
          <MouseComponent mouse={this.state} />
        </div>
      )
    }
  }
}

const EnhanceMouse = MouseHoc(Mouse)
const EnhanceCat = MouseHoc(Cat)

那么在Hoc的Props Proxy模式下, 我们可以做什么?

操作Props
如上面的MouseHoc, 假设在日常开发中,我们需要传入一个props给Mouse或者Cat,那么我们可以在HOC里面对props进行增删查改等操作,如下:

const MouseHoc = (MouseComponent, props) => {
  props.text = props.text + '---I can operate props'
  return class extends React.Component {
    ......
    render() {
      return (
        <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
          <MouseComponent {...props} mouse={this.state} />
        </div>
      )
    }
  }
}
MouseHoc(Mouse, {
  text: 'some thing...'
})

通过 Refs 访问组件实例

function refsHOC(WrappedComponent) {
  return class RefsHOC extends React.Component {
    proc(wrappedComponentInstance) {
      wrappedComponentInstance.method()
    }

    render() {
      const props = Object.assign({}, this.props, {ref: this.proc.bind(this)})
      return <WrappedComponent {...props}/>
    }
  }
}

提取state
就是我们的例子。

<MouseComponent mouse={this.state} />

包裹 WrappedComponent

<div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
    <MouseComponent mouse={this.state} />
</div>

另外一种HOC模式则是Inheritance Inversion,不过该模式比较少见,一个最简单的例子如下:

function iiHOC(WrappedComponent) {
  return class Enhancer extends WrappedComponent {
    render() {
      return super.render()
    }
  }
}
你可以看到,返回的 HOC 类(Enhancer)继承了 WrappedComponent。之所以被称为 Inheritance Inversion 是因为 WrappedComponent 被 Enhancer 继承了,而不是 WrappedComponent 继承了 Enhancer。在这种方式中,它们的关系看上去被反转(inverse)了。Inheritance Inversion 允许 HOC 通过 this 访问到 WrappedComponent,意味着它可以访问到 state、props、组件生命周期方法和 render 方法

那么在我们的例子中它是这样的:

class Mouse extends React.Component {
  render(props) {
    const { x, y } = props.mouse
    return (
      <p>The current mouse position is ({x}, {y})</p>
    )
  }
}

class Cat extends React.Component {
  render(props) {
    const { x, y } = props.mouse
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: x, top: y }} alt="" />
    )
  }
}


const MouseHoc = (MouseComponent) => {
  return class extends MouseComponent {
    constructor(props) {
      super(props)
      this.handleMouseMove = this.handleMouseMove.bind(this)
      this.state = { x: 0, y: 0 }
    }

    handleMouseMove(event) {
      this.setState({
        x: event.clientX,
        y: event.clientY
      })
    }
    render() {
      const props = {
        mouse: this.state
      }
      return (
        <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
          {super.render(props)}
        </div>
      )
    }
  }
}

const EnhanceMouse = MouseHoc(Mouse)
const EnhanceCat = MouseHoc(Cat)

同样, 在II模式下,我们能做些什么呢?

渲染劫持
因为render()返回的就是JSX编译后的对象,如下:
image

可以通过手动修改这个tree,来达到一些需求效果,不过这通常不会用到:

function iiHOC(WrappedComponent) {
  return class Enhancer extends WrappedComponent {
    render() {
      const elementsTree = super.render()
      let newProps = {};
      if (elementsTree && elementsTree.type === 'input') {
        newProps = {value: 'may the force be with you'}
      }
      const props = Object.assign({}, elementsTree.props, newProps)
      const newElementsTree = React.cloneElement(elementsTree, props, elementsTree.props.children)
      return newElementsTree
    }
  }
}

操作 state

HOC 可以读取、编辑和删除 WrappedComponent 实例的 state,如果你需要,你也可以给它添加更多的 state。记住,这会搞乱 WrappedComponent 的 state,导致你可能会破坏某些东西。要限制 HOC 读取或添加 state,添加 state 时应该放在单独的命名空间里,而不是和 WrappedComponent 的 state 混在一起。
export function IIHOCDEBUGGER(WrappedComponent) {
  return class II extends WrappedComponent {
    render() {
      return (
        <div>
          <h2>HOC Debugger Component</h2>
          <p>Props</p> <pre>{JSON.stringify(this.props, null, 2)}</pre>
          <p>State</p><pre>{JSON.stringify(this.state, null, 2)}</pre>
          {super.render()}
        </div>
      )
    }
  }
}

为什么有Class而不去使用继承返回来使用HOC

可能有人看到这里会有疑惑,为什么有Class而不去使用继承返回来使用HOC, 这里推荐知乎的一个比较好的答案

OOP和FP并不矛盾,所以混着用没毛病,很多基于FP思想的库也需要OOP来搭建。
为什么React推崇HOC和组合的方式,我的理解是React希望组件是按照最小可用的思想来进行封装的,理想的说,就是一个组件只做一件的事情,且把它做好,DRY。在OOP原则,这叫单一职责原则。如果要对组件增强,首先应该先思路这个增强的组件需要用到哪些功能,这些功能由哪些组件提供,然后把这些组件组合起来.

image

D中A相关的功能交由D内部的A来负责,D中B相关的功能交由D内部的B来负责,D仅仅负责维护A,B,C的关系,另外也可以额外提供增加项,实现组件的增强。

继承没有什么不好,注意,React只是推荐,但没限制。其实用继承来扩展组件也没问题,而且也存在这样的场景。比如:有一个按钮组件,仅仅是对Button进行一个包装,我们且叫它Button,可是,按照产品需求,很多地方的按钮都是带着一个icon的,我们需要提供一个IconButton。这是时候,就可以通过继承来扩展,同时组合另外一个独立的组件,我们且叫它Icon,显示icon的功能交给Icon组件来做,原来按钮的功能继续延续着。对于这种同类型组件的扩展,我认为用继承的方式是没关系的,灵活性,复用性还在。
但是,用继承的方式扩展前,要先思考,新组件是否与被继承的组件是不是同一类型的,同一类职责的。如果是,可以继承,如果不是,那么就用组合。怎么定义同一类呢,回到上面的Button的例子,所谓同一类,就是说,我直接用IconButton直接替换掉Button,不去改动其他代码,页面依然可以正常渲染,功能可以正常使用,就可以认为是同一类的,在OOP中,这叫做里氏替换原则。

继承会带来什么问题,以我的实践经验,过渡使用继承,虽然给编码带来便利,但容易导致代码失控,组件膨胀,降低组件的复用性。比如:有一个列表组件,叫它ListView吧,可以上下滚动显示一个item集,突然有一天需求变了,PM说,我要这个ListView能像iOS那样有个回弹效果。好,用继承对这个ListView进行扩展,加入了回弹效果,任务closed。第二天PM找上门来了,希望所有上下滚动的地方都可以支持回弹效果,这时候就懵逼啦,怎么办?把ListView中回弹效果的代码copy一遍?这就和DRY原则相悖了不是,而且有可能受到其他地方代码的影响,处理回弹效果略有不同,要是有一天PM希望对这个回弹效果做升级,那就有得改啦。应对这种场景,最好的办法是啥?用组合,封装一个带回弹效果的Scroller,ListView看成是Scroller和item容器组件的组合,其他地方需要要用到滚动的,直接套一个Scroller,以后不管回弹效果怎么变,我只要维护这个Scroller就好了。当然,最理想的,把回弹效果也做成一个组件SpringBackEffect,从Scroller分离出来,这样,需要用回弹效果的地方就加上SpringBackEffect组件就好了,这就是为什么组合优先于继承的原因。

页面简单的时候,组合也好,继承也罢,可维护就好,能够快速的响应需求迭代就好,用什么方式实现到无所谓。但如果是一个大项目,页面用到很多组件,或者是团队多人共同维护的话,就要考虑协作中可能存在的矛盾,然后通过一定约束来闭坑。组合的方式是可以保证组件具有充分的复用性,灵活度,遵守DRY原则的其中一种实践。

Mixin和HOC的对比

Mixin就像他的名字,他混入了组件中,我们很难去对一个混入了多个Mixin的组件进行管理,好比一个盒子,我们在盒子里面塞入了各种东西(功能),最后肯定是难以理清其中的脉络。
HOC则像是一个装饰器,他是在盒子的外面一层一层的装饰,当我们想要抽取某一层或者增加某一层都非常容易。

HOC的约定

贯穿传递不相关props属性给被包裹的组件
高阶组件应该贯穿传递与它专门关注无关的props属性。

render() {
  // 过滤掉专用于这个阶组件的props属性,
  // 不应该被贯穿传递
  const { extraProp, ...passThroughProps } = this.props;

  // 向被包裹的组件注入props属性,这些一般都是状态值或
  // 实例方法
  const injectedProp = someStateOrInstanceMethod;

  // 向被包裹的组件传递props属性
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
}

最大化的组合性

// 不要这样做……
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent))

// ……你可以使用一个函数组合工具
// compose(f, g, h) 和 (...args) => f(g(h(...args)))是一样的
const enhance = compose(
  // 这些都是单独一个参数的高阶组件
  withRouter,
  connect(commentSelector)
)
const EnhancedComponent = enhance(WrappedComponent)

包装显示名字以便于调试

最常用的技术是包裹显示名字给被包裹的组件。所以,如果你的高阶组件名字是 withSubscription,且被包裹的组件的显示名字是 CommentList,那么就是用 WithSubscription(CommentList)这样的显示名字
function withSubscription(WrappedComponent) {
  class WithSubscription extends React.Component {/* ... */}
  WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`;
  return WithSubscription;
}

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

HOC的警戒

  • 不要在render方法内使用高阶组件,因为每次高阶组件返回的都是不同的组件,会造成不必要的渲染。
  • 必须将静态方法做拷贝。

HOC带来的问题:

  • 当存在多个HOC时,你不知道Props是从哪里来的。
  • 和Mixin一样, 存在相同名称的props,则存在覆盖问题,而且react并不会报错。
  • JSX层次中多了很多层次(即无用的空组件),不利于调试。
  • HOC属于静态构建,静态构建即是重新生成一个组件,即返回的新组件,不会马上渲染,即新组件中定义的生命周期函数只有新组件被渲染时才会执行。

Render Props

Render Props概念

Render Props从名知义,也是一种剥离重复使用的逻辑代码,提升组件复用性的解决方案。在被复用的组件中,通过一个名为“render”(属性名也可以不是render,只要值是一个函数即可)的属性,该属性是一个函数,这个函数接受一个对象并返回一个子组件,会将这个函数参数中的对象作为props传入给新生成的组件。

Render Props应用

可以看下最初的例子在render props中的应用:

class Cat extends React.Component {
  render() {
    const mouse = this.props.mouse;
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
    );
  }
}

class Mouse extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    });
  }

  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>

        {/*
          Instead of providing a static representation of what <Mouse> renders,
          use the `render` prop to dynamically determine what to render.
        */}
        {this.props.render(this.state)}
      </div>
    );
  }
}

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <Mouse render={mouse => (
          <Cat mouse={mouse} />
        )}/>
      </div>
    );
  }
}

render props的优势

  • 不用担心Props是从哪里来的, 它只能从父组件传递过来。
  • 不用担心props的命名问题。
  • render props是动态构建的。

动态构建和静态构建

这里简单的说下动态构建,因为React官方推崇动态组合,然而HOC实际上是一个静态构建,比如,在某个需求下,我们需要根据Mouse中某个字段来决定渲染Cat组件或者Dog组件,使用HOC会是如下:

const EnhanceCat =  MounseHoc(Cat)
const EnhanceDog =  MounseHoc(Dog)
class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        isCat ? <EnhanceCat /> : <EnhanceDog />
      </div>
    );
  }
}

可以看到,我们不得不提前静态构建好Cat和Dog组件

假如我们用Render props:

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <Mouse render={(mouse, isCat) => (
          isCat ? <Cat mouse={mouse} /> : <Dog mouse={mouse} />
        )}/>
      </div>
    );
  }
}

很明显,在动态构建的时候,我们具有更多的灵活性,我们可以更好的利用生命周期

Render Props的缺点

无法使用SCU做优化, 具体参考官方文档

总结

抛开被遗弃的Mixin和尚未稳定的Hooks,目前社区的代码复用方案主要还是HOC和Render Props,个人感觉,如果是多层组合或者需要动态渲染那就选择Render Props,而如果是诸如在每个View都要执行的简单操作,如埋点、title设置等或者是对性能要求比较高如大量表单可以采用HOC。

参考

Function as Child Components Not HOCs
React高阶组件和render props的适用场景有区别吗,还是更多的是个人偏好?
深入理解 React 高阶组件
高阶组件-React
精读《我不再使用高阶组件》
为什么 React 推崇 HOC 和组合的方式,而不是继承的方式来扩展组件?
React 中的 Render Props
使用 Render props 吧!
渲染属性(Render Props)


菜的黑人牙膏
299 声望10 粉丝