React高階元件的那些事

Srtian發表於2018-04-19

前言

學習react已經有一段時間了,期間在閱讀官方文件的基礎上也看了不少文章,但感覺對很多東西的理解還是不夠深刻,因此這段時間又在擼一個基於react全家桶的聊天App(現在還在瞎78寫的階段,在往這個聊天App這個方向寫),通過實踐倒是對react相關技術棧有了更為深刻的理解,而在使用react-redux的過程中,發現connect好像還挺有意思的,也真實感受到了高階元件所帶來的便利,出於自己寫專案本身就是為了學習的目的,因此對高階元件又進行了一番學習。寫下這篇文章主要是對高階元件的知識進行一個梳理與總結,如有錯誤疏漏之處,敬請指出,不勝感激。

初識高階元件

要學習高階元件首先我們要知道的就是高階元件是什麼,解決了什麼樣的問題。

React官方文件的對高階元件的說明是這樣的:

A higher-order component (HOC) is an advanced technique in React for reusing component logic. HOCs are not part of the React API, perse. They are a pattern that emerges from React’s compositional nature.

從上面的說明我們可以看出,react的高階元件並不是react API的一部分。它源自於react的生態。

簡單來說,一個高階元件就是一個函式,它接受一個元件作為輸入,然後會返回一個新的元件作為結果,且所返回的新元件會進行相對增強。值得注意的是,我們在這說的元件並不是元件例項,而是一個元件類或者一個無狀態元件的函式。就像這樣:

import React from 'react'

function removeUserProp(WrappedComponent) {
//WrappingComponent這個元件名字並不重要,它至少一個區域性變數,繼承自React.Component
    return class WrappingComponent extends React.Component {
        render() {
// ES6的語法,可以將一個物件的特定欄位過濾掉
            const {user, ...otherProps} = this.props
            return <WrappedComponent {...otherProps} />
        }
      }
}

複製程式碼

瞭解設計模式的大佬們應該發現了,它其實就是的設計模式中的裝飾者模式在react中的應用,它通過組合的方式從而達到很高的靈活程度和複用。 就像上面的程式碼,我們定義了一個叫做 removeUserProp 的高階元件,傳入一個叫做 WrappedComponent 的引數(代表一個元件類),然後返回一個新的元件 ,新的元件與原元件並沒有太大的區別,只是將原元件中的 prop 值 user 給剔除了出來。

有了上面這個高階元件的,當我們不希望某個元件接收到 user 時,我們就可以將這個元件作為引數傳入 removeUserProp() 函式中,然後使用這個返回的元件就行了:

const NewComponent = removeUserProp(OldComponent)
複製程式碼

這樣 NewComponent 元件與 OldComponent 元件擁有完全一樣的行為,唯一的區別就在於傳入的name屬性對這個元件沒有任何作用,它會自動遮蔽這個屬性。也就是說,我們這個高階元件成功的為傳入的元件增加了一個遮蔽某個prop的功能。

那麼明白了什麼是高階元件後,我們接下來要做的是,弄清楚高階元件主要解決的問題,或者說我們為什麼需要高階元件?總結起來主要是以下兩個方面:

  1. 程式碼重用

在很多情況下,react元件都需要共用同一個邏輯,我們在這個時候就可以把這部分共用的邏輯提取出來,然後利用高階元件的形式將其組合,從而減少很多重複的元件程式碼。

2.修改React元件的行為

很多時候有些現成的react元件並不是我們自己擼出來的,而是來自於GitHub上的大佬們的開源貢獻,而當我們要對這些元件進行復用的時候,我們往往都不想去觸碰這些元件的內部邏輯,這時我們就能通過高階元件產生新的元件滿足自身需求,同時也對原元件沒有任何損害。

現在我們對高階元件有了一個較為直觀的認識,知道了什麼是高階元件以及高階元件的主要用途。接下來我們就要具體瞭解高階元件的實現方式以及它的具體用途了。

高階元件的實現分類

對於高階元件的實現方式我們可以根據作為引數傳入的元件與返回的新元件的關係將高階元件的實現方式分為以下兩大類:

  • 代理方式的高階元件
  • 繼承方式的高階元件

代理方式的高階元件

從高階元件的使用頻率來講,我們使用的絕大多數的高階元件都是代理方式的高階元件,如react-redux中的connect,還有我們在上面所實現的那個removeUserProp。這類高階元件的特點是返回的新元件類直接繼承於 React.Component 類。新組建在其中扮演的角色是一個傳入引數元件的代理,在新組建的render函式中,把被包裹的元件渲染出來。在此過程中,除了高階元件自己需要做的工作,其他的工作都會交給被包裹的元件去完成。

代理方式的高階元件具體而言,應用場景可以分為以下幾個:

  • 操作prop
  • 通過ref獲取元件例項
  • 抽取狀態
  • 包裝元件

控制prop

代理型別的高階元件返回的新元件時,渲染過程也會被新組建的render函式所控制,而在此過程中,render函式相對於一個代理,完全決定該如何使用被包裹在其中的元件。在render函式中,this.props包含了新元件接受到的所有prop。因此最直觀的用法就是接受到props,然後進行任何讀取,增減,修改等控制props的自定義操作。 就比如我們上面的那個示例,就做到了刪除prop的功能,當然我們也能實現一個新增prop的高階元件:

function addNewProp(WrappedComponent, newProps) {
    return class WrappingComponent extends React.Component {
        render() {
          return <WrappedComponent {...thisProps} {...newProps} />
        }
      }
}
複製程式碼

這個addNewProp高階元件與我們最開始舉例的removeUserProp高階元件在實現上並無太大的區別。唯一區別較大的就是我們傳入的引數除了WrappedComponent元件類外,還新增了newProps引數。這樣的高階元件在複用性方面會跟友好,我們可以利用這樣一個高階元件給不同的元件新增不同的新屬性,比如這樣:

const FirstComponent = addNewProp(OldComponent,{num: First})
const LastComponent = addNewProp(NewComponent,{num: Last})
複製程式碼

在上面的程式碼中,我們實現了讓兩個完全不同的元件分別通過高階元件生成了兩個完成不同的新的元件,而這其中唯一相同的是都新增了一個屬性值,且這個屬性還不相同。從上面的程式碼我們也不難發現,高階元件可以重用在不同元件上,減少了重複的程式碼。當需要注意的是,在修改和刪除 Props的時候,除非由特殊的要求,否則最好不要影響到原本傳遞給普通元件的 Props。

通過ref獲取元件例項

我們可以通過ref獲取元件例項,但值得注意的是,React官方不提倡訪問ref,我們只是討論一下這個技術的可行性。在此我們寫一個refsHOC的高階元件,可以獲得被包裹元件的ref,從而根據ref直接操縱被包裹元件的例項:

import React from 'react'

function refsHOC(WrappedComponent) => {
  return class HOCComponent extends React.Component {
    constructor() {
      super(...arguments)
      this.linkRef = this.linkRef.bind(this)
    }
    linkRef(wrappedInstance) {
      this._root = wrappedInstance
    }
    render() {
      const props = {...this.props, ref: this.linkRef}
      return <WrappedComponent {...props}/>
    }
  }
}

export default refsHOC
複製程式碼

這個refs高階元件的工作原理其實也是增加傳遞給被包裹元件的props,不同的是利用了ref這個特殊的prop而已。我們通過linkRef來給被包裹元件傳遞ref值,linkRef被呼叫時,我們就可以得到被包裹元件的DOM例項。

這種高階元件在用途上來講可以說是無所不能的,因為只要能夠獲得對被包裹元件的引用,就能通過這個引用任意操縱一個元件的DOM元素,賊酸爽。但它從某個角度來講也是啥也幹不了的,因為react團隊表示:不要過度使用 Refs。且我們也有更好的替代品——控制元件(Controlled Component)來解決相關問題,因此這個坑建議大家還是儘量少踩為好。

抽取狀態

對於抽取狀態,我想大家應該都不會很陌生。react-redux中的connect函式就實現了這種功能,它異常的強大,也成功吸引了我對高階元件的注意力。但在這有一點需要明確的是:connect函式本身並不是高階元件,connect函式執行的結果才是一個高階元件。讓我們來看看connect的原始碼的主要邏輯:

export default function connect(mapStateToProps, mapDispatchToProps, mergeProps, options = {}) {
    return function wrapWithConnect(WrappedComponent) {
        class Connect extends Component {
            constructor(props, context) {
                //引數獲取
                super(props, context)
                this.store = props.store || context.store
                const storeState = this.store.getState()
                this.state = { storeState }
            }
            // 進行判斷,當資料發生改變時,Component重新渲染
            shouldComponentUpdate(nextProps, nextState) {
                if (propsChanged || mapStateProducedChange || dispatchPropsChanged) {
                 this.updateState(nextProps)
                  return true
                 }
                }
            // 改變Component中的state
            componentDidMount() {
                 this.store.subscribe(() = {
                  this.setState({
                   storeState: this.store.getState()
                  })
                 })
                }
            render(){
                this.renderedElement = createElement(WrappedComponent,
                    this.mergedProps
                )
                return this.renderedElement
            }
        }
        return hoistStatics(Connect, WrappedComponent)
    }
}
複製程式碼

從上面的程式碼我們不難看出connect模組的返回值wrapWithConnect是一個函式,而這個函式才是我們所認知的高階元件。wrapWithConnect函式會返回一個ReactComponent物件Connect,Connect會重新render外部傳入的原元件WrappedComponent,並把connect中所傳入的mapStateToProps, mapDispatchToProps和this.props合併後結合成一個物件,通過屬性的方式傳給WrappedComponent,這才是最終的渲染結果。

包裝元件

在日常開發中我們所接觸到的大多數的高階元件都是通過修改props部分來對輸入的元件進行相對增強的。但其實高階元件還有其他的方式來增強元件,比如我們可以通過在render函式中的JSX引入其他元素,甚至將多個react元件合併起來,來獲得更騷氣的樣式或方法,例如我們可以給元件增加style來改變元件樣式:

const styleHOC = (WrappedComponent, style) => {
    return class HOCComponent extends React.Component {
        render() {
            return (
            <div style={style}>
                <WrappedComponent {...this.props} />
            </div>
            )
        }
    }
}
複製程式碼

當我們想改變元件的樣式的時候,我們就可以直接呼叫這個函式,比如這樣:

const style = {
			background-color: #f1fafa;
			font-family: "微軟雅黑";
			font-size: 20px;
		}
const BeautifulComponent = styleHOC(uglyComponent, style)
複製程式碼

繼承方式的高階元件

前面我們討論了代理方式實現的高階元件以及它們的主要使用方式,現在我們繼續來討論一下以繼承方式實現的高階元件。

。繼承方式的高階元件通過繼承來關聯作為引數傳入的元件和返回的元件,比如傳入的元件引數是OldComponent,那函式所返回的元件就直接繼承於OldComponemt。

碼界有句老話說的好:組合優於繼承。在高階元件裡也不例外。 繼承方式的高階元件相對於代理方式的高階元件有很多不足之處,比如輸入的元件與輸出的元件共有一個生命週期等,因此通常我們接觸到的高階元件大多是代理方式實現的高階元件,也推薦大家首先考慮以代理方式來實現高階元件。但我們還是需要去了解並學習它,畢竟它也是有可取之處的,比如在操作生命週期函式上它還是具有其優越性的。

操作生命週期函式

說繼承方式的高階元件在操縱生命週期函式上有其優越性其實不夠說明它在這個領域的地位,更準確地表達是:操作生命週期函式是繼承方式的高階元件所特有的功能。這是由於繼承方式的高階元件返回的新元件繼承於作為引數傳入的元件,兩個元件的生命週期是共用的,因此可以重新定義元件的生命週期函式並作用於新元件。而代理方式的高階元件作為引數輸入的元件與輸出的元件完全是兩個生命週期,因此改變生命週期函式也就無從說起了。

例如我們可以定義一個讓引數元件只有在使用者登入時才顯示的高階元件:

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

操縱Prop

除了操作生命週期函式外,繼承方式的高階函式也能對Prop進行操作,但總的難說賊麻煩,當然也有簡單的方式,比如這樣:

function removeProps(WrappedComponent) {
    return class NewComponent extends WrappedComponent {
        render() {
        const{ user, ...otherProps } = this.props
        this.props = otherProps
        return super.render()
        }
    }
}
複製程式碼

雖然這樣看起來很簡單,但我們直接修改了this.props,這不是一個好的實踐,可能會產生不可預料的後果,更好的操作辦法是這樣的:

function removeProps(WrappedComponent) {
    return class NewComponent extends WrappedComponent {
        render() {
        const element =super.render()
        const{ user, ...otherProps } = this.props
        this.props = otherProps
        return React.cloneElement(element, this.props, element.props.children)
        }
    }
}
複製程式碼

我們可以通過React.cloneElement來傳入新的props,讓這些產生的元件重新渲染一次。但雖然這種方式可以解決直接修改this.props所帶來的問題,但實現起來賊麻煩,唯一用得上的就是高階元件需要根據引數元件WrappedComponent渲染結果來決定如何修改props時用得上,其他的時候顯然使用代理模式更便捷清晰。

高階元件命名

用 HOC 包裹了一個元件會使它失去原本 WrappedComponent 的名字,可能會影響開發和debug。

因此我們通常會用 WrappedComponent 的名字加上一些 字首作為 HOC 的名字。我們來看看React-Redux是怎麼做的:

function getDisplayName(WrappedComponent) {
  return WrappedComponent.displayName ||
         WrappedComponent.name ||
         ‘Component’
}

HOC.displayName = `HOC(${getDisplayName(WrappedComponent)})`
//或
class HOC extends ... {
  static displayName = `HOC(${getDisplayName(WrappedComponent)})`
  ...
}
複製程式碼

實際上我們不用自己來寫getDisplayName這個函式,recompose 提供了這個函式,我們只要使用即可。

結尾語

我們其他要注意的就是官方文件所說的幾個約定與相關規範,在此我就不一一贅述了,感興趣的可以自己去看看。最後很感謝能看到這裡的朋友,因為水平有限,如果有錯誤敬請指正,十分感激!

相關文章