在這篇文章的開始之前,我們有兩點需要注意:首先,我們所討論的僅僅是一種設計模式。它甚至就像元件結構一樣不是 React 裡的東西。第二,它不是構建一個 React 應用所必須的知識。你可以關掉這篇文章、不學習在這篇文章中我們所討論的內容,之後仍然可以構建一個正常的 React 應用。不過,就像構建所有東西一樣,你有更多可用的工具就會得到更好的結果。如果你在寫 React 應用,在你的“工具箱”之中沒有這個(React 高階元件)的話會對你是非常不利的。
在你聽到 Don't Repeat Yourself
或者 D.R.Y 這樣(中邪一樣)的口號之前你是不會在軟體開發的鑽研之路上走得很遠的。有時候實行這些名言會有點過於麻煩,但是在大多數情況下,(實行它)是一個有價值的目標。在這篇文章中我們將會去探討在 React 庫中實現 DRY 的最著名的模式——高階元件。不過在我們探索答案之前,我們首先必須要完全明確問題來源。
假設我們要負責重新建立一個類似於 Sprite(譯者注:國外的一個線上支付公司)的儀表盤。正如大多數專案那樣,一切事務在最後收尾之前都工作得很正常。你發現在儀表盤上有一串不一樣的提示框需要你某些元素 hover 的時候顯示。 => 你在儀表盤上面發現了一些不同的、(當滑鼠)懸停在某些組成元素上面會出現的提示資訊。
這裡有好幾種方式可以實現這個效果。其中一個你可能想到的是監聽特定的元件的 hover 狀態來決定是否展示 tooltip。在上圖中,你有三個元件需要新增它們的監聽功能—— Info
、TrendChart
和 DailyChart
。
讓我們從 Info
元件開始。現在它只是一個簡單的 SVG 圖示。
1 2 3 4 5 6 7 8 9 10 11 12 |
class Info extends React.Component { render() { return ( <svg className="Icon-svg Icon--hoverable-svg" height={this.props.height} viewBox="0 0 16 16" width="16"> <path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" /> </svg> ) } } |
現在我們需要新增讓它可以監測到自身是否被(滑鼠)懸停的功能。我們可以使用 React 所附帶的 onMouseOver
和 onMouseOut
這兩個滑鼠時間。我們傳遞給 onMouseOver
的函式將會在元件被滑鼠懸停後觸發,同時我們傳遞給 onMouseOut
的函式將會在元件不再被滑鼠懸停時觸發。要以 React 的方式來操作,我們會給給我們的元件新增一個 hovering
state 屬性,所以我們可以在 hovering
state 屬性改變的時候觸發重繪,來展示或者隱藏我們的提示框。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 |
class Info extends React.Component { state = { hovering: false } mouseOver = () => this.setState({ hovering: true }) mouseOut = () => this.setState({ hovering: false }) render() { return ( <> {this.state.hovering === true ? <Tooltip id={this.props.id} /> : null} <svg onMouseOver={this.mouseOver} onMouseOut={this.mouseOut} className="Icon-svg Icon--hoverable-svg" height={this.props.height} viewBox="0 0 16 16" width="16"> <path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" /> </svg> </> ) } } |
上面的程式碼看起來很棒。現在我們要新增同樣的功能給我們的其他兩個元件——TrendChart
和 DailyChart
。如果這兩個元件沒有出問題,就請不要修復它。我們對於 Info
的懸停功能執行的很好,所以請再寫一遍之前的程式碼。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class TrendChart extends React.Component { state = { hovering: false } mouseOver = () => this.setState({ hovering: true }) mouseOut = () => this.setState({ hovering: false }) render() { return ( <> {this.state.hovering === true ? <Tooltip id={this.props.id}/> : null} <Chart type='trend' onMouseOver={this.mouseOver} onMouseOut={this.mouseOut} /> </> ) } } |
你或許知道下一步了:我們要對最後一個元件 DailyChart
做同樣的事情。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
class DailyChart extends React.Component { state = { hovering: false } mouseOver = () => this.setState({ hovering: true }) mouseOut = () => this.setState({ hovering: false }) render() { return ( <> {this.state.hovering === true ? <Tooltip id={this.props.id}/> : null} <Chart type='daily' onMouseOver={this.mouseOver} onMouseOut={this.mouseOut} /> </> ) } } |
這樣的話,我們就全部做完了。你可能以前曾經這樣寫過 React 程式碼。但這並不該是你最終所該做的(不過這樣做也還湊合),但是它很不 “DRY”。正如我們所看到的,我們在我們的每一個元件中都 重複著完全一樣的的滑鼠懸停邏輯。
從這點看的話,問題變得非常清晰了:我們希望避免在在每個需要新增滑鼠懸停邏輯的元件是都再寫一遍相同的邏輯。所以,解決辦法是什麼?在我們開始前,讓我們先討論一些能讓我們更容易理解答案的程式設計思想—— 回撥函式
和 高階函式
。
在 JavaScript 中,函式是 “一等公民”。這意味著它就像物件/陣列/字串那樣可以被宣告為一個變數、當作函式的引數或者在函式中返回一個函式,即使返回的是其他函式也可以。
1 2 3 4 5 6 7 8 9 10 |
function add (x, y) { return x + y } function addFive (x, addReference) { return addReference(x, 5) } addFive(10, add) // 15 |
如果你沒這樣用過,你可能會感到困惑。我們將 add
函式作為一個引數傳入 addFive
函式,重新命名為 addReference
,然後我們呼叫了著個函式。
這時候,你作為引數所傳遞進去的函式被叫做回撥函式同時你使用回撥函式所構建的新函式被叫做高階函式。
因為這些名詞很重要,下面是一份根據它們所表示的含義重新命名變數後的同樣邏輯的程式碼。
1 2 3 4 5 6 7 8 9 10 |
function add (x,y) { return x + y } function higherOrderFunction (x, callback) { return callback(x, 5) } higherOrderFunction(10, add) |
這個模式很常見,哪裡都有它。如果你之前用過任何 JavaScript 陣列方法、jQuery 或者是 lodash 這類的庫,你就已經用過高階函式和回撥函式了。
1 2 3 4 5 6 7 8 |
[1,2,3].map((i) => i + 5) _.filter([1,2,3,4], (n) => n % 2 === 0 ); $('#btn').on('click', () => console.log('回撥函式哪裡都有') ) |
讓我們回到我們之前的例子。如果我們不僅僅想建立一個 addFive
函式,我們也想建立 addTen
函式、 addTwenty
函式等等,我們該怎麼辦?在我們當前的實踐方法中,我們必須在需要的時候去重複地寫我們的邏輯。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function add (x, y) { return x + y } function addFive (x, addReference) { return addReference(x, 5) } function addTen (x, addReference) { return addReference(x, 10) } function addTwenty (x, addReference) { return addReference(x, 20) } addFive(10, add) // 15 addTen(10, add) // 20 addTwenty(10, add) // 30 |
再一次出現這種情況,這樣寫並不糟糕,但是我們重複寫了好多相似的邏輯。這裡我們的目標是要能根據需要寫很多 “adder” 函式(addFive
、addTen
、addTwenty
等等),同時儘可能減少程式碼重複。為了完成這個目標,我們建立一個 makeAdder
函式怎麼樣?著個函式可以傳入一個數字和原始 add
函式。因為這個函式的目的是建立一個新的 adder 函式,我們可以讓其返回一個全新的傳遞數字來實現加法的函式。這兒講的有點多,讓我們來看下程式碼吧。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function add (x, y) { return x + y } function makeAdder (x, addReference) { return function (y) { return addReference(x, y) } } const addFive = makeAdder(5, add) const addTen = makeAdder(10, add) const addTwenty = makeAdder(20, add) addFive(10) // 15 addTen(10) // 20 addTwenty(10) // 30 |
太酷了!現在我們可以在需要的時候隨意地用最低的程式碼重複度建立 “adder” 函式。
如果你在意的話,這個通過一個多引數的函式來返回一個具有較少引數的函式的模式被叫做 “部分應用(Partial Application)“,它也是函數語言程式設計的技術。JavaScript 內建的 “.bind“ 方法也是一個類似的例子。
好吧,那這與 React 以及我們之前遇到滑鼠懸停的元件有什麼關係呢?我們剛剛通過建立了我們的 makeAdder
這個高階函式來實現了程式碼複用,那我們也可以建立一個類似的 “高階元件” 來幫助我們實現相同的功能(程式碼複用)。不過,不像高階函式返回一個新的函式那樣,高階元件返回一個新的元件來渲染 “回撥” 元件。這裡有點複雜,讓我們來攻克它。
(我們的)高階函式
- 是一個函式
- 有一個回撥函式做為引數
- 返回一個新的函式
- 返回的函式會觸發我們之前傳入的回撥函式
1 2 3 4 5 6 |
function higherOrderFunction (callback) { return function () { return callback() } } |
(我們的)高階元件
- 是一個元件
- 有一個元件做為引數
- 返回一個新的元件
- 返回的元件會渲染我們之前傳入的元件
1 2 3 4 5 6 7 |
function higherOrderComponent (Component) { return class extends React.Component { render() { return <Component /> } } } |
我們已經有了一個高階函式的基本概念了,現在讓我們來完善它。如果你還記得的話,我們之前的問題是我們重複地在每個需要的元件上寫我們的滑鼠懸停的處理邏輯。
1 2 3 4 |
state = { hovering: false } mouseOver = () => this.setState({ hovering: true }) mouseOut = () => this.setState({ hovering: false }) |
考慮到這一點,我們希望我們的高階元件(我們把它稱作 withHover
)自身需要能封裝我們的滑鼠懸停處理邏輯然後傳遞 hovering
state 給其所需要渲染的元件。這將允許我們能夠複用滑鼠懸停邏輯,並將其裝入單一的位置(withHover
)。
最後,下面的程式碼就是我們的最終目標。無論什麼時候我們想讓一個元件具有 hovering
state,我們都可以通過將它傳遞給withHover 高階元件來實現。
1 2 3 4 |
const InfoWithHover = withHover(Info) const TrendChartWithHover = withHover(TrendChart) const DailyChartWithHover = withHover(DailyChart) |
於是,無論給 withHover
傳遞什麼元件,它都會渲染原始元件,同時傳遞一個 hovering
prop。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 |
function Info ({ hovering, height }) { return ( <> {hovering === true ? <Tooltip id={this.props.id} /> : null} <svg className="Icon-svg Icon--hoverable-svg" height={height} viewBox="0 0 16 16" width="16"> <path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" /> </svg> </> ) } |
現在我們需要做的最後一件事是實現 withHover
。正如我們上面所看到的:
- 傳入一個元件引數
- 返回一個新的元件
- 渲染傳入引數的那個元件同時注入一個 “hovering” prop。
傳入一個元件引數
1 2 3 4 |
function withHover (Component) { } |
返回一個新的元件
1 2 3 4 5 6 |
function withHover (Component) { return class WithHover extends React.Component { } } |
渲染傳入引數的那個元件同時注入一個 “hovering” prop
現在問題變為了我們應該如何獲取 hovering
呢?好吧,我們已經有之前寫邏輯的程式碼了。我們僅僅需要將其新增到一個新的元件同時將 hovering
state 作為一個 prop 傳遞給引數中的 元件
。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 |
function withHover(Component) { return class WithHover extends React.Component { state = { hovering: false } mouseOver = () => this.setState({ hovering: true }) mouseOut = () => this.setState({ hovering: false }) render() { return ( <div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}> <Component hovering={this.state.hovering} /> </div> ); } } } |
我比較喜歡的思考這些知識的方式(同時也在 React 文件中有提到)是 **元件是將 props 轉化到檢視層,高階元件則是將一個元件轉化到另一個元件。**在我們的例子中,我們將我們的 Info
、TrendChart
和 DailyChart
元件搬運到一個具有 hovering
prop 的元件中。
至此,我們已經涵蓋到了高階元件的所有基礎知識。這裡還有一些很重要的知識我們需要來說明下。
如果你再回去看我們的 withHover
高階元件的話,它有一個缺點就是它已經假定了一個名為 hovering
的 prop。在大多數情況下這樣或許是沒問題的,但是在某些情況下會出問題。舉個例子,如果(原來的)元件已經有一個叫做 hovering
的 prop 呢?這裡我們出現了命名衝突。我們可以做的是讓我們的 withHover
高階元件能夠允許使用者自己定義傳入子元件的 prop 名。因為 withHover
只是一個函式,讓我們讓它的第二個引數來描述傳遞給子元件 prop 的名字。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 |
function withHover(Component, propName = 'hovering') { return class WithHover extends React.Component { state = { hovering: false } mouseOver = () => this.setState({ hovering: true }) mouseOut = () => this.setState({ hovering: false }) render() { const props = { [propName]: this.state.hovering } return ( <div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}> <Component {...props} /> </div> ); } } } |
現在我們設定了預設的 prop 名稱為 hovering
(通過使用 ES6 的預設引數特性來實現),如果使用者想改變 withHover
的預設 prop 名的話,可以通過第二個引數來傳遞一個新的 prop 名。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 |
function withHover(Component, propName = 'hovering') { return class WithHover extends React.Component { state = { hovering: false } mouseOver = () => this.setState({ hovering: true }) mouseOut = () => this.setState({ hovering: false }) render() { const props = { [propName]: this.state.hovering } return ( <div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}> <Component {...props} /> </div> ); } } } function Info ({ showTooltip, height }) { return ( <> {showTooltip === true ? <Tooltip id={this.props.id} /> : null} <svg className="Icon-svg Icon--hoverable-svg" height={height} viewBox="0 0 16 16" width="16"> <path d="M9 8a1 1 0 0 0-1-1H5.5a1 1 0 1 0 0 2H7v4a1 1 0 0 0 2 0zM4 0h8a4 4 0 0 1 4 4v8a4 4 0 0 1-4 4H4a4 4 0 0 1-4-4V4a4 4 0 0 1 4-4zm4 5.5a1.5 1.5 0 1 0 0-3 1.5 1.5 0 0 0 0 3z" /> </svg> </> ) } const InfoWithHover = withHover(Info, 'showTooltip') |
你可能發現了我們的 withHover
函式實現的另外一個問題。看看我們的 Info
元件,·你可能會發現其還有一個 height
屬性,但是 height
將會是 undefined。其原因是我們的 withHover
元件是渲染 Component
元件的函式。事實上我們這樣做的話,除了 hovering
prop 以外我們不會傳遞任何 prop 給我們最終建立的<Component />
。
1 2 3 4 5 |
const InfoWithHover = withHover(Info) ... return <InfoWithHover height="16px" /> |
height
prop 通過 InfoWithHover
元件傳入,但是這個元件是從哪兒來的?它是我們通過 withHover
所建立並返回的那個元件。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 |
function withHover(Component, propName = 'hovering') { return class WithHover extends React.Component { state = { hovering: false } mouseOver = () => this.setState({ hovering: true }) mouseOut = () => this.setState({ hovering: false }) render() { console.log(this.props) // { height: "16px" } const props = { [propName]: this.state.hovering } return ( <div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}> <Component {...props} /> </div> ); } } } |
深入 WithHover
元件內部,this.props.height
的值是 16px
但是我們沒有用它做任何事情。我們需要確保我們將其傳入給我們實際渲染的 Component
。
1 2 3 4 5 6 7 |
render() { const props = { [propName]: this.state.hovering, ...this.props, } return ( |
1 2 3 4 |
); } 複製程式碼 |
由此來看,我們已經感受到了使用高階元件減少程式碼重複的諸多優點。但是,它(高階元件)還有什麼坑嗎?當然有,我們馬上就去踩踩這些坑。
當我們使用高階元件時,會發生一些 控制反轉 的情況。想象下我們正在用類似於 React Router 的 withRouter
這類第三方的高階元件。 根據它們的文件,“withRouter
將會在任何被它包裹的元件渲染時,將 match
、location
和 history
prop 傳遞給它們。
1 2 3 4 5 6 7 8 9 10 11 12 |
render() { const props = { [propName]: this.state.hovering, ...this.props, } return ( <div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}> <Component {...props} /> </div> ); } |
請注意,我們並沒有(由 <Game /> 元件直接)在介面上渲染
Game
元素。我們將我們的元件全權交給了 React Router 同時我們也相信其不止能正確渲染元件,也能正確傳遞 props。我們之前在討論 hovering
prop 命名衝突的時候看到過這個問題。為了修復這個問題我們嘗試著給我們的 withHover
高階元件傳遞第二個引數來允許修改 prop 的名字。但是在使用第三方高階元件的時候,我們沒有這個配置項。如果我們的 Game
元件已經使用了 match
、location
或者 history
的話,就沒有(像使用我們自己的元件)那沒幸運了。我們除了改變我們之前所需要使用的 props 名之外就只能不使用 withRouter
高階元件了。