[譯] 使用 Render props 吧!

吳曉軍發表於2017-12-13

更新我提交了一個 PR 到 React 官方文件,為其新增了 Render props

更新2:新增一部分內容來說明 “children 作為一個函式” 也是相同的概念,只是 prop 名稱不同罷了。


幾個月前,我發了一個 twitter:

[譯] 使用 Render props 吧!

譯註:@reactjs 我可以在一個普通元件上使用一個 render prop 來完成 HOC(高階元件) 能夠做到的事情。不服來辯。

我認為,高階元件模式 作為一個在許多基於 React 的程式碼中流行的程式碼複用手段,是可以被一個具有 “render prop” 的普通元件 100% 地替代的。“不服來辯” 一詞是我對 React 社群朋友們的友好 “嘲諷”,隨之而來的是一個系列好的討論,但最終,我對我自己無法用 140 字來完整描述我想說的而感到失望。 我 決定在未來的某個時間點寫一篇更長的文章 來公平公正的探討這個主題。

兩週前,當 Tyler 邀請我到 Phoenix ReactJS 演講時,我認為是時候去對此進行更進一步的探討了。那周我已經到達 Phoenix 去啟動 我們的 React 基礎和進階補習課 了,而且我還從我的商業夥伴 Ryan 聽到了關於大會的好訊息,他在四月份做了演講

在大會上,我的演講似乎有點標題黨的嫌疑:不要再寫另一個 HOC 了。你可以在 Phoenix ReactJS 的 YouTube 官方頻道 上觀看我的演講,也可以通過下面這個內嵌的視訊進行觀看:

如果你不想看視訊的話,可以閱讀後文對於演講主要內容的介紹。但是嚴肅地說:視訊要有趣多了 ?。

如果你直接跳過視訊開始閱讀,但並沒有領會我所說的意思,就折回去看視訊吧。演講時的細節會更豐富。

Mixins 存在的問題

我的演講始於高階元件主要解決的問題:程式碼複用

讓我們回到 2015 年使用 React.createClass 那會兒。假定你現在有一個簡單的 React 應用需要跟蹤並在頁面上實時顯示滑鼠位置。你可能會構建一個下面這樣的例子:

import React from 'react'
import ReactDOM from 'react-dom'

const App = React.createClass({
  getInitialState() {
    return { x: 0, y: 0 }
  },

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    })
  },

  render() {
    const { x, y } = this.state

    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        <h1>The mouse position is ({x}, {y})</h1>
      </div>
    )
  }
})

ReactDOM.render(<App/>, document.getElementById('app'))
複製程式碼

現在,假定我們在另一個元件中也需要跟蹤滑鼠位置。我們可以重用 <App> 中的程式碼嗎?

createClass 這個正規化中,程式碼重用問題是通過被稱為 “mixins” 的技術解決的。我們建立一個 MouseMixin,讓任何人都能通過它來追蹤滑鼠位置。

import React from 'react'
import ReactDOM from 'react-dom'

// mixin 中含有了你需要在任何應用中追蹤滑鼠位置的樣板程式碼。
// 我們可以將樣板程式碼放入到一個 mixin 中,這樣其他元件就能共享這些程式碼
const MouseMixin = {
  getInitialState() {
    return { x: 0, y: 0 }
  },

  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    })
  }
}

const App = React.createClass({
  // 使用 mixin!
  mixins: [ MouseMixin ],
  
  render() {
    const { x, y } = this.state

    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        <h1>The mouse position is ({x}, {y})</h1>
      </div>
    )
  }
})

ReactDOM.render(<App/>, document.getElementById('app'))
複製程式碼

問題解決了,對吧?現在,任何人都能輕鬆地將 MouseMixin 混入他們的元件中,並通過 this.state 屬性獲得滑鼠的 xy 座標。

HOC 是新的 Mixin

去年,隨著ES6 class 的到來,React 團隊最終決定使用 ES6 class 來代替 createClass。這是一個明智的決定,沒有人會在 JavaScript 都內建了 class 時還會維護自己的類模型。

但就存在一個問題:ES6 class 不支援 mixin。除了不是 ES6 規範的一部分,Dan 已經在一篇 React 部落格上釋出的博文上詳細討論了 mixin 存在的其他問題。

minxins 的問題總結下來就是

  • ES6 class。其不支援 mixins。
  • 不夠直接。minxins 改變了 state,因此也就很難知道一些 state 是從哪裡來的,尤其是當不止存在一個 mixins 時。
  • 名字衝突。兩個要更新同一段 state 的 mixins 可能會相互覆蓋。createClass API 會對兩個 mixins 的 getInitialState 是否具有相同的 key 做檢查,如果具有,則會發出警告,但該手段並不牢靠。

所以,為了替代 mixin,React 社群中的不少開發者最終決定用高階元件(簡稱 HOC)來做程式碼複用。在這個正規化下,程式碼通過一個類似於 裝飾器(decorator) 的技術進行共享。首先,你的一個元件定義了大量需要被渲染的標記,之後用若干具有你想用共享的行為的元件包裹它。因此,你現在是在 裝飾 你的元件,而不是混入你需要的行為!

import React from 'react'
import ReactDOM from 'react-dom'

const withMouse = (Component) => {
  return class extends React.Component {
    state = { x: 0, y: 0 }

    handleMouseMove = (event) => {
      this.setState({
        x: event.clientX,
        y: event.clientY
      })
    }

    render() {
      return (
        <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
          <Component {...this.props} mouse={this.state}/>
        </div>
      )
    }
  }
}

const App = React.createClass({
  render() {
    // 現在,我們得到了一個滑鼠位置的 prop,而不再需要維護自己的 state
    const { x, y } = this.props.mouse

    return (
      <div style={{ height: '100%' }}>
        <h1>The mouse position is ({x}, {y})</h1>
      </div>
    )
  }
})

// 主需要用 withMouse 包裹元件,它就能獲得 mouse prop
const AppWithMouse = withMouse(App)

ReactDOM.render(<AppWithMouse/>, document.getElementById('app'))
複製程式碼

讓我們和 mixin 說再見,去擁抱 HOC 吧。

在 ES6 class 的新時代下,HOC 的確是一個能夠優雅地解決程式碼重用問題方案,社群也已經廣泛採用它了。

此刻,我想問一句:是什麼驅使我們遷移到 HOC ? 我們是否解決了在使用 mixin 時遇到的問題?

讓我們看下:

  • ES6 class。這裡不再是問題了,ES6 class 建立的元件能夠和 HOC 結合。
  • 不夠直接。即便用了 HOC,這個問題仍然存在。在 mixin 中,我們不知道 state 從何而來,在 HOC 中,我們不知道 props 從何而來。
  • 名字衝突。我們仍然會面臨該問題。兩個使用了同名 prop 的 HOC 將遭遇衝突並且彼此覆蓋,並且這次問題會更加隱晦,因為 React 不會在 prop 重名是發出警告。

另一個 HOC 和 mixin 都有的問題就是,二者使用的是 靜態組合 而不是 動態組合。問問你自己:在 HOC 這個正規化下,組合是在哪裡發生的?當元件類(如上例中的的 AppWithMouse)被建立後,發生了一次靜態組合。

你無法在 render 方法中使用 mixin 或者 HOC,而這恰是 React 動態 組合模型的關鍵。當你在 render 中完成了組合,你就可以利用到所有 React 生命期的優勢了。動態組合或許微不足道,但興許某天也會出現一篇專門探討它的部落格,等等,我有點離題了。?

總而言之:使用 ES6 class 建立的 HOC 仍然會遇到和使用 createClass 時一樣的問題,它只能算一次重構。

現在不要說擁抱 HOC 了,我們不過在擁抱新的 mixin!?

除了上述缺陷,由於 HOC 的實質是包裹元件並建立了一個混入現有元件的 mixin 替代,因此,HOC 將引入大量的繁文縟節。從 HOC 中返回的元件需要表現得和它包裹的元件儘可能一樣(它需要和包裹元件接收一樣的 props 等等)。這一事實使得構建健壯的 HOC 需要大量的樣板程式碼(boilerplate code)。

上面我所講到的,以 React Router 中的 withRouter HOC 為例,你可以看到 props 傳遞wrappedComponentRef被包裹元件的靜態屬性提升(hoist)等等這樣的樣板程式碼,當你需要為你的 React 新增 HOC 時,就不得不撰寫它們。

Render Props

現在,有了另外一門技術來做程式碼複用,該技術可以規避 mixin 和 HOC 的問題。在 React Training 中,稱之為 “Render Props”。

我第一次見到 render prop 是在 ChengLou 在 React Europe 上 關於 react-motion 的演講,大會上,他提到的 <Motion children> API 能讓元件與它的父元件共享 interpolated animation。如果讓我來定義 render prop,我會這麼定義:

一個 render prop 是一個型別為函式的 prop,它讓元件知道該渲染什麼。

更通俗的說法是:不同於通過 “混入” 或者裝飾來共享元件行為,一個普通元件只需要一個函式 prop 就能夠進行一些 state 共享

繼續到上面的例子,我們將通過一個型別為函式的 render 的 prop 來簡化 withMouse HOC 到一個普通的 <Mouse> 元件。然後,在 <Mouse>render 方法中,我們可以使用一個 render prop 來讓元件知道如何渲染:

import React from 'react'
import ReactDOM from 'react-dom'
import PropTypes from 'prop-types'

// 與 HOC 不同,我們可以使用具有 render prop 的普通元件來共享程式碼
class Mouse extends React.Component {
  static propTypes = {
    render: PropTypes.func.isRequired
  }

  state = { x: 0, y: 0 }

  handleMouseMove = (event) => {
    this.setState({
      x: event.clientX,
      y: event.clientY
    })
  }

  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        {this.props.render(this.state)}
      </div>
    )
  }
}

const App = React.createClass({
  render() {
    return (
      <div style={{ height: '100%' }}>
        <Mouse render={({ x, y }) => (
          // render prop 給了我們所需要的 state 來渲染我們想要的
          <h1>The mouse position is ({x}, {y})</h1>
        )}/>
      </div>
    )
  }
})

ReactDOM.render(<App/>, document.getElementById('app'))
複製程式碼

這裡需要明確的概念是,<Mouse> 元件實際上是呼叫了它的 render 方法來將它的 state 暴露給 <App> 元件。因此,<App> 可以隨便按自己的想法使用這個 state,這太美妙了。?

在此,我想說明,“children as a function” 是一個 完全相同的概念,只是用 children prop 替代了 render prop。我掛在嘴邊的 render prop 並不是在強調一個 名叫 prop 的 prop,而是在強調你使用一個 prop 去進行渲染的概念。

該技術規避了所有 mixin 和 HOC 會面對的問題:

  • ES6 class。不成問題,我們可以在 ES6 class 建立的元件中使用 render prop。
  • 不夠直接。我們不必再擔心 state 或者 props 來自哪裡。我們可以看到通過 render prop 的引數列表看到有哪些 state 或者 props 可供使用。
  • 名字衝突。現在不會有任何的自動屬性名稱合併,因此,名字衝突將全無可乘之機。

並且,render prop 也不會引入 任何繁文縟節,因為你不會 包裹裝飾 其他的元件。它僅僅是一個函式!如果你使用了 TypeScript 或者 Flow,你會發現相較於 HOC,現在很容易為你具有 render prop 的元件寫一個型別定義。當然,這是另外一個話題了。

另外,這裡的組合模型是 動態的!每次組合都發生在 render 內部,因此,我們就能利用到 React 生命週期以及自然流動的 props 和 state 帶來的優勢。

使用這個模式,你可以將 任何 HOC 替換一個具有 render prop 的一般元件。這點我們可以證明!?

Render Props > HOCs

一個更將強有力的,能夠證明 render prop 比 HOC 要強大的證據是,任何 HOC 都能使用 render prop 替代,反之則不然。下面的程式碼展示了使用一個一般的、具有 render prop 的 <Mouse> 元件來實現的 withMouse HOC:

const withMouse = (Component) => {
  return class extends React.Component {
    render() {
      return <Mouse render={mouse => (
        <Component {...this.props} mouse={mouse}/>
      )}/>
    }
  }
}
複製程式碼

有心的讀者可能已經意識到了 withRouter HOC 在 React Router 程式碼庫中確實就是通過**一個 render prop ** 實現的!

所以還不心動?快去你自己的程式碼中使用 render prop 吧!嘗試使用具有 render prop 元件來替換 HOC。當你這麼做了之後,你將不再受困於 HOC 的繁文縟節,並且你也將利用到 React 給予的動態組合模型的好處,那是特別酷的特性。?

MichaelReact Training 的成員,也是 React 社群中一個多產的開源軟體貢獻者。想了解最新的培訓和課程就[訂閱郵件推送](subscribe to the mailing list) 並 在 Twitter 上關注 React Training


掘金翻譯計劃 是一個翻譯優質網際網路技術文章的社群,文章來源為 掘金 上的英文分享文章。內容覆蓋 AndroidiOSReact前端後端產品設計 等領域,想要檢視更多優質譯文請持續關注 掘金翻譯計劃官方微博知乎專欄

相關文章