两种运用hooks实现redux的方案

353 阅读7分钟

React Hooks是React@16.8版本官方新的编写推荐,前阵子简单对hooks进行了学习,也在探索适用它的使用场景,本文介绍的是redux的hooks实现方案,包括redux常用api的hooks版实现原理及优化方案。 PS:关于如何使用hooks进行编程可以直接看下官方文档Hooks官方文档

先列个目录: 1、实现原理 2、简单方案:递归Store实现redux 3、优化方案:合并Store实现redux

1. 实现思路

在 React 项目中,我们一般按照单一的组件数据流就能实现大部分功能,但随着业务复杂度的增加,跨组件之间的通讯在单一数据流的情况下难以实现。由此 redux、flux、dva、vuex 等统一管理数据的库活着工具就出来了。

redux 集中式的管理数据,通过 Provider 组件将数据共享给子组件,然后再通过 connect 将 store 与组件进行连接,这是 redux 最核心的功能。

既然 redux 核心是为了实现数据共享,我们可以使用 React Context API 上下文来实现, 原理是使用生产子/消费者模式,顶层通过 Provider 去下发数据,子组件通过 Consumer 来消费由 Provider 里提供的数据和方法,从而实现数据共享。

2.方案一:递归Store

目录

目录结构

应用入口:index.js

import React from 'react';
import ReactDOM, {render} from 'react-dom';
import App from './components/App'
import Provider from './store/provider'

// 挂载节点
render((
    <Provider>
        <App/>
    </Provider>
), document.getElementById('app')
)

在顶层引入【Provider】组件,为所有的子孙组件提供所有数据源store,使得子孙组件拥有获取数据的能力。

store 设计

数据项:count.js

import React, { useReducer } from 'react'

// 初始化state
const initState = {
    count: 0
}
// reducer处理器
const reducer = (state, action) => {
const { type, payload } = action
switch (type) {
    case 'ADD_COUNT': return { ...state, count: state.count + 1 }
    default: return state;
}
}
// 创建上下文
const Context = React.createContext()
const Provider = (props) => {
const [state, dispatch] = useReducer(reducer, initState)
return (
    <Context.Provider value={{state, dispatch}}>
        {props.children}
    </Context.Provider>
)
}
export default { Context, Provider }

数据项的代码中可以看出initState,reducer的定义与用redux时是一摸一样,重点看下面的创建上下文 首相通过React.createContext() 创建一个空的上下文Context,然后定义Provider这个组件,内部使用useReducer把处理器reducer和初始化的initState传入进去,返回的state和dispatch提供到Provider作为数据源。

数据项聚合:provider.js

import React from 'react'
import Count from './modules/count';
import Todo from './modules/todo';

// 聚合count、todo这些数据项
const providers = [
    Count.Provider,
    Todo.Provider
];
// 递归包裹Provider
const ProvidersComposer = (props) => (
    props.providers.reduceRight((children, Parent) => (
        return Parent({children})
    ), props.children)
)
const Provider = (props) => {
return (
    <ProvidersComposer providers={providers}>
        {props.children}
    </ProvidersComposer>
)
}
export default Provider

98b8e2f56f57155251528d8f6b1489a5

最后出来的组件结构: Provider > Context.Provider > Context.Provider > App 我们通过ProviderComposer进行递归包裹,把每个Provider进行一层一层的包裹 这里使用了parent({children})替代了<Parent>{children}</Parent>,这样的做法可以减少一层嵌套结构。

数据聚合的思路方案来自:github.com/facebook/re…

如何使用

import React, { useContext, useEffect } from 'react';
// 引入count数据源
import CountStore from '@/store/modules/count'

const App = (props) => {
    // 通过useContext使用Count这个store的上下文
    const {state, dispatch} = useContext(CountStore.Context)
    // 每秒更新count
    useEffect(() => {
        setInterval(() => {
            dispatch({ type: 'ADD_COUNT' })
        }, 1000);
    }, [])
    
    return (
        <div className='app'>
            {JSON.stringify(state)}
        </div>
    )
}

export default App

通过useContext传入对应store的Context,从而获取到由外部对应store的Provider提供的state和dispatch,最终展示效果如下:

5436a4a9fe590b3ad74d893d68c4b346

存在问题

  • 问题一:嵌套复杂问题

98b8e2f56f57155251528d8f6b1489a5

实际上,被包裹了很多层,在这个store不断拓展的情况下,N个store,就会包裹N层,App会被包裹得很深入,而且我们调试起来也很麻烦,这简直就是「嵌套地狱」啊!

  • 问题二:重新渲染问题
// 获取count、todo、xxx
const {countState, countDispatch} = useContext(CountStore.Context)
const {totoState, todoDispatch} = useContext(TodoStore.Context)
const {xxxState, xxxDispatch} = useContext(xxxStore.Context)

当内部组件需要使用多个store的情况下,我们需要使用多个上下文。当其中一个store中某个不相关的state进行更新,组件可能又需要重新渲染一遍,不仅调用麻烦,还会造成无谓的渲染问题。

那么,有没有更好的方案呢?

3.方案二:合并Store

针对方案一的问题,我们有了新的目标: 1、能否减少嵌套地狱的问题? 2、能否对使用的state,dispatch进行合理的分发? 3、能否减少无谓的重新渲染问题?

Store设计

数据项:count.js

export const state = {
    count: 0
}

export const actions = {
    addCount: payload => ({
        type: 'ADD_COUNT',
        payload
    })
}

export const reducer = (state, action) => {
    const { type, payload } = action;
    switch (type) {
        case 'ADD_COUNT':
            return { ...state, count: state.count + 1 }
        default:
            return state;
    }
}
export default { reducer, state, actions }

数据项这里有点不一样,state跟reducer与方案一样,这里多了个actions,主要用于dispatch的分发。

上下文:context.js

import React, { createContext } from 'react'
// 全局上下文Context
const Context = createContext('context')
export default Context

创建并提供全局的上下文

供应商:provider.js

import React, { useReducer, useContext } from 'react'

import Context from './context'
import count from './modules/count'
import todo from './modules/count'

// 聚合数据项store
const stores = {
    count,
    todo
}

// 全局供应商Provider
const Provider = (props) => {
    const reducers = {}
    const combineStates = {}
    Object.keys(stores).forEach((key) => {
        reducers[key] = stores[key].reducer
        // 合并state
        combineStates[key] = stores[key].state
    })
    // 合并reducer
    const combineReducers = combine(reducers)
    // 获取state、dispatch
    const [state, dispatch] = useReducer(combineReducers, combineStates);
    return (
        <Context.Provider value={{ state, dispatch }}>
            {props.children}
        </Context.Provider>
    )
}

// 对reducer进行合并
function combine (reducers) {
    return function (state = {}, action) {
        return Object.keys(reducers).reduce((newState, key) => {
            newState[key] = reducers[key](state[key], action)
            return newState
        }, {})
    }
}

export default Provider

这里是关键代码,我们看看这里做了什么 1、引入全局上下文Context 2、聚合store,汇总到stores对象里 3、把所有store的state合并combineStates 4、把所有store的reducer合并combineReducers 5、把合并后的combineReducerscombineStates传入useReducer,获取到state和dispatch 5、把state、dispatch作为数据源给到Context.Provider 在完成上面所有步骤后,我们就得到一个全局唯一的供应商Provider

如何使用?

import React, { useState, useContext, useEffect} from 'react';
import { Context } from '@/store'

const App = (props) => {
    const {state, dispatch} = useContext(Context)
    
    // 每秒更新count
    useEffect(() => {
        setInterval(() => {
            dispatch({ type: 'ADD_COUNT' })
        }, 1000);
    }, [])
    
    return (
        <div className='app'>
            {JSON.stringify(state)}
        </div>
    )
}
export default App

使用方法跟方案一一样,但是唯一不同的是:Context由使用单个store,变为使用全局stores。这个Context包含了全部的store。

最终展示效果如下:

4ae24289e1baf22c207c5cd640048bdc

存在问题

  • 问题一:重新渲染问题

任何一个store中某个不相关的state进行更新,使用了useContext的组件就得重新渲染。

解决方案

1、如何分发state、dispatch

我们使用连接器conncet把组件与store连接起来,并把所需的state、dispatch分发到组件的props里。

import React, { useContext, useMemo } from 'react';
import { Context } from './context'
// 连接器
function connect (mapStateToProps, mapDispatchToProps) {
    return WrappedComponent => {
        const MemoHook = (props = {}) => {
            const { state, dispatch } = useContext(Context)
            // 分发state
            const mapState = handleState(mapStateToProps, state, props)
            // 分发dispatch
            const mapDispatch = handleDispatch(mapDispatchToProps, dispatch, props)
            return (
                <WrappedComponent {...props} {...mapState} {...mapDispatch} />
            )
        }
        return MemoHook
    }
}
// 实现mapStateToProps
function handleState (mapStateToProps, state, props) {
    if (typeof mapStateToProps === 'function') {
        return mapStateToProps(state, props)
    } else {
        return {}
    }
}
// 实现mapDispatchToProps
function handleDispatch (mapDispatchToProps, dispatch, props) {
    if (typeof mapDispatchToProps === 'function') {
        // 如果mapDispatchToProps是函数,传入dispatch、props
        return mapDispatchToProps(dispatch, props)
    } else if (typeof mapDispatchToProps === 'object') {
        // 如果mapDispatchToProps是对象,则遍历对象进行处理
        let obj = {}
        Object.keys(mapDispatchToProps).forEach((key) => {
            obj[key] = (p) => dispatch(mapDispatchToProps[key](p), props)
        })
        return obj
        } else {
        return {}
    }
}
export default connect;

conncet连接器与redux的conncet原理相似, 接受2个参数mapStateToProps、mapDispatchToProps

1、获取全局上下文Context的state、dispatch 2、通过handleState进行state的分发 3、通过handleDispatch进行action的分发 4、把过滤出来的state、action注入到组件里

376ce2c94ba25cd30b30a9d24ff9fbe1

如何使用?

import React, { useState, useEffect} from 'react';
import { Context, connect } from '@/store'
import { actions as countActions } from '@/store/modules/count'

const App = (props) => {

    // 每秒更新count
    useEffect(() => {
        setInterval(() => {
            // addCount已经注入到props里了
            props.addCount()
        }, 1000);
    }, [])
    
    return (
        <div className='app'>
            {/* state.count 已经注入到props了 */}
            {JSON.stringify(props.count)}
        </div>
    )
}
// 使用方法与redux的conncet一样
export default connect(
    // mapStateToProps
    ({count}) => ({count}),
    // mapDispatchToProps
    (dispatch, props) => ({
    addCount: (args) => dispatch(countActions.addCount(args))
    })
)(App)

我们通过conncet把组件与store连接起来,并把对应的state和dispatch分发到组件的props中。 使用方法和redux的connect一样,可以看看文档connect使用方法

2、如何解决多余的重新渲染问题

我们使用到useMemo,只需在conncet中进行点小改造

function connect (mapStateToProps, mapDispatchToProps) {
    return WrappedComponent => {
        const MemoHook = (props = {}) => {
            const { state, dispatch } = useContext(Context)
            const mapState = handleState(mapStateToProps, state, props)
            const memoState = [...Object.values(mapState), ...Object.values(props)]
            return useMemo(() => {
                const mapDispatch = handleDispatch(mapDispatchToProps, dispatch, props)
                return (
                    <WrappedComponent {...props} {...mapState} {...mapDispatch} />
                )
            }, memoState)
        }
    return MemoHook
    }
}

我们把分发state及组件的props记录下来,作为useMemo的监听目标,当state或者props的内容更新后才重新渲染组件WrapperComponent。这样那些不相关的store更新时就不会触发到WrapperComponent进行重新渲染。 我们把handleDispatch放到的useMemo内,因为actions在connect时就应该被定义好,后续也不会修改,所以没必要重新渲染时再去重新处理

最终效果:

3b2b482e4317f225679c6bf9ca332512

参考: 使用 React Hooks 代替 Redux Hook API 索引