React 重溫之高階元件(HOC)

紫日殘月發表於2018-06-13

什麼是高階元件

話不多說,先看官方釋義:

Concretely, a higher-order component is a function that takes a component and returns a new component.

上面這段話,已經很清楚明白的告訴我們高階元件是什麼,以及高階元件是幹啥的。a higher-order component is a function告訴我們說高階元件是一個函式(function),是一個什麼函式呢? takes a component and returns a new component.是一個接收一個元件作為引數,最終返回一個新元件的函式。

所以說,高階元件並不是一個“元件”,而是一個函式,叫“高階函式”可能更加合適一些,但高階函式這個名字被人佔用了,高階函式是以函式為引數,最終返回一個新函式的函式。那為什麼又要加高階元件呢?這個高階元件具體指的是什麼東西呢?

其實,高階元件指的是函式接收一個元件後,最終返回的那個新元件。因為這個新元件把我們當做引數傳入的元件給包裹在內,相對於我們傳入的元件來說,這個返回的新的元件就是“高階元件”了。

幹啥這麼麻煩

我們都知道,React讓我們抽象出一些可複用的元件從而減少前端工作量,一般情況下我們只需要定義一些元件,然後把他們組裝成一個元件樹就好了,為啥還要弄一個函式來去包裹元件呢?

其實呢,歸根結底,都是因為懶。。。因為我們懶得一遍遍寫相同的程式碼,我們把具有相同邏輯的內容抽象成一個元件,一次定義,到處可用;同樣因為懶,我們把具有類似功能的元件抽象,用一個新的元件去包裹它,把相同的部分放到包裹元件裡,不同的部分放到各自原本元件裡,那麼這個新的用來包裹我們類似元件的新元件,就是“高階元件”了。

說到底,我們在業務邏輯的基礎上完成一次抽象過程,得到一個個元件;在元件的基礎再做一次抽象,得到一個高階元件(高階函式)。

Show me the code

閒話少說,讓我們來看下官方的示例:

首先是一個CommentList元件,這個元件從外部資料來源訂閱資料並展示評論列表:

class CommentList extends React.Component {
  constructor() {
    super();
    this.handleChange = this.handleChange.bind(this);
    this.state = {
      // "DataSource" 就是全域性的資料來源
      comments: DataSource.getComments()
    };
  }

  componentDidMount() {
    // 新增事件處理函式訂閱資料
    DataSource.addChangeListener(this.handleChange);
  }

  componentWillUnmount() {
    // 清除事件處理函式
    DataSource.removeChangeListener(this.handleChange);
  }

  handleChange() {
    // 任何時候資料發生改變就更新元件
    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} />;
  }
}

從這兩個元件的程式碼上來看,我們很容易就可以發現一個問題:他倆長的太像了。。。這不都是監聽外部資料來源,有變動了就更新自己的state,然後把資料按照各自的邏輯渲染出來嘛。唯一不一樣的地方就是每個元件需要的資料和渲染方式不一樣。

作為一個以出名的程式設計師,看到這樣的元件,你很可能已經想把他們相同的東西拿出來放到一個地方,只保留各自不同的部分,不然誰知道以後業務邏輯變化了,還有多少類似的元件等著你,難道要把重複的程式碼到處寫嗎?Don‘t Repeat Yourself!

OK,如果你這麼想了,那就很靠近高階元件的思想了,下面就是針對上面的元件,官方給出的高階元件:

function withSubscription(WrappedComponent, selectData) {
  // ……返回另一個新元件……
  return class extends React.Component {
    constructor(props) {
      super(props);
      this.handleChange = this.handleChange.bind(this);
      this.state = {
        data: selectData(DataSource, props)
      };
    }

    componentDidMount() {
      // ……注意訂閱資料……
      DataSource.addChangeListener(this.handleChange);
    }

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

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

    render() {
      // ……使用最新的資料渲染元件
      // 注意此處將已有的props屬性傳遞給原元件
      return <WrappedComponent data={this.state.data} {...this.props} />;
    }
  };
}

我們看到,withSubscription是一個函式,接收WrappedComponent, selectData兩個引數,最終返回一個新的元件。在新元件的render()函式裡,直接返回了WrappedComponent這個被包裹的元件。在handleChange函式裡,使用selectData函式來篩選被包裹元件需要的資料。

我們上面說到,BlogPost和CommentList這兩個元件除了需要的資料和渲染資料的方式不同外,其它基本都一樣,於是在withSubscription函式裡,我們把傳入元件原封不動的渲染,在篩選資料的時候,使用傳入的selectData函式來篩選,於是withSubscription這個函式就可以很容易的返回一個高階元件來包裹 需要不同資料和渲染方式 的元件。

使用方式如下:

//首先簡化元件定義

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

class BlogPost extends React.Component {
  render() {
    return <TextBlock text={this.state.blogPost} />;
  }
}
//去包裹元件

const CommentListWithSubscription = withSubscription(
  CommentList,
  (DataSource) => DataSource.getComments()//自定義篩選資料
);

const BlogPostWithSubscription = withSubscription(
  BlogPost,
  (DataSource, props) => DataSource.getBlogPost(props.id)//自定義篩選資料
);

以上就是所有程式碼,我們把原來BlogPost和CommentList元件中重複的程式碼都放到包裹元件裡,只保留各自不同的部分,然後呼叫高階元件函式來生成CommentListWithSubscription和BlogPostWithSubscription這兩個元件,之後在需要用到BlogPost和CommentList元件的地方都用CommentListWithSubscription和BlogPostWithSubscription來替換就好了。

好像哪裡不太對

看完上面的官方示例後,如果你感覺好像哪裡不太對,那麼恭喜你,你基本上算是一個React高手了

那麼到底是哪裡不太對呢?細心的朋友可能已經發現了,我們在比較兩個被包裹元件的時候提到,兩個元件 需要不同資料和渲染方式,渲染方式是每個元件最核心的功能,這個沒法變動,可是資料有兩個來源啊,為啥非要從state裡拿資料?

我們完全可以把資料來源從元件內部的state拿到外部的props裡啊,這一樣一來同樣可以簡化元件的程式碼啊!

然而事情並沒有那麼簡單,我們之前提到,這些元件的資料來自 外部資料來源,如果我們把資料來源從state遷移到props,同樣需要在使用元件的地方去篩選資料,並沒有減少這個工作量,只是把這個工作量從元件內部移到使用元件的地方罷了。。。

注意

不要在render函式中使用高階元件

React使用的差異演算法(稱為協調)使用元件標識確定是否更新現有的子物件樹或丟掉現有的子樹並重新掛載。如果render函式返回的元件和之前render函式返回的元件是相同的,React就遞迴的比較新子物件樹和舊子物件樹的差異,並更新舊子物件樹。如果他們不相等,就會完全解除安裝掉舊的之物件樹。

在render使用高階元件,其實就是呼叫函式生成一個高階元件,基本每次render都會生成一個新的元件,這個就比較。。。

如果確實需要動態的呼叫高階元件,一個比較合理的方式是在元件的建構函式或生命週期函式中呼叫。

必須將靜態方法做拷貝

使用高階元件包裝元件,原始元件被容器元件包裹,也就意味著新元件會丟失原始元件的所有靜態方法。

決這個問題的方法就是,將原始元件的所有靜態方法全部拷貝給新元件:

Refs屬性不能傳遞

一般來說,高階元件可以傳遞所有的props屬性給包裹的元件,但是不能傳遞refs引用。因為並不是像key一樣,refs是一個偽屬性,React對它進行了特殊處理。如果你向一個由高階元件建立的元件的元素新增ref應用,那麼ref指向的是最外層容器元件例項的,而不是包裹元件。

具體可以參考React 重溫之 Refs

參考連結
參考連結

相關文章