開始之前,有兩點需要說明一下:1、React 高階元件 僅僅是一種模式,並不是 React 的基礎知識;2、它不是開發 React app 的必要知識。你可以略過此文章,仍然可以開發 React app。然而,技多不壓身,如果你也是一位 React 開發者,強烈建議你掌握它。
一、為什麼需要高階元件
如果你不知道 Don't Repeat Yourself
或 D.R.Y
,那麼在軟體開發中必定走不太遠。對於大多數開發者來說,它是一個開發準則。在這篇文章當中,我們將瞭解到如何在 React 當中運用 DRY
原則 —— 高階元件
。開始闡述之前,我們先來認識一下問題所在。
假設我們要開發類似下圖的功能。正如大多的專案一樣,我們先按流程開發著。當開發到差不多的時候,你會發現頁面上有很多,滑鼠懸浮在某個元素上出現 tooltip
的場景。
有很多種方法做到這樣。你可能想到寫一個帶懸浮狀態的元件來控制 tooltip
的顯示與否。那麼你需要新增三個元件——Info, TrendChart 和 DailyChart。
我們從 Info 元件開始。它很簡單,僅僅是一個 SVG icon
.
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>
);
}
}
複製程式碼
然後我們需要新增一個狀態來記錄元件是否被 Hover,可以用 React 滑鼠事件當中的 onMouseOver
和 onMouseOut
來實現。
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
寫同樣的邏輯。
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}
/>
</>
);
}
}
複製程式碼
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}
/>
</>
);
}
}
複製程式碼
三個元件我們都開發完成。但正如你看到的,非常不 DRY
,因為我們在三個元件中把同一套 hover 邏輯 重複了三次。
問題就顯而易見了。當一個新元件需要類似 hover 邏輯 時,我們應避免重複。那麼,我們該如何解決呢?為了便於理解,先來了解一下程式設計當中的兩個概念—— 回撥 和 高階函式。
二、什麼是回撥和高階函式
在 JavaScript 當中,函式是第一公民。也就是說它可以像 objects/arrays/strings 被賦值給變數、被當作引數傳遞給函式和被函式返回。
function add(x, y) {
return x + y;
}
function addFive(x, addReference) {
return addReference(x, 5);
}
addFive(10, add); // 15
複製程式碼
你可能會感到有點兒繞:我們在 函式addFive 中傳入一個函式名為 addReference 的引數,並且在內部返回時呼叫它。類似這種情況,你把它當作引數傳遞的函式叫 回撥;接收函式作為引數的函式叫 高階函式。
為了更直觀,我們把上述程式碼的命名概念化。
function add(x, y) {
return x + y;
}
function higherOrderFunction(x, callback) {
return callback(x, 5);
}
higherOrderFunction(10, add);
複製程式碼
這種寫法其實很常見。如果你用過陣列方法、jQuery 或 lodash 庫,那麼你就使用過 回撥 和 高階函式。
[1, 2, 3].map(i => i + 5);
_.filter([1, 2, 3, 4], n => n % 2 === 0);
$("#btn").on("click", () => console.log("Callbacks are everywhere"));
複製程式碼
三、高階函式的簡單應用
回到之前寫的那個例子。我們不僅需要 addFive
,可能還需 addTen
addTwenty
等等。依照現在的寫法,當我們寫一個新函式的時候,不得不重複原有邏輯。
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函式
,此函式接收一個 數字 和 一個函式 作為引數,長話少說,直接看程式碼。
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函式
就能寫多少,並且沒必要寫那麼多重複程式碼。
這種使用一個函式並將其應用一個或多個引數,但不是全部引數,在這個過程中建立並返回一個新函式叫『偏函式應用』。 JavaScript 當中的
.bind
便是這種方法的一個例子。
四、高階元件
那麼,這些和我們最初寫 React 程式碼重複又有什麼關係呢?也像建立 高階函式makeAdder
一樣地建立類似 高階元件
。看起來還不錯,我們試試吧。
高階函式
- 一個函式
- 接收一個回撥函式為引數
- 返回一個新的函式
- 返回的函式可以呼叫傳進去的回撥函式
function higherOrderFunction(callback) {
return function() {
return callback();
};
}
複製程式碼
高階元件
- 一個元件
- 接收一個元件為引數
- 返回一個新的元件
- 返回的元件可以渲染當初傳進去的元件
function higherOrderComponent(Component) {
return class extends React.Component {
render() {
return <Component />;
}
};
}
複製程式碼
五、高階元件的簡單應用
好,我們現在理解了高階元件的基本概念。你應該還記得,最初面臨的問題是在太多地方重複了 Hover 邏輯 部分。
state = { hovering: false };
mouseOver = () => this.setState({ hovering: true });
mouseOut = () => this.setState({ hovering: false });
複製程式碼
記住,我們希望高階元件(命名為 withHover
)能壓縮 Hover 邏輯 部分,並帶有 hovering
狀態,這樣能避免我們重複 Hover 邏輯。
最終目標,無論何時我們想寫一個帶 Hover 狀態的元件時,都可以把這個元件作為引數傳入我們的高階元件 withHover
。
const InfoWithHover = withHover(Info);
const TrendChartWithHover = withHover(TrendChart);
const DailyChartWithHover = withHover(DailyChart);
複製程式碼
接著,無論什麼元件傳入 withHover
,都會返回元件本身,並且會接收一個 hovering
屬性。
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” 屬性
1、接收一個『元件』為引數
function withHover(Component) {}
複製程式碼
2、返回一個新的元件
function withHover(Component) {
return class WithHover extends React.Component {};
}
複製程式碼
3、引數元件接收一個 “hovering” 屬性
新問題來了, hovering
該從哪裡來?我們可以建立一個新的元件,把 hovering
當作該元件的狀態,然後傳給最初的那個引數元件。
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>
);
}
};
}
複製程式碼
我想起了一句話:元件是把 props 轉換成 UI 的過程;高階元件是把一個元件轉換成另一個元件的過程。
我們已經學習完了高階函式的基礎知識,但仍然有幾點值得討論。
六、高階元件的進階應用
回頭看看元件 withHover
,還是有一點不足:就是它假想了使用者傳進去的引數元件必須要接收一個名為 hovering 的 prop;如果引數元件本身就有一個名為 hovering 的 prop,並且這個 prop 並不是來處理 hover 的, 就會造成命名衝突。我們可以嘗試一下讓使用者自定義控制 hover 的 prop 命名。
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>
);
}
};
}
複製程式碼
在 withHover 中,我們給 propName 設定了一個預設值 hovering
,使用者也可以在元件中傳入第二個引數自定義命名。
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");
複製程式碼
你可能又注意到了另外一個問題,在元件 Info
中,它還接收一個名為 height 的 prop。按照現在這種寫法,height 只能是 undefined,但我們期望能達到如下效果:
const InfoWithHover = withHover(Info)
...
return <InfoWithHover height="16px" />
複製程式碼
我們把 height 傳入 InfoWithHover
,但是該如何使它生效呢?
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>
);
}
};
}
複製程式碼
從 console 中可以看出, this.props 的值是 { height: "16px" }
。我們要做的就是不管 this.props 為何值,都把 它傳給引數元件 Component
。
render() {
const props = {
[propName]: this.state.hovering,
...this.props,
}
return (
<div onMouseOver={this.mouseOver} onMouseOut={this.mouseOut}>
<Component {...props} />
</div>
);
}
複製程式碼
最終,我們可以看出,通過使用高階元件可以有效地複用同套邏輯,避免過多的重複程式碼。但是,它真的沒有任何缺點嗎?顯然不是。
七、高階元件的小瑕疵
當我們使用高階元件的時候,可能會發生 inversion of control(控制反轉)
。想象一下,假如我們正使用 React Router 的 withRouter
,根據文件:無論是什麼元件,它都會把 match
, location
和history
傳給該元件的 prop。
class Game extends React.Component {
render() {
const { match, location, history } = this.props // From React Router
...
}
}
export default withRouter(Game)
複製程式碼
從上可以看出,如果我們的元件 Game
也有命名為 match
, location
和history
的 prop 時,便會引發命名衝突。這個問題,我們在寫元件 withHover
遇到過,並通過傳入第二引數自定義命名的方式解決了該問題。但是當我們用到第三方庫中的高階元件時,就不一定會有那麼幸運了。我們不得不修改我們自身元件 prop 的命名 或 停止使用第三方庫中的該高階元件。
八、結尾
本文是翻譯自 [React Higher-Order Components](React Higher-Order Components),僅供學習參考。如果給您學習理解造成了迷惑,歡迎聯絡我。