Redux最新实践指南之Redux-Toolkit

前言

redux-toolkit是目前redux官方推荐的编写redux逻辑的方法,针对redux的创建store繁琐、样板代码太多、依赖外部库等问题进行了优化,官方总结了四个特点是简易的/有想法的/强劲的/高效的,总结来看,就是更加的方便简单了。

从官方github的记录来看,该项目从2018年就开始了,从官方文档的更新可以看出,现在redux已经正式开始推广,其文档内容中结合了大量的redux-toolkit示例,具体可参考Redux中文官网Redux官网,最新的redux4.2.0版本也把redux-toolkit定为最佳redux使用方式

而且原来的createStore方法已经被标识为弃用。

本文主要对redux-toolkit进行介绍,以下简称为RTK。如果对redux的概念有不清楚的请先了解后再来学习。本文示例代码见https://github.com/storepage/redux-toolkit-demo

使用介绍

安装包依赖

yarn add @reduxjs/toolkit react-redux

不再需要单独安装redux了

react配置

import React from 'react';
import { createRoot } from 'react-dom/client';
import { Provider } from 'react-redux';
import { store } from './app/store';
import App from './App';
import './index.css';

const container = document.getElementById('root')!;
const root = createRoot(container);

root.render(
  <Provider store={store}>
    <App />
  </Provider>
);

这里和之前react-redux基本没有差别,还是提供Provider的根组件,传入store树作为参数,但store的定义有些许区别,具体见下一节。(root组件渲染这里用了react 18的写法)

创建Store

/* app/store.ts */
import { configureStore } from '@reduxjs/toolkit';
import counterReducer from '../features/counter/counterSlice';
import todoReducer from '../features/todo/todoSlice';

export const store = configureStore({
  reducer: {
    counter: counterReducer,
    todo: todoReducer,
  },
});

调用rtk的configureStore方法,该方法相当于集成了redux和redux-redux的createStore、combineReducers、middleware、enhancers,以及默认支持了扩展工具Redux DevTools。

参数选项为一个对象,包含以下内容

参数key值 说明
reducer 创建reducer,传递给combineReducers使用
middleware 中间件,传递给applyMiddleware使用
devTools 扩展工具,默认为true
preloadedState 初始state值,传递给createStore
enhancers 增强 store,传递给createStore

数据获取和发起action

先来看下组件内如何使用的,这一步可以认为完全是react-redux的内容

import React, { useState } from 'react';
import { useDispatch, useSelector } from 'react-redux';

import {
  decrement,
  selectCount
} from './counterSlice';

export function Counter() {
  const count = useSelector(selectCount);
  const dispatch = useAppDispatch();

  return (
    <div>
      <div className={styles.row}>
        <button
          className={styles.button}
          aria-label="Decrement value"
          onClick={() => dispatch(decrement())}
        >
          -
        </button>
        <span className={styles.value}>{count}</span>
        <button
          className={styles.button}
          aria-label="Increment value"
          onClick={() => dispatch(increment())}
        >
          +
        </button>
      </div>
    </div>
  );
}

示例为一个简单的计数器,自react支持hooks的写法后,react-redux也提供了获取state和dispatch一个action的自定义hooks,可以不再需要使用connect方法往props上绑定。

针对上面两个hooks,关于如何使用redux的数据,官方也给出了新的建议

  • 任意组件都能从 Redux Store 中读取任意数据
  • 任意组件都能通过 dispatch actions 引发状态更新(state updates)

目前官方文档上已经找不到之前关于pressenational和container component的概念(翻译不用,有叫做UI组件和容器组件,也有叫做智能组件和木偶组价)(这个概念是redux作者于2015年推荐的划分,喜欢考究的可以阅读Presentational and Container Components)

 ——以上截图出自目前找到的繁体版的文档,Read Me | Redux

redux官方不再推荐上面组件的划分,一方面原因应该是他把重心更多放在了redux本身上,至于用户怎么拆分组件,就交给用户思考好了。另一方面可能是之前connect的方法相比较会更影响性能。

定义切片Slice

rtk引入了新的定义slice,它是应用中对action和reducer逻辑的整合,通过createSlice方法进行创建

import { createSlice, PayloadAction } from '@reduxjs/toolkit';


export interface CounterState {
  value: number;
  status: 'idle' | 'loading' | 'failed';
}

const initialState: CounterState = {
  value: 0,
  status: 'idle',
};


export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  reducers: {
    increment: (state: CounterState) => {
      state.value += 1;
    },
    decrement: (state: CounterState) => {
      state.value -= 1;
    },
    // Use the PayloadAction type to declare the contents of `action.payload`
    incrementByAmount: (state: CounterState, action: PayloadAction<number>) => {
      state.value += action.payload;
    },
  },
});

export const { increment, decrement, incrementByAmount } = counterSlice.actions;

export default counterSlice.reducer;


这里的reducers参数既定义了reducers,又创建了关联的action。这个方法最终返回一个包含actions和reducer的对象。

可以看到这里对state进行了直接修改,表面看来是违背了redux禁止修改state原则,实际是因为引用了immer的库,总是返回一个安全的、不可变的更新值,极大的简化reducer的写法。注意该方式只能在createSlice和createReducer中编写。

从redux扩展工具可以看到,这里相当生成了{name}/{reducers.key}的type

注意这里reducer的action中如果要传入参数,只能是一个payload,如果是多个参数的情况,那就需要封装成一个payload的对象。

实际是用中可能需要对数据进行处理,reducer的定义除了支持一个方法,还可以提供一个预处理方法,因为reducer必须是纯函数,其他可变的参数就可以放到预处理函数中,预处理函数必须返回一个带有payload字段的对象


import { createSlice, PayloadAction, nanoid } from '@reduxjs/toolkit';

const todosSlice = createSlice({
  name: 'todos',
  initialState: [],
  reducers: {
    addTodo: {
      reducer(state: TodoState[], action: PayloadAction<TodoState>) {
        const { id, text } = action.payload;
        state.push({ id, text, completed: false });
      },
      prepare(text: string) {
        /* nanoid 随机一个字符串 */
        return { payload: { text, id: nanoid() } };
      },
    },
    toggleTodo(state: TodoState[], action: PayloadAction<string>) {
      const todo = state.find((todo: { id: any }) => todo.id === action.payload);
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
  },
});

 rtk还提供了一个nanoid方法,用于生成一个固定长度的随机字符串,类似uuid功能。

可以打印dispatch(addTodo(text))的结果看到,返回了一个带有payload字段的action

副作用处理

rtk集成了redux-thunk来处理异步事件,所以可以按照之前thunk的写法来写异步请求

// 外部的 thunk creator 函数
const fetchSomething = params => {
  // 内部的 thunk 函数
  return async (dispatch, getState) => {
    try {
      // thunk 内发起异步数据请求
      const res = await someApi(params)
      // 但数据响应完成后 dispatch 一个 action
      dispatch(someAction(res));
      return res;
    } catch (err) {
      // 如果过程出错,在这里处理
    }
  }
}
/* 组件中发起action */
dispatch(fetchSomething(params))

 rtk提供新的方法createAsyncThunk支持thunk,这也是redux推荐的方式

export const incrementAsync = createAsyncThunk('counter/fetchCount', async (amount: number) => {
  const response = await fetchCount(amount);
  return response.data;
});

export const counterSlice = createSlice({
  name: 'counter',
  initialState,
  extraReducers: (builder) => {
    builder
      .addCase(incrementAsync.pending, (state) => {
        state.status = 'loading';
      })
      .addCase(incrementAsync.fulfilled, (state, action) => {
        state.status = 'idle';
        state.value += action.payload;
      })
      .addCase(incrementAsync.rejected, (state) => {
        state.status = 'failed';
      });
  },
});

createAsyncThunk 可以写在任何一个slice的extraReducers中,它接收2个参数,

  • 生成action的type值,这里type是要自己定义,不像是createSlice自动生成type,这就要注意避免命名冲突问题了(如果createSlice定义了相当的name和方法,也是会冲突的)
  • 包含数据处理的promise,首先会dispatch一个action类型为'counter/fetchCount/pending',当异步请求完成后,根据结果成功或是失败,决定dispatch出action的类型为'counter/fetchCount/fulfilled'或'counter/fetchCount/rejected',这三个action可以在slice的extraReducers中进行处理。这个promise也只接收2个参数,分别是payload和包含了dispatch、getState的thunkAPI对象,所以除了在slice的extraReducers中处理之外,createAsyncThunk中也可以调用任意的action,这样就很像原本thunk的写法了,并不推荐
export const incrementAsync = createAsyncThunk('counter/fetchCount', 
  async (amount: number, thunkApi) => {
    const response = await fetchCount(amount);
    thunkApi.dispatch(counterSlice.actions.incrementByAmount(response.data));
    return response.data;
  }
);

  另外, extraReducers除了上面链式方法外,还可以定义为也可以是一个对象,结构为{[type]: function(state, action)},这是一种遗留的语法,虽然支持,但不被官方推荐。

  extraReducers: {
    [incrementAsync.pending.type]: (state) => {
      state.status = 'loading';
    },
    [incrementAsync.fulfilled.type]: (state, action) => {
      state.status = 'idle';
      state.value += action.payload;
    },
    [incrementAsync.rejected.type]: (state) => {
      state.status = 'failed';
    },
  },

 dispatch了createAsyncThunk的action会捕获所有的异常,最终返回一个action

如果要想要获取createAsyncThunk的promose里返回的数据,并且自己处理try/catch,可以调用unwrap方法

async () => {
  try {
    const payload = await dispatch(incrementAsync(incrementValue)).unwrap();
    console.log(payload);
  } catch (error) {
    // deal with error
  }
}

进阶使用

Selector使用缓存

当useSelector方法涉及到复杂逻辑运算时,且返回一个对象的时候,每次运行都返回了一个新的引用值,会使组件重新渲染,即使返回的数据内容并没有改变,如下带有过滤的todoList所示

const list = useSelector((state: RootState) => {
    const { todo, visibilityFilter } = state;
    switch (visibilityFilter) {
      case VisibilityFilters.SHOW_ALL:
        return todo;
      case VisibilityFilters.SHOW_COMPLETED:
        return todo.filter((t: TodoState) => t.completed);
      case VisibilityFilters.SHOW_ACTIVE:
        return todo.filter((t: TodoState) => !t.completed);
      default:
        throw new Error('Unknown filter: ' + visibilityFilter);
    }
  });

为了解决这个问题,可以使用Reselect库,它是一个创建记忆化selector的库,只有在输入发生变化时才会重新计算结果,rtk正是集成了这个库,并把它导出为createSelector函数,上面的selector就可以做如下改写

const selectTodos = (state: RootState) => state.todo;
const selectFilter = (state: RootState) => state.visibilityFilter;

// 创建记忆化selector
const selectList = createSelector(selectTodos, selectFilter, (todo, filter) => {
  switch (filter) {
    case VisibilityFilters.SHOW_ALL:
      return todo;
    case VisibilityFilters.SHOW_COMPLETED:
      return todo.filter((t: TodoState) => t.completed);
    case VisibilityFilters.SHOW_ACTIVE:
      return todo.filter((t: TodoState) => !t.completed);
    default:
      throw new Error('Unknown filter: ' + filter);
  }
});

// 使用记忆化selector
const list = useSelector((state: RootState) => selectList(state));

createSelector接收一个或多个selector输入函数(逐个传入或作为一个数组)和一个selector输出函数,其中每个输入函数的出参会作为最后一个输出函数的入参。

另外,上述问题还可以通过useSelector传入第二个参数比较函数来解决。

范式化state结构

“范式化 state”是指:

  • 我们 state 中的每个特定数据只有一个副本,不存在重复。
  • 已范式化的数据保存在查找表中,其中项目 ID 是键,项本身是值。
  • 也可能有一个特定项用于保存所有 ID 的数组。

根据以上定义,决定了它的结构形式为

{
  ids: ["user1", "user2", "user3"],
  entities: {
    "user1": {id: "user1", firstName, lastName},
    "user2": {id: "user2", firstName, lastName},
    "user3": {id: "user3", firstName, lastName},
  } 
}

这样的结构类似于字典,方便增删改查功能。rtk提供了createEntityAdapter api,对范式化结构的存储进行一系列标准化操作。

import { 
  createSlice, 
  PayloadAction, 
  createEntityAdapter, 
  nanoid, 
  EntityState 
} from '@reduxjs/toolkit';
import { RootState } from '../../app/store';

export interface TodoPayload {
  todoId: string;
  text: string;
  completed?: boolean;
  createdTimestamp: number;
}

/* 创建EntityAdapter */
const todoAdapter = createEntityAdapter<TodoPayload>({
  /* 默认值为id */
  selectId: (todo) => todo.todoId,
  /* 对ids进行排序,方法与Array.sort相同,如果不提供,不能保证ids顺序 */
  sortComparer: (a, b) => a.createdTimestamp - b.createdTimestamp,
});

const todosSlice = createSlice({
  name: 'todosEntity',
  initialState: todoAdapter.getInitialState(),
  reducers: {
    /* 增 */
    addTodo: {
      reducer(state: EntityState<TodoPayload>, action: PayloadAction<TodoPayload>) {
        todoAdapter.addOne(state, action.payload);
      },
      prepare(text: string) {
        return {
          payload: {
            text,
            todoId: nanoid(),
            createdTimestamp: Date.now(),
          },
        };
      },
    },
    /* 删 */
    removeTodo(state: EntityState<TodoPayload>, action: PayloadAction<string>) {
      todoAdapter.removeOne(state, action.payload);
    },
    /* 改 */
    toggleTodo(state: EntityState<TodoPayload>, action: PayloadAction<string>) {
      const todo = state.entities[action.payload];
      if (todo) {
        todo.completed = !todo.completed;
      }
    },
  },
});

/* 查 */
export const { selectAll: selectAllTodos } = todoAdapter.getSelectors((state: RootState) => state.todoEntity);

/* action */
export const { actions: todoActions } = todosSlice;
/* reducer */
export default todosSlice.reducer;

掌握该结构主要包含三个要点

  1. 创建一个特定id标识、支持排序的EntityAdapter
  2. 获取state的初始值getInitialState,除了默认的ids和entities外,还可以添加自定义字段
  3. 一系列的增删改查方法,这些方法大都需要传入当前的state和要操作的数据

使用 Redux-Persist

如果要使用Redux-Persist的话,需要特定地忽略它dispatch的所有的action types。参考Question: How to use this with redux-persist? #121

import { configureStore, getDefaultMiddleware } from '@reduxjs/toolkit'
import {
  persistStore,
  persistReducer,
  FLUSH,
  REHYDRATE,
  PAUSE,
  PERSIST,
  PURGE,
  REGISTER
} from 'redux-persist'
import storage from 'redux-persist/lib/storage'
import { PersistGate } from 'redux-persist/integration/react'

import App from './App'
import rootReducer from './reducers'

const persistConfig = {
  key: 'root',
  version: 1,
  storage
}

const persistedReducer = persistReducer(persistConfig, rootReducer)

const store = configureStore({
  reducer: persistedReducer,
  middleware: getDefaultMiddleware({
    serializableCheck: {
      ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER]
    }
  })
})

let persistor = persistStore(store)

ReactDOM.render(
  <Provider store={store}>
    <PersistGate loading={null} persistor={persistor}>
      <App />
    </PersistGate>
  </Provider>,
  document.getElementById('root')
)

上面rootReducer写法同之前的reducer一样,需要使用combineReducers整合

import { combineReducers } from 'redux';

import counterReducer from '../features/counter/counterSlice';
import todoReducer from '../features/todos/todoSlice';
import todoEntityReducer from '../features/todosEntity/todoSlice';
import visibilityFilterReducer from '../features/filters/filtersSlice';

export default combineReducers({
  counter: counterReducer,
  todo: todoReducer,
  todoEntity: todoEntityReducer,
  visibilityFilter: visibilityFilterReducer,
});

猜你喜欢

转载自blog.csdn.net/cscj2010/article/details/125705530