高阶组件

React高阶组件详解

高阶组件(HOC)是React中用于复用组件逻辑的一种高级技巧。HOC自身不是React API的一部分,它是一种基于React的组合特性而形成的设计模式。具体而言,高阶组件是参数为组件,返回值为新组件的函数;高阶组件就是一个React组件包裹着另外一个React组件。这种模式通常使用函数来实现,基本上是一个类工厂;它的函数签名可以用类似Haskell的伪代码表示:

hocFactory:: W: React.Component => E: React.Component

其中W (WrappedComponent)指被包裹的React.ComponentE (EnhancedComponent)指返回类型为React.Component的新的HOC

const EnhancedComponent = higherOrderComponent(WrappedComponent);

const higherOrderComponent = BaseComponent => {
  // ...
  // create new component from old one and update
  // ...

  return EnhancedComponent;
};

React中两种HOC的实现方法:Props Proxy (PP) and Inheritance Inversion (II),两种方法都可以操作WrappedComponent

Props Proxy

Props Proxy (PP)主要是HOCrender方法中 返回 了一个WrappedComponent类型的React Element。我们还传入了HOC接收到的props,这就是名字Props Proxy的由来。

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

React内部的一致化处理(reconciliation process)中,两者都创建了一个React Element用于渲染。

操作props

我们可以读取、添加、编辑、删除传给WrappedComponentprops。当删除或者编辑重要的props时要小心,你可能应该通过命名空间确保高阶组件的props不会破坏WrappedComponent。譬如添加新的props。在这个应用中,当前登录的用户可以在WrappedComponent中通过this.props.user访问到。

function ppHOC(WrappedComponent) {
  return class PP extends React.Component {
    render() {
      const newProps = {
        user: currentLoggedInUser
      };
      return <WrappedComponent {...this.props} {...newProps} />;
    }
  };
}

TypeScript中我们往往还需要声明传入到下一层组件的Props对象:

import { Subtract } from "utility-types";

export interface InjectedCounterProps {
  value: number;
  onIncrement(): void;
  onDecrement(): void;
}

interface MakeCounterProps {
  minValue?: number;
  maxValue?: number;
}

interface MakeCounterState {
  value: number;
}

const makeCounter = <P extends InjectedCounterProps>(
  Component: React.ComponentType<P>
) =>
  class MakeCounter extends React.Component<
    Subtract<P, InjectedCounterProps> & MakeCounterProps,
    MakeCounterState
  > {
    state: MakeCounterState = {
      value: 0
    };

    increment = () => {
      this.setState(prevState => ({
        value:
          prevState.value === this.props.maxValue
            ? prevState.value
            : prevState.value + 1
      }));
    };

    decrement = () => {
      this.setState(prevState => ({
        value:
          prevState.value === this.props.minValue
            ? prevState.value
            : prevState.value - 1
      }));
    };

    render() {
      const { minValue, maxValue, ...props } = this.props;
      return (
        <Component
          {...(props as P)}
          value={this.state.value}
          onIncrement={this.increment}
          onDecrement={this.decrement}
        />
      );
    }
  };

通过Refs访问到组件实例

你可以通过引用(ref)访问到thisWrappedComponent的实例,但为了得到引用,WrappedComponent还需要一个初始渲染,意味着你需要在HOCrender方法中返回WrappedComponent元素,让React开始它的一致化处理,你就可以得到WrappedComponent的实例的引用。譬如通过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} />;
    }
  };
}

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

提取state

我们可以通过传入props和回调函数把state提取出来,类似于smart componentdumb component。譬如提取了inputvalueonChange方法:

function ppHOC(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: {
          value: this.state.name,
          onChange: this.onNameChange
        }
      };
      return <WrappedComponent {...this.props} {...newProps} />;
    }
  };
}

然后可以这样用:

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

用其他元素包裹WrappedComponent

为了封装样式、布局或别的目的,你可以用其它组件和元素包裹WrappedComponent。基本方法是使用父组件实现,但通过HOC你可以得到更多灵活性。

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

HOC和参数

有时候我们也需要在HOC中传入自定义的参数,Props Proxy模式 的HOC最简参数使用方法。关键在于HOCFactoryFactory函数。

function HOCFactoryFactory(...params) {
  // do something with params
  return function HOCFactory(WrappedComponent) {
    return class HOC extends React.Component {
      render() {
        return <WrappedComponent {...this.props} />;
      }
    };
  };
}

你可以这样用:

HOCFactoryFactory(params)(WrappedComponent);
//或
@HOCFatoryFactory(params)
class WrappedComponent extends React.Component {}

案例分析

为此,我们首先定义一个名为withColor的高阶组件函数。它接受BaseComponent作为参数,并返回增强的组件。返回的EnhancedComponentgetRandomColorrender方法组成。在render方法中,我们返回分配了具有color属性PropsBaseComponent,并利用 {... this.props} 解构的优势,BaseComponent还可以访问从HOC外部传递给BaseComponent的所有Props

import React from "react";

const withColor = BaseComponent => {
  class EnhancedComponent extends React.Component {
    getRandomColor() {
      var letters = "0123456789ABCDEF";
      var color = "#";

      for (let i = 0; i < 6; i++) {
        color += letters[Math.floor(Math.random() * 16)];
      }

      return color;
    }

    render() {
      return <BaseComponent color={this.getRandomColor()} {...this.props} />;
    }
  }

  return EnhancedComponent;
};

export default withColor;

对于该HoC的使用,我们只要将其包裹正常的组件即可:

import React from "react";
import withColor from "./withColor";

const ColoredComponent = props => {
  return <div style={{ background: props.color }}>{props.color}</div>;
};

export default withColor(ColoredComponent);

最后,可以在外部的应用中直接传入color属性:

import React, { Component } from "react";
import ColoredComponent from "./ColoredComponent";

class App extends Component {
  render() {
    return (
      <div>
        <ColoredComponent someProp="Prop 1" />
        <ColoredComponent someProp="Prop 2" />
        <ColoredComponent someProp="Prop 3" />
      </div>
    );
  }
}

export default App;

Inheritance Inversion

Inheritance Inversion (II)的最简实现:

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

可以看到,返回的HOC类(Enhancer)继承了WrappedComponent。之所以被称为Inheritance Inversion是因为WrappedComponentEnhancer继承了,而不是WrappedComponent继承了Enhancer。在这种方式中,它们的关系看上去被反转(inverse)了。

Inheritance Inversion允许HOC通过this访问到WrappedComponent,意味着它可以访问到state、props、组件生命周期方法和render方法。

渲染劫持(Render Highjacking)

之所以被称为渲染劫持是因为HOC控制着WrappedComponent的渲染输出,可以用它做各种各样的事。通过渲染劫持你可以:

  • 在由render输出的任何React元素中读取、添加、编辑、删除props
  • 读取和修改由render输出的React元素树
  • 有条件地渲染元素树
  • 把样式包裹进元素树(就像在Props Proxy中的那样)

II类型的HOC不一定会解析完整子树,意味着渲染劫持有一些限制。根据经验,使用渲染劫持你可以完全操作WrappedComponentrender方法返回的元素树。但是如果元素树包括一个函数类型的React组件,你就不能操作它的子组件了(被React的一致化处理推迟到了真正渲染到屏幕时。譬如当this.props.loggedIntrue时,这个HOC会完全渲染WrappedComponent的渲染结果(假设HOC接收到了loggedIn这个prop

function iiHOC(WrappedComponent) {
  return class Enhancer extends WrappedComponent {
    render() {
      if (this.props.loggedIn) {
        return super.render();
      } else {
        return null;
      }
    }
  };
}

再如,修改由render方法输出的React组件树。

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;
    }
  };
}

在这个例子中,如果WrappedComponent的输出在最顶层有一个input,那么就把它的value设为 “may the force be with you”。在Props Proxy中不能做到渲染劫持。虽然通过WrappedComponent.prototype.render你可以访问到render方法,不过还需要模拟WrappedComponent的实例和它的props,还可能亲自处理组件的生命周期,而不是交给React。根据我的实验,这么做不值,你要是想做到渲染劫持你应该用Inheritance Inversion而不是Props Proxy。记住,React在内部处理了组件实例,你处理实例的唯一方法是通过this或者refs

操作state

HOC可以读取、编辑和删除WrappedComponent实例的state,如果你需要,你也可以给它添加更多的state。记住,这会搞乱WrappedComponentstate,导致你可能会破坏某些东西。要限制HOC读取或添加state,添加state时应该放在单独的命名空间里,而不是和WrappedComponentstate混在一起。

譬如通过访问WrappedComponentpropsstate来做调试:

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>
      );
    }
  };
}

这里HOC用其他元素包裹着WrappedComponent,还输出了WrappedComponent实例的propsstate

技巧与注意

命名

HOC包裹了一个组件会使它失去原本WrappedComponent的名字,可能会影响开发和调试。通常会用WrappedComponent的名字加上一些前缀作为HOC的名字。下面的代码来自React-Redux

HOC.displayName = `HOC(${getDisplayName(WrappedComponent)})`;

//或

class HOC extends Component {
  static displayName = `HOC(${getDisplayName(WrappedComponent)})`;
  // ...
}

getDisplayName函数:

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

不要改变原始组件

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

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

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

这样做会产生一些不良后果。其一是输入组件再也无法像HOC增强之前那样使用了。更严重的是,如果你再用另一个同样会修改componentWillReceivePropsHOC增强它,那么前面的HOC就会失效!同时,这个HOC也无法应用于没有生命周期的函数组件。修改传入组件的HOC是一种糟糕的抽象方式。调用者必须知道他们是如何实现的,以避免与其他HOC发生冲突。HOC不应该修改传入组件,而应该使用组合的方式,通过将组件包装在容器组件中实现功能:

function logProps(WrappedComponent) {
  return class extends React.Component {
    componentWillReceiveProps(nextProps) {
      console.log("Current props: ", this.props);
      console.log("Next props: ", nextProps);
    }
    render() {
      // 将 input 组件包装在容器中,而不对其进行修改。Good!
      return <WrappedComponent {...this.props} />;
    }
  };
}

HOC与上文中修改传入组件的HOC功能相同,同时避免了出现冲突的情况。它同样适用于class组件和函数组件。而且因为它是一个纯函数,它可以与其他HOC组合,甚至可以与其自身组合。

不要在render方法中使用HOC

Reactdiff算法(称为协调)使用组件标识来确定它是应该更新现有子树还是将其丢弃并挂载新子树。如果从render返回的组件与前一个渲染中的组件相同(===,则React通过将子树与新子树进行区分来递归更新子树。如果它们不相等,则完全卸载前一个子树。通常,你不需要考虑这点。但对HOC来说这一点很重要,因为这代表着你不应在组件的render方法中对一个组件应用HOC

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

这不仅仅是性能问题-重新挂载组件会导致该组件及其所有子组件的状态丢失。如果在组件之外创建HOC,这样一来组件只会创建一次。因此,每次render时都会是同一个组件。一般来说,这跟你的预期表现是一致的。在极少数情况下,你需要动态调用HOC。你可以在组件的生命周期方法或其构造函数中进行调用。

务必复制静态方法

有时在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;
}

但要这样做,你需要知道哪些方法应该被拷贝。你可以使用hoist-non-react-statics自动拷贝所有非React静态方法:

import hoistNonReactStatic from "hoist-non-react-statics";
function enhance(WrappedComponent) {
  class Enhance extends React.Component {
    /*...*/
  }
  hoistNonReactStatic(Enhance, WrappedComponent);
  return Enhance;
}

除了导出组件,另一个可行的方案是再额外导出这个静态方法。

// 使用这种方式代替...
MyComponent.someFunction = someFunction;
export default MyComponent;

// ...单独导出该方法...
export { someFunction };

// ...并在要使用的组件中,import 它们
import MyComponent, { someFunction } from "./MyComponent.js";

案例分析

通用数据加载

在以下示例中,我们将开发一个高阶组件,该组件接受BaseComponentAPI URL来获取所需的数据。加载数据时,它将显示加载状态,加载数据后,我们将显示基于该数据呈现的任何BaseComponent

首先,我们将首先创建一个高阶组件函数。我们将使用Loader对其进行调用,并首先将组件状态的data属性设置为null。一旦加载了组件,我们将开始获取数据,完成后,将data属性设置为返回的响应。如前所述,当状态为null时,我们将显示加载状态。一旦获取了数据,我们将返回BaseComponent,而BaseComponent将根据返回的数据呈现标记。HOC定义如下:

import React from "react";

const withLoader = (BaseComponent, apiUrl) => {
  class EnhancedComponent extends React.Component {
    state = {
      data: null
    };

    componentDidMount() {
      fetch(apiUrl)
        .then(res => res.json())
        .then(data => {
          this.setState({ data });
        });
    }

    render() {
      if (!this.state.data) {
        return <div>Loading ...</div>;
      }

      return <BaseComponent data={this.state.data} {...this.props} />;
    }
  }

  return EnhancedComponent;
};

export default withLoader;

使用此HOC的组件非常简单。我们只需要从道具中获取数据并根据需要使用即可。在下面的示例中,我们使用数据显示用户列表。与往常一样,我们首先需要导入HOC函数并将该组件作为函数参数传递。伴随着它,我们还传递了从中获取数据的API URL。请参阅以下基本组件的代码:

import React from "react";
import withLoader from "./withLoader";

const Users = props => {
  return (
    <div>
      <h1>Users:</h1>
      <ul>
        {props.data.map(user => (
          <li key={user.id}>{user.name}</li>
        ))}
      </ul>
    </div>
  );
};

export default withLoader(Users, "https://jsonplaceholder.typicode.com/users");

类似地,我们也可以有如下的加载Posts的组件:

import React from "react";
import withLoader from "./withLoader";

const Posts = props => {
  return (
    <div>
      <h1>Posts:</h1>
      <ul>
        {props.data.map(post => (
          <li key={post.title}>{post.title}</li>
        ))}
      </ul>
    </div>
  );
};

export default withLoader(Posts, "https://jsonplaceholder.typicode.com/posts/");

Props Throttle

代码定义如下:

import { createElement, Component } from "react";
import getDisplayName from "react-display-name";
import throttle from "just-throttle";

const throttleHandler = (handlerName, interval, leadingCall) => Target => {
  class ThrottleHandler extends Component {
    constructor(props, context) {
      super(props, context);

      const intervalValue =
        typeof interval === "function" ? interval(props) : interval;

      this.throttlePropInvoke = throttle(
        (...args) => this.props[handlerName](...args),
        intervalValue,
        leadingCall
      );

      this.handler = (e, ...rest) => {
        if (e && typeof e.persist === "function") {
          e.persist();
        }

        return this.throttlePropInvoke(e, ...rest);
      };
    }

    render() {
      return createElement(Target, {
        ...this.props,
        [handlerName]: this.handler
      });
    }
  }

  if (process.env.NODE_ENV !== "production") {
    ThrottleHandler.displayName = `throttleHandler(${getDisplayName(Target)})`;
  }

  return ThrottleHandler;
};

export default throttleHandler;

使用案例如下:

const Demo = ({ count, onButtonClick, label }) => (
  <div className="demo">
    {label || ""}
    <h1>{count}</h1>
    <button onClick={onButtonClick}>CLICK ME FAST</button>
  </div>
);

const Demo1 = compose(
  withState("count", "setCount", 0),
  withHandlers({
    onButtonClick: ({ count, setCount }) => () => setCount(count + 1)
  }),
  throttleHandler("onButtonClick", 1000)
)(Demo);

const Demo2 = compose(
  withState("count", "setCount", 0),
  withHandlers({
    onButtonClick: ({ count, setCount }) => () => setCount(count + 1)
  }),
  throttleHandler("onButtonClick", props => props.throttle || 0)
)(Demo);

const MainDemo = () => (
  <div>
    <style>
      {`.demo {
          margin-bottom: 10px;
          border-style: dotted;
          border-radius: 10px;
          padding: 5px;
        }`}
    </style>
    <Demo1 label="Delay as argument" />
    <Demo2 label="Delay from props" throttle={300} />
    <Demo2 label="No delay (zero by default)" />
  </div>
);

export default MainDemo;
上一页
下一页