React-程式碼複用(mixin.hoc.render props)

菜的黑人牙膏發表於2019-03-22

前言

最近在學習React的封裝,雖然日常的開發中也有用到HOC或者Render Props,但從繼承到組合,靜態構建到動態渲染,都是似懂非懂,索性花時間系統性的整理,如有錯誤,請輕噴~~

例子

以下是React官方的一個例子,我會採用不同的封裝方法來嘗試程式碼複用,例子地址

元件在 React 是主要的程式碼複用單元,但如何共享狀態或一個元件的行為封裝到其他需要相同狀態的元件中並不是很明瞭
例如,下面的元件在 web 應用追蹤滑鼠位置:

class MouseTracker extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

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

  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        <h1>Move the mouse around!</h1>
        <p>The current mouse position is ({this.state.x}, {this.state.y})</p>
      </div>
    );
  }
}
複製程式碼

隨著滑鼠在螢幕上移動,在一個 p的元件上顯示它的 (x, y) 座標。

現在的問題是:我們如何在另一個元件中重用行為?換句話說,若另一元件需要知道滑鼠位置,我們能否封裝這一行為以讓能夠容易在元件間共享?

由於元件是 React 中最基礎的程式碼重用單元,現在嘗試重構一部分程式碼能夠在 元件中封裝我們需要在其他地方的行為。

// The <Mouse> component encapsulates the behavior we need...
class Mouse extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

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

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

        {/* ...but how do we render something other than a <p>? */}
        <p>The current mouse position is ({this.state.x}, {this.state.y})</p>
      </div>
    );
  }
}

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <Mouse />
      </div>
    );
  }
}
複製程式碼

現在 元件封裝了所有關於監聽 mousemove 事件和儲存滑鼠 (x, y) 位置的行為,但其仍不失真正的可重用。

例如,假設我們現在有一個在螢幕上跟隨滑鼠渲染一張貓的圖片的 元件。我們可能使用 <Cat mouse={{ x, y }} prop 來告訴元件滑鼠的座標以讓它知道圖片應該在螢幕哪個位置。

首先,你可能會像這樣,嘗試在 的內部的渲染方法 渲染 元件:

class Cat extends React.Component {
  render() {
    const mouse = this.props.mouse
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
    );
  }
}

class MouseWithCat extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

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

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

        {/*
          We could just swap out the <p> for a <Cat> here ... but then
          we would need to create a separate <MouseWithSomethingElse>
          component every time we need to use it, so <MouseWithCat>
          isn't really reusable yet.
        */}
        <Cat mouse={this.state} />
      </div>
    );
  }
}

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <MouseWithCat />
      </div>
    );
  }
}
複製程式碼

這一方法對我們的具體用例來說能夠生效,但我們卻沒法實現真正的將行為封裝成可重用的方式的目標。現在,每次我們在不同的用例中想要使用滑鼠的位置,我們就不得不建立一個新的針對那一用例渲染不同內容的元件 (如另一個關鍵的 <MouseWithCat>)

Mixin

Mixin概念

React Mixin將通用共享的方法包裝成Mixins方法,然後注入各個元件實現,事實上已經是不被官方推薦使用了,但仍然可以學習一下,瞭解其為什麼被遺棄,先從API看起。
React Mixin只能通過React.createClass()使用, 如下:

var mixinDefaultProps = {}
var ExampleComponent = React.createClass({
    mixins: [mixinDefaultProps],
    render: function(){}
});
複製程式碼

Mixin實現

// 封裝的Mixin
const mouseMixin = {
  getInitialState() {
    return {
      x: 0,
      y: 0
    }
  },
  handleMouseMove(event) {
    this.setState({
      x: event.clientX,
      y: event.clientY
    })
  }
}

const Mouse = createReactClass({
  mixins: [mouseMixin],
  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        <p>The current mouse position is ({this.state.x}, {this.state.y})</p>
      </div>
    )
  }
})

const Cat = createReactClass({
  mixins: [mouseMixin],
  render() {
    return (
      <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
        <img src="/cat.jpg" style={{ position: 'absolute', left: this.state.x, top: this.state.y }} alt="" />
      </div>
    )
  }
})
複製程式碼

Mixin的問題

然而,為什麼Mixin會被不推薦使用?歸納起來就是以下三點

1. Mixin引入了隱式依賴關係 如:

你可能會寫一個有狀態的元件,然後你的同事可能會新增一個讀取這個狀態的mixin。在幾個月內,您可能需要將該狀態移至父元件,以便與兄弟元件共享。你會記得更新mixin來讀取道具嗎?如果現在其他元件也使用這個mixin呢?

2. Mixin導致名稱衝突 如:

你在該Mixin定義了getSomeName, 另外一個Mixin又定義了同樣的名稱getSomeName, 造成了衝突。

3. Mixin導致複雜的滾雪球

隨著時間和業務的增長, 你對Mixin的修改越來越多, 到最後會變成一個難以維護的Mixin。

4. 擁抱ES6,ES6的class不支援Mixin

HOC

HOC概念

高階元件(HOC)是react中的高階技術,用來重用元件邏輯。但高階元件本身並不是React API。它只是一種模式,這種模式是由react自身的組合性質必然產生的,是React社群發展中產生的一種模式
高階元件的名稱是從高階函式來的, 如果瞭解過函數語言程式設計, 就會知道高階函式就是一個入參是函式,返回也是函式的函式,那麼高階元件顧名思義,就是一個入參是元件,返回也是元件的函式,如:

const EnhancedComponent = higherOrderComponent(WrappedComponent);
複製程式碼

HOC實現

高階元件在社群中, 有兩種使用方式, 分別是:

其中 W (WrappedComponent) 指被包裹的 React.Component,E (EnhancedComponent) 指返回型別為 React.Component 的新的 HOC。

  • Props Proxy: HOC 對傳給 WrappedComponent W 的 porps 進行操作。
  • Inheritance Inversion: HOC 繼承 WrappedComponent W。

依然是使用之前的例子, 先從比較普通使用的Props Proxy看起:

class Mouse extends React.Component {
  render() {
    const { x, y } = this.props.mouse
    return (
      <p>The current mouse position is ({x}, {y})</p>
    )
  }
}

class Cat extends React.Component {
  render() {
    const { x, y } = this.props.mouse
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: x, top: y }} alt="" />
    )
  }
}


const MouseHoc = (MouseComponent) => {
  return class extends React.Component {
    constructor(props) {
      super(props)
      this.handleMouseMove = this.handleMouseMove.bind(this)
      this.state = { x: 0, y: 0 }
    }

    handleMouseMove(event) {
      this.setState({
        x: event.clientX,
        y: event.clientY
      })
    }
    render() {
      return (
        <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
          <MouseComponent mouse={this.state} />
        </div>
      )
    }
  }
}

const EnhanceMouse = MouseHoc(Mouse)
const EnhanceCat = MouseHoc(Cat)
複製程式碼

那麼在Hoc的Props Proxy模式下, 我們可以做什麼?

操作Props
如上面的MouseHoc, 假設在日常開發中,我們需要傳入一個props給Mouse或者Cat,那麼我們可以在HOC裡面對props進行增刪查改等操作,如下:

const MouseHoc = (MouseComponent, props) => {
  props.text = props.text + '---I can operate props'
  return class extends React.Component {
    ......
    render() {
      return (
        <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
          <MouseComponent {...props} mouse={this.state} />
        </div>
      )
    }
  }
}
MouseHoc(Mouse, {
  text: 'some thing...'
})
複製程式碼

通過 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}/>
    }
  }
}
複製程式碼

提取state 就是我們的例子。

<MouseComponent mouse={this.state} />
複製程式碼

包裹 WrappedComponent

<div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
    <MouseComponent mouse={this.state} />
</div>
複製程式碼

另外一種HOC模式則是Inheritance Inversion,不過該模式比較少見,一個最簡單的例子如下:

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

你可以看到,返回的 HOC 類(Enhancer)繼承了 WrappedComponent。之所以被稱為 Inheritance Inversion 是因為 WrappedComponent 被 Enhancer 繼承了,而不是 WrappedComponent 繼承了 Enhancer。在這種方式中,它們的關係看上去被反轉(inverse)了。Inheritance Inversion 允許 HOC 通過 this 訪問到 WrappedComponent,意味著它可以訪問到 state、props、元件生命週期方法和 render 方法

那麼在我們的例子中它是這樣的:

class Mouse extends React.Component {
  render(props) {
    const { x, y } = props.mouse
    return (
      <p>The current mouse position is ({x}, {y})</p>
    )
  }
}

class Cat extends React.Component {
  render(props) {
    const { x, y } = props.mouse
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: x, top: y }} alt="" />
    )
  }
}


const MouseHoc = (MouseComponent) => {
  return class extends MouseComponent {
    constructor(props) {
      super(props)
      this.handleMouseMove = this.handleMouseMove.bind(this)
      this.state = { x: 0, y: 0 }
    }

    handleMouseMove(event) {
      this.setState({
        x: event.clientX,
        y: event.clientY
      })
    }
    render() {
      const props = {
        mouse: this.state
      }
      return (
        <div style={{ height: '100%' }} onMouseMove={this.handleMouseMove}>
          {super.render(props)}
        </div>
      )
    }
  }
}

const EnhanceMouse = MouseHoc(Mouse)
const EnhanceCat = MouseHoc(Cat)
複製程式碼

同樣, 在II模式下,我們能做些什麼呢?

渲染劫持
因為render()返回的就是JSX編譯後的物件,如下:

image

可以通過手動修改這個tree,來達到一些需求效果,不過這通常不會用到:

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

操作 state

HOC 可以讀取、編輯和刪除 WrappedComponent 例項的 state,如果你需要,你也可以給它新增更多的 state。記住,這會搞亂 WrappedComponent 的 state,導致你可能會破壞某些東西。要限制 HOC 讀取或新增 state,新增 state 時應該放在單獨的名稱空間裡,而不是和 WrappedComponent 的 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>
      )
    }
  }
}
複製程式碼

為什麼有Class而不去使用繼承返回來使用HOC

可能有人看到這裡會有疑惑,為什麼有Class而不去使用繼承返回來使用HOC, 這裡推薦知乎的一個比較好的答案

OOP和FP並不矛盾,所以混著用沒毛病,很多基於FP思想的庫也需要OOP來搭建。 為什麼React推崇HOC和組合的方式,我的理解是React希望元件是按照最小可用的思想來進行封裝的,理想的說,就是一個元件只做一件的事情,且把它做好,DRY。在OOP原則,這叫單一職責原則。如果要對元件增強,首先應該先思路這個增強的元件需要用到哪些功能,這些功能由哪些元件提供,然後把這些元件組合起來.

image

D中A相關的功能交由D內部的A來負責,D中B相關的功能交由D內部的B來負責,D僅僅負責維護A,B,C的關係,另外也可以額外提供增加項,實現元件的增強。

繼承沒有什麼不好,注意,React只是推薦,但沒限制。其實用繼承來擴充套件元件也沒問題,而且也存在這樣的場景。比如:有一個按鈕元件,僅僅是對Button進行一個包裝,我們且叫它Button,可是,按照產品需求,很多地方的按鈕都是帶著一個icon的,我們需要提供一個IconButton。這是時候,就可以通過繼承來擴充套件,同時組合另外一個獨立的元件,我們且叫它Icon,顯示icon的功能交給Icon元件來做,原來按鈕的功能繼續延續著。對於這種同型別元件的擴充套件,我認為用繼承的方式是沒關係的,靈活性,複用性還在。 但是,用繼承的方式擴充套件前,要先思考,新元件是否與被繼承的元件是不是同一型別的,同一類職責的。如果是,可以繼承,如果不是,那麼就用組合。怎麼定義同一類呢,回到上面的Button的例子,所謂同一類,就是說,我直接用IconButton直接替換掉Button,不去改動其他程式碼,頁面依然可以正常渲染,功能可以正常使用,就可以認為是同一類的,在OOP中,這叫做里氏替換原則。

繼承會帶來什麼問題,以我的實踐經驗,過渡使用繼承,雖然給編碼帶來便利,但容易導致程式碼失控,元件膨脹,降低元件的複用性。比如:有一個列表元件,叫它ListView吧,可以上下滾動顯示一個item集,突然有一天需求變了,PM說,我要這個ListView能像iOS那樣有個回彈效果。好,用繼承對這個ListView進行擴充套件,加入了回彈效果,任務closed。第二天PM找上門來了,希望所有上下滾動的地方都可以支援回彈效果,這時候就懵逼啦,怎麼辦?把ListView中回彈效果的程式碼copy一遍?這就和DRY原則相悖了不是,而且有可能受到其他地方程式碼的影響,處理回彈效果略有不同,要是有一天PM希望對這個回彈效果做升級,那就有得改啦。應對這種場景,最好的辦法是啥?用組合,封裝一個帶回彈效果的Scroller,ListView看成是Scroller和item容器元件的組合,其他地方需要要用到滾動的,直接套一個Scroller,以後不管回彈效果怎麼變,我只要維護這個Scroller就好了。當然,最理想的,把回彈效果也做成一個元件SpringBackEffect,從Scroller分離出來,這樣,需要用回彈效果的地方就加上SpringBackEffect元件就好了,這就是為什麼組合優先於繼承的原因。

頁面簡單的時候,組合也好,繼承也罷,可維護就好,能夠快速的響應需求迭代就好,用什麼方式實現到無所謂。但如果是一個大專案,頁面用到很多元件,或者是團隊多人共同維護的話,就要考慮協作中可能存在的矛盾,然後通過一定約束來閉坑。組合的方式是可以保證元件具有充分的複用性,靈活度,遵守DRY原則的其中一種實踐。

Mixin和HOC的對比

Mixin就像他的名字,他混入了元件中,我們很難去對一個混入了多個Mixin的元件進行管理,好比一個盒子,我們在盒子裡面塞入了各種東西(功能),最後肯定是難以理清其中的脈絡。
HOC則像是一個裝飾器,他是在盒子的外面一層一層的裝飾,當我們想要抽取某一層或者增加某一層都非常容易。

HOC的約定

貫穿傳遞不相關props屬性給被包裹的元件
高階元件應該貫穿傳遞與它專門關注無關的props屬性。

render() {
  // 過濾掉專用於這個階元件的props屬性,
  // 不應該被貫穿傳遞
  const { extraProp, ...passThroughProps } = this.props;

  // 向被包裹的元件注入props屬性,這些一般都是狀態值或
  // 例項方法
  const injectedProp = someStateOrInstanceMethod;

  // 向被包裹的元件傳遞props屬性
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
}
複製程式碼

最大化的組合性

// 不要這樣做……
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent))

// ……你可以使用一個函式組合工具
// compose(f, g, h) 和 (...args) => f(g(h(...args)))是一樣的
const enhance = compose(
  // 這些都是單獨一個引數的高階元件
  withRouter,
  connect(commentSelector)
)
const EnhancedComponent = enhance(WrappedComponent)
複製程式碼

包裝顯示名字以便於除錯

最常用的技術是包裹顯示名字給被包裹的元件。所以,如果你的高階元件名字是 withSubscription,且被包裹的元件的顯示名字是 CommentList,那麼就是用 WithSubscription(CommentList)這樣的顯示名字

function withSubscription(WrappedComponent) {
  class WithSubscription extends React.Component {/* ... */}
  WithSubscription.displayName = `WithSubscription(${getDisplayName(WrappedComponent)})`;
  return WithSubscription;
}

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

HOC的警戒

  • 不要在render方法內使用高階元件,因為每次高階元件返回的都是不同的元件,會造成不必要的渲染。
  • 必須將靜態方法做拷貝。

HOC帶來的問題:

  • 當存在多個HOC時,你不知道Props是從哪裡來的。
  • 和Mixin一樣, 存在相同名稱的props,則存在覆蓋問題,而且react並不會報錯。
  • JSX層次中多了很多層次(即無用的空元件),不利於除錯。
  • HOC屬於靜態構建,靜態構建即是重新生成一個元件,即返回的新元件,不會馬上渲染,即新元件中定義的生命週期函式只有新元件被渲染時才會執行。

Render Props

Render Props概念

Render Props從名知義,也是一種剝離重複使用的邏輯程式碼,提升元件複用性的解決方案。在被複用的元件中,通過一個名為“render”(屬性名也可以不是render,只要值是一個函式即可)的屬性,該屬性是一個函式,這個函式接受一個物件並返回一個子元件,會將這個函式引數中的物件作為props傳入給新生成的元件。

Render Props應用

可以看下最初的例子在render props中的應用:

class Cat extends React.Component {
  render() {
    const mouse = this.props.mouse;
    return (
      <img src="/cat.jpg" style={{ position: 'absolute', left: mouse.x, top: mouse.y }} />
    );
  }
}

class Mouse extends React.Component {
  constructor(props) {
    super(props);
    this.handleMouseMove = this.handleMouseMove.bind(this);
    this.state = { x: 0, y: 0 };
  }

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

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

        {/*
          Instead of providing a static representation of what <Mouse> renders,
          use the `render` prop to dynamically determine what to render.
        */}
        {this.props.render(this.state)}
      </div>
    );
  }
}

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <Mouse render={mouse => (
          <Cat mouse={mouse} />
        )}/>
      </div>
    );
  }
}
複製程式碼

render props的優勢

  • 不用擔心Props是從哪裡來的, 它只能從父元件傳遞過來。
  • 不用擔心props的命名問題。
  • render props是動態構建的。

動態構建和靜態構建

這裡簡單的說下動態構建,因為React官方推崇動態組合,然而HOC實際上是一個靜態構建,比如,在某個需求下,我們需要根據Mouse中某個欄位來決定渲染Cat元件或者Dog元件,使用HOC會是如下:

const MouseHoc = (Component) => {
  return Class extends React.Component {
   render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        {
          isCat ? <Cat mouse={mouse} /> : <Dog mouse={mouse} />
        }
      </div>
    );
  }
  }
}
複製程式碼

可以看到,我們不得不提前靜態構建好Cat和Dog元件

假如我們用Render props:

class MouseTracker extends React.Component {
  render() {
    return (
      <div>
        <h1>Move the mouse around!</h1>
        <Mouse render={(mouse, isCat) => (
          isCat ? <Cat mouse={mouse} /> : <Dog mouse={mouse} />
        )}/>
      </div>
    );
  }
}
複製程式碼

很明顯,在動態構建的時候,我們具有更多的靈活性,我們可以更好的利用生命週期,相比較HOC,就不得不引入Cat和Dog元件,汙染了MouseHoc。

Render Props的缺點

無法使用SCU做優化, 具體參考官方文件

總結

拋開被遺棄的Mixin和尚未穩定的Hooks,目前社群的程式碼複用方案主要還是HOC和Render Props,個人感覺,如果是多層組合或者需要動態渲染那就選擇Render Props,而如果是諸如在每個View都要執行的簡單操作,如埋點、title設定等或者是對效能要求比較高如大量表單可以採用HOC。

參考

Function as Child Components Not HOCs
React高階元件和render props的適用場景有區別嗎,還是更多的是個人偏好?
深入理解 React 高階元件
高階元件-React
精讀《我不再使用高階元件》
為什麼 React 推崇 HOC 和組合的方式,而不是繼承的方式來擴充套件元件?
React 中的 Render Props
使用 Render props 吧!
渲染屬性(Render Props)

相關文章