React 高階元件(HOC)入門指南

請叫我王磊同學發表於2017-05-12

  之前的文章React Mixins入門指南介紹了React Mixin的使用。在實際使用中React Mixin的作用還是非常強大的,能夠使得我們在多個元件中共用相同的方法。但是工程中大量使用Mixin也會帶來非常多的問題。Dan Abramov在文章Mixins Considered Harmful 介紹了Mixin帶來的一些問題,總結下來主要是以下幾點:

  • 破壞元件封裝性: Mixin可能會引入不可見的屬性。例如在渲染元件中使用Mixin方法,給元件帶來了不可見的屬性(props)和狀態(state)。並且Mixin可能會相互依賴,相互耦合,不利於程式碼維護。
  • 不同的Mixin中的方法可能會相互衝突

  為了處理上述的問題,React官方推薦使用高階元件(High Order Component)

高階元件(HOC)

  剛開始學習高階元件時,這個概念就透漏著高階的氣味,看上去就像是一種先進的程式設計技術的一個深奧術語,畢竟名字裡就有"高階"這種字眼,實質上並不是如此。高階元件的概念應該是來源於JavaScript的高階函式:

高階函式就是接受函式作為輸入或者輸出的函式

  這麼看來柯里化也是高階函式了。React官方定義高階元件的概念是:

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

  (本人也翻譯了React官方文件的Advanced Guides部分,官方的高階元件中文文件戳這裡)

  這麼看來,高階元件僅僅只是是一個接受元件組作輸入並返回元件的函式。看上去並沒有什麼,那麼高階元件能為我們帶來什麼呢?首先看一下高階元件是如何實現的,通常情況下,實現高階元件的方式有以下兩種:

  1. 屬性代理(Props Proxy)
  2. 反向繼承(Inheritance Inversion)

屬性代理

  又是一個聽起來很高大上的名詞,實質上是通過包裹原來的元件來操作props,舉個簡單的例子:


import React, { Component } from 'React';
//高階元件定義
const HOC = (WrappedComponent) =>
  class WrapperComponent extends Component {
    render() {
      return <WrappedComponent {...this.props} />;
    }
}
//普通的元件
class WrappedComponent extends Component{
    render(){
        //....
    }
}

//高階元件使用
export default HOC(WrappedComponent)複製程式碼

  上面的例子非常簡單,但足以說明問題。我們可以看見函式HOC返回了新的元件(WrapperComponent),這個元件原封不動的返回作為引數的元件(也就是被包裹的元件:WrappedComponent),並將傳給它的引數(props)全部傳遞給被包裹的元件(WrappedComponent)。這麼看起來好像並沒有什麼作用,其實屬性代理的作用還是非常強大的。

操作props

  我們看到之前要傳遞給被包裹元件WrappedComponent的屬性首先傳遞給了高階元件返回的元件(WrapperComponent),這樣我們就獲得了props的控制權(這也就是為什麼這種方法叫做屬性代理)。我們可以按照需要對傳入的props進行增加、刪除、修改(當然修改帶來的風險需要你自己來控制),舉個例子:

const HOC = (WrappedComponent) =>
    class WrapperComponent extends Component {
        render() {
            const newProps = {
                name: 'HOC'
            }
            return <WrappedComponent
                {...this.props}
                {...newProps}
            />;
        }
    }複製程式碼

  在上面的例子中,我們為被包裹元件(WrappedComponent)新增加了固定的name屬性,因此WrappedComponent元件中就會多一個name的屬性。

獲得refs的引用

  我們在屬性代理中,可以輕鬆的拿到被包裹的元件的例項引用(ref),例如:

import React, { Component } from 'React';
 
const HOC = (WrappedComponent) =>
    class wrapperComponent extends Component {
        storeRef(ref) {
            this.ref = ref;
        }
        render() {
            return <WrappedComponent
                {...this.props}
                ref = {::this.storeRef}
            />;
        }
    }複製程式碼

  上面的例子中,wrapperComponent渲染接受後,我們就可以拿到WrappedComponent元件的例項,進而實現呼叫例項方法的操作(當然這樣會在一定程度上是反模式的,不是非常的推薦)。

抽象state

  屬性代理的情況下,我們可以將被包裹元件(WrappedComponent)中的狀態提到包裹元件中,一個常見的例子就是實現不受控元件受控的元件的轉變(關於不受控元件和受控元件戳這裡)

class WrappedComponent extends Component {
    render() {
        return <input name="name" {...this.props.name} />;
    }
}

const HOC = (WrappedComponent) =>
    class extends 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} />;
        }
    }複製程式碼

  上面的例子中通過高階元件,我們將不受控元件(WrappedComponent)成功的轉變為受控元件.

用其他元素包裹元件

  我們可以通過類似:

    render(){
        <div>
            <WrappedComponent {...this.props} />
        </div>
    }複製程式碼

  這種方式將被包裹元件包裹起來,來實現佈局或者是樣式的目的。

  在屬性代理這種方式實現的高階元件,以上述為例,元件的渲染順序是: 先WrappedComponent再WrapperComponent(執行ComponentDidMount的時間)。而解除安裝的順序是先WrapperComponent再WrappedComponent(執行ComponentWillUnmount的時間)。

反向繼承

  反向繼承是指返回的元件去繼承之前的元件(這裡都用WrappedComponent代指)

const HOC = (WrappedComponent) =>
  class extends WrappedComponent {
    render() {
      return super.render();
    }
  }複製程式碼

   我們可以看見返回的元件確實都繼承自WrappedComponent,那麼所有的呼叫將是反向呼叫的(例如:super.render()),這也就是為什麼叫做反向繼承。

渲染劫持

  渲染劫持是指我們可以有意識地控制WrappedComponent的渲染過程,從而控制渲染控制的結果。例如我們可以根據部分引數去決定是否渲染元件:

const HOC = (WrappedComponent) =>
  class extends WrappedComponent {
    render() {
      if (this.props.isRender) {
        return super.render();
      } else {
        return null;
      }
    }
  }複製程式碼

  甚至我們可以修改修改render的結果:

//例子來源於《深入React技術棧》

const HOC = (WrappedComponent) =>
    class 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;
    }
}
class WrappedComponent extends Component{
    render(){
        return(
            <input value={'Hello World'} />
        )
    }
}
export default HOC(WrappedComponent)
//實際顯示的效果是input的值為"may the force be with you"複製程式碼

  上面的例子中我們將WrappedComponent中的input元素value值修改為:may the force be with you。我們可以看到前後elementTree的區別:
elementsTree:

React 高階元件(HOC)入門指南
elementsTree

newElementsTree:

React 高階元件(HOC)入門指南
newElementsTree

  在反向繼承中,我們可以做非常多的操作,修改state、props甚至是翻轉Element Tree。反向繼承有一個重要的點: 反向繼承不能保證完整的子元件樹被解析,開始我對這個概念也不理解,後來在看了React Components, Elements, and Instances這篇文章之後對這個概念有了自己的一點體會。
React Components, Elements, and Instances這篇文章主要明確了一下幾個點:

  • 元素(element)是一個是用DOM節點或者元件來描述螢幕顯示的純物件,元素可以在屬性(props.children)中包含其他的元素,一旦建立就不會改變。我們通過JSXReact.createClass建立的都是元素。
  • 元件(component)可以接受屬性(props)作為輸入,然後返回一個元素樹(element tree)作為輸出。有多種實現方式:Class或者函式(Function)。

  所以, 反向繼承不能保證完整的子元件樹被解析的意思的解析的元素樹中包含了元件(函式型別或者Class型別),就不能再操作元件的子元件了,這就是所謂的不能完全解析。舉個例子:

import React, { Component } from 'react';

const MyFuncComponent = (props)=>{
    return (
        <div>Hello World</div>
    );
}

class MyClassComponent extends Component{

    render(){
        return (
            <div>Hello World</div>
        )
    }

}

class WrappedComponent extends Component{
    render(){
        return(
            <div>
                <div>
                    <span>Hello World</span>
                </div>
                <MyFuncComponent />
                <MyClassComponent />
            </div>

        )
    }
}

const HOC = (WrappedComponent) =>
    class extends WrappedComponent {
        render() {
            const elementsTree = super.render();
            return elementsTree;
        }
    }

export default HOC(WrappedComponent);複製程式碼

React 高階元件(HOC)入門指南
element tree1

React 高階元件(HOC)入門指南
element tree2

  我們可以檢視解析的元素樹(element tree),div下的span是可以被完全被解析的,但是MyFuncComponentMyClassComponent都是元件型別的,其子元件就不能被完全解析了。

操作props和state

  在上面的圖中我們可以看到,解析的元素樹(element tree)中含有propsstate(例子的元件中沒有state),以及refkey等值。因此,如果需要的話,我們不僅可以讀取propsstate,甚至可以修改增加、修改和刪除。

  在某些情況下,我們可能需要為高階屬性傳入一些引數,那我們就可以通過柯里化的形式傳入引數,例如:

import React, { Component } from 'React';

const HOCFactoryFactory = (...params) => {
    // 可以做一些改變 params 的事
    return (WrappedComponent) => {
        return class HOC extends Component {
            render() {
                return <WrappedComponent {...this.props} />;
            }
        }
    }
}複製程式碼

可以通過下面方式使用:

HOCFactoryFactory(params)(WrappedComponent)複製程式碼

  這種方式是不是非常類似於React-Redux庫中的connect函式,因為connect也是類似的一種高階函式。反向繼承不同於屬性代理的呼叫順序,元件的渲染順序是: 先WrappedComponent再WrapperComponent(執行ComponentDidMount的時間)。而解除安裝的順序也是先WrappedComponent再WrapperComponent(執行ComponentWillUnmount的時間)。

HOC和Mixin的比較

  借用《深入React技術棧》一書中的圖:

React 高階元件(HOC)入門指南
HOCandMixin

  高階元件屬於函數語言程式設計(functional programming)思想,對於被包裹的元件時不會感知到高階元件的存在,而高階元件返回的元件會在原來的元件之上具有功能增強的效果。而Mixin這種混入的模式,會給元件不斷增加新的方法和屬性,元件本身不僅可以感知,甚至需要做相關的處理(例如命名衝突、狀態維護),一旦混入的模組變多時,整個元件就變的難以維護,也就是為什麼如此多的React庫都採用高階元件的方式進行開發。

相關文章