React系列-Mixin、HOC、Render Props(上)

6,400 阅读19分钟

React系列-Mixin、HOC、Render Props(上)
React系列-轻松学会Hooks(中)
React系列-自定义Hooks很简单(下)

用了一段时间的Hooks,结合几篇文档,整理些笔记📒

在讲react-hooks之前,我们来捋捋react状态逻辑复用相关知识点,这会帮助你理解hooks

React 里,组件是代码复用的基本单元,基于组合的组件复用机制相当优雅。而对于更细粒度的逻辑(状态逻辑、行为逻辑等)复用起来却不那么容易,所以我们是通过分析以下几种模式来解决我们状态逻辑复用问题

Mixin(混入)

Mixin设计模式

Mixin模式就是一些提供能够被一个或者一组子类简单继承功能的类,意在重用其功能


很多开源库提供了Mixin的实现,比如Underscore的_.extend方法

动手实现Minxin方法

const mixin = function (target, mixins{
    const newObj = target;
    newObj.prototype = Object.create(target.prototype);

    for (let prop in mixins) {
        if (mixins.hasOwnProperty(prop)) {
            newObj.prototype[prop] = mixins[prop];
        }
    }

    return newObj;
}

const Person = {
    run() => {
        console.log('I can run');
    }
};

const Son = function ({
    console.log('hello world');
};

const User = mixin(Son, Person);

const vnues = new User(); // 'hello world'
vnues.run(); // 'I can run'

❗️可以看到使用mixin方法将任意对象的任意方法扩展到目标对象上,也就是说采用Mixin方式可以复用状态逻辑、行为逻辑等

React中的Mixin

Mixin 方案的出现源自一种 OOP 直觉,虽然 React 本身有些函数式味道,但为了迎合用户习惯,早期只提供了React.createClass() API 来定义组件:

// 定义Mixin
var Mixin1 = {
  getMessagefunction({
    return 'hello react';
  }
};
var Mixin2 = {
  componentDidMountfunction({
    console.log('Mixin2.componentDidMount()');
  }
};

// 用Mixin来增强现有组件
var MyComponent = React.createClass({
  mixins: [Mixin1, Mixin2],
  renderfunction({
    return <div>{this.getMessage()}</div>;
  }
});

Mixin带来的缺陷

Mixins 引入了隐式的`依赖关系(Mixins introduce implicit dependencies)`

你可能会写一个有状态的组件,然后你的同事可能添加一个读取这个组件state的mixin。几个月之后,你可能希望将该state移动到父组件,以便与其兄弟组件共享。你会记得更新这个mixin来读取props而不是state吗?如果此时,其它组件也在使用这个mixin呢?

var RowMixin = {
  renderHeaderfunction({
    return (
      <div className='row-header'>
        <h1>
          {this.getHeaderText()} // 隐式依赖
        </h1>
      </div>

    );
  }
};

var UserRow = React.createClass({
  mixins: [RowMixin], // 混入renderHeader方法
  getHeaderText: function({
    return this.props.user.fullName;
  },
  renderfunction({
    return (
      <div>
        {this.renderHeader()}
        <h2>{this.props.user.biography}</h2>
      </div>

    )
  }
});
隐式依赖导致依赖关系不透明,维护成本和理解成本迅速攀升:
  • 难以快速理解组件行为,需要全盘了解所有依赖 Mixin 的扩展行为,及其之间的相互影响

  • 组价自身的方法和state字段不敢轻易删改,因为难以确定有没有 Mixin 依赖它

  • Mixin 也难以维护,因为 Mixin 逻辑最后会被打平合并到一起,很难搞清楚一个 Mixin 的输入输出

Mixins 引起名称冲突`(Mixins cause name clashes)`

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

var DefaultNameMixin = {
   // 名称冲突
    getDefaultProps: function ({
        return {name"Lizie"};
    }
};

var DefaultFoodMixin = {
  // 名称冲突
    getDefaultProps: function ({
        return {food"Pancakes"};
    }
};

var ComponentTwo = React.createClass({
    mixins: [DefaultNameMixin, DefaultFoodMixin],
    renderfunction ({
        return (
            <div>
                <h4>{this.props.name}</h4>
                <p>Favorite food: {this.props.food}</p>
            </div>

        );
    }
});

Mixins 导致滚雪球式的复杂性`(Mixins cause snowballing complexity)`

即使刚开始的时候 mixins 很简单,它们往往随着时间的推移变得复杂

Mixin `倾向于增加更多状态`,这降低了应用的可预测性(`The more state in your application, the harder it is to reason about it.`),导致复杂度剧增,但其实我们希望能努力让状态精简一点。

// someMixin
const someMixin = { 
  // 往组件添加了`newNumber状态`
  // 如果使用 createReactClass() 方法创建组件,你需要提供一个单独的 getInitialState 方法,让其返回初始 state:
  getInitialState() {
    return {
      newNumber1
    }
  },
  setNewNumber(num) {
    this.setState({
      newNumber: num
    })
  }
}
//  someOtherMixin
const someOtherMixin = {  
  someMethod(number) {
    console.log(number)
  }
}
const Composer = React.createClass({
  mixins: [
    someMixin,
    someOtherMixin
  ],
  render() {
    return <p>{this.state.newNumber}</p>
  },
  someStaticMethod(num) {
    this.setNewNumber(num)
    this.someMethod(num)
  }
})
class App extends React.Component {  
  constructor(props) {
    super(props)
    this.callChildMethod = this.callChildMethod.bind(this)
  }
  callChildMethod() {
    this.refs.composer.someStaticMethod(5)
  }
  render() {
    return (
      <div>
        <button onClick={this.callChildMethod}></button>
        <Composer ref="composer" />
      </div>
    )
  }
}

所以,React v0.13.0 放弃了 Mixin(继承),转而走向HOC(组合),而且❗️拥抱ES6,ES6的class支持继承但不支持Mixin

HOC高阶组件

高阶组件(HOC)是React 中用于复用组件逻辑的一种高级技巧HOC 自身不是React API 的一部分,它是一种基于React 的组合特性而形成的设计模式

高阶组件可以看作React对装饰者模式的一种实现,具体而言,高阶组件是参数为组件,返回值为新组件的函数。

export default (WrappedComponent) => {
  class NewComponent extends Component {
    // 可以做很多自定义逻辑
    render () {
      return <WrappedComponent />
    }
  }
  return NewComponent
}

装饰者模式

所谓装饰者模式,就是动态的给类或对象增加职责的设计模式。它能在不改变类或对象自身的基础上,在程序的运行期间动态的`添加职责。

ES7提供了一种类似的Java注解的语法糖decorator,来实现装饰者模式。使用起来非常简洁:

@testable
class MyTestableClass {
  // ...
}

function testable(target{
  target.isTestable = true;
}

MyTestableClass.isTestable // true

HOC 工厂的实现

属性代理

Props Proxy属性代理,这种实现方法本质上是“组合”,可能是我们第一时间会想到的实现方式,它通常的实现框架是这样的:

function proxyHOC(WrappedComponent{
  return class PP extends React.Component {
    render() {
      return <WrappedComponent {...this.props}/>
    }
  }
}

反向继承

Inheritance Inversion 即 反向继承,是 HOC 去继承 WrappedComponent。这样我们获得了这个组件之后,能够从内部对它进行装饰和修改,先看看它的实现框架:

function inheritHOC(WrappedComponent{
  return class extends WrappedComponent {
    render() {
      return super.render();
    }
  }
}

HOC可以做什么❓

可操作所有传入的props

你可以读取、添加、编辑、删除传给 WrappedComponent 的 props(属性)

在删除或编辑重要的 props(属性) 时要小心,你应该通过命名空间确保高阶组件的 props 不会破坏 WrappedComponent。

// 示例:添加新 props(属性)。 应用程序中当前登录的用户可以在 WrappedComponent 中通过 this.props.user访问。
function proxyHOC(WrappedComponent{
  return class PP extends React.Component {
    render() {
      const newProps = {
        user: currentLoggedInUser
      }
      return <WrappedComponent {...this.props} {...newProps}/>
    }
  }
}

通过refs访问到组件实例

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

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

Ref 的回调函数会在 WrappedComponent 渲染时执行,你就可以得到WrappedComponent的引用。这可以用来读取/添加实例的 props ,调用实例的方法。

不过这里有个问题,如果WrappedComponent是个无状态组件,则在proc中的wrappedComponentInstance是null,因为无状态组件没有this,不支持ref。

提取state

你可以通过传入 props 和回调函数把 state 提取出来,

function proxyHOC(WrappedComponent{
  return class PP extends React.Component {
    constructor(props) {
      super(props)
      this.state = {
        name''
      }

      this.onNameChange = this.onNameChange.bind(this)
    }
    onNameChange(event) {
      this.setState({
        name: event.target.value
      })
    }
    render() {
      const newProps = {
        name: {
          valuethis.state.name,
          onChangethis.onNameChange
        }
      }
      return <WrappedComponent {...this.props} {...newProps}/>
    }
  }
}

使用的时候:

@proxyHOC
class Test extends React.Component {
    render () {
        return (
            <input name="name" {...this.props.name}/>
        );
    }
}

export default proxyHOC(Test);

这样的话,就可以实现将input转化成受控组件

用其他元素包裹 WrappedComponent

这个比较好理解,就是将WrappedComponent组件外面包一层需要的嵌套结构

function proxyHOC(WrappedComponent{
  return class PP extends React.Component {
    render() {
      return (
        <div style={{display: 'block'}}>
          <WrappedComponent {...this.props}/>
        </div>
      )
    }
  }
}

渲染劫持(Render Highjacking)

它被称为 渲染劫持(Render Highjacking)因为 HOC 控制了 WrappedComponent 的渲染输出,并且可以用它做各种各样的事情。

在渲染劫持中,您可以:state(状态),props(属性)

  • 读取,添加,编辑,删除渲染输出的任何 React 元素中的 props(属性)
  • 读取并修改 render 输出的 React 元素树
  • 有条件地渲染元素树
  • 把样式包裹进元素树(就像在 Props Proxy(属性代理) 中的那样)
function inheritHOC(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
    }
  }
}

如何优雅的使用HOC

组合compose

 function compose(...funcs{
  return funcs.reduce((a, b) => (...args) => a(b(...args)))
}

用法

compose(fn1, fn2, fn3) (...args) 相当于 fn1(fn2(fn3(...args)))

装饰器

@proxyHOC
class Test extends React.Component {
    render () {
        return (
            <input name="name" {...this.props.name}/>
        );
    }
}

使用HOC的注意事项

⚠️不要在 render 方法中使用 HOC

React 的 diff 算法使用组件标识来确定它是应该更新现有子树还是将其丢弃并挂载新子树。 如果从 render 返回的组件与前一个渲染中的组件相同(===),则 React 通过将子树与新子树进行区分来递归更新子树。 如果它们不相等,则完全卸载前一个子树。

render() {
  // 每次调用 render 函数都会创建一个新的 EnhancedComponent
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedComponent = enhance(MyComponent);
  // 这将导致子树每次渲染都会进行卸载,和重新挂载的操作!
  return <EnhancedComponent />;
}

这不仅仅是性能问题 - 重新挂载组件会导致该组件及其所有子组件的状态丢失。

如果在组件之外创建 HOC,这样一来组件只会创建一次。因此,每次 render 时都会是同一个组件。一般来说,这跟你的预期表现是一致的。

⚠️务必拷贝静态方法

有时在 React 组件上定义静态方法很有用。例如,Relay 容器暴露了一个静态方法 getFragment 以方便组合 GraphQL 片段。

但是,当你将 HOC 应用于组件时,原始组件将使用容器组件进行包装。这意味着新组件没有原始组件的任何静态方法。

// 定义静态函数
WrappedComponent.staticMethod = function({/*...*/}
// 现在使用 HOC
const EnhancedComponent = enhance(WrappedComponent);

// 增强组件没有 staticMethod
typeof EnhancedComponent.staticMethod === 'undefined' // true

为了解决这个问题,你可以在返回之前把这些方法拷贝到容器组件上

function enhance(WrappedComponent{
  class Enhance extends React.Component {/*...*/}
  // 必须准确知道应该拷贝哪些方法 :(
  Enhance.staticMethod = WrappedComponent.staticMethod;
  return Enhance;
}

⚠️Refs 不会被传递

虽然高阶组件的约定是将所有 props 传递给被包装组件,但这对于 refs 并不适用。那是因为 ref 实际上并不是一个 prop - 就像 key 一样,它是由 React 专门处理的。如果将 ref 添加到 HOC 的返回组件中,则 ref 引用指向容器组件,而不是被包装组件

使用Forwarding Refs API传递Refs

Ref forwarding:是一种将ref钩子自动传递给组件的子孙组件的技术

考虑一个渲染原生 button DOM 元素的 FancyButton 组件:

function FancyButton(props{
  return (
    <button className="FancyButton">
      {props.children}
    </button>

  );
}

 const ref = React.createRef();
<FancyButton ref={ref}>Click me!</FancyButton>;

上面的代码FancyButton组件渲染了一个HTML元素。对于使用者而言,React隐藏了将代码渲染成页面元素的过程,当其他组件使用FancyButton时,并没有任何直接的方法来获取FancyButton中的元素,这样的设计方法有利于组件的分片管理,降低耦合

但是像FancyButton这样的组件,其实仅仅是对基本的HTML元素进行了简单的封装。某些时候,上层组件使用他时更希望将其作为一个基本的HTML元素来看待,实现某些效果需要直接操作DOM,比如focus、selection和animations效果。

在下面的例子中, FancyButton 使用React.forwardRef 来获取传递给它的 ref , 然后将其转发给它渲染的的 DOM button

const FancyButton = React.forwardRef((props, ref) => (
  <button ref={ref} className="FancyButton">
    {props.children}
  </button>

));

 const ref = React.createRef();
<FancyButton ref={ref}>Click me!</FancyButton>;

约定:不要改变原始组件

不要试图在 HOC 中修改组件原型(或以其他方式改变它)。

function logProps(InputComponent{
  InputComponent.prototype.componentDidUpdate = function(prevProps{
    console.log('Current props: 'this.props);
    console.log('Previous props: ', prevProps);
  };
  // 返回原始的 input 组件,暗示它已经被修改。
  return InputComponent;
}

// 每次调用 logProps 时,增强组件都会有 log 输出。
const EnhancedComponent = logProps(InputComponent);

这样做会产生一些不良后果。其一是输入组件再也无法像 HOC 增强之前那样使用了。更严重的是,如果你再用另一个同样会修改 componentDidUpdate 的 HOC 增强它,那么前面的 HOC 就会失效!同时,这个 HOC 也无法应用于没有生命周期的函数组件。

约定:将不相关的 props 传递给被包裹的组件

HOC 为组件添加特性。自身不应该大幅改变约定。HOC 返回的组件与原组件应保持类似的接口

HOC 应该透传与自身无关的 props。大多数 HOC 都应该包含一个类似于下面的 render 方法

render() {
  // 高阶组件只需要用到visible这个属性
  const { visible, ...props } = this.props;
  // 将 props 传递给被包装组件
  return (
    <WrappedComponent
     {...props}
    />

  );
}

约定:包装显示名称以便轻松调试

HOC 创建的容器组件会与任何其他组件一样,会显示在 React Developer Tools 中。为了方便调试,请选择一个显示名称,以表明它是 HOC 的产物。

❗️为了方便调试,我们可以手动为HOC指定一个displayName,官方推荐使用HOCName(WrappedComponentName)

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缺陷

  • 扩展性限制:HOC 并不能完全替代 Mixin

  • Ref 传递问题:Ref 被隔断,开发中需要专门去处理

  • Wrapper Hell:HOC 泛滥,出现 Wrapper Hell

Render Props

前面我们说过react 里组件是代码复用的基本单元,基于组合的组件复用机制相当优雅。而对于更细粒度的逻辑(状态逻辑、行为逻辑等)复用起来却不那么容易,而前面我们处理逻辑复用的模式实际就是将复用逻辑处理成组件形式包括接下来来要讲的render props易是如此,严格来讲,Mixin、Render Props、HOC 等方案都只能算是在既有(组件机制的)游戏规则下探索出来的上层模式

创建render props

简单理解render props:组件不自己定义render函数,而是通过一个名为render的props(所以如果名字为children 也可称为children props) 将外部定义的render函数传入使用

// render props
const Test = props => props.render('hello world')
const App = () => (
    <Test
      {/**
      带有渲染属性(Render Props)的组件需要一个返回 React 元素并调用它的函数,而不是实现自己的渲染逻辑。
      **/}
      render={text =>
 <div>{text}</div>}
    />
)

ReactDOM.render((<App />, root)  // 返回<div>hello world</div>

类比 HOC,技术上,二者都基于组件组合机制,Render Props 拥有与 HOC 一样的扩展能力,之所以称之为 Render Props,并不是说只能用来复用渲染逻辑,
而是表示在这种模式下,组件是通过render()组合起来的类似于 HOC 模式下通过 Wrapper 的render()建立组合关系,形式上,二者非常相像,同样都会产生一层“Wrapper”(EComponent和RP)

// HOC定义
const HOC = Component => WrappedComponent;
// HOC使用
const Component;
const EComponent = HOC(Component);
<EComponent />

// Render Props定义
const RP = ComponentWithSpecialProps;
// Render Props使用
const Component;
<RP specialRender={() => <Component />} />

更有意思的是,Render Props 与 HOC 甚至能够相互转换(它们可以共存,并不是互斥的关系):

function RP2HOC(RP{
  return Component => {
    return class extends React.Component {
      static displayName = "RP2HOC";
      render() {
        return (
          <RP
            specialRender={renderOptions => (
              <Component {...this.props} renderOptions={renderOptions} />
            )}
          />
        )
      }
    }
  }
}

function HOC2RP(HOC) {
  const RP = class extends React.Component {
    static displayName = "HOC2RP";
    render() {
      return this.props.specialRender();
    }
  }
  return HOC(RP);
}

class Hello extends React.Component {
  render() {
    return <div>Hello {this.props.name}</div>;
  }
}

class RP extends React.Component {
  render() {
    return (
      <div style={{ background: "#2b80b6" }}>{this.props.specialRender()}</div>
    );
  }
}

// RP -> HOC
const HOC = RP2HOC(RP)
const EComponent = HOC(Hello);
ReactDOM.render(
  <EComponent name="RP -> HOC" />,
  document.getElementById("container1")
);

// HOC -> RP
const NewRP = HOC2RP(HOC)
ReactDOM.render(
  <NewRP specialRender={() => <Hello name="HOC -> RP" />} />,
  document.getElementById("container2")
);

Render Props缺陷

  • 使用繁琐: HOC使用只需要借助装饰器语法通常一行代码就可以进行复用,Render Props无法做到如此简单

  • 嵌套过深: Render Props虽然摆脱了组件多层嵌套的问题,但是转化为了函数回调的嵌套

最后