[譯]React高階話題之高階元件

鯊叔發表於2018-11-27

前言

本文為意譯,翻譯過程中摻雜本人的理解,如有誤導,請放棄繼續閱讀。

原文地址:Higher-Order Components

正文

高階元件(後文中均以HOCs來指代)是React生態裡面的一種用來複用元件邏輯的高階技術。HOCs本身並不是React API的一部分,而是一種從React的可組合性中產生的模式。

具體來說,HOCs其實就是一個函式。只不過這個函式跟程式語言中普通的函式不同的是,它接受一個React元件作為輸入,返回了一個新的React元件。

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

我們從轉化的角度可以這麼說:“如果說,React元件是將props轉化為UI,那麼HOCs就是將一箇舊的元件轉化為一個新的元件(一般情況下,是作了增強)”。HOCs在第三方類庫中很常見,比如:Redux的connect方法,Relay的createFragmentContainer

在這個文件裡面,我麼將會討論為什麼HOCs這麼有用和我們該怎樣寫自己的高階元件。

使用HOCs來完成關注點分離

注意:我們之前一直在推薦使用mixins來完成關注點分離。但是後面我們發現了mixins所帶來的問題遠大於它存在所帶來的價值,我們就放棄使用它了。查閱這裡,看看我們為什麼放棄了mixins,並且看看你可以如何升級你的現有元件。

在React中,元件是程式碼複用的基本單元。然而你會發現,一些模式並不能簡單地適用於傳統意義上的元件。

舉個例子來說,假設你有一個叫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>
    );
  }
}
複製程式碼

而後,你又以相同的模式去寫了一個用於訂閱一篇部落格文章的元件。如下:

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和BlogPost是不完全相同的。它們分別在DataSource上呼叫不同的方法,渲染了不同的UI。但是,除了這些點之外,它們大部分是相同的:

  • 在掛載之後,都往DataSource裡面註冊了一個change listener。
  • 在change listener裡面,當資料來源發生改變時都呼叫了setState。
  • 在解除安裝之前,都要移除change listener。

你可以想象,在一個大型的專案中,這種模式的程式碼(訂閱一個DataSource,然後在資料發生變化的時候,呼叫setState來更新UI)會到處出現。我們需要將這種邏輯抽取出來,定義在單獨的地方,然後跨元件去共用它。而,這恰恰是HOCs要做的事情。

我們可以寫一個函式用於建立像CommentList和BlogPost那樣訂閱了DataSource的元件。這個函式將會接收子元件作為它的一個引數。然後這個子元件會接收訂閱的資料作為它的prop。我們姑且稱這個函式為withSubscription。

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

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

第一個引數是被包裹的元件(wrapped component),第二個引數是一個函式,負責通過我們傳遞進去的DataSource和props來獲取並返回我們需要的資料。

當CommentListWithSubscription和BlogPostWithSubscription被渲染之後,元件CommentList和BlogPost的data屬性將會得到從訂閱源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} />;
    }
  };
}
複製程式碼

注意,HOCs並沒有篡改我們傳遞進入的元件,也沒有繼承它然後複製它的行為。HOCs只是單純地將我們傳遞進入的元件包裹在一個容器元件(container component)。一個高階元件是純函式,不能包含任何的副作用。

就這麼多。wrapped component接受從container component傳遞下來的所有props,與此同時,還多了一個用於渲染最終UI的,新的prop:data。HOC它不關注你怎麼使用資料,為什麼要這樣使用。而wrapped component也不關心資料是從哪裡來的。

因為withSubscription只是一個普通函式,你可以定義任意多的引數。舉個例子,你想讓data 屬性變得更加的可配置,以便將HOC和wrapped component作進一步的解耦。又或者你可以增加一個引數來定義shouldComponentUpdate的實現。這些都是可以做到的,因為HOC只是一個純函式而已,它對元件的定義擁有百分百的話語權。

正如React元件一樣,高階元件withSubscription跟wrapped component的唯一關聯點只有props。這樣的清晰的關注點分離,使得wrapped component與其他HOC的結合易如反掌。前提是,另外一個HOC也提供相同的props給wrapped component。就拿上面的例子來說,如果你切換data-fetching類庫(DataSource),這將會是很簡單的。

戒律

1.不要修改原始元件,使用組合。

在HOC的內部,要抵制修改原始元件的prototype的這種誘惑(畢竟這種誘惑是觸手可及的)。

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

這樣做有以下的幾個壞處:

  • 原始元件不能脫離該高階元件去複用了。
  • 假如你把EnhancedComponent應用到另外一個HOC,恰好這個HOC在原型鏈上也做了同樣的修改。那麼,你第一個HOC的功能就被覆蓋掉了。
  • 上述例子中的寫法不能應用於function component。因為function component沒有生命週期函式。
  • 造成抽象封裝上的漏洞。一旦你這麼做了,那麼使用者為了避免衝突,他必須知道你到底在上一個HOC對wrapped component 做了什麼樣的修改,以免他也作出同樣的修改而導致衝突。

相對於修改,HOCs應該使用組合來實現。也就是說,把傳遞進來的元件包裹到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} />;
    }
  }
}
複製程式碼

上面這個組合版的HOC跟修改版的擁有相同的功能。相比修改版的HOC,它很好地避免了一些潛在的衝突。同時,它也能很好地跟function component和class component組合使用。最後,它也能很方便地跟其他HOC組合使用,或者甚至跟它自己。

container component是對高層級關注點與低層級關注點進行職責分離策略的一部分。在這個策略裡面,container component負責管理資料訂閱和儲存state,並且將所有的資料衍生為props傳遞給它的子元件,然後子元件負責渲染UI。HOCs把container模式作為了它實現的一部分。你可以理解為HOC是引數化的container component定義。

2.不要在React元件的render方法中使用HOCs

React的diff演算法(也稱之為reconciliation)是根據component的唯一標識(component identity )來決定這個元件是否應該從已經存在的子元件樹中更新還是徹底棄用它,掛載一個新的元件。如果一個component的render函式的返回值全等於(===)另外一個元件render函式的返回值,那麼React就認為他們是同一個元件,然後遞迴地更新這個元件的子元件樹。否則的話,就完全解除安裝之前的那個元件。

通常來說,你不需要考慮這些東西。但是,在使用HOCs的時候,你需要做這樣的考慮。因為你不能在一個元件的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 />;
}
複製程式碼

像上面這樣的做法會導致效能上的問題。什麼問題呢?那就是重新掛載一個元件會導致無法利用現有的元件state和子元件樹。 而我們想要的恰恰相反。我們想要的增強後的元件的標識在多次render呼叫過程中都是一致的。要想達成這種效果,我們需要在元件定義的外部去呼叫HOC來僅僅建立一次這個增強元件。

在極少數的情況下,你可能想動態地使用HOC,你可以在元件的非render生命週期函式或者constructor裡面這麼做。

約定俗成

1. 將(HOC)非相關的props傳遞給Wrapped component

HOCs本質就是給元件增加新的特性。他們不應該去大幅度地修改它與wrapped component的契約之所在-props。我們期待從HOC返回的新的元件與wrapped component擁有相同的介面(指的也是props)。

HOCs應該將它不關注的props原樣地傳遞下去(給增強後的新元件)。大部分的HOCs都會包含一個render方法,這個render方法看起來是這樣的:

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

我們這麼做的目的是讓HOCs能做到儘可能的可擴充套件和可複用。

2. 可組合性最大化

並不是所有的HOCs看起來都是一樣的。有一些HOC僅僅接收一個引數-wrapped component。

const NavbarWithRouter = withRouter(Navbar);
複製程式碼

一般來說,HOCs會接收其餘的一些引數。比如說Relay的createContainer方法,它的第二個引數就是一個配置型的引數,用於指明元件的資料依賴。

const CommentWithRelay = Relay.createContainer(Comment, config);
複製程式碼

HOCs最常見的函式簽名是這樣的:

// React Redux's `connect`
const ConnectedComment = connect(commentSelector, commentActions)(CommentList);
複製程式碼

什麼鬼?如果你把這行程式碼拆開來看,你就會知道這到底是怎麼回事。

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

換句話說,connect就是一個返回高階元件的高階函式。(注意,原文這裡用的是higher-orderfunction 和 higher-order component)!

這種寫法可能看起來是讓人疑惑或者是多餘的,實際上,它是有用的。從函數語言程式設計的角度來講,那種引數型別和返回型別一致的單引數函式是很容易組合的。而connect之所以要這麼實現,也是基於這種考慮。也就是說,相比這種簽名的函式(arg1,component)=> component,component => component 型別的函式更容易跟同型別的函式組合使用。具體的示例如下:

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

像connect的這種型別寫法的函式可以被用作ES7提案特性之一的裝飾器(decorators)。

像compose這種工具函式很多第三方的類庫都會提供,比如說lodash(loadash.flowRight),ReduxRamda

3. 給HOC追加displayName屬性

那個被HOCs建立的container component在React Developer Tools中長得跟普通的元件是一樣的。為了更方便除錯,我們要選擇一個display name 給它。

最常見的做法是給container component的靜態屬性displayName直接賦值。假如你的高階元件叫withSubscription,wrapped component叫CommentList,那麼container component的displayName的值就是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';
}
複製程式碼

注意點

1. 記得要把Wrapped component的靜態方法複製到增強後的元件中去

有時候,在React component上定義一個靜態方法還是挺有用的。比如說,Relay container就暴露了一個叫getFragment靜態方法來方便與GraphQL fragments的組合。

當你把一個元件傳遞進HOC,也僅僅意味著你把它包裹在container 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
複製程式碼

為了解決這個問題,你可以在HOC的內部先將原元件的靜態方法一一複製了,再返回出去。

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去自動地將所有非React的靜態方法一起復制過去。

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

另外一個可以考慮得解決方案是,在定義原元件的時候,把元件和這個元件的靜態方法分開匯出。然後在使用的時候,分開來匯入。

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

2. 記得將ref屬性傳遞下去

雖然說,將所有的prop都原樣傳遞下去是實現HOC的一個慣例,但是這種傳遞對ref這個屬性不起作用。那是因為,嚴格意義上說,ref並不是一個真正的prop,key也不是。它倆都是專用於React的內部實現的。如果你在一個由HOC建立並返回的元件(也就是說增強後的元件)上增加了ref屬性,那麼這個ref屬性指向的將會是HOC內部container component最外層那個元件的例項,而不是我們期待的wrapped component。

針對這個問題的解決方案是使用React.forwardRef這個API(在React的16.3版本引入的)。關於React.forwardRef,你可以查閱更多

相關文章