[譯] 深入 React 高階元件

江米小棗tonylua發表於2018-05-17

原文: https://medium.com/@franleplant/react-higher-order-components-in-depth-cf9032ee6c3e

概要

本文面向想要探索 HOC 模式的進階使用者,如果你是 React 的初學者則應該從官方文件開始。高階元件(Higher Order Components)是一種很棒的模式,已被很多 React 庫證實是非常有價值的。在本文中,我們首先回顧一下 HOC 是什麼、有什麼用、有何侷限,以及是如何實現它的。

在附錄中,檢視了相關的話題,這些話題並非 HOC 的核心,但我認為應該提及。

本文旨在儘量詳細的論述,以便於讀者查閱;並假定你已經知曉 ES6。

走你!

高階元件是什麼?

高階元件就是包裹了其他 React Component 的元件

通常,這個模式被實現為一個函式,基本算是個類工廠方法(yes, a class factory!),其函式簽名用 haskell 風格的虛擬碼寫出來就是這樣的:

hocFactory:: W: React.Component => E: React.Component
複製程式碼

W (WrappedComponent) 是被包裹的 React.Component;而函式返回的 E (Enhanced Component) 則是新得到的 HOC,也是個 React.Component。

定義中的“包裹”是一種有意的模糊,意味著兩件事情:

  • 屬性代理:由 HOC 操縱那些被傳遞給被包裹元件 W 的 props
  • 繼承反轉:HOC 繼承被包裹元件 W

後面會詳述這兩種模式的。

HOC 能做什麼?

在大的維度上 HOC 能用於:

  • 程式碼重用和邏輯抽象
  • render 劫持
  • state 抽象和操縱
  • 操縱屬性(props)

後面將會看到這些類目的細節,但首先來學習一下實現 HOC 的方式,因為實現方式決定了 HOC 實際能做的事情。

HOC 工廠實現

屬性代理(PP)和繼承反轉(II)。兩者皆提供了不同的途徑以操縱被包裹的元件。

屬性代理

屬性代理(Props Proxy)可以用以下方式簡單的實現:

function ppHOC(WrappedComponent) {
  return class PP extends React.Component {
    render() {
      return <WrappedComponent {...this.props}/>
    }
  }
}
複製程式碼

此處關鍵的部分在於 HOC 的 render() 方法返回了一個被包裹元件的 React Element。同時,將 HOC 接受到的屬性傳遞給了被包裹的元件,因此稱為**“屬性代理”**。

注意:

<WrappedComponent {...this.props}/>
// 等價於
React.createElement(WrappedComponent, this.props, null)
複製程式碼

兩者都會建立一個 React Element,用於描述 React 在其一致性比較過程中應該渲染什麼。

瞭解更多:

關於 React Elment vs Components 的內容可以檢視
https://facebook.github.io/react/blog/2015/12/18/react-components-elements-and-instances.html

一致性比較過程
http://www.css88.com/react/docs/reconciliation.html

可以用屬性代理做些什麼?

  • 操縱屬性
  • 通過 refs 訪問例項
  • 抽象 state
  • 包裹元件

操縱屬性

可以對傳遞給被包裹元件的屬性進行增刪查改。但刪除或編輯重要屬性時要謹慎,應合理設定 HOC 的名稱空間以免影響被包裹元件。

例子:增加新屬性。應用中通過 this.props.user 將可以得到已登入使用者

function ppHOC(WrappedComponent) {
  return class PP extends React.Component {
    render() {
      const newProps = {
        user: currentLoggedInUser
      }
      return <WrappedComponent {...this.props} {...newProps}/>
    }
  }
}
複製程式碼

通過 refs 訪問例項

可以通過 ref 訪問到 this(被包裹元件的例項),但這需要 ref 所引用的被包裹元件執行一次完整的初始化 render 過程,這就意味著要從 HOC 的 render 方法中返回被包裹元件的元素,並讓 React 完成其一致性比較過程,而 ref 能引用該元件的例項就好了。

例子:下例中展示瞭如何通過 refs 訪問到被包裹元件的例項方法和例項本身

function refsHOC(WrappedComponent) {
  return class RefsHOC extends React.Component {
    proc(wrappedComponentInstance) {
      wrappedComponentInstance.method()
    }
    
    render() {
      const props = Object.assign({}, this.props, {ref: this.proc.bind(this)})
      return <WrappedComponent {...props}/>
    }
  }
}
複製程式碼

當被包裹元件被渲染,ref 回撥就將執行,由此就能獲得其例項的引用。這可以用於讀取、增加例項屬性,或呼叫例項方法。

抽象 state

通過提供給被包裹元件的屬性和回撥,可以抽象 state,這非常類似於 smart 元件是如何處理 dumb 元件的。

關於上述兩種元件可以參閱:
https://medium.com/@dan_abramov/smart-and-dumb-components-7ca2f9a7c7d0

例子:在下面這個抽象 state 的例子裡我們簡單的將 value 和 onChange 處理函式從 name 輸入框中抽象出來。之所以說“簡單”是因為這非常普遍,但你必須明白這一點。

function ppHOC(WrappedComponent) {
  return class PP extends React.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}/>
    }
  }
}
複製程式碼

用起來可能會是這樣的:

@ppHOC
class Example extends React.Component {
  render() {
    return <input name="name" {...this.props.name}/>
  }
}
複製程式碼

於是這個輸入框就自動成為了一個受控元件。

關於受控元件:
https://mp.weixin.qq.com/s/I3aPxyZA_iArUDmsXtXGcw

包裹元件

可以利用元件的包裹,實現樣式定義、佈局或其他目標。一些基礎用法可以由普通的父元件完成(參閱附錄B),但如前所述,用 HOC 可以更加靈活。

例子:為定義樣式而實現的包裹

function ppHOC(WrappedComponent) {
  return class PP extends React.Component {
    render() {
      return (
        <div style={{display: 'block'}}>
          <WrappedComponent {...this.props}/>
        </div>
      )
    }
  }
}
複製程式碼

繼承反轉

繼承反轉 (Inheritance Inversion) 只需要這樣實現就可以:

function iiHOC(WrappedComponent) {
  return class Enhancer extends WrappedComponent {
    render() {
      return super.render()
    }
  }
}
複製程式碼

如你所見,被返回的 HOC 類(強化過的類)繼承了被包裹的元件。之所以被稱為“繼承反轉”是因為,被包裹元件並不去繼承強化類,而是被動的讓強化類繼承。通過這種方式,兩個類的關係看起來反轉了。

繼承反轉使得 HOC 可以用 this 訪問被包裹元件的例項,這意味著可以訪問 state、props、元件生命週期鉤子,以及 render 方法

這裡並不深入探討可以在生命週期鉤子中實現的細節,因為那屬於 React 的範疇。但要知道通過繼承反轉可以為被包裹元件建立新的生命週期鉤子;並記住總是應該呼叫 super.[lifecycleHook] 以確保不會破壞被包裹的元件。

一致性比較過程

在深入之前我們大概說一下這些理論。

一致性比較
https://facebook.github.io/react/docs/reconciliation.html

React Elements 描述了 React 執行其一致性比較過程時,什麼會被渲染。

React Elements 可以是兩種型別:字串和函式。字串型別的 React Elements(STRE)代表 DOM 節點,函式型別的 React Elements(FTRE)代表繼承自 React.Component 的元件。

React 元素和元件
https://facebook.github.io/react/blog/2015/12/18/react-components-elements-and-instances.html

在 React 的一致性比較過程(最終結果是 DOM 元素)中,FTRE 會被處理成一棵完整的 STRE 樹。

之所以很重要,就在於這意味著繼承反轉高階元件並不保證處理完整的子樹

後面學習到 render 劫持的時候將會證明其重要性。

可以用繼承反轉做些什麼?

  • render 劫持
  • 操縱 state

render 劫持

稱之為“render 劫持”是因為 HOC 控制了被包裹元件的 render 輸出,並能對其做任何事情。

在 render 劫持中可以:

  • 在任何 render 輸出的 React Elements 上增刪查改 props
  • 讀取並修改 render 輸出的 React Elements 樹
  • 條件性顯示元素樹
  • 出於定製樣式的目的包裹元素樹(正如屬性代理中展示的)

*用 render 引用被包裹元件的 render 方法

不能對被包裹元件的例項編輯或建立屬性,因為一個 React Component 無法編輯其收到的 props,但可以改變被 render 方法輸出的元素的屬性。

就如我們之前學到的,繼承反轉 HOC 不保證處理完整的子樹,這意味著 render 劫持技術有一些限制。經驗法則是,藉助於 render 劫持,可以不多不少的操作被包裹元件的 render 方法輸出的元素樹。如果那個元素數包含了一個函式型別的 React Component,那就無法操作其子元件(被 React 的一致性比較過程延遲到真正渲染到螢幕上時)。

例子1:條件性渲染

function iiHOC(WrappedComponent) {
  return class Enhancer extends WrappedComponent {
    render() {
      if (this.props.loggedIn) {
        return super.render()
      } else {
        return null
      }
    }
  }
}
複製程式碼

例子2:修改 render 輸出的元素樹

function iiHOC(WrappedComponent) {
  return class Enhancer 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
    }
  }
}
複製程式碼

本例中,如果由 render 輸出的被包裹元件有一個 input 頂級元素,就改變其 value。

可以在這裡做任何事情,可以遍歷整個元素樹並改變其中的任何一個元素屬性。

注意:不能通過屬性代理劫持 render

雖然通過 WrappedComponent.prototype.render 訪問 render 方法是可能的,但這樣一來你就要模擬被包裹元件的例項及其屬性,並自己處理元件生命週期而非依靠 React 去解決。以我的經驗來說這是得不償失的,如果要劫持 render 應該用繼承反轉而非屬性代理。要記住 React 內在地處置元件例項,而你只能通過 this 或 refs 來處理例項。

操縱 state

HOC 可以讀取、編輯和刪除被包裹元件例項的 state,也可以按需增加更多的 state。要謹記如果把 state 搞亂會很糟糕。大部分 HOC 應該限制讀取或增加 state,而後者(譯註:增加 state)應該使用名稱空間以免和被包裹元件的 state 搞混。

例子:對訪問被包裹元件的 props 和 state 的除錯

export function IIHOCDEBUGGER(WrappedComponent) {
  return class II extends WrappedComponent {
    render() {
      return (
        <div>
          <h2>HOC Debugger Component</h2>
          <p>Props</p> <pre>{JSON.stringify(this.props, null, 2)}</pre>
          <p>State</p><pre>{JSON.stringify(this.state, null, 2)}</pre>
          {super.render()}
        </div>
      )
    }
  }
}
複製程式碼

該 HOC 將被包裹元件嵌入其他元素中,並顯示了其 props 和 state。

命名

使用 HOC 時,就失去了被包裹元件原有的名字,可能會影響開發和除錯。

人們通常的做法就是用原有名字加上些什麼來命名 HOC。下面的例子取自 React-Redux

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

而 getDisplayName 函式的定義如下:

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

其實你都不需要自己寫一遍這個函式,recompose 庫(https://github.com/acdlite/recompose)已經提供了。

附錄 A:HOC 和引數

以下為可以跳過的選讀內容

在 HOC 中可以善用引數。這本來已經在上面所有例子中隱含的出現過,並且對於中級 JS 開發者也已經稀鬆平常了,但是本著知無不言的原則,還是快速過一遍吧。

例子:結合屬性代理和 HOC 引數,需要關注的是 HOCFactoryFactory 函式

function HOCFactoryFactory(...params){
  // do something with params
  return function HOCFactory(WrappedComponent) {
    return class HOC extends React.Component {
      render() {
        return <WrappedComponent {...this.props}/>
      }
    }
  }
}
複製程式碼

可以這樣使用:

HOCFactoryFactory(params)(WrappedComponent)
//or
@HOCFatoryFactory(params)
class WrappedComponent extends React.Component{}
複製程式碼

附錄 B:和父元件的區別

以下為可以跳過的選讀內容

有一些子元件的 React 元件稱為父元件,React 有一些訪問和控制元件子成員的 API。

例子:父元件訪問子元件

class Parent extends React.Component {
    render() {
      return (
        <div>
          {this.props.children}
        </div>
      )
    }
  }
}

render((
  <Parent>
    {children}
  </Parent>
), mountNode)
複製程式碼

相比於 HOC,來細數一下父元件能做和不能做的:

  • render 劫持(在繼承反轉中看見過)
  • 控制內部 props(同樣在繼承反轉中看見過)
  • 抽象 state,但存在缺點。將無法在外部訪問父元素的 state,除非特意為止建立鉤子。這限制了其實用性
  • 包裹新的 React Elements。這可能是父元件唯一強於 HOC 的用例,雖然 HOC 也能做到
  • 操縱子元件有一些陷阱。比如說如果 children 的根一級並不只有單一的子元件(多於一個的第一級子元件),你就得新增一個額外的元素來收納所有子元素,這會讓你的程式碼有些冗繁。在 HOC 中一個單一的頂級子元件是被 React/JSX 的約束所保證的。

通常,父元件的做法沒有 HOC 那麼 hacky,但上述列表是其相比於 HOC 的不靈活之處。

結語

希望閱讀本文後你能對 React HOC 多一些瞭解。在不同的庫中,HOC 都被證明是很有價值並非常好用的。

React 帶來了很多創新,人們廣泛應用著 Radium、React-Redux、React-Router 等等,也很好的印證了這一點。

----------------------------------------

長按二維碼或搜尋 fewelife 關注我們哦

[譯] 深入 React 高階元件

相關文章