React HOC高階元件詳解

Jokcy發表於2018-10-08

High Order Component(包裝元件,後面簡稱HOC),是React開發中提高元件複用性的高階技巧。HOC並不是React的API,他是根據React的特性形成的一種開發模式。

HOC具體上就是一個接受元件作為引數並返回一個新的元件的方法

const EnhancedComponent = higherOrderComponent(WrappedComponent)
複製程式碼

在React的第三方生態中,有非常多的使用,比如Redux的connect方法或者React-Router的withrouter方法。

舉個例子

我們有兩個元件:

// CommentList
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>
    );
  }
}
複製程式碼
// BlogPost
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} />;
  }
}
複製程式碼

他們雖然是兩個不同的元件,對DataSource的需求也不同,但是他們有很多的內容是相似的:

  • 在元件渲染之後監聽DataSource
  • 在監聽器裡面呼叫setState
  • 在unmout的時候刪除監聽器

在大型的工程開發裡面,這種相似的程式碼會經常出現,那麼如果有辦法把這些相似程式碼提取並複用,對工程的可維護性和開發效率可以帶來明顯的提升。

使用HOC我們可以提供一個方法,並接受不了元件和一些元件間的區別配置作為引數,然後返回一個包裝過的元件作為結果。

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} />;
    }
  };
}
複製程式碼

然後我們就可以通過簡單的呼叫該方法來包裝元件:

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

const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)
);
複製程式碼

注意:在HOC中我們並沒有修改輸入的元件,也沒有通過繼承來擴充套件元件。HOC是通過組合的方式來達到擴充套件元件的目的,一個HOC應該是一個沒有副作用的方法。

在這個例子中我們把兩個元件相似的生命週期方法提取出來,並提供selectData作為引數讓輸入元件可以選擇自己想要的資料。因為withSubscription是個純粹的方法,所以以後如果有相似的元件,都可以通過該方法進行包裝,能夠節省非常多的重複程式碼。

不要修改原始元件,使用組合進行功能擴充套件

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);
複製程式碼

通過以上方式我們也可以達到擴充套件元件的效果,但是會存在一些問題

  • 如果InputComponent本身也有componentWillReceiveProps生命週期方法,那麼就會被覆蓋
  • functional component不適用,因為他根本不存在生命週期方法

修改原始元件的方式缺乏抽象化,使用者必須知道這個方法是如何實現的來避免上面提到的問題。

如果通過組合的方式來做,我們就可以避免這些問題

function logProps(InputComponent) {
  return class extends React.Component{
    componentWillReceiveProps(nextProps) {
        console.log('Current props: ', this.props);
        console.log('Next props: ', nextProps);
    }
    render() {
        <InputComponent {...this.props} />
    }
  }
}

// EnhancedComponent will log whenever props are received
const EnhancedComponent = logProps(InputComponent);
複製程式碼

慣例:無關的props傳入到原始元件

HOC元件會在原始元件的基礎上增加一些擴充套件功能使用的props,那麼這些props就不應該傳入到原始元件(當然有例外,比如HOC元件需要使用原始元件指定的props),一般來說我們會這樣處理props:

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}
    />
  );
}
複製程式碼

extraProp是HOC元件中要用的props,不用的剩下的props我們都認為是原始元件需要使用的props,如果是兩者通用的props你可以單獨傳遞。

慣例:包裝元件的顯示名稱來方便除錯

function withSubscription(WrappedComponent) {
  class WithSubscription extends React.Component {/* ... */}
  WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`;
  return WithSubscription;
}

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}
複製程式碼

簡單來說就是通過手動指定displayName來讓HOC元件能夠更方便得被react devtool觀察到

慣例:不要在render方法裡面呼叫HOC方法

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 />;
}
複製程式碼

一來每次呼叫enhance返回的都是一個新的class,react的diffing演算法是根據元件的特徵來判斷是否需要重新渲染的,如果兩次render的時候元件之間不是(===)完全相等的,那麼會直接重新渲染,而部署根據props傳入之後再進行diff,對效能損耗非常大。並且重新渲染會讓之前的元件的state和children全部丟失。

二來React的元件是通過props來改變其顯示的,完全沒有必要每次渲染動態產生一個元件,理論上需要在渲染時自定義的引數,都可以通過事先指定好props來實現可配置。

靜態方法必須被拷貝

有時候會在元件的class上面外掛一下幫助方法,如果按照上面的方法進行包裝,那麼包裝之後的class就沒有來這些靜態方法,這時候為了保持元件使用的一致性,一般我們會把這些靜態方法拷貝到包裝後的元件上。

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

這個之適用於你已知輸入元件存在那些靜態方法的情況,如果需要可擴充套件性更高,那麼可以選擇使用第三方外掛hoist-non-react-statics

import hoistNonReactStatic from 'hoist-non-react-statics';
function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  hoistNonReactStatic(Enhance, WrappedComponent);
  return Enhance;
}
複製程式碼

ref

ref作為React中的特殊屬性--類似於key,並不屬於props,也就是說我們使用傳遞props的方式並不會把ref傳遞進去,那麼這時候如果我們在HOC元件上放一個ref,拿到的是包裝之後的元件而不是原始元件,這可能就會導致一些問題。

在React 16.3之後官方增加來一個React.forwardRef方法來解決這個問題,具體可以參考這裡

我是Jocky,一個專注於React技巧和深度分析的前端工程師,React絕對是一個越深入學習,越能讓你覺得他的設計精巧,思想超前的框架。關注我獲取最新的React動態,以及最深度的React學習。更多的文章看這裡

相關文章