React專題:元件

馬蹄疾發表於2018-08-30

本文是『horseshoe·React專題』系列文章之一,後續會有更多專題推出

來我的 GitHub repo 閱讀完整的專題文章

來我的 個人部落格 獲得無與倫比的閱讀體驗

刀耕火種時期的前端,HTML描述頁面結構,CSS描述樣式,JavaScript描述功能。它們彼此是分離的。

然而這種方式卻滿足不了開發者對程式碼複用的需求。

近幾年各大前端框架做了很多探索,其中元件化就是最璀璨的成果之一。

一個元件就是一個功能模組,所有的前端元素都封裝在元件內部,對外只暴露有限的介面。這樣開發者拿來就能用,通過介面與元件互動而不必知道元件的內部細節。

而React是前端框架裡面元件化思想貫徹的最徹底的。

class元件和函式元件

在React中,構造一個元件既可以用class類,也可以僅用一個普通函式。

兩者有什麼區別呢?

class類不僅允許內部狀態的存在,還有完整的生命週期鉤子。

這讓一個元件變的有生命力而且可以管理。

代價就是它的效能稍遜。

還有一點,class類元件必須繼承React內建的元件類。因為那些生命週期鉤子都是繼承自React內建的元件類。

那你說,我不用生命週期鉤子,可不可以不繼承呢?

不可以,因為你必須要用生命週期鉤子。

在一個class類元件中,render方法是必須的,沒有了它就不可能返回UI,也就不能稱其為一個元件。而render方法就是生命週期鉤子之一。

import React, { Component } from 'react';

class App extends Component {
    state = { star: 1 };
    
    render() {
        return (
            <div>{this.state.star}</div>
        );
    }
}

export default App;
複製程式碼

函式元件沒有辦法例項化,除了一些邏輯判斷之外,它的功能只是返回UI。

所以函式元件常常被稱為無狀態元件。

然而這正是React強大之處,它構建元件可以如此隨意,只需要一個普通函式返回一段JSX元素。在React中,有的時候函式和元件的界限會非常模糊。

有一種設計模式把React元件分為容器元件和展示元件,容器元件管理資料、狀態和業務邏輯,展示元件僅僅負責接收props展示UI。

所以函式元件的使命肯定是做好展示元件咯。

import React from 'react';

function App(props) {
    return (
        <div>{props.star}</div>
    );
}

export default App;
複製程式碼

那麼在React中函式和元件的區別到底在哪?

元件必須返回一段JSX元素,class類元件和函式元件都一樣。

Component和PureComponent

前面說到class類元件有完整的生命週期鉤子。這些生命週期鉤子是從哪來的呢?畢竟class類元件就是原生的class類寫法。

其實React內建了一個Component類,生命週期鉤子都是從它這裡來的,麻煩的地方就是每次都要繼承。

PureComponent類又是幹嘛用的?

憑名字猜測,它是一個純純的元件類。

怎麼個純法子?

它自動幫開發者做了一些優化工作,使得元件看起來更加純粹。

而元件效能優化的主要手段就是通過shouldComponentUpdate生命週期鉤子。

我們來看看它是如何自動優化的。

從下面的例子看,React專門有一個方法來判斷元件該不該更新。如果typeof instance.shouldComponentUpdate === 'function',那這就是一個繼承了Component類的元件,直接執行shouldComponentUpdate,返回true則更新,返回false則不更新。

如果ctor.prototype.isPureReactComponent,那這就是一個繼承了PureComponent類的元件,這時React會將oldPropsnewProps做一層淺比較,同時將oldStatenewState做一層淺比較,只要有一個淺比較不相等,則返回true更新,否則返回false不更新。

function checkShouldComponentUpdate(
    workInProgress,
    oldProps,
    newProps,
    oldState,
    newState,
    newContext,
) {
    const instance = workInProgress.stateNode;
    const ctor = workInProgress.type;
    if (typeof instance.shouldComponentUpdate === 'function') {
        startPhaseTimer(workInProgress, 'shouldComponentUpdate');
        const shouldUpdate = instance.shouldComponentUpdate(
            newProps,
            newState,
            newContext,
        );
        stopPhaseTimer();
        return shouldUpdate;
    }
    if (ctor.prototype && ctor.prototype.isPureReactComponent) {
        return (
            !shallowEqual(oldProps, newProps) || !shallowEqual(oldState, newState)
        );
    }
    return true;
}
複製程式碼

shallowEqual又幹了什麼呢?

  • 首先用Object.is判斷是否相等(React自己寫的polyfill)。
  • 如果Object.is判斷不相等,再作引用型別的比較,比較屬性key的長度,比較屬性key的一一對應,比較屬性value的引用。

總的來說,它只做了一層比較,所以才叫做淺比較。

聰明的你肯定要問了:為什麼不遞迴呢(併發射一個傲嬌臉)?

因為遞迴的深比較非常耗費效能。PureComponent類只是幫開發者適度優化效能,它還是要找到成本與收益的平衡點的。

PureComponent類有其正確開啟方式

例如像陣列這樣的資料結構,在不改變引用的情況下,使用陣列的方法運算元組,然後再setState該陣列,元件是不會更新的。

你說不對呀,老陣列和新陣列雖然是同一個引用,但是長度不一樣了,淺比較是能識別出來的呀。

我們以上面的例子來看,pop方法會修改老陣列,所以此時老陣列和新陣列是一模一樣的,引用一樣,長度一樣。

React拿著這兩份資料做淺比較,肯定返回true。

import React, { Component } from 'react';

class App extends Component {
    state = {
        list: [1, 2, 3],
    };
    
    render() {
        const { list } = this.state;
        return (
            <div>
                <button onClick={this.handleDelete}>Delete</button>
                <ul>{list.map(n => <li key={n}>{n}</li>)}</ul>
            </div>
        );
    }

    handleDelete = () => {
        this.state.list.pop();
        this.setState((prevState) => ({ list: prevState.list }));
    }
}

export default App;
複製程式碼

這就是會誤導開發者的地方。

正確的做法是永遠不修改原資料,生成新資料時依賴於原資料的淺拷貝,避免新資料和老資料指向同一個引用。

import React, { Component } from 'react';

class App extends Component {
    state = {
        list: [1, 2, 3],
    };
    
    render() {
        const { list } = this.state;
        return (
            <div>
                <button onClick={this.handleDelete}>Delete</button>
                <ul>{list.map(n => <li key={n}>{n}</li>)}</ul>
            </div>
        );
    }

    handleDelete = () => {
        this.setState((prevState) => ({ list: [...prevState.list].pop() }));
    }
}

export default App;
複製程式碼

當然還有一種情況,採用傳入時定義的方式給子元件傳遞props。

結果就是子元件做props的淺比較時永遠返回false,因為每次的值都是重新定義的,絕非同一個引用。

PureComponent類就失去它的意義了。

import React from 'react';

function App() {
    return (
        <Child method={value => console.log(value)} />
    );
}

export default App;
複製程式碼

總之,要想使用PureComponent類,得時刻盯緊引用資料型別。

在繼承了PureComponent類的元件裡寫shouldComponentUpdate生命週期鉤子會怎麼樣?

小夥子果然骨骼清奇。

原則上,React並不推薦這種寫法,並且在開發環境下會列印一個警告。

你要麼偷個懶讓React幫你做優化,要麼自己做優化,這麼幹不是赤裸裸的挑釁麼!

不過React早就預測到你會這麼幹的,所以它只能隨你的脾氣,只要元件裡定義了shouldComponentUpdate生命週期鉤子,PureComponent類的自動優化就不再起作用了。

元件複用

話說程式碼複用還真不是因為開發者懶。

假如一個藍色卡片元件,分別有十個地方要用到。而且這個開發者異常勤奮,把這段程式碼小心翼翼的複製到這十個地方。看起來大功告成了。但是有一天,產品經理說:“我希望卡片背景換成檸檬色,再刪減掉一些資訊。”

那麼問題來了:該開發者是應該砍死產品經理還是應該考慮程式碼複用?

React世界中一切都是元件,元件思想的根本訴求又是什麼呢?當然是程式碼複用。一個元件就好像一個集裝箱,我不用知道里面裝的什麼貨物,我只用知道它能上我的貨輪。

集裝箱是商業世界的偉大發明,元件也是前端世界的偉大發明。

React的程式碼複用都是以元件為單位的。

元件四海為家

最普通的元件複用的方式就是直接使用元件。

一個<Card />元件,我可以將它放在任何地方。它對外可以提供一些介面,以保證填充所在上下文要求的內容。開發者可以很方便的一處修改,處處生效。

高階元件

首先我們回憶一下,什麼是高階函式?

定義非常簡單:一個函式,它的引數是函式,或者它的返回值是函式。

基本就是雞吃雞或者雞生雞的意思。

那麼高階元件就好理解了。它的定義是:一個函式,傳入一個元件,並且返回一個元件。

區別就是,高階函式只要滿足一個條件,高階元件要滿足兩個條件。

高階元件既是函式,也是元件,因為在React中一個函式只要返回JSX元素就是元件。但這個元件有點特殊,因為它不是用來堆砌UI的,而是一個元件裝飾工廠。

話不多說,我們直接來看一下react-redux中的connect方法(極度簡化的虛擬碼)。

connect方法是用來將redux中的state作為props傳給元件的。這就是一個典型的高階元件,當然它還在外面套了一層函式,為了傳值。

不使用connect方法的元件是這樣匯出的:export default App;

使用connect方法的元件是這樣匯出的:export default connect(mapState, mapDispatch)(App);

開發者首先傳參執行connect,然後再傳元件執行返回的函式,匯出的元件就多了一些屬性。

可以看出,這種型別的高階元件主要是為了給目標元件傳遞一些props。

import React, { Component } from 'react';

function connect(mapStateToProps, mapDispatchToProps) {
    return function wrapWithConnect(WrappedComponent) {
        return class Connect extends Component {
            render() {
                return (
                    <WrappedComponent {...mapStateToProps} {...mapDispatchToProps} />
                );
            }
        }
    }
}

export default connect;
複製程式碼

說高階元件是一個元件裝飾工廠是有道理的,因為我們可以用裝飾器的寫法來部署高階元件。

下面是connect方法的裝飾器寫法。

import React, { Component } from 'react';

@connect(mapStateToProps, mapDispatchToProps);
class App extends Component {
    render() {
        return (
            <div>app</div>
        );
    }
}

export default App;
複製程式碼

另一種型別的高階元件更加巧妙,它使用到了繼承的特性。

返回的元件可以獲得傳入元件的所有屬性和方法,如果複用元件需要對傳入元件做一些侵入性比較強的改動,那麼這種方式非常具有靈活性。

import React from 'react';

function HOC(WrappedComponent) {
    return class WithComponent extends WrappedComponent {
        render() {
            return (
                <WrappedComponent />
            );
        }
    }
}

export default HOC;
複製程式碼

理解高階元件的精髓是什麼呢?我認為是理解複用的到底是什麼。

假如我們造一個元件,然後用到不同的地方,那直接用就好了,元件本身就是可複用的。

關鍵在於元件之外的東西。我們需要重複的給一個元件傳入props,或者我們需要重複的給元件增強一些功能。這些都可以理解為元件的裝飾,高階元件其實解決的是元件裝飾的複用。

大多數時候,使用高階元件的場景都有更簡單的替代方案。

高階元件之所以這麼流行,是因為React生態系統中很多庫都有高階元件的身影,它們或許是為了提供一個優雅的API,或許是需要處理複雜的場景,或許是...炫技。

如果哪天你複用元件時產生了瓶頸,不妨來看看老朋友高階元件,它有多強大完全取決於你。

忘了告訴你們,高階元件簡稱HOC,高階元件的第一種型別叫屬性代理,第二種型別叫反向繼承。不過讓這些概念都見鬼去吧,它只會讓初學者望而卻步。

render props

高階元件就是傳入一個元件,然後返回一個元件。

那我能不能不通過函式的引數傳遞元件,比如說通過props傳遞呢?

這是一個絕好的思路。

它有一個名字叫render props

當然,render props也需要一個用來複用的容器。通過給這個容器傳遞render屬性,它可以渲染不同的元件。

屬性名是無所謂的,render或者rander都可以,關鍵它的值得是一個元件,或者說無狀態元件。

import React, { Component } from 'react';

class WithRender extends Component {
    state = { star: 1 }

    render() {
        return (
            <div>{this.props.render(this.state)}</div>
        );
    }
}
複製程式碼

react-router中的withRouter方法就是用的render props

const withRouter = (Component) => {
    return (props) => {
        return (
            <Route children={routeComponentProps => <Component {...routeComponentProps} />} />
        );
    }
}
複製程式碼

React專題一覽

什麼是UI

JSX

可變狀態

不可變屬性

生命週期

元件

事件

操作DOM

抽象UI

相關文章