React中的高階元件

WindrunnerMax發表於2021-01-15

React中的高階元件

高階元件HOCHigher Order ComponentReact中用於複用元件邏輯的一種高階技巧,HOC自身不是React API的一部分,它是一種基於React的組合特性而形成的設計模式。

描述

高階元件從名字上就透漏出高階的氣息,實際上這個概念應該是源自於JavaScript的高階函式,高階函式就是接受函式作為輸入或者輸出的函式,可以想到柯里化就是一種高階函式,同樣在React文件上也給出了高階元件的定義,高階元件是接收元件並返回新元件的函式。

A higher-order component is a function that takes a component and returns a new component.

具體而言,高階元件是引數為元件,返回值為新元件的函式,元件是將props轉換為UI,而高階元件是將元件轉換為另一個元件。HOCReact的第三方庫中很常見,例如ReduxconnectRelaycreateFragmentContainer

// 高階元件定義
const higherOrderComponent = (WrappedComponent) => {
    return class EnhancedComponent extends React.Component {
        // ...
        render() {
          return <WrappedComponent {...this.props} />;
        }
  };
}

// 普通元件定義
class WrappedComponent extends React.Component{
    render(){
        //....
    }
}

// 返回被高階元件包裝過的增強元件
const EnhancedComponent = higherOrderComponent(WrappedComponent);

在這裡要注意,不要試圖以任何方式在HOC中修改元件原型,而應該使用組合的方式,通過將元件包裝在容器元件中實現功能。通常情況下,實現高階元件的方式有以下兩種:

  • 屬性代理Props Proxy
  • 反向繼承Inheritance Inversion

屬性代理

例如我們可以為傳入的元件增加一個儲存中的id屬性值,通過高階元件我們就可以為這個元件新增一個props,當然我們也可以對在JSX中的WrappedComponent元件中props進行操作,注意不是操作傳入的WrappedComponent類,我們不應該直接修改傳入的元件,而可以在組合的過程中對其操作。

const HOC = (WrappedComponent, store) => {
    return class EnhancedComponent extends React.Component {
        render() {
            const newProps = {
                id: store.id
            }
            return <WrappedComponent
                {...this.props}
                {...newProps}
            />;
        }
    }
}

我們也可以利用高階元件將新元件的狀態裝入到被包裝元件中,例如我們可以使用高階元件將非受控元件轉化為受控元件。

class WrappedComponent extends React.Component {
    render() {
        return <input name="name" />;
    }
}

const HOC = (WrappedComponent) => {
    return class EnhancedComponent extends React.Component {
        constructor(props) {
            super(props);
            this.state = { name: "" };
        }
        render() {
            const newProps = {
                value: this.state.name,
                onChange: e => this.setState({name: e.target.value}),
            }
            return <WrappedComponent 
                {...this.props} 
                {...newProps} 
            />;
        }
    }
}

或者我們的目的是將其使用其他元件包裹起來用以達成佈局或者是樣式的目的。

const HOC = (WrappedComponent) => {
    return class EnhancedComponent extends React.Component {
        render() {
            return (
                <div class="layout">
                    <WrappedComponent  {...this.props} />
                </div>
            );
        }
    }
}

反向繼承

反向繼承是指返回的元件去繼承之前的元件,在反向繼承中我們可以做非常多的操作,修改stateprops甚至是翻轉Element Tree,反向繼承有一個重要的點,反向繼承不能保證完整的子元件樹被解析,也就是說解析的元素樹中包含了元件(函式型別或者Class型別),就不能再操作元件的子元件了。
當我們使用反向繼承實現高階元件的時候可以通過渲染劫持來控制渲染,具體是指我們可以有意識地控制WrappedComponent的渲染過程,從而控制渲染控制的結果,例如我們可以根據部分引數去決定是否渲染元件。

const HOC = (WrappedComponent) => {
    return class EnhancedComponent extends WrappedComponent {
        render() {
            return this.props.isRender && super.render();  
        }
    }
}

甚至我們可以通過重寫的方式劫持原元件的生命週期。

const HOC = (WrappedComponent) => {
    return class EnhancedComponent extends WrappedComponent {
        componentDidMount(){
          // ...
        }
        render() {
            return super.render();  
        }
    }
}

由於實際上是繼承關係,我們可以去讀取元件的propsstate,如果有必要的話,甚至可以修改增加、修改和刪除propsstate,當然前提是修改帶來的風險需要你自己來控制。在一些情況下,我們可能需要為高階屬性傳入一些引數,那我們就可以通過柯里化的形式傳入引數,配合高階元件可以完成對元件的類似於閉包的操作。

const HOCFactoryFactory = (params) => {
    // 此處操作params
    return (WrappedComponent) => {
        return class EnhancedComponent extends WrappedComponent {
            render() {
                return params.isRender && this.props.isRender && super.render();
            }
        }
    }
}

HOC與Mixin

使用MixinHOC都可以用於解決橫切關注點相關的問題。
Mixin是一種混入的模式,在實際使用中Mixin的作用還是非常強大的,能夠使得我們在多個元件中共用相同的方法,但同樣也會給元件不斷增加新的方法和屬性,元件本身不僅可以感知,甚至需要做相關的處理(例如命名衝突、狀態維護等),一旦混入的模組變多時,整個元件就變的難以維護,Mixin可能會引入不可見的屬性,例如在渲染元件中使用Mixin方法,給元件帶來了不可見的屬性props和狀態state,並且Mixin可能會相互依賴,相互耦合,不利於程式碼維護,此外不同的Mixin中的方法可能會相互衝突。之前React官方建議使用Mixin用於解決橫切關注點相關的問題,但由於使用Mixin可能會產生更多麻煩,所以官方現在推薦使用HOC
高階元件HOC屬於函數語言程式設計functional programming思想,對於被包裹的元件時不會感知到高階元件的存在,而高階元件返回的元件會在原來的元件之上具有功能增強的效果,基於此React官方推薦使用高階元件。

注意

不要改變原始元件

不要試圖在HOC中修改元件原型,或以其他方式改變它。

function logProps(InputComponent) {
  InputComponent.prototype.componentDidUpdate = function(prevProps) {
    console.log("Current props: ", this.props);
    console.log("Previous props: ", prevProps);
  };
  // 返回原始的 input 元件,其已經被修改。
  return InputComponent;
}

// 每次呼叫 logProps 時,增強元件都會有 log 輸出。
const EnhancedComponent = logProps(InputComponent);

這樣做會產生一些不良後果,其一是輸入元件再也無法像HOC增強之前那樣使用了,更嚴重的是,如果你再用另一個同樣會修改componentDidUpdateHOC增強它,那麼前面的HOC就會失效,同時這個HOC也無法應用於沒有生命週期的函式元件。
修改傳入元件的HOC是一種糟糕的抽象方式,呼叫者必須知道他們是如何實現的,以避免與其他HOC發生衝突。HOC不應該修改傳入元件,而應該使用組合的方式,通過將元件包裝在容器元件中實現功能。

function logProps(WrappedComponent) {
  return class extends React.Component {
    componentDidUpdate(prevProps) {
      console.log("Current props: ", this.props);
      console.log("Previous props: ", prevProps);
    }
    render() {
      // 將 input 元件包裝在容器中,而不對其進行修改,Nice!
      return <WrappedComponent {...this.props} />;
    }
  }
}

過濾props

HOC為元件新增特性,自身不應該大幅改變約定,HOC返回的元件與原元件應保持類似的介面。HOC應該透傳與自身無關的props,大多數HOC都應該包含一個類似於下面的render方法。

render() {
  // 過濾掉額外的 props,且不要進行透傳
  const { extraProp, ...passThroughProps } = this.props;

  // 將 props 注入到被包裝的元件中。
  // 通常為 state 的值或者例項方法。
  const injectedProp = someStateOrInstanceMethod;

  // 將 props 傳遞給被包裝元件
  return (
    <WrappedComponent
      injectedProp={injectedProp}
      {...passThroughProps}
    />
  );
}

最大化可組合性

並不是所有的HOC都一樣,有時候它僅接受一個引數,也就是被包裹的元件。

const NavbarWithRouter = withRouter(Navbar);

HOC通常可以接收多個引數,比如在RelayHOC額外接收了一個配置物件用於指定元件的資料依賴。

const CommentWithRelay = Relay.createContainer(Comment, config);

最常見的HOC簽名如下,connect是一個返回高階元件的高階函式。

// React Redux 的 `connect` 函式
const ConnectedComment = connect(commentSelector, commentActions)(CommentList);

// connect 是一個函式,它的返回值為另外一個函式。
const enhance = connect(commentListSelector, commentListActions);
// 返回值為 HOC,它會返回已經連線 Redux store 的元件
const ConnectedComment = enhance(CommentList);

這種形式可能看起來令人困惑或不必要,但它有一個有用的屬性,像connect函式返回的單引數HOC具有簽名Component => Component,輸出型別與輸入型別相同的函式很容易組合在一起。同樣的屬性也允許connect和其他HOC承擔裝飾器的角色。此外許多第三方庫都提供了compose工具函式,包括lodashReduxRamda

const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent))

// 你可以編寫組合工具函式
// compose(f, g, h) 等同於 (...args) => f(g(h(...args)))
const enhance = compose(
  // 這些都是單引數的 HOC
  withRouter,
  connect(commentSelector)
)
const EnhancedComponent = enhance(WrappedComponent)

不要在render方法中使用HOC

Reactdiff演算法使用元件標識來確定它是應該更新現有子樹還是將其丟棄並掛載新子樹,如果從render返回的元件與前一個渲染中的元件相同===,則React通過將子樹與新子樹進行區分來遞迴更新子樹,如果它們不相等,則完全解除安裝前一個子樹。
通常在使用的時候不需要考慮這點,但對HOC來說這一點很重要,因為這代表著你不應在元件的render方法中對一個元件應用HOC

render() {
  // 每次呼叫 render 函式都會建立一個新的 EnhancedComponent
  // EnhancedComponent1 !== EnhancedComponent2
  const EnhancedComponent = enhance(MyComponent);
  // 這將導致子樹每次渲染都會進行解除安裝,和重新掛載的操作!
  return <EnhancedComponent />;
}

這不僅僅是效能問題,重新掛載元件會導致該元件及其所有子元件的狀態丟失,如果在元件之外建立HOC,這樣一來元件只會建立一次。因此每次render時都會是同一個元件,一般來說,這跟你的預期表現是一致的。在極少數情況下,你需要動態呼叫HOC,你可以在元件的生命週期方法或其建構函式中進行呼叫。

務必複製靜態方法

有時在React元件上定義靜態方法很有用,例如Relay容器暴露了一個靜態方法getFragment以方便組合GraphQL片段。但是當你將HOC應用於元件時,原始元件將使用容器元件進行包裝,這意味著新元件沒有原始元件的任何靜態方法。

// 定義靜態函式
WrappedComponent.staticMethod = function() {/*...*/}
// 現在使用 HOC
const EnhancedComponent = enhance(WrappedComponent);

// 增強元件沒有 staticMethod
typeof EnhancedComponent.staticMethod === "undefined" // true

為了解決這個問題,你可以在返回之前把這些方法拷貝到容器元件上。

function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  // 必須準確知道應該拷貝哪些方法 :(
  Enhance.staticMethod = WrappedComponent.staticMethod;
  return Enhance;
}

但要這樣做,你需要知道哪些方法應該被拷貝,你可以使用hoist-non-react-statics依賴自動拷貝所有非React靜態方法。

import hoistNonReactStatic from "hoist-non-react-statics";
function enhance(WrappedComponent) {
  class Enhance extends React.Component {/*...*/}
  hoistNonReactStatic(Enhance, WrappedComponent);
  return Enhance;
}

除了匯出元件,另一個可行的方案是再額外匯出這個靜態方法。

// 使用這種方式代替...
MyComponent.someFunction = someFunction;
export default MyComponent;

// ...單獨匯出該方法...
export { someFunction };

// ...並在要使用的元件中,import 它們
import MyComponent, { someFunction } from "./MyComponent.js";

Refs不會被傳遞

雖然高階元件的約定是將所有props傳遞給被包裝元件,但這對於refs並不適用,那是因為ref實際上並不是一個prop,就像key一樣,它是由React專門處理的。如果將ref新增到HOC的返回元件中,則ref引用指向容器元件,而不是被包裝元件,這個問題可以通過React.forwardRef這個API明確地將refs轉發到內部的元件。。

function logProps(Component) {
  class LogProps extends React.Component {
    componentDidUpdate(prevProps) {
      console.log('old props:', prevProps);
      console.log('new props:', this.props);
    }

    render() {
      const {forwardedRef, ...rest} = this.props;

      // 將自定義的 prop 屬性 “forwardedRef” 定義為 ref
      return <Component ref={forwardedRef} {...rest} />;
    }
  }

  // 注意 React.forwardRef 回撥的第二個引數 “ref”。
  // 我們可以將其作為常規 prop 屬性傳遞給 LogProps,例如 “forwardedRef”
  // 然後它就可以被掛載到被 LogProps 包裹的子元件上。
  return React.forwardRef((props, ref) => {
    return <LogProps {...props} forwardedRef={ref} />;
  });
}

示例

<!DOCTYPE html>
<html>

<head>
    <meta charset="UTF-8" />
    <title>React</title>
</head>

<body>
    <div id="root"></div>
</body>
<script src="https://unpkg.com/react@17/umd/react.development.js"></script>
<script src="https://unpkg.com/react-dom@17/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script type="text/babel">
    class WrappedComponent extends React.Component {
        render() {
            return <input name="name" />;
        }
    }

    const HOC = (WrappedComponent) => {
        return class EnhancedComponent extends React.Component {
            constructor(props) {
                super(props);
                this.state = { name: "" };
            }
            render() {
                const newProps = {
                    value: this.state.name,
                    onChange: e => this.setState({name: e.target.value}),
                }
                return <WrappedComponent 
                    {...this.props} 
                    {...newProps} 
                />;
            }
        }
    }

    const EnhancedComponent = HOC(WrappedComponent);

    const HOC2 = (WrappedComponent) => {
        return class EnhancedComponent extends WrappedComponent {
            render() {
                return this.props.isRender && super.render();  
            }
        }
    }

    const EnhancedComponent2 = HOC2(WrappedComponent);

    var vm = ReactDOM.render(
        <>
            <EnhancedComponent />
            <EnhancedComponent2 isRender={true} />
        </>,
        document.getElementById("root")
    );
</script>

</html>

每日一題

https://github.com/WindrunnerMax/EveryDay

參考

https://juejin.cn/post/6844903477798256647
https://juejin.cn/post/6844904050236850184
https://zh-hans.reactjs.org/docs/higher-order-components.htm

相關文章