一起學習造輪子(三):從零開始寫一個React-Redux

JAVASCRIPT發表於2018-07-03

本文是一起學習造輪子系列的第三篇,本篇我們將從零開始寫一個React-Redux,本系列文章將會選取一些前端比較經典的輪子進行原始碼分析,並且從零開始逐步實現,本系列將會學習Promises/A+,Redux,react-redux,vue,dom-diff,webpack,babel,kao,express,async/await,jquery,Lodash,requirejs,lib-flexible等前端經典輪子的實現方式,每一章原始碼都託管在github上,歡迎關注~
相關係列文章:
一起學習造輪子(一):從零開始寫一個符合Promises/A+規範的promise
一起學習造輪子(二):從零開始寫一個Redux
一起學習造輪子(三):從零開始寫一個React-Redux
本系列github倉庫:
一起學習造輪子系列github(歡迎star~)

前言

上一章我們寫了一個redux,當redux與react結合時一般為了方便會使用react-redux, 這個庫是可以選用的。實際專案中,應該權衡一下,是直接使用 Redux,還是使用 React-Redux。後者雖然提供了便利,但是需要掌握額外的 API,並且要遵守它的元件拆分規範。 本文對於react-redux的用法不會過多介紹,重點仍然放在原始碼實現上。如果還不太瞭解如何使用,可以看相關文章學習。

推薦文章:
Redux 入門教程:React-Redux 的用法

本文所有程式碼在github建有程式碼倉庫,可以點此檢視本文程式碼,也歡迎大家star~

開始

context

講React-Redux前,我們先來講一下React.js裡的context。React.js裡的context一直被視為一個不穩定的、危險的、可能會被去掉的特性而不被官網文件所記載,但是使用它卻非常方便,比如說我們有一棵很龐大的元件樹,在我們沒有使用redux時我們想要改變一個狀態並讓所有元件生效,我們需要一層一層的往下傳props。但是有了context就很簡單了。某個元件只要往自己的context裡面放了某些狀態,這個元件之下的所有子元件都可以直接訪問這個狀態而不需要通過中間元件的傳遞。

例如有這麼一棵元件樹:

props傳遞
userinfo使用者資訊這個資料是很多元件都需要用的,所以我們按照正常的思路在根節點的 Index 上獲取,然後把這個狀態通過 props一層層傳遞下去,最終所有元件都拿到了userinfo,進行使用。 但是這樣有個問題:

如果元件層級很深的話,用props向下傳值就是災難。

我們想,如果這顆元件樹能夠全域性共享這個一個狀態倉庫就好了,我們要的時候就去狀態倉庫裡取對應的狀態,不用手動地傳,這該多好啊。

全域性狀態
React.js 的 context 就是這麼一個東西,某個元件只要往自己的 context 裡面放了某些狀態,這個元件之下的所有子元件都直接訪問這個狀態而不需要通過中間元件的傳遞,來看下具體怎麼用:

//在根元件上將userInfo放入context
class Index extends Component {
    static childContextTypes = {
        userInfo: PropTypes.object
    }

    constructor() {
        super()
        this.state = { 
            userInfo: {
                name:"小明",
                id:17
                } 
        }
    }

    getChildContext() {
        return { userInfo: this.state.userInfo }
    }

    render() {
        return ( <div >
                    <Header/>
                </div>
        )
    }
}

class Header extends Component {
    render() {
        return ( <div>
                <Title/>
            </div>
        )
    }
}
class Title extends Component {
    static contextTypes = {
        title: PropTypes.object
    }
    render() {
        // 無論元件層級有多深,子元件都可以直接從context屬性獲取狀態
        return ( <h1> 歡迎{ this.context.userInfo.name } </h1>)
    }
}
複製程式碼

上面,我們將userInfo定義在了根元件Index上,並且將它掛載到Index的context上,之後無論下面有多少層子元件,都可以直接從context上獲取這個title狀態了。

那麼既然context用著這麼方便還用redux管理全域性狀態幹什麼?

因為context裡面的資料能被隨意接觸就能被隨意修改,導致程式執行的不可預料。這也是context一直不建議使用的原因,而redux雖然使用起來很麻煩,但是卻能做到修改資料的行為變得可預測可追蹤,因為在redux裡你必須通過dispatch執行某些允許的修改操作,而且必須事先在action裡面明確宣告要做的操作。

那麼我們能不能結合一下二者的優點,使我們可以既安全又容易的來管理全域性狀態呢?

React-Redux

react-redux
React-Redux是Redux的作者封裝了一個 React 專用的庫 ,為了能讓React使用者更方便的使用Redux,廢話不多說,我們平時在使用React-Redux時一般這樣寫:

// root.js
import { Provider } from 'react-redux'
import { createStore } from 'redux'
import userReducer from 'reducers/userReducer'
import Header from 'containers/header'
const store = createStore(userReducer)

export default class Root extends Component {
    render() {
        return (<div>
                    <Header></Header>
                </div>
        );
    };
}
ReactDOM.render( <Provider store = { store } >
                        <Root/>
                </Provider>, 
document.getElementById('root'));


//containers/header.js
import { connect } from 'react-redux'
import { bindActionCreators } from 'redux'
import * as userinfoActions from 'actions/userinfo.js';
import fetch from 'isomorphic-fetch'

class Header extends Component {
    constructor() {
        super();
        this.state = {
            username:""
        }
    }
    componentDidMount(){
        this.getUserInfo()
    }
    getUserInfo(){
        fetch("/api/pay/getUserInfo")
            .then(response => {
                return response.json()
            })
            .then(json =>{
                this.props.userinfoActions.login(data);
                this.setState({username: data.username});
            })
            .catch(e => {
                console.log(e)
            })
    }
    render(){
         return (
            <div>
                歡迎使用者{this.state.username}
            </div>
        );
    }
}

function mapStateToProps(state) {
    return { userinfo: state.userinfo }
}

function mapDispatchToProps(dispatch) {
    return {
        userinfoActions: bindActionCreators(userinfoActions, dispatch)
    }
}

export default connect(mapStateToProps, mapDispatchToProps)(Header)


// reducers/userReducer.js
export default function userinfo(state = {}, action) {
    switch (action.type) {
        case "USERINFO_LOGIN":
            return action.data
        default:
            return state
    }
}


// actions/useraction.js
export function login(data) {
    return {
        type: "USERINFO_LOGIN",
        data
    }
}
複製程式碼

上面是一個簡單的場景,進入頁面獲取使用者資訊後把使用者資訊裡的使用者名稱顯示在頁面頭部,因為使用者資訊多個元件都需要使用,不光頭部元件要用,所以放到redux裡共享。

我們可以看到使用react-redux後主要用到裡面的兩個東西,一個是Provider,一個是connect,另外,還需要自己定義兩個函式mapStateToProps, mapDispatchToProps傳給connect,接下來我們分別來說說這些東西是幹什麼的以及如何實現。

Provider

我們先來看下Provider,Provider是個高階元件,我們可以看到使用它時將包裹在根元件外邊,並且store作為它的props傳入進去,它的作用就是 將自己作為所有元件的根元件,然後將store掛載到它的context上讓它下面的所有子元件都可以共享全域性狀態。來看下如何實現:

// Provider.js
import React, { Component } from 'react';
import propTypes from 'prop-types';
export default class Provider extends Component {
    static childContextTypes = {
        store: propTypes.object.isRequired
    }
    getChildContext() {
        return { store: this.props.store };
    }
    render() {
        return this.props.children;
    }
}
複製程式碼

這個還是比較好實現的,寫一個元件Provider,將store掛載到Provider的context上,然後使用的時候將Provider包在根元件外邊,因為Provider是原來根元件的父元件,所以它就成了真正的根元件,所有下面的子元件都可以通過context訪問到store,Provider元件利用context的特性解決了專案裡每個元件都需要import一下store才能使用redux的問題,大大增加了便利性。

connect

首先我們想一下,只用Provider行不行,當然可以,因為store已經掛載到根元件上的context,所有子元件都可以通過context訪問到store,然後使用store裡的狀態,並且用store的dispatch提交action更新狀態,但是這樣還是有些不便利,因為每個元件都對context依賴過強,造成了元件與store打交道的邏輯和元件本身邏輯都耦合了一起,使得元件無法複用。

我們的理想狀態是一個元件的渲染只依賴於外界傳進去的props和自己的state,而並不依賴於其他的外界的任何資料,這樣的元件複用性是最強的。如何把元件與store打交道的邏輯和元件自身的邏輯分開呢,答案還是使用高階元件,我們把原來的寫的業務元件(如header,list等)外邊再包裝一層元件,讓元件與store打交道的部分放在外層元件,內層元件只負責自身的邏輯,外層元件與內層元件通過props進行交流,這樣元件與store打交道的地方就像一層殼一樣與元件實體分開了,我們可以將元件實體複用到任何地方只需要換殼即可,connect函式就是負責做上述事情。

示例
學習如何實現connect前先來看下使用connect時需要傳入的引數,mapStateToProps是一個函式。它的作用就是像它的名字那樣,建立一個從(外部的)state物件到(UI 元件的)props物件的對映關係。

mapDispatchToProps是connect函式的第二個引數,用來建立UI元件的引數到store.dispatch方法的對映。也就是說,它定義了使用者的哪些操作應該當作 Action,傳給Store。它可以是一個函式,也可以是一個物件。

這兩個函式我們可以簡單的理解為內層元件實體對外層殼元件的要求,元件實體通過mapStateToProps告訴殼元件要store上的哪些狀態,殼元件就去store上拿了以後以props的形式傳給元件實體,mapDispatchToProps同理。

另外我們在使用connect時一般這樣寫export default connect(mapStateToProps,mapDispatchToProps)(Header),所以connect函式要先接收mapStateToProps, mapDispatchToProps這兩個函式,再返回一個函式,返回的這個函式的引數接收要包裝的元件,最後函式執行返回包好殼的元件。 有朋友可能會問,為什麼不直接connect(mapStateToProps,mapDispatchToProps,Header),還得分成兩個函式來寫,因為React-redux官方就是這麼設計的,個人覺得作者是想提高connect函式的複用性,這裡我們不去深究它的設計思路,我麼還是把重心放到它的程式碼實現上。

import React, { Component } from 'react';
import { bindActionCreators } from 'redux';
import propTypes from 'prop-types';
export default function connect(mapStateToProps, mapDispatchToProps) {
    return function(WrapedComponent) {
        //殼元件
        class ProxyComponent extends Component {
            static contextTypes = {
                store: propTypes.object
            }
            constructor(props, context) {
                super(props, context);
                this.store = context.store;
                this.state = mapStateToProps(this.store.getState());
            }

            componentWillMount() {
                this.store.subscribe(() => {
                    this.setState(mapStateToProps(this.store.getState()));
                });
            }
            render() {
                let actions = {};
                if (typeof mapDispatchToProps == 'function') {
                    actions = mapDispatchToProps(this.store.disaptch);
                } else if (typeof mapDispatchToProps == 'object') {
                    actions = bindActionCreators(mapDispatchToProps, this.store.dispatch);
                }
                //殼元件內部渲染真正的元件實體,並將業務元件想要的store裡的狀態及想要觸發的action以props形式傳入
                return <WrapedComponent {...this.state } {...actions}
                />
            }
        }
        return ProxyComponent;
    }
}
複製程式碼

我們來看下connect函式做了什麼?

  1. 首先接收mapStateToProps, mapDispatchToProps並返回一個函式,返回的函式接收一個元件。
  2. 宣告瞭一個殼元件ProxyComponent,並通過context拿到store物件。
  3. 然後在constructor裡通過傳進來的mapStateToProps函式把元件實體想要的狀態通過上一步拿到的store物件裡面的getState方法拿到並存在殼元件的state上。
  4. 在殼元件componentWillMount的生命週期中註冊當store狀態發生變化的回撥函式:store變化,同步更新自己的state為最新的狀態,與store上的狀態保持一致。
  5. 將元件要使用dispatch提交的相關action都封裝成函式。這一步我們具體展開看下是怎麼做的,首先判斷一下mapDispatchToProps是函式還是物件,因為我們在平常使用mapDispatchToProps時一般有兩種常見寫法,一種是在mapDispatchToProps引數位置傳一個函式:
function mapDispatchToProps(dispatch) {
    return {
        userinfoActions: bindActionCreators(userinfoActions, dispatch)
    }
}
export default connect(mapStateToProps, mapDispatchToProps)(Header)
複製程式碼

另一種是直接傳一個action creator物件
export default connect(mapStateToProps, ...userinfoActions )(Header)

我們要保證無論使用者傳入的mapDispatchToProps是函式還是action creator物件,我們都要讓使用者在元件實體內提交action時都可以使用this.props.xxx()的方式去提交,而不用直接接觸store的dispatch方法。

所以,我們需要藉助redux的bindActionCreators方法, 本系列的第二篇文章 一起學習造輪子(二):從零開始寫一個Redux裡曾經介紹過這個方法的實現原理,這個方法能夠讓我們以方法的形式來提交action,同時,自動dispatch對應的action。所以我們可以看到,當使用者傳入的是函式時,使用者在mapDispatchToProps函式內部使用bindActionCreators將action creator轉化成了一個一個的方法,而如果直接傳入action creator物件,那麼我們在connect內部使用bindActionCreators將傳入的action creator轉化成了一個一個的方法,也就是說假如使用者不做這步操作,那麼react-redux幫你做。

  1. 下一步將殼元件state上的所有屬性及上一步所有已經封裝成函式的action都通過props的方法傳給元件實體。
  2. 最後,把包裝後的元件返回出去,現在我們在元件實體內部就可以使用this.props.username的方式去獲取store上的狀態,或者使用this.props.userinfoActions.login(data)的方式來提交action,此時元件與store打交道的邏輯和元件自身的邏輯分開,內部元件實體可以進行復用。

最後

本篇介紹了React-Redux的核心實現原理,通過封裝Provider元件和connect方法實現了一個簡單小巧的react-redux,本篇相關程式碼都放在github上,可以點此檢視,如果覺得不錯,歡迎star,本系列不定期更新,歡迎關注~

相關文章