DeanPaul / blog

MIT License
2 stars 1 forks source link

HOC(Higher-Order Components) and mixin #18

Open DeanPaul opened 6 years ago

DeanPaul commented 6 years ago

Higher-Order Components A higher-order component (HOC) is an advanced technique in React for reusing component logic. HOCs are not part of the React API, per se. They are a pattern that emerges from React’s compositional nature. Concretely, a higher-order component is a function that takes a component and returns a new component. const EnhancedComponent = higherOrderComponent(WrappedComponent); Where as a component transforms props into UI, a higher-order component transforms a component into another component. HOCs are common in third-party React libraries, such as Redux’s connect and Relay’s createFragmentContainer. In this document, we’ll discuss why higher-order components are useful, and how to write your own. Use HOCs For Cross-Cutting Concerns Components are the primary unit of code reuse in React. However, you’ll find that some patterns aren’t a straightforward fit for traditional components.

For example, say you have a CommentList component that subscribes to an external data source to render a list of comments:

class CommentList extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      // "DataSource" is some global data source
      comments: DataSource.getComments()
    };
  }

  componentDidMount() {
    // Subscribe to changes
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    // Clean up listener
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    // Update component state whenever the data source changes
    this.setState({
      comments: DataSource.getComments()
    });
  }

  render() {
    return (
      <div>
        {this.state.comments.map((comment) => (
          <Comment comment={comment} key={comment.id} />
        ))}
      </div>
    );
  }
}

Later, you write a component for subscribing to a single blog post, which follows a similar pattern:

class BlogPost extends React.Component {
  constructor(props) {
    super(props);
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      blogPost: DataSource.getBlogPost(props.id)
    };
  }

  componentDidMount() {
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    this.setState({
      blogPost: DataSource.getBlogPost(this.props.id)
    });
  }

  render() {
    return <TextBlock text={this.state.blogPost} />;
  }
}

CommentList and BlogPost aren’t identical — they call different methods on DataSource, and they render different output. But much of their implementation is the same:

On mount, add a change listener to DataSource. Inside the listener, call setState whenever the data source changes. On unmount, remove the change listener.

You can imagine that in a large app, this same pattern of subscribing to DataSource and calling setState will occur over and over again. We want an abstraction that allows us to define this logic in a single place and share them across many components. This is where higher-order components excel.

We can write a function that creates components, like CommentList and BlogPost, that subscribe to DataSource. The function will accept as one of its arguments a child component that receives the subscribed data as a prop. Let’s call the function withSubscription:

const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments()
);

const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)
);

The first parameter is the wrapped component. The second parameter retrieves the data we’re interested in, given a DataSource and the current props.

When CommentListWithSubscription and BlogPostWithSubscription are rendered, CommentList and BlogPost will be passed a data prop with the most current data retrieved from DataSource:

// This function takes a component...
function withSubscription(WrappedComponent, selectData) {
  // ...and returns another component...
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      this.state = {
        data: selectData(DataSource, props)
      };
    }

    componentDidMount() {
      // ... that takes care of the subscription...
      DataSource.addChangeListener(this.handleChange);
    }

    componentWillUnmount() {
      DataSource.removeChangeListener(this.handleChange);
    }

    handleChange() {
      this.setState({
        data: selectData(DataSource, this.props)
      });
    }

    render() {
      // ... and renders the wrapped component with the fresh data!
      // Notice that we pass through any additional props
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}

Note that a HOC doesn’t modify the input component, nor does it use inheritance to copy its behavior. Rather, a HOC composes the original component by wrapping it in a container component. A HOC is a pure function with zero side-effects.

And that’s it! The wrapped component receives all the props of the container, along with a new prop, data, which it uses to render its output. The HOC isn’t concerned with how or why the data is used, and the wrapped component isn’t concerned with where the data came from.

Because withSubscription is a normal function, you can add as many or as few arguments as you like. For example, you may want to make the name of the data prop configurable, to further isolate the HOC from the wrapped component. Or you could accept an argument that configures shouldComponentUpdate, or one that configures the data source. These are all possible because the HOC has full control over how the component is defined.

Like components, the contract between withSubscription and the wrapped component is entirely props-based. This makes it easy to swap one HOC for a different one, as long as they provide the same props to the wrapped component. This may be useful if you change data-fetching libraries, for example.

Don’t Mutate the Original Component. Use Composition. Resist the temptation to modify a component’s prototype (or otherwise mutate it) inside a HOC.

function logProps(InputComponent) {
  InputComponent.prototype.componentWillReceiveProps = function(nextProps) {
    console.log('Current props: ', this.props);
    console.log('Next props: ', nextProps);
  };
  // The fact that we're returning the original input is a hint that it has
  // been mutated.
  return InputComponent;
}

// EnhancedComponent will log whenever props are received
const EnhancedComponent = logProps(InputComponent);

There are a few problems with this. One is that the input component cannot be reused separately from the enhanced component. More crucially, if you apply another HOC to EnhancedComponent that also mutates componentWillReceiveProps, the first HOC’s functionality will be overridden! This HOC also won’t work with function components, which do not have lifecycle methods.

Mutating HOCs are a leaky abstraction—the consumer must know how they are implemented in order to avoid conflicts with other HOCs.

Instead of mutation, HOCs should use composition, by wrapping the input component in a container component:

function logProps(WrappedComponent) {
  return class extends React.Component {
    componentWillReceiveProps(nextProps) {
      console.log('Current props: ', this.props);
      console.log('Next props: ', nextProps);
    }
    render() {
      // Wraps the input component in a container, without mutating it. Good!
      return <WrappedComponent {...this.props} />;
    }
  }
}

This HOC has the same functionality as the mutating version while avoiding the potential for clashes. It works equally well with class and functional components. And because it’s a pure function, it’s composable with other HOCs, or even with itself.

You may have noticed similarities between HOCs and a pattern called container components. Container components are part of a strategy of separating responsibility between high-level and low-level concerns. Containers manage things like subscriptions and state, and pass props to components that handle things like rendering UI. HOCs use containers as part of their implementation. You can think of HOCs as parameterized container component definitions.

Convention: Pass Unrelated Props Through to the Wrapped Component HOCs add features to a component. They shouldn’t drastically alter its contract. It’s expected that the component returned from a HOC has a similar interface to the wrapped component.

HOCs should pass through props that are unrelated to its specific concern. Most HOCs contain a render method that looks something like this:

render() {
  // Filter out extra props that are specific to this HOC and shouldn't be
  // passed through
  const { extraProp, ...passThroughProps } = this.props;

  // Inject props into the wrapped component. These are usually state values or
  // instance methods.
  const injectedProp = someStateOrInstanceMethod;

  // Pass props to wrapped component
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
}

This convention helps ensure that HOCs are as flexible and reusable as possible.

Convention: Maximizing Composability Not all HOCs look the same. Sometimes they accept only a single argument, the wrapped component:

const NavbarWithRouter = withRouter(Navbar); Usually, HOCs accept additional arguments. In this example from Relay, a config object is used to specify a component’s data dependencies:

const CommentWithRelay = Relay.createContainer(Comment, config); The most common signature for HOCs looks like this:

// React Redux's connect const ConnectedComment = connect(commentSelector, commentActions)(CommentList); What?! If you break it apart, it’s easier to see what’s going on.

// connect is a function that returns another function
const enhance = connect(commentListSelector, commentListActions);
// The returned function is a HOC, which returns a component that is connected
// to the Redux store
const ConnectedComment = enhance(CommentList);

In other words, connect is a higher-order function that returns a higher-order component!

This form may seem confusing or unnecessary, but it has a useful property. Single-argument HOCs like the one returned by the connect function have the signature Component => Component. Functions whose output type is the same as its input type are really easy to compose together.

// Instead of doing this...
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent))

// ... you can use a function composition utility
// compose(f, g, h) is the same as (...args) => f(g(h(...args)))
const enhance = compose(
  // These are both single-argument HOCs
  withRouter,
  connect(commentSelector)
)
const EnhancedComponent = enhance(WrappedComponent)

(This same property also allows connect and other enhancer-style HOCs to be used as decorators, an experimental JavaScript proposal.)

The compose utility function is provided by many third-party libraries including lodash (as lodash.flowRight), Redux, and Ramda.

Convention: Wrap the Display Name for Easy Debugging The container components created by HOCs show up in the React Developer Tools like any other component. To ease debugging, choose a display name that communicates that it’s the result of a HOC.

The most common technique is to wrap the display name of the wrapped component. So if your higher-order component is named withSubscription, and the wrapped component’s display name is CommentList, use the display name 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';
}

Caveats Higher-order components come with a few caveats that aren’t immediately obvious if you’re new to React.

Don’t Use HOCs Inside the render Method React’s diffing algorithm (called reconciliation) uses component identity to determine whether it should update the existing subtree or throw it away and mount a new one. If the component returned from render is identical (===) to the component from the previous render, React recursively updates the subtree by diffing it with the new one. If they’re not equal, the previous subtree is unmounted completely.

Normally, you shouldn’t need to think about this. But it matters for HOCs because it means you can’t apply a HOC to a component within the render method of a component:

render() {
  // A new version of EnhancedComponent is created on every render
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedComponent = enhance(MyComponent);
  // That causes the entire subtree to unmount/remount each time!
  return <EnhancedComponent />;
}

The problem here isn’t just about performance — remounting a component causes the state of that component and all of its children to be lost.

Instead, apply HOCs outside the component definition so that the resulting component is created only once. Then, its identity will be consistent across renders. This is usually what you want, anyway.

In those rare cases where you need to apply a HOC dynamically, you can also do it inside a component’s lifecycle methods or its constructor.

Static Methods Must Be Copied Over Sometimes it’s useful to define a static method on a React component. For example, Relay containers expose a static method getFragment to facilitate the composition of GraphQL fragments.

When you apply a HOC to a component, though, the original component is wrapped with a container component. That means the new component does not have any of the static methods of the original component.

// Define a static method
WrappedComponent.staticMethod = function() {/*...*/}
// Now apply a HOC
const EnhancedComponent = enhance(WrappedComponent);

// The enhanced component has no static method
typeof EnhancedComponent.staticMethod === 'undefined' // true

To solve this, you could copy the methods onto the container before returning it:

function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  // Must know exactly which method(s) to copy :(
  Enhance.staticMethod = WrappedComponent.staticMethod;
  return Enhance;
}

However, this requires you to know exactly which methods need to be copied. You can use hoist-non-react-statics to automatically copy all non-React static methods:

import hoistNonReactStatic from 'hoist-non-react-statics';

function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  hoistNonReactStatic(Enhance, WrappedComponent);
  return Enhance;
}

Another possible solution is to export the static method separately from the component itself.

// Instead of...
MyComponent.someFunction = someFunction;
export default MyComponent;

// ...export the method separately...
export { someFunction };

// ...and in the consuming module, import both
import MyComponent, { someFunction } from './MyComponent.js';

Refs Aren’t Passed Through While the convention for higher-order components is to pass through all props to the wrapped component, it’s not possible to pass through refs. That’s because ref is not really a prop — like key, it’s handled specially by React. If you add a ref to an element whose component is the result of a HOC, the ref refers to an instance of the outermost container component, not the wrapped component.

If you find yourself facing this problem, the ideal solution is to figure out how to avoid using ref at all. Occasionally, users who are new to the React paradigm rely on refs in situations where a prop would work better.

That said, there are times when refs are a necessary escape hatch — React wouldn’t support them otherwise. Focusing an input field is an example where you may want imperative control of a component. In that case, one solution is to pass a ref callback as a normal prop, by giving it a different name:

function Field({ inputRef, ...rest }) {
  return <input ref={inputRef} {...rest} />;
}

// Wrap Field in a higher-order component
const EnhancedField = enhance(Field);

// Inside a class component's render method...
<EnhancedField
  inputRef={(inputEl) => {
    // This callback gets passed through as a regular prop
    this.inputEl = inputEl
  }}
/>

// Now you can call imperative methods
this.inputEl.focus();

This is not a perfect solution by any means. We prefer that refs remain a library concern, rather than require you to manually handle them. We are exploring ways to solve this problem so that using a HOC is unobservable

DeanPaul commented 6 years ago

React By Example: Mixins

Components are React's preferred reuse mechanism, but it's not the only one. Sometimes different components share the same functions. It may be awkward to wrap these cross-cutting concerns in a higher order component, or the common code may need access to a component's state. In these scenarios, React mixins are useful.

Before I continue, I should note mixins seem to be on the way out. The React team is focused on making components more powerful versus continuing to build on the mixin concept. In fact, React's ES6 syntax doesn't support them at all.

That's too bad because I like mixins. I feel they're easier to use than higher order components. So until the React team rips them out of the codebase, damn the torpedos, full speed ahead!

Detecting Clicks Outside of a Component

One asset that came out of a recent React app is a mixin to detect clicks outside of a component. It works by invoking a callback whenever a click event fires outside of the target component or one of its child elements. I use it to close popup panels like the options list of a custom dropdown or a modal dialog.

How It Works

The ClickAway mixin works by taking advantage of a couple of React's component lifecycle methods. It implements componentDidMount to attach a unique ID to the component's top level DOM node. The ID is used to identify the component when tracking click events.

var elementId = id++;
var element = ReactDOM.findDOMNode(self);
element.setAttribute(attributeName, elementId);

Next, an event listener is added to the document node to monitor all mouse clicks (or taps for mobile) on the page. When a click event occurs, the handler locates the DOM node where the event originated and searches it and all its parent nodes for the ID added by the mixin. If the ID is found, the click event is ignored. If not, the onClickAway callback is invoked indicating the event was triggered outside of the component. For something like a popup menu, the callback would close the menu.

self.clickAway = function (event) {
    if (!elementWithIdExists(event.target, elementId) && self.onClickAway) {
        self.onClickAway(event);
    }
};

window.document.addEventListener('click', self.clickAway);

Finally, the click event listener is removed in the componentWillUnmount lifecycle method.

componentWillUnmount: function () {
    window.document.removeEventListener('click', this.clickAway);
}

How to Use It

Mixins are included in a component by adding a mixins property to the component's class specification. Its value is set to an array of mixin components.

// Mixin to detect clicks outside of this component.
mixins: [ClickAway],

Finally, provide an implementation for the onClickAway callback. In the case of the popup panel, it invokes an onClose callback to notify the parent component it should be closed.

// Close the panel when the user clicks outside of the component.
onClickAway: function () {
    if (this.props.onClose) {
        this.props.onClose();
    }
},
DeanPaul commented 6 years ago

React Mixin 的前世今生 在 React component 构建过程中,常常有这样的场景,有一类功能要被不同的 Component 公用,然后看得到文档经常提到 Mixin(混入) 这个术语。此文就从 Mixin 的来源、含义、在 React 中的使用说起。 在 ruby 中 include 关键词即是 mixin,是将一个模块混入到一个另一个模块中,或是一个类中。为什么编程语言要引入这样一种特性呢?事实上,包括 C++ 等一些年龄较大的 OOP 语言,有一个强大但危险的多重继承特性。现代语言为了权衡利弊,大都舍弃了多重继承,只采用单继承。但单继承在实现抽象时有着诸多不便之处,为了弥补缺失,如 Java 就引入 interface,其它一些语言引入了像 Mixin 的技巧,方法不同,但都是为创造一种 类似多重继承 的效果,事实上说它是 组合 更为贴切。

在 ES 历史中,并没有严格的类实现,早期 YUI、MooTools 这些类库中都有自己封装类实现,并引入 Mixin 混用模块的方法。到今天 ES6 引入 class 语法,各种类库也在向标准化靠拢。 封装一个 Mixin 方法 看到这里,我们既然知道了广义的 mixin 方法的作用,那不妨试试自己封装一个 mixin 方法来感受下。

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

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

  return newObj;
}

const BigMixin = {
  fly: () => {
    console.log('I can fly');
  }
};

const Big = function() {
  console.log('new big');
};

const FlyBig = mixin(Big, BigMixin);

const flyBig = new FlyBig(); // 'new big'
flyBig.fly(); // 'I can fly'

对于广义的 mixin 方法,就是用赋值的方式将 mixins 对象里的方法都挂载到原对象上,就实现了对对象的混入。

是否看到上述实现会联想到 underscore 中的 extend 或 lodash 中的 assign 方法,或者说在 ES6 中一个方法 Object.assign()。它的作用是什么呢,MDN 上的解释是把任意多个的源对象所拥有的自身可枚举属性拷贝给目标对象,然后返回目标对象。

因为 JS 这门语言的特别,在没有提到 ES6 Classes 之前没有真正的类,仅是用方法去模拟对象,new 方法即为创建一个实例。正因为这样地弱,它也那样的灵活,上述 mixin 的过程就像对象拷贝一样。

那问题是 React component 中的 mixin 也是这样的吗?

React createClass

React 最主流构建 Component 的方法是利用 createClass 创建。顾名思义,就是创造一个包含 React 方法 Class 类。这种实现,官方提供了非常有用的 mixin 属性。我们就先来看看它来做 mixin 的方式是怎样的。

import React from 'react';
import PureRenderMixin from 'react-addons-pure-render-mixin';

React.createClass({
  mixins: [PureRenderMixin],

  render() {
    return <div>foo</div>;
  }
});

以官方封装的 PureRenderMixin 来举例,在 createClass 对象参数中传入一个 mixins 的数组,里面封装了我们所需要的模块。mixins 也可以增加多个重用模块,使用多个模块,方法之间的有重合会对普通方法和生命周期方法有所区分。

在不同的 mixin 里实现两个名字一样的普通方法,在常规实现中,后面的方法应该会覆盖前面的方法。那在 React 中是否一样会覆盖呢。事实上,它并不会覆盖,而是在控制台里报了一个在 ReactClassInterface 里的 Error,说你在尝试定义一个某方法在 component 中多于一次,这会造成冲突。因此,在 React 中是不允许出现重名普通方法的 Mixin。

如果是 React 生命周期定义的方法呢,是会将各个模块的生命周期方法叠加在一起,顺序执行。 因为,我们看到 createClass 实现的 mixin 为 Component 做了两件事:

  1. 工具方法 这是 mixin 的基本功能,如果你想共享一些工具类方法,就可以定义它们,直接在各个 Component 中使用。
  2. 生命周期继承,props 与 state 合并 这是 react mixin 特别也是重要的功能,它能够合并生命周期方法。如果有很多 mixin 来定义 componentDidMount 这个周期,那 React 会非常智能的将它们都合并起来执行。 同样地,mixins 也可以作用在 getInitialState 的结果上,作 state 的合并,同时 props 也是这样合并。 ## 未来的 React Classes 当 ECMAScript 发展到今天,这已经是一个百家争鸣的时代,各种优异的语言特性都出现在 ES6 和 ES7 的草案中。

React 在发展过程中一直崇尚拥抱标准,尽管它自己看上去是一个异类。当 React 0.13 释出的时候,React 增加并推荐使用 ES6 Classes 来构建 Component。但非常不幸,ES6 Classes 并不原生支持 mixin。尽管 React 文档中也未能给出解决方法,但如此重要的特性没有解决方案,也是一件十分困扰的事。

为了可以用这个强大的功能,还得想想其它方法,来寻找可能的方法来实现重用模块的目的。先回归 ES6 Classes,我们来想想怎么封装 mixin。 让 ES6 Class 与 Decorator 跳舞 要在 Class 上封装 mixin,就要说到 Class 的本质。ES6 没有改变 JavaScript 面向对象方法基于原型的本质,不过在此之上提供了一些语法糖,Class 就是其中之一,换汤不换药。

对于 Class 具体用法可以参考 MDN。目前 Class 仅是提供一些基本写法与功能,随着标准化的进展,相信会有更多的功能加入。

那对于实现 mixin 方法来说就没什么不一样了。但既然讲到了语法糖,就来讲讲另一个语法糖 Decorator,正巧可以来实现 Class 上的 mixin。

Decorator 在 ES7 中定义的新特性,与 Java 中的 pre-defined Annotations 相似。但与 Java 的 annotations 不同的是 decorators 是被运用在运行时的方法。在 Redux 或其他一些应用层框架中渐渐用 decorator 实现对 component 的『修饰』。现在,我们来用 decorator 来现实 mixin。

core-decorators.js 为开发者提供了一些实用的 decorator,其中实现了我们正想要的@minxin。我们来解读一下核心实现。

import { getOwnPropertyDescriptors } from './private/utils';

const { defineProperty } = Object;

function handleClass(target, mixins) {
  if (!mixins.length) {
    throw new SyntaxError(`@mixin() class ${target.name} requires at least one mixin as an argument`);
  }

  for (let i = 0, l = mixins.length; i < l; i++) {
    // 获取 mixins 的 attributes 对象
    const descs = getOwnPropertyDescriptors(mixins[i]);

    // 批量定义 mixin 的 attributes 对象
    for (const key in descs) {
      if (!(key in target.prototype)) {
        defineProperty(target.prototype, key, descs[key]);
      }
    }
  }
}

export default function mixin(...mixins) {
  if (typeof mixins[0] === 'function') {
    return handleClass(mixins[0], []);
  } else {
    return target => {
      return handleClass(target, mixins);
    };
  }
}

它实现部分的源代码十分简单,它将每一个 mixin 对象的方法都叠加到 target 对象的原型上以达到 mixin 的目的。这样,就可以用 @mixin 来做多个重用模块的叠加了。

import React, { Component } from 'React';
import { mixin } from 'core-decorators';

const PureRender = {
  shouldComponentUpdate() {}
};

const Theme = {
  setTheme() {}
};

@mixin(PureRender, Theme)
class MyComponent extends Component {
  render() {}
}

细心的读者有没有发现这个 mixin 与 createClass 上的 mixin 有区别。上述实现 mixin 的逻辑和最早实现的简单逻辑是很相似的,之前直接给对象的 prototype 属性赋值,但这里用了getOwnPropertyDescriptor 和 defineProperty 这两个方法,有什么区别呢?

事实上,这样实现的好处在于 defineProperty 这个方法,也是定义与赋值的区别,定义则是对已有的定义,赋值则是覆盖已有的定义。所以说前者并不会覆盖已有方法,后者是会的。本质上与官方的 mixin 方法都很不一样,除了定义方法级别的不能覆盖之外,还得加上对生命周期方法的继承,以及对 State 的合并。

再回到 decorator 身上,上述只是作用在类上的方法,还有作用在方法上的,它可以控制方法的自有属性,也可以作 decorator 工厂方法。在其它语言里,decorator 用途广泛,具体扩展不在本文讨论的范围。

讲到这里,对于 React 来说我们自然可以用上述方法来做 mixin。但 React 开发社区提出了『全新』的方式来取代 mixin,那就是 Higher-Order Components。

DeanPaul commented 6 years ago

Higher-Order Components(HOCs)

Higher-Order Components(HOCs)最早由 Sebastian Markbåge(React 核心开发成员)在 gist 提出的一段代码。

Higher-Order 这个单词相信都很熟悉,Higher-Order function(高阶函数)在函数式编程是一个基本概念,它描述的是这样一种函数,接受函数作为输入,或是输出一个函数。比如常用的工具方法 map、reduce、sort 都是高阶函数。

而 HOCs 就很好理解了,将 Function 替代成 Component 就是所谓的高阶组件。如果说 mixin 是面向 OOP 的组合,那 HOCs 就是面向 FP 的组合。先看一个 HOC 的例子,

import React, { Component } from 'React';

const PopupContainer = (Wrapper) =>
  class WrapperComponent extends Component {
    componentDidMount() {
      console.log('HOC did mount')
    }

    componentWillUnmount() {
      console.log('HOC will unmount')
    }

    render() {
      return <Wrapper {...this.props} />;
    }
  } 

上面例子中的 PopupContainer 方法就是一个 HOC,返回一个 React Component。值得注意的是 HOC 返回的总是新的 React Component。要使用上述的 HOC,那可以这么写。

import React, { Component } from 'React';

class MyComponent extends Component {
  render() {}
}

export default PopupContainer(MyStatelessComponent);

封装的 HOC 就可以一层层地嵌套,这个组件就有了嵌套方法的功能。对,就这么简单,保持了封装性的同时也保留了易用性。我们刚才讲到了 decorator,也可以用它转换。

import React, { Component } from 'React';

@PopupContainer
class MyComponent extends Component {
  render() {}
}

export default MyComponent;

简单地替换成作用在类上的 decorator,理解起来就是接收需要装饰的类为参数,返回一个新的内部类。恰与 HOCs 的定义完全一致。所以,可以认为作用在类上的 decorator 语法糖简化了高阶组件的调用。

如果有很多个 HOC 呢,形如 f(g(h(x)))。要不很多嵌套,要不写成 decorator 叠罗汉。再看一下它,有没有想到 FP 里的方法?

import React, { Component } from 'React';

// 来自 https://gist.github.com/jmurzy/f5b339d6d4b694dc36dd
let as = T => (...traits) => traits.reverse().reduce((T, M) => M(T), T);

class MyComponent extends as(Component)(Mixin1, Mixin2, Mixin3(param)) { }

绝妙的方法!或用更好理解的 compose 来做

import React, { Component } from 'React';
import R from 'ramda';

const mixins = R.compose(Mixin3(param), Mixin2, Mixin1);

class MyComponent extends mixins(Component) {}

讲完了用法,这种 HOC 有什么特殊之处呢,

从侵入 class 到与 class 解耦,React 一直推崇的声明式编程优于命令式编程,而 HOCs 恰是。 调用顺序不同于 React Mixin,上述执行生命周期的过程类似于 堆栈调用: didmount -> HOC didmount -> (HOCs didmount) -> (HOCs will unmount) -> HOC will unmount -> unmount; HOCs 对于主 Component 来说是 隔离 的,this 变量不能传递,以至于不能传递方法,包括 ref。但可以用 context 来传递全局参数,一般不推荐这么做,很可能会造成开发上的困扰。 当然,HOCs 不仅是上述这一种方法,我们还可以利用 Class 继承 来写,再来一个例子

const PopupContainer = (Wrapper) =>
  class WrapperComponent extends Wrapper {
    static propTypes = Object.assign({}, Component.propTypes, {
      foo: React.PropTypes.string,
    });

    componentDidMount() {
      super.componentDidMount && super.componentDidMount();
      console.log('HOC did mount')
    }

    componentWillUnmount() {
      super.componentWillUnmount && super.componentWillUnmount();
      console.log('HOC will unmount')
    }
  }

其实,这种方法与第一种构造是完全不一样的。区别在哪,仔细看 Wrapper 的位置处在了继承的位置。这种方法则要通用得多,它通过继承原 Component 来做,方法都是可以通过 super 来顺序调用。因为依赖于继承的机制,HOC 的调用顺序和 队列 是一样的。

didmount -> HOC didmount -> (HOCs didmount) -> will unmount -> HOC will unmount -> (HOC will unmount) 细心的你是否已经看出 HOCs 与 React Mixin 的顺序是反向的,很简单,将 super 执行放在后面就可以达到正向的目的,尽管看上去很怪。这种不同很可能会导致问题的产生。尽管它是未来可能的选项,但现在看还有不少问题。

总结 未来的 React 中 mixin 方案 已经有伪代码现实,还是利用继承特性来做。

但继承并不是 "React Way",Sebastian Markbåge 认为实现更方便地 Compsition(组合)比做一个抽象的 mixin 更重要。而且聚焦在更容易的组合上,我们才可以摆脱掉 "mixin"。

对于『重用』,可以从语言层面上去说,都是为了可以更好的实现抽象,实现的灵活性与写法也存在一个平衡。在 React 未来的发展中,期待有更好的方案出现,同样期待 ES 未来的草案中有增加 Mixin 的方案。就今天来说,怎么去实现一个不复杂又好用的 mixin 是我们思考的内容。

DeanPaul commented 6 years ago

为什么使用 Mixin ? React回避子类组件,但是我们知道,到处重复地编写同样的代码是不好的。所以为了将同样的功能添加到多个组件当中,你需要将这些通用的功能包装成一个mixin,然后导入到你的模块中。 可以说,相比继承而已,React更喜欢这种组合的方式。嗯,我也喜欢这样。

写一个简单的 Mixin 现在假设我们在写一个app,我们知道在某些情况下我们需要在好几个组件当中设置默认的name属性。 现在,我们不再是像以前一样在每个组件中写多个同样的getDefaultProps方法,我们可以像下面一样定义一个mixin:

var DefaultNameMixin = {
    getDefaultProps: function () {
        return {name: "Skippy"};
    }
};

它没什么特殊的,就是一个简单的对象而已。

加入到 React 组件中 为了使用mixin,我们只需要简单得在组件中加入mixins属性,然后把刚才我们写的mixin包裹成一个数组,将它作为该属性的值即可:


var ComponentOne = React.createClass({
    mixins: [DefaultNameMixin],
    render: function() {
    return <h2>Hello {this.props.name}</h2>;
    }
});

React.renderComponent(<ComponentOne />, document.body);

重复使用 就像你想象的那样,我们可以在任何其他组件中包含我们的mixin:

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

生命周期方法会被重复调用! 如何你的mixin当中包含生命周期方法,不要焦急,你仍然可以在你的组件中使用这些方法,而且它们都会被调用:

JSFiddle 示例:展示了两个 default props 都会被设置点击预览

两个getDefaultProps方法都将被调用,所以我们可以得到默认为Skippy的name属性和默认为Pancakes的food属性。任何一个生命周期方法或属性都会被顺利地重复调用,但是下面的情况除外:

render:包含多个render方法是不行的。React 会跑出异常:

Uncaught Error: Invariant Violation: ReactCompositeComponentInterface: You are attempting to define render on your component more than once. This conflict may be due to a mixin. displayName:你多次的对它进行设置是没有问题的,但是,最终的结果只以最后一次设置为准。

需要指出的是,mixin是可以包含在其他的mixin中的:

var UselessMixin = {
    componentDidMount: function () {
      console.log("asdas");
    }
};

var LolMixin = {
   mixins: [UselessMixin]
};

var PantsOpinion = React.createClass({
   mixins: [LolMixin],
   render: function () {
       return (<p>I dislike pants</p>);
   }
});

React.renderComponent(<PantsOpinion />, document.body);

程序会在控制台打印出asdas。

包含多个 Mixins 我们的mixins要包裹在数组当中,提醒了我们可以在组件中包含多个mixins:

var DefaultNameMixin = {
    getDefaultProps: function () {
        return {name: "Lizie"};
    }
};

var DefaultFoodMixin = {
    getDefaultProps: function () {
        return {food: "Pancakes"};
    }
};

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

注意事项 这里有几件事需要引起我们的注意,当我们使用mixins的时候。 幸运地是,这些看起来并不是什么大问题,下面是我们在实践当中发现的一些问题:

设置相同的 Prop 和 State 如果你尝试在不同的地方定义相同的属性时会出现下面的异常:

Uncaught Error: Invariant Violation: mergeObjectsWithNoDuplicateKeys(): Tried to merge two objects with the same key: name 设置相同的方法 在不同的mixin中定义相同的方法,或者mixin和组件中包含了相同的方法时,会抛出异常:

var LogOnMountMixin = {
    componentDidMount: function () {
        console.log("mixin mount method");
        this.logBlah()
    },
    // add a logBlah method here...
    logBlah: function () {
        console.log("blah");
    }
};

var MoreLogOnMountMixin = {
    componentDidMount: function () {
        console.log("another mixin mount method");
    },
    // ... and again here.
    logBlah: function () {
        console.log("something other than blah");
    }
};

异常信息同多次定义rander方法时抛出的异常一样:

Uncaught Error: Invariant Violation: ReactCompositeComponentInterface: You are attempting to define logBlah on your component more than once. This conflict may be due to a mixin. 多个生命周期方法的调用顺序 如果我们的组件和mixin中都包含了相同的生命周期方法的话会怎样呢?

我们的mixin方法首先会被调用,然后再是组件的中方法被调用。

那当我们的组件中包含多个mixin,而这些mixin中又包含相同的生命周期方法时,调用顺序又是如何?

它们会根据mixins中的顺序从左到右的进行调用。

var LogOnMountMixin = {
    componentDidMount: function () {
        console.log("mixin mount method");
    }
};

var MoreLogOnMountMixin = {
    componentDidMount: function () {
        console.log("another mixin mount method");
    }
};
var ComponentOne = React.createClass({
    mixins: [MoreLogOnMountMixin, LogOnMountMixin],
    componentDidMount: function () {
        console.log("component one mount method");
    },
    ...

var ComponentTwo = React.createClass({
    mixins: [LogOnMountMixin, MoreLogOnMountMixin],
    componentDidMount: function () {
        console.log("component two mount method");
    },

控制台将输出:

another mixin mount method mixin mount method component one mount method

mixin mount method another mixin mount method component two mount method