天天看點

動手實作一個 react-redux

react-redux 是什麼

react-redux

 是 

redux

 官方 

React

 綁定庫。它幫助我們連接配接UI層和資料層。本文目的不是介紹 

react-redux

 的使用,而是要動手實作一個簡易的 

react-redux

,希望能夠對你有所幫助。

首先思考一下,倘若不使用 

react-redux

,我們的 

react

 項目中該如何結合 

redux

 進行開發呢。

每個需要與 

redux

 結合使用的元件,我們都需要做以下幾件事:
  • 在元件中擷取 

    store

     中的狀态
  • 監聽 

    store

     中狀态的改變,在狀态改變時,重新整理元件
  • 在元件解除安裝時,移除對狀态變化的監聽。

如下:

import React from 'react';
import store from '../store';
import actions from '../store/actions/counter';
/**
 * reducer 是 combineReducer({counter, ...})
 * state 的結構為 
 * {
 *      counter: {number: 0},
 *      ....
 * }
 */
class Counter extends React.Component {
    constructor(props) {
        super(props);
        this.state = {
            number: store.getState().counter.number
        }
    }
    componentDidMount() {
        this.unsub = store.subscribe(() => {
            if(this.state.number === store.getState().counter.number) {
                return;
               }
            this.setState({
                number: store.getState().counter.number
            });
        });
    }
    render() {
        return (
            <div>
                <p>{`number: ${this.state.number}`}</p>
                <button onClick={() => {store.dispatch(actions.add(2))}}>+</button>
                <button onClick={() => {store.dispatch(actions.minus(2))}}>-</button>
            <div>
        )
    }
    componentWillUnmount() {
        this.unsub();
    }
}
           

如果我們的項目中有很多元件需要與 

redux

 結合使用,那麼這些元件都需要重複寫這些邏輯。顯然,我們需要想辦法複用這部分的邏輯,不然會顯得我們很蠢。我們知道,

react

 中高階元件可以實作邏輯的複用。

文中所用到的 

Counter

 代碼在 

https://github.com/YvetteLau/Blog

 中的 

myreact-redux/counter

 中,建議先 

clone

 代碼,當然啦,如果覺得本文不錯的話,給個star鼓勵。

邏輯複用

在 

src

 目錄下建立一個 

react-redux

 檔案夾,後續的檔案都建立在此檔案夾中。

建立 connect.js 檔案

檔案建立在 

react-redux/components

 檔案夾下:

我們将重複的邏輯編寫 

connect

 中。

import React, { Component } from 'react';
import store from '../../store';

export default function connect (WrappedComponent) {
    return class Connect extends Component {
        constructor(props) {
            super(props);
            this.state = store.getState();
        }
        componentDidMount() {
            this.unsub = store.subscribe(() => {
                this.setState({
                    this.setState(store.getState());
                });
            });
        }
        componentWillUnmount() {
            this.unsub();
        }
        render() {
            return (
                <WrappedComponent {...this.state} {...this.props}/>
            )
        }
    }
}
           

有個小小的問題,盡管這邏輯是重複的,但是每個元件需要的資料是不一樣的,不應該把所有的狀态都傳遞給元件,是以我們希望在調用 

connect

 時,能夠将需要的狀态内容告知 

connect

。另外,元件中可能還需要修改狀态,那麼也要告訴 

connect

,它需要派發哪些動作,否則 

connect

 無法知道該綁定那些動作給你。

為此,我們新增兩個參數:

mapStateToProps

 和 

mapDispatchToProps

,這兩個參數負責告訴 

connect

 元件需要的 

state

 内容和将要派發的動作。

mapStateToProps 和 mapDispatchToProps

我們知道 mapStateToProps 和 mapDispatchToProps 的作用是什麼,但是目前為止,我們還不清楚,這兩個參數應該是一個什麼樣的格式傳遞給 connect 去使用。

import { connect } from 'react-redux';
....
//connect 的使用
export default connect(mapStateToProps, mapDispatchToProps)(Counter);
           
  • mapStateToProps 告訴 

    connect

     ,元件需要綁定的狀态。

    mapStateToProps

     需要從整個狀态中挑選元件需要的狀态,但是在調用 

    connect

     時,我們并不能擷取到 

    store

     ,不過 

    connect

     内部是可以擷取到 

    store

     的,為此,我們将 

    mapStateToProps

     定義為一個函數,在 

    connect

     内部調用它,将 

    store

     中的 

    state

     傳遞給它,然後将函數傳回的結果作為屬性傳遞給元件。元件中通過 

    this.props.XXX

     來擷取。是以,

    mapStateToProps

     的格式應該類似下面這樣:
    //将 store.getState() 傳遞給 mapStateToProps
    mapStateToProps = state => ({
        number: state.counter.number
    });
               
  • mapDispatchToProps 告訴 

    connect

    ,元件需要綁定的動作。

    回想一下,元件中派發動作:

    store.dispatch({actions.add(2)})

    connect

     包裝之後,我們仍要能派發動作,肯定是 

    this.props.XXX()

     這樣的一種格式。

    比如,計數器的增加,調用 

    this.props.add(2)

    ,就是需要派發 

    store.dispatch({actions.add(2)})

    ,是以 

    add

     屬性,對應的内容就是 

    (num) => { store.dispatch({actions.add(num)}) }

    。傳遞給元件的屬性類似下面這樣:
    {
        add: (num) => {
            store.dispatch(actions.add(num))
        },
        minus: (num) => {
            store.dispatch(actions.minus(num))
        }
    }
               
    和 

    mapStateToProps

     一樣,在調用 

    connect

     時,我們并不能擷取到 

    store.dispatch

    ,是以我們也需要将 

    mapDispatchToProps

     設計為一個函數,在 

    connect

     内部調用,這樣可以将 

    store.dispatch

     傳遞給它。是以,

    mapStateToProps

     應該是下面這樣的格式:
    //将 store.dispacth 傳遞給 mapDispatchToProps
    mapDispatchToProps = (dispatch) => ({
        add: (num) => {
            dispatch(actions.add(num))
        },
        minus: (num) => {
            dispatch(actions.minus(num))
        }
    })
               

至此,我們已經搞清楚 

mapStateToProps

 和 

mapDispatchToProps

 的格式,是時候進一步改進 

connect

 了。

connect 1.0 版本

import React, { Component } from 'react';
import store from '../../store';

export default function connect (mapStateToProps, mapDispatchToProps) {
    return function wrapWithConnect (WrappedComponent) {
        return class Connect extends Component {
            constructor(props) {
                super(props);
                this.state = mapStateToProps(store.getState());
                this.mappedDispatch = mapDispatchToProps(store.dispatch);
            }
            componentDidMount() {
                this.unsub = store.subscribe(() => {
                    const mappedState = mapStateToProps(store.getState());
                    //TODO 做一層淺比較,如果狀态沒有改變,則不setState
                    this.setState(mappedState);
                });
            }
            componentWillUnmount() {
                this.unsub();
            }
            render() {
                return (
                    <WrappedComponent {...this.props} {...this.state} {...this.mappedDispatch} />
                )
            }
        }
    }
}
           

我們知道,

connect

 是作為 

react-redux

 庫的方法提供的,是以我們不可能直接在 

connect.js

 中去導入 

store

,這個 

store

 應該由使用 

react-redux

 的應用傳入。

react

 中資料傳遞有兩種:通過屬性 

props

 或者是通過上下文對象 

context

,通過 

connect

 包裝的元件在應用中分布,而 

context

 設計目的是為了共享那些對于一個元件樹而言是“全局”的資料。

我們需要把 

store

 放在 

context

 上,這樣根元件下的所有子孫元件都可以擷取到 

store

。這部分内容,我們當然可以自己在應用中編寫相應代碼,不過很顯然,這些代碼在每個應用中都是重複的。是以我們把這部分内容也封裝在 

react-redux

 内部。

此處,我們使用舊的 

Context API

 來寫(鑒于我們實作的 react-redux 4.x 分支的代碼,是以我們使用舊版的 context API)。

Provider

我們需要提供一個 

Provider

 元件,它的功能就是接收應用傳遞過來的 

store

,将其挂在 

context

 上,這樣它的子孫元件就都可以通過上下文對象擷取到 

store

建立 Provider.js 檔案

檔案建立在 

react-redux/components

 檔案夾下:

import React, { Component } from 'react';
import PropTypes from 'prop-types';

export default class Provider extends Component {
    static childContextTypes = {
        store: PropTypes.shape({
            subscribe: PropTypes.func.isRequired,
            dispatch: PropTypes.func.isRequired,
            getState: PropTypes.func.isRequired
        }).isRequired
    }

    constructor(props) {
        super(props);
        this.store = props.store;
    }

    getChildContext() {
        return {
            store: this.store
        }
    }

    render() {
        /**
         * 早前傳回的是 return Children.only(this.props.children)
         * 導緻Provider隻能包裹一個子元件,後來取消了此限制
         * 是以此處,我們直接傳回 this.props.children
         */
        return this.props.children
    }
}
           

建立一個 index.js 檔案

檔案建立在 

react-redux

 目錄下:

此檔案隻做一件事,即将 

connect

 和 

Provider

 導出

import connect from './components/connect';
import Provider from './components/Provider';

export {
    connect,
    Provider
}
           

Provider 的使用

使用時,我們隻需要引入 

Provider

,将 

store

 傳遞給 

Provider

import React, { Component } from 'react';
import { Provider } from '../react-redux';
import store from './store';
import Counter from './Counter';

export default class App extends Component {
    render() {
        return (
            <Provider store={store}>
                <Counter />
            </Provider>
        )
    }
}
           

至此,

Provider

 的源碼和使用已經說明清楚了,不過相應的 

connect

 也需要做一些修改,為了通用性,我們需要從 

context

 上去擷取 

store

,取代之前的導入。

connect 2.0 版本

import React, { Component } from 'react';
import PropTypes from 'prop-types';

export default function connect(mapStateToProps, mapDispatchToProps) {
    return function wrapWithConnect(WrappedComponent) {
        return class Connect extends Component {
            //PropTypes.shape 這部分代碼與 Provider 中重複,是以後面我們可以提取出來
            static contextTypes = {
                store: PropTypes.shape({
                    subscribe: PropTypes.func.isRequired,
                    dispatch: PropTypes.func.isRequired,
                    getState: PropTypes.func.isRequired
                }).isRequired
            }

            constructor(props, context) {
                super(props, context);
                this.store = context.store;
                //源碼中是将 store.getState() 給了 this.state
                this.state = mapStateToProps(this.store.getState());
                this.mappedDispatch = mapDispatchToProps(this.store.dispatch);
            }
            componentDidMount() {
                this.unsub = this.store.subscribe(() => {
                    const mappedState = mapStateToProps(this.store.getState());
                    //TODO 做一層淺比較,如果狀态沒有改變,則無需 setState
                    this.setState(mappedState);
                });
            }
            componentWillUnmount() {
                this.unsub();
            }
            render() {
                return (
                    <WrappedComponent {...this.props} {...this.state} {...this.mappedDispatch} />
                )
            }
        }
    }
}
           

使用 

connect

 關聯 

Counter

 與 

store

 中的資料。

import React, { Component } from 'react';
import { connect } from '../react-redux';
import actions from '../store/actions/counter';

class Counter extends Component {
    render() {
        return (
            <div>
                <p>{`number: ${this.props.number}`}</p>
                <button onClick={() => { this.props.add(2) }}>+</button>
                <button onClick={() => { this.props.minus(2) }}>-</button>
            </div>
        )
    }
}

const mapStateToProps = state => ({
    number: state.counter.number
});

const mapDispatchToProps = (dispatch) => ({
    add: (num) => {
        dispatch(actions.add(num))
    },
    minus: (num) => {
        dispatch(actions.minus(num))
    }
});


export default connect(mapStateToProps, mapDispatchToProps)(Counter);
           
store/actions/counter.js 定義如下:
import { INCREMENT, DECREMENT } from '../action-types';

const counter = {
    add(number) {
        return {
            type: INCREMENT,
            number
        }
    },
    minus(number) {
        return {
            type: DECREMENT,
            number
        }
    }
}
export default counter;
           

至此,我們的 

react-redux

 庫已經可以使用了,不過很有很多細節問題待處理:

  • mapDispatchToProps

     的定義寫起來有點麻煩,不夠簡潔

    大家是否還記得 

    redux

     中的 

    bindActionCreators

    ,借助于此方法,我們可以允許傳遞 

    actionCreator

     給 

    connect

    ,然後在 

    connect

     内部進行轉換。
  • connect

     和 

    Provider

     中的 

    store

     的 

    PropType

     規則可以提取出來,避免代碼的備援
  • mapStateToProps

     和 

    mapDispatchToProps

     可以提供預設值

    mapStateToProps

     預設值為 

    state => ({})

    ; 不關聯 

    state

    mapDispatchToProps

     的預設值為 

    dispatch => ({dispatch})

    ,将 

    store.dispatch

     方法作為屬性傳遞給被包裝的屬性。
  • 目前,我們僅傳遞了 

    store.getState()

     給 

    mapStateToProps

    ,但是很可能在篩選過濾需要的 

    state

     時,需要依據元件自身的屬性進行處理,是以,可以将元件自身的屬性也傳遞給 

    mapStateToProps

    ,同樣的原因,也将自身屬性傳遞給 

    mapDispatchToProps

connect 3.0 版本

我們将 

store

 的 PropType 規則提取出來,放在 

utils/storeShape.js

 檔案中。

淺比較的代碼放在 

utils/shallowEqual.js

 檔案中,通用的淺比較函數,此處不列出,有興趣可以直接閱讀下代碼。

import React, { Component } from 'react';
import { bindActionCreators } from 'redux';
import storeShape from '../utils/storeShape';
import shallowEqual from '../utils/shallowEqual';
/**
 * mapStateToProps 預設不關聯state
 * mapDispatchToProps 預設值為 dispatch => ({dispatch}),将 `store.dispatch` 方法作為屬性傳遞給元件
 */
const defaultMapStateToProps = state => ({});
const defaultMapDispatchToProps = dispatch => ({ dispatch });

export default function connect(mapStateToProps, mapDispatchToProps) {
    if(!mapStateToProps) {
        mapStateToProps = defaultMapStateToProps;
    }
    if (!mapDispatchToProps) {
        //當 mapDispatchToProps 為 null/undefined/false...時,使用預設值
        mapDispatchToProps = defaultMapDispatchToProps;
    }
    return function wrapWithConnect(WrappedComponent) {
        return class Connect extends Component {
            static contextTypes = {
                store: storeShape
            };
            constructor(props, context) {
                super(props, context);
                this.store = context.store;
                //源碼中是将 store.getState() 給了 this.state
                this.state = mapStateToProps(this.store.getState(), this.props);
                if (typeof mapDispatchToProps === 'function') {
                    this.mappedDispatch = mapDispatchToProps(this.store.dispatch, this.props);
                } else {
                    //傳遞了一個 actionCreator 對象過來
                    this.mappedDispatch = bindActionCreators(mapDispatchToProps, this.store.dispatch);
                }
            }
            componentDidMount() {
                this.unsub = this.store.subscribe(() => {
                    const mappedState = mapStateToProps(this.store.getState(), this.props);
                    if (shallowEqual(this.state, mappedState)) {
                        return;
                    }
                    this.setState(mappedState);
                });
            }
            componentWillUnmount() {
                this.unsub();
            }
            render() {
                return (
                    <WrappedComponent {...this.props} {...this.state} {...this.mappedDispatch} />
                )
            }
        }
    }
}
           

現在,我們的 

connect

 允許 

mapDispatchToProps

 是一個函數或者是 

actionCreators

 對象,在 

mapStateToProps

 和 

mapDispatchToProps

 預設或者是 

null

 時,也能表現良好。

不過還有一個問題,

connect

 傳回的所有元件名都是 

Connect

,不便于調試。是以我們可以為其新增 

displayName

connect 4.0 版本

import React, { Component } from 'react';
import { bindActionCreators } from 'redux';
import storeShape from '../utils/storeShape';
import shallowEqual from '../utils/shallowEqual';
/**
 * mapStateToProps 預設時,不關聯state
 * mapDispatchToProps 預設時,設定其預設值為 dispatch => ({dispatch}),将`store.dispatch` 方法作為屬性傳遞給元件
 */ 
const defaultMapStateToProps = state => ({});
const defaultMapDispatchToProps = dispatch => ({ dispatch });

function getDisplayName(WrappedComponent) {
    return WrappedComponent.displayName || WrappedComponent.name || 'Component';
}

export default function connect(mapStateToProps, mapDispatchToProps) {
    if(!mapStateToProps) {
        mapStateToProps = defaultMapStateToProps;
    }
    if(!mapDispatchToProps) {
        //當 mapDispatchToProps 為 null/undefined/false...時,使用預設值
        mapDispatchToProps = defaultMapDispatchToProps;
    }
    return function wrapWithConnect (WrappedComponent) {
        return class Connect extends Component {
            static contextTypes = storeShape;
            static displayName = `Connect(${getDisplayName(WrappedComponent)})`;
            constructor(props) {
                super(props);
                //源碼中是将 store.getState() 給了 this.state
                this.state = mapStateToProps(store.getState(), this.props);
                if(typeof mapDispatchToProps === 'function') {
                    this.mappedDispatch = mapDispatchToProps(store.dispatch, this.props);
                }else{
                    //傳遞了一個 actionCreator 對象過來
                    this.mappedDispatch = bindActionCreators(mapDispatchToProps, store.dispatch);
                }
            }
            componentDidMount() {
                this.unsub = store.subscribe(() => {
                    const mappedState = mapStateToProps(store.getState(), this.props);
                    if(shallowEqual(this.state, mappedState)) {
                        return;
                    }
                    this.setState(mappedState);
                });
            }
            componentWillUnmount() {
                this.unsub();
            }
            render() {
                return (
                    <WrappedComponent {...this.props} {...this.state} {...this.mappedDispatch} />
                )
            }
        }
    }
}
           

至此,

react-redux

 我們就基本實作了,不過這個代碼并不完善,比如,

ref

 丢失的問題,元件的 

props

 變化時,重新計算 

this.state

 和 

this.mappedDispatch

,沒有進一步進行性能優化等。你可以在此基礎上進一步進行處理。

react-redux

 主幹分支的代碼已經使用 

hooks

 改寫,後期如果有時間,會輸出一篇新版本的代碼解析。

最後,使用我們自己編寫的 

react-redux

 和 

redux

 編寫了 

Todo

 的demo,功能正常,代碼在 在 

https://github.com/YvetteLau/Blog

 中的 

myreact-redux/todo

 下。

附上新老 

context API

 的使用方法:

context

目前有兩個版本的 

context API

,舊的 API 将會在所有 16.x 版本中得到支援,但是未來版本中會被移除。

context API(新)

const MyContext = React.createContext(defaultValue);
           

建立一個 

Context

 對象。當 

React

 渲染一個訂閱了這個 

Context

 對象的元件,這個元件會從元件樹中離自身最近的那個比對的 

Provider

 中讀取到目前的 

context

 值。

注意:隻有當元件所處的樹中沒有比對到 

Provider

 時,其 

defaultValue

 參數才會生效。

使用

Context.js

首先建立 Context 對象

import React from 'react';

const MyContext = React.createContext(null);

export default MyContext;
           

根元件( Pannel.js )

  • 将需要共享的内容,設定在 <MyContext.Provider> 的 

    value

     中(即 context 值)
  • 子元件被 <MyContext.Provider> 包裹
import React from 'react';
import MyContext from './Context';
import Content from './Content';

class Pannel extends React.Component {
    state = {
        theme: {
            color: 'rgb(0, 51, 254)'
        }
    }
    render() {
        return (
            // 屬性名必須叫 value
            <MyContext.Provider value={this.state.theme}>
                <Content />
            </MyContext.Provider>
        )
    }
}
           

子孫元件( Content.js )

類元件
  • 定義 

    Class.contextType

    :  

    static contextType = ThemeContext

    ;
  • 通過 

    this.context

     擷取 <ThemeContext.Provider> 中 

    value

     的内容(即 

    context

     值)
//類元件
import React from 'react';
import ThemeContext from './Context';

class Content extends React.Component {
    //定義了 contextType 之後,就可以通過 this.context 擷取 ThemeContext.Provider value 中的内容
    static contextType = ThemeContext;
    render() {
        return (
            <div style={{color: `2px solid ${this.context.color}`}}>
                //....
            </div>
        )
    }
}
           
函數元件
  • 子元素包裹在 <ThemeContext.Consumer> 中
  • <ThemeContext.Consumer> 的子元素是一個函數,入參 

    context

     值(

    Provider

     提供的 

    value

    )。此處是 

    {color: XXX}

import React from 'react';
import ThemeContext from './Context';

export default function Content() {
    return (
        <ThemeContext.Consumer>
            {
                context => (
                    <div style={{color: `2px solid ${context.color}`}}>
                        //....
                    </div>
                )
            }
        </ThemeContext.Consumer>
    )
}
           

context API(舊)

使用

  • 定義根元件的 

    childContextTypes

     (驗證 

    getChildContext

     傳回的類型)
  • 定義 

    getChildContext

     方法

根元件( Pannel.js )

import React from 'react';
import PropTypes from 'prop-types';
import Content from './Content';

class Pannel extends React.Component {
    static childContextTypes = {
        theme: PropTypes.object
    }
    getChildContext() {
        return { theme: this.state.theme }
    }
    state = {
        theme: {
            color: 'rgb(0, 51, 254)'
        }
    }
    render() {
        return (
            // 屬性名必須叫 value
            <>
                <Content />
            </>
        )
    }
}
           

子孫元件( Content.js )

  • 定義子孫元件的 

    contextTypes

     (聲明和驗證需要擷取的狀态的類型)
  • 通過 this.context 即可以擷取傳遞過來的上下文内容。

繼續閱讀