高阶组件

React 高阶组件详解

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

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

其中 W (WrappedComponent) 指被包裹的 React.Component,E (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) 主要是 HOC 在 render 方法中 返回 了一个 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

我们可以读取、添加、编辑、删除传给 WrappedComponent 的 props。当删除或者编辑重要的 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)访问到 this(WrappedComponent 的实例),但为了得到引用,WrappedComponent 还需要一个初始渲染,意味着你需要在 HOC 的 render 方法中返回 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 component 与 dumb component。譬如提取了 input 的 value 和 onChange 方法:

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 作为参数,并返回增强的组件。返回的 EnhancedComponent 由 getRandomColor 和 render 方法组成。在 render 方法中,我们返回分配了具有 color 属性 Props 的 BaseComponent,并利用 {... 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 是因为 WrappedComponent 被 Enhancer 继承了,而不是 WrappedComponent 继承了 Enhancer。在这种方式中,它们的关系看上去被反转(inverse)了。

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

渲染劫持(Render Highjacking)

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

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

II 类型的 HOC 不一定会解析完整子树,意味着渲染劫持有一些限制。根据经验,使用渲染劫持你可以完全操作 WrappedComponent 的 render 方法返回的元素树。但是如果元素树包括一个函数类型的 React 组件,你就不能操作它的子组件了。(被 React 的一致化处理推迟到了真正渲染到屏幕时)。譬如当 this.props.loggedIn 为 true 时,这个 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。记住,这会搞乱 WrappedComponent 的 state,导致你可能会破坏某些东西。要限制 HOC 读取或添加 state,添加 state 时应该放在单独的命名空间里,而不是和 WrappedComponent 的 state 混在一起。

譬如通过访问 WrappedComponent 的 props 和 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>
      );
    }
  };
}

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

技巧与注意

命名

用 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 增强之前那样使用了。更严重的是,如果你再用另一个同样会修改 componentWillReceiveProps 的 HOC 增强它,那么前面的 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

React 的 diff 算法(称为协调)使用组件标识来确定它是应该更新现有子树还是将其丢弃并挂载新子树。如果从 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";

案例分析

通用数据加载

在以下示例中,我们将开发一个高阶组件,该组件接受 BaseComponent 和 API 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;
上一页
下一页