深入淺出理解 React高階元件

題歌發表於2018-11-15

開始之前,有兩點需要說明一下:1、React 高階元件 僅僅是一種模式,並不是 React 的基礎知識;2、它不是開發 React app 的必要知識。你可以略過此文章,仍然可以開發 React app。然而,技多不壓身,如果你也是一位 React 開發者,強烈建議你掌握它。

一、為什麼需要高階元件

如果你不知道 Don't Repeat YourselfD.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 滑鼠事件當中的 onMouseOveronMouseOut來實現。

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>
      </>
    );
  }
}
複製程式碼

看起來還不錯,我們需要在 TrendChartDailyChart寫同樣的邏輯。

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, locationhistory 傳給該元件的 prop。

class Game extends React.Component {
  render() {
    const { match, location, history } = this.props // From React Router

    ...
  }
}

export default withRouter(Game)
複製程式碼

從上可以看出,如果我們的元件 Game 也有命名為 match, locationhistory 的 prop 時,便會引發命名衝突。這個問題,我們在寫元件 withHover 遇到過,並通過傳入第二引數自定義命名的方式解決了該問題。但是當我們用到第三方庫中的高階元件時,就不一定會有那麼幸運了。我們不得不修改我們自身元件 prop 的命名 或 停止使用第三方庫中的該高階元件。

八、結尾

本文是翻譯自 [React Higher-Order Components](React Higher-Order Components),僅供學習參考。如果給您學習理解造成了迷惑,歡迎聯絡我。

相關文章