React 中的高階元件及其應用場景

舞動乾坤發表於2019-02-25

關鍵詞:高階函式、高階元件、屬性代理、反向繼承、裝飾器模式、受控元件

本文目錄:

  • 什麼是高階元件
  • React 中的高階元件
    • 屬性代理(Props Proxy)
    • 反向繼承(Inheritance Inversion)
  • 高階元件存在的問題
  • 高階元件的約定
  • 高階元件的應用場景
  • 裝飾者模式?高階元件?AOP?
  • 總結

什麼是高階元件

在解釋什麼是高階元件之前,可以先了解一下什麼是 高階函式,因為它們的概念非常相似,下面是 高階函式 的定義:

如果一個函式 接受一個或多個函式作為引數或者返回一個函式 就可稱之為 高階函式

下面就是一個簡單的高階函式:

function withGreeting(greeting = () => {}) {
    return greeting;
}
複製程式碼

高階元件 的定義和 高階函式 非常相似:

如果一個函式 接受一個或多個元件作為引數並且返回一個元件 就可稱之為 高階元件

下面就是一個簡單的高階元件:

function HigherOrderComponent(WrappedComponent) {
    return <WrappedComponent />;
}
複製程式碼

所以你可能會發現,當高階元件中返回的元件是 無狀態元件(Stateless Component) 時,該高階元件其實就是一個 高階函式,因為 無狀態元件 本身就是一個純函式。

無狀態元件也稱函式式元件。

React 中的高階元件

React 中的高階元件主要有兩種形式:屬性代理反向繼承

屬性代理(Props Proxy)

最簡單的屬性代理實現:

// 無狀態
function HigherOrderComponent(WrappedComponent) {
    return props => <WrappedComponent {...props} />;
}
// or
// 有狀態
function HigherOrderComponent(WrappedComponent) {
    return class extends React.Component {
        render() {
            return <WrappedComponent {...this.props} />;
        }
    };
}
複製程式碼

可以發現,屬性代理其實就是 一個函式接受一個 WrappedComponent 元件作為引數傳入,並返回一個繼承了 React.Component 元件的類,且在該類的 render() 方法中返回被傳入的 WrappedComponent 元件

那我們可以利用屬性代理型別的高階元件做一些什麼呢?

因為屬性代理型別的高階元件返回的是一個標準的 React.Component 元件,所以在 React 標準元件中可以做什麼,那在屬性代理型別的高階元件中就也可以做什麼,比如:

  • 操作 props
  • 抽離 state
  • 通過 ref 訪問到元件例項
  • 用其他元素包裹傳入的元件 WrappedComponent

操作 props

WrappedComponent 新增新的屬性:

function HigherOrderComponent(WrappedComponent) {
    return class extends React.Component {
        render() {
            const newProps = {
                name: '大板慄',
                age: 18,
            };
            return <WrappedComponent {...this.props} {...newProps} />;
        }
    };
}
複製程式碼

抽離 state

利用 props 和回撥函式把 state 抽離出來:

function withOnChange(WrappedComponent) {
    return class extends React.Component {
        constructor(props) {
            super(props);
            this.state = {
                name: '',
            };
        }
        onChange = () => {
            this.setState({
                name: '大板慄',
            });
        }
        render() {
            const newProps = {
                name: {
                    value: this.state.name,
                    onChange: this.onChange,
                },
            };
            return <WrappedComponent {...this.props} {...newProps} />;
        }
    };
}
複製程式碼

如何使用:

const NameInput = props => (<input name="name" {...props.name} />);
export default withOnChange(NameInput);
複製程式碼

這樣就將 input 轉化成受控元件了。

通過 ref 訪問到元件例項

有時會有需要訪問 DOM element (使用第三方 DOM 操作庫)的時候就會用到元件的 ref 屬性。它只能宣告在 Class 型別的元件上,而無法宣告在函式(無狀態)型別的元件上。

ref 的值可以是字串(不推薦使用)也可以是一個回撥函式,如果是回撥函式的話,它的執行時機是:

  • 元件被掛載後(componentDidMount),回撥函式立即執行,回撥函式的引數為該元件的例項。
  • 元件被解除安裝(componentDidUnmount)或者原有的 ref 屬性本身發生變化的時候,此時回撥函式也會立即執行,且回撥函式的引數為 null

如何在 高階元件 中獲取到 WrappedComponent 元件的例項呢?答案就是可以通過 WrappedComponent 元件的 ref 屬性,該屬性會在元件 componentDidMount 的時候執行 ref 的回撥函式並傳入該元件的例項:

function HigherOrderComponent(WrappedComponent) {
    return class extends React.Component {
        executeInstanceMethod = (wrappedComponentInstance) => {
            wrappedComponentInstance.someMethod();
        }
        render() {
            return <WrappedComponent {...this.props} ref={this.executeInstanceMethod} />;
        }
    };
}
複製程式碼

注意:不能在無狀態元件(函式型別元件)上使用 ref 屬性,因為無狀態元件沒有例項。

用其他元素包裹傳入的元件 WrappedComponent

WrappedComponent 元件包一層背景色為 #fafafadiv 元素:

function withBackgroundColor(WrappedComponent) {
    return class extends React.Component {
        render() {
            return (
                <div style={{ backgroundColor: '#fafafa' }}>
                    <WrappedComponent {...this.props} {...newProps} />
                </div>
            );
        }
    };
}
複製程式碼

反向繼承(Inheritance Inversion)

最簡單的反向繼承實現:

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

反向繼承其實就是 一個函式接受一個 WrappedComponent 元件作為引數傳入,並返回一個繼承了該傳入 WrappedComponent 元件的類,且在該類的 render() 方法中返回 super.render() 方法

會發現其屬性代理和反向繼承的實現有些類似的地方,都是返回一個繼承了某個父類的子類,只不過屬性代理中繼承的是 React.Component,反向繼承中繼承的是傳入的元件 WrappedComponent

反向繼承可以用來做什麼:

  • 操作 state
  • 渲染劫持(Render Highjacking)

操作 state

高階元件中可以讀取、編輯和刪除 WrappedComponent 元件例項中的 state。甚至可以增加更多的 state 項,但是 非常不建議這麼做 因為這可能會導致 state 難以維護及管理。

function withLogging(WrappedComponent) {
    return class extends WrappedComponent {
        render() {
            return (
                <div>
                    <h2>Debugger Component Logging...</h2>
                    <p>state:</p>
                    <pre>{JSON.stringify(this.state, null, 4)}</pre>
                    <p>props:</p>
                    <pre>{JSON.stringify(this.props, null, 4)}</pre>
                    {super.render()}
                </div>
            );
        }
    };
}
複製程式碼

在這個例子中利用高階函式中可以讀取 stateprops 的特性,對 WrappedComponent 元件做了額外元素的巢狀,把 WrappedComponent 元件的 stateprops 都列印了出來,

渲染劫持

之所以稱之為 渲染劫持 是因為高階元件控制著 WrappedComponent 元件的渲染輸出,通過渲染劫持我們可以:

  • 有條件地展示元素樹(element tree
  • 操作由 render() 輸出的 React 元素樹
  • 在任何由 render() 輸出的 React 元素中操作 props
  • 用其他元素包裹傳入的元件 WrappedComponent (同 屬性代理
條件渲染

通過 props.isLoading 這個條件來判斷渲染哪個元件。

function withLoading(WrappedComponent) {
    return class extends WrappedComponent {
        render() {
            if(this.props.isLoading) {
                return <Loading />;
            } else {
                return super.render();
            }
        }
    };
}
複製程式碼
修改由 render() 輸出的 React 元素樹

修改元素樹:

function HigherOrderComponent(WrappedComponent) {
    return class extends WrappedComponent {
        render() {
            const tree = super.render();
            const newProps = {};
            if (tree && tree.type === 'input') {
                newProps.value = 'something here';
            }
            const props = {
                ...tree.props,
                ...newProps,
            };
            const newTree = React.cloneElement(tree, props, tree.props.children);
            return newTree;
        }
    };
}
複製程式碼

高階元件存在的問題

  • 靜態方法丟失
  • refs 屬性不能透傳
  • 反向繼承不能保證完整的子元件樹被解析

靜態方法丟失

因為原始元件被包裹於一個容器元件內,也就意味著新元件會沒有原始元件的任何靜態方法:

// 定義靜態方法
WrappedComponent.staticMethod = function() {}
// 使用高階元件
const EnhancedComponent = HigherOrderComponent(WrappedComponent);
// 增強型元件沒有靜態方法
typeof EnhancedComponent.staticMethod === 'undefined' // true
複製程式碼

所以必須將靜態方法做拷貝:

function HigherOrderComponent(WrappedComponent) {
    class Enhance extends React.Component {}
    // 必須得知道要拷貝的方法
    Enhance.staticMethod = WrappedComponent.staticMethod;
    return Enhance;
}
複製程式碼

但是這麼做的一個缺點就是必須知道要拷貝的方法是什麼,不過 React 社群實現了一個庫 hoist-non-react-statics 來自動處理,它會 自動拷貝所有非 React 的靜態方法

import hoistNonReactStatic from 'hoist-non-react-statics';

function HigherOrderComponent(WrappedComponent) {
    class Enhance extends React.Component {}
    hoistNonReactStatic(Enhance, WrappedComponent);
    return Enhance;
}
複製程式碼

refs 屬性不能透傳

一般來說高階元件可以傳遞所有的 props 給包裹的元件 WrappedComponent,但是有一種屬性不能傳遞,它就是 ref。與其他屬性不同的地方在於 React 對其進行了特殊的處理。

如果你向一個由高階元件建立的元件的元素新增 ref 引用,那麼 ref 指向的是最外層容器元件例項的,而不是被包裹的 WrappedComponent 元件。

那如果有一定要傳遞 ref 的需求呢,別急,React 為我們提供了一個名為 React.forwardRef 的 API 來解決這一問題(在 React 16.3 版本中被新增):

function withLogging(WrappedComponent) {
    class Enhance extends WrappedComponent {
        componentWillReceiveProps() {
            console.log('Current props', this.props);
            console.log('Next props', nextProps);
        }
        render() {
            const {forwardedRef, ...rest} = this.props;
            // 把 forwardedRef 賦值給 ref
            return <WrappedComponent {...rest} ref={forwardedRef} />;
        }
    };

    // React.forwardRef 方法會傳入 props 和 ref 兩個引數給其回撥函式
    // 所以這邊的 ref 是由 React.forwardRef 提供的
    function forwardRef(props, ref) {
        return <Enhance {...props} forwardRef={ref} />
    }

    return React.forwardRef(forwardRef);
}
const EnhancedComponent = withLogging(SomeComponent);
複製程式碼

反向繼承不能保證完整的子元件樹被解析

React 元件有兩種形式,分別是 class 型別和 function 型別(無狀態元件)。

我們知道反向繼承的渲染劫持可以控制 WrappedComponent 的渲染過程,也就是說這個過程中我們可以對 elements treestatepropsrender() 的結果做各種操作。

但是如果渲染 elements tree 中包含了 function 型別的元件的話,這時候就不能操作元件的子元件了。

高階元件的約定

高階元件帶給我們極大方便的同時,我們也要遵循一些 約定

  • props 保持一致
  • 你不能在函式式(無狀態)元件上使用 ref 屬性,因為它沒有例項
  • 不要以任何方式改變原始元件 WrappedComponent
  • 透傳不相關 props 屬性給被包裹的元件 WrappedComponent
  • 不要再 render() 方法中使用高階元件
  • 使用 compose 組合高階元件
  • 包裝顯示名字以便於除錯

props 保持一致

高階元件在為子元件新增特性的同時,要儘量保持原有元件的 props 不受影響,也就是說傳入的元件和返回的元件在 props 上儘量保持一致。

不要改變原始元件 WrappedComponent

不要在高階元件內以任何方式修改一個元件的原型,思考一下下面的程式碼:

function withLogging(WrappedComponent) {
    WrappedComponent.prototype.componentWillReceiveProps = function(nextProps) {
        console.log('Current props', this.props);
        console.log('Next props', nextProps);
    }
    return WrappedComponent;
}
const EnhancedComponent = withLogging(SomeComponent);
複製程式碼

會發現在高階元件的內部對 WrappedComponent 進行了修改,一旦對原元件進行了修改,那麼就失去了元件複用的意義,所以請通過 純函式(相同的輸入總有相同的輸出) 返回新的元件:

function withLogging(WrappedComponent) {
    return class extends React.Component {
        componentWillReceiveProps() {
            console.log('Current props', this.props);
            console.log('Next props', nextProps);
        }
        render() {
            // 透傳引數,不要修改它
            return <WrappedComponent {...this.props} />;
        }
    };
}
複製程式碼

這樣優化之後的 withLogging 是一個 純函式,並不會修改 WrappedComponent 元件,所以不需要擔心有什麼副作用,進而達到元件複用的目的。

透傳不相關 props 屬性給被包裹的元件 WrappedComponent

function HigherOrderComponent(WrappedComponent) {
    return class extends React.Component {
        render() {
            return <WrappedComponent name="name" {...this.props} />;
        }
    };
}
複製程式碼

不要在 render() 方法中使用高階元件

class SomeComponent extends React.Component {
    render() {
        // 呼叫高階函式的時候每次都會返回一個新的元件
        const EnchancedComponent = enhance(WrappedComponent);
        // 每次 render 的時候,都會使子物件樹完全被解除安裝和重新
        // 重新載入一個元件會引起原有元件的狀態和它的所有子元件丟失
        return <EnchancedComponent />;
    }
}
複製程式碼

使用 compose 組合高階元件

// 不要這麼使用
const EnhancedComponent = withRouter(connect(commentSelector)(WrappedComponent));
// 可以使用一個 compose 函式組合這些高階元件
// lodash, redux, ramda 等第三方庫都提供了類似 `compose` 功能的函式
const enhance = compose(withRouter, connect(commentSelector));
const EnhancedComponent = enhance(WrappedComponent);
複製程式碼

因為按照 約定 實現的高階元件其實就是一個純函式,如果多個函式的引數一樣(在這裡 withRouter 函式和 connect(commentSelector) 所返回的函式所需的引數都是 WrappedComponent),所以就可以通過 compose 方法來組合這些函式。

使用 compose 組合高階元件使用,可以顯著提高程式碼的可讀性和邏輯的清晰度。

包裝顯示名字以便於除錯

高階元件建立的容器元件在 React Developer Tools 中的表現和其它的普通元件是一樣的。為了便於除錯,可以選擇一個顯示名字,傳達它是一個高階元件的結果。

const getDisplayName = WrappedComponent => WrappedComponent.displayName || WrappedComponent.name || 'Component';
function HigherOrderComponent(WrappedComponent) {
    class HigherOrderComponent extends React.Component {/* ... */}
    HigherOrderComponent.displayName = `HigherOrderComponent(${getDisplayName(WrappedComponent)})`;
    return HigherOrderComponent;
}
複製程式碼

實際上 recompose 庫實現了類似的功能,懶的話可以不用自己寫:

import getDisplayName from 'recompose/getDisplayName';
HigherOrderComponent.displayName = `HigherOrderComponent(${getDisplayName(BaseComponent)})`;
// Or, even better:
import wrapDisplayName from 'recompose/wrapDisplayName';
HigherOrderComponent.displayName = wrapDisplayName(BaseComponent, 'HigherOrderComponent');
複製程式碼

高階元件的應用場景

不談場景的技術就是在耍流氓,所以接下來說一下如何在業務場景中使用高階元件。

許可權控制

利用高階元件的 條件渲染 特性可以對頁面進行許可權控制,許可權控制一般分為兩個維度:頁面級別頁面元素級別,這裡以頁面級別來舉一個栗子:

// HOC.js
function withAdminAuth(WrappedComponent) {
    return class extends React.Component {
        state = {
            isAdmin: false,
        }
        async componentWillMount() {
            const currentRole = await getCurrentUserRole();
            this.setState({
                isAdmin: currentRole === 'Admin',
            });
        }
        render() {
            if (this.state.isAdmin) {
                return <WrappedComponent {...this.props} />;
            } else {
                return (<div>您沒有許可權檢視該頁面,請聯絡管理員!</div>);
            }
        }
    };
}
複製程式碼

然後是兩個頁面:

// pages/page-a.js
class PageA extends React.Component {
    constructor(props) {
        super(props);
        // something here...
    }
    componentWillMount() {
        // fetching data
    }
    render() {
        // render page with data
    }
}
export default withAdminAuth(PageA);

// pages/page-b.js
class PageB extends React.Component {
    constructor(props) {
        super(props);
        // something here...
    }
    componentWillMount() {
        // fetching data
    }
    render() {
        // render page with data
    }
}
export default withAdminAuth(PageB);
複製程式碼

使用高階元件對程式碼進行復用之後,可以非常方便的進行擴充,比如產品經理說,PageC 頁面也要有 Admin 許可權才能進入,我們只需要在 pages/page-c.js 中把返回的 PageC 巢狀一層 withAdminAuth 高階元件就行,就像這樣 withAdminAuth(PageC)。是不是非常完美!非常高效!!但是。。第二天產品經理又說,PageC 頁面只要 VIP 許可權就可以訪問了。你三下五除二實現了一個高階元件 withVIPAuth。第三天。。。

其實你還可以更高效的,就是在高階元件之上再抽象一層,無需實現各種 withXXXAuth 高階元件,因為這些高階元件本身程式碼就是高度相似的,所以我們要做的就是實現一個 返回高階元件的函式,把 變的部分(Admin、VIP) 抽離出來,保留 不變的部分,具體實現如下:

// HOC.js
const withAuth = role => WrappedComponent => {
    return class extends React.Component {
        state = {
            permission: false,
        }
        async componentWillMount() {
            const currentRole = await getCurrentUserRole();
            this.setState({
                permission: currentRole === role,
            });
        }
        render() {
            if (this.state.permission) {
                return <WrappedComponent {...this.props} />;
            } else {
                return (<div>您沒有許可權檢視該頁面,請聯絡管理員!</div>);
            }
        }
    };
}
複製程式碼

可以發現經過對高階元件再進行了一層抽象後,前面的 withAdminAuth 可以寫成 withAuth('Admin') 了,如果此時需要 VIP 許可權的話,只需在 withAuth 函式中傳入 'VIP' 就可以了。

有沒有發現和 react-reduxconnect 方法的使用方式非常像?沒錯,connect 其實也是一個 返回高階元件的函式

元件渲染效能追蹤

藉助父元件子元件生命週期規則捕獲子元件的生命週期,可以方便的對某個元件的渲染時間進行記錄:

class Home extends React.Component {
    render() {
        return (<h1>Hello World.</h1>);
    }
}
function withTiming(WrappedComponent) {
    return class extends WrappedComponent {
        constructor(props) {
            super(props);
            this.start = 0;
            this.end = 0;
        }
        componentWillMount() {
            super.componentWillMount && super.componentWillMount();
            this.start = Date.now();
        }
        componentDidMount() {
            super.componentDidMount && super.componentDidMount();
            this.end = Date.now();
            console.log(`${WrappedComponent.name} 元件渲染時間為 ${this.end - this.start} ms`);
        }
        render() {
            return super.render();
        }
    };
}

export default withTiming(Home);
複製程式碼

withTiming

withTiming 是利用 反向繼承 實現的一個高階元件,功能是計算被包裹元件(這裡是 Home 元件)的渲染時間。

頁面複用

假設我們有兩個頁面 pageApageB 分別渲染兩個分類的電影列表,普通寫法可能是這樣:

// pages/page-a.js
class PageA extends React.Component {
    state = {
        movies: [],
    }
    // ...
    async componentWillMount() {
        const movies = await fetchMoviesByType('science-fiction');
        this.setState({
            movies,
        });
    }
    render() {
        return <MovieList movies={this.state.movies} />
    }
}
export default PageA;

// pages/page-b.js
class PageB extends React.Component {
    state = {
        movies: [],
    }
    // ...
    async componentWillMount() {
        const movies = await fetchMoviesByType('action');
        this.setState({
            movies,
        });
    }
    render() {
        return <MovieList movies={this.state.movies} />
    }
}
export default PageB;
複製程式碼

頁面少的時候可能沒什麼問題,但是假如隨著業務的進展,需要上線的越來越多型別的電影,就會寫很多的重複程式碼,所以我們需要重構一下:

const withFetching = fetching => WrappedComponent => {
    return class extends React.Component {
        state = {
            data: [],
        }
        async componentWillMount() {
            const data = await fetching();
            this.setState({
                data,
            });
        }
        render() {
            return <WrappedComponent data={this.state.data} {...this.props} />;
        }
    }
}

// pages/page-a.js
export default withFetching(fetching('science-fiction'))(MovieList);
// pages/page-b.js
export default withFetching(fetching('action'))(MovieList);
// pages/page-other.js
export default withFetching(fetching('some-other-type'))(MovieList);
複製程式碼

會發現 withFetching 其實和前面的 withAuth 函式類似,把 變的部分(fetching(type)) 抽離到外部傳入,從而實現頁面的複用。

裝飾者模式?高階元件?AOP?

可能你已經發現了,高階元件其實就是裝飾器模式在 React 中的實現:通過給函式傳入一個元件(函式或類)後在函式內部對該元件(函式或類)進行功能的增強(不修改傳入引數的前提下),最後返回這個元件(函式或類),即允許向一個現有的元件新增新的功能,同時又不去修改該元件,屬於 包裝模式(Wrapper Pattern) 的一種。

什麼是裝飾者模式:在不改變物件自身的前提下在程式執行期間動態的給物件新增一些額外的屬性或行為

相比於使用繼承,裝飾者模式是一種更輕便靈活的做法。

使用裝飾者模式實現 AOP

面向切面程式設計(AOP)和麵向物件程式設計(OOP)一樣,只是一種程式設計正規化,並沒有規定說要用什麼方式去實現 AOP。

// 在需要執行的函式之前執行某個新新增的功能函式
Function.prototype.before = function(before = () => {}) {
    return () => {
        before.apply(this, arguments);
        return this.apply(this, arguments);
    };
}
// 在需要執行的函式之後執行某個新新增的功能函式
Function.prototype.after = function(after = () => {}) {
    return () => {
        const result = after.apply(this, arguments);
        this.apply(this, arguments);
        return result;
    };
}
複製程式碼

可以發現其實 beforeafter 就是一個 高階函式,和高階元件非常類似。

面向切面程式設計(AOP)主要應用在 與核心業務無關但又在多個模組使用的功能比如許可權控制、日誌記錄、資料校驗、異常處理、統計上報等等領域

類比一下 AOP 你應該就知道高階元件通常是處理哪一型別的問題了吧。

總結

React 中的 高階元件 其實是一個非常簡單的概念,但又非常實用。在實際的業務場景中合理的使用高階元件,可以提高程式碼的複用性和靈活性

最後的最後,再對高階元件進行一個小小的總結:

  • 高階元件 不是元件 一個把某個元件轉換成另一個元件的 函式
  • 高階元件的主要作用是 程式碼複用
  • 高階元件是 裝飾器模式在 React 中的實現

follow

更多幹貨請關注公眾號「前端小專欄:QianDuanXiaoZhuanLan」

相關文章