@reduxjs/toolkit黑魔法

7,278 阅读7分钟

前言

年中,公司启动新项目,需要搭建微前端架构,经过多番调研,确定了乾坤、umi、dva的技术方案,开始一个月就遇到了大的难题。第一,dva是约定式,不能灵活的配置;第二,乾坤并不能完全满足业务需求,需要更改很多源码,比如主子通信,兄弟通信等。经过一番取舍,放弃了这个方案。后基于single-spa,搭建一套微前端架构,同时通过命令生成模板,类似create-react-app,使用技术栈react、redux。 之前习惯了dva的操作方法,使用redux比较繁琐,因新项目比较庞大,不建议使用mobx。调研了多种方案,最终选择redux作者Dan Abramov今年三月份出的工具库@reduxjs/toolkit(以下简称RTK)。

简介

RTK旨在帮助解决关于Redux的三个问题:

  • 配置Redux存储太复杂;
  • 必须添加很多包才能让Redux做预期的事情;
  • Redux需要太多样板代码;

简单讲配置Redux存储的流程太复杂,完整需要actionTypes、actions、reducer、store、通过connect连接。使用RTK,只需一个reducer即可,前提是组件必须是hooks的方式。

目录

  1. configureStore
  2. createAction
  3. createReducer
  4. createSlice
  5. createAsyncThunk
  6. createEntityAdapter
  7. 部分难点代码的unit test

configureStore

configureStore是对标准的Redux的createStore函数的抽象封装,添加了默认值,方便用户获得更好的开发体验。 传统的Redux,需要配置reducer、middleware、devTools、enhancers等,使用configureStore直接封装了这些默认值。代码如下:

import { configureStore } from '@reduxjs/toolkit'
import rootReducer from './reducers'

// 这个store已经集成了redux-thunk和Redux DevTools
const store = configureStore({ reducer: rootReducer })

相较于原生的Redux简化了很多,具体的Redux配置方法就不在这儿赘述了。

createAction、createReducer

createAction语法: function createAction(type, prepareAction?)

  1. type:Redux中的actionTypes
  2. prepareAction:Redux中的actions 如下:
const INCREMENT = 'counter/increment'

function increment(amount: number) {
  return {
    type: INCREMENT,
    payload: amount,
  }
}
const action = increment(3) // { type: 'counter/increment', payload: 3 }

createReducer简化了Redux reducer函数创建程序,在内部集成了immer,通过在reducer中编写可变代码,简化了不可变的更新逻辑,并支持特定的操作类型直接映射到case reducer函数,这些操作将调度更新状态。 不同于Redux reducer使用switch case的方式,createReducer简化了这种方式,它支持两种不同的形式:

  1. builder callback
  2. map object

第一种方式如下:

import { createAction, createReducer } from '@reduxjs/toolkit'

interface CounterState {
  value: number
}

// 创建actions
const increment = createAction('counter/increment')
const decrement = createAction('counter/decrement')
const incrementByAmount = createAction<number>('counter/incrementByAmount')

const initialState: CounterState = { value: 0 }

// 创建reducer
const counterReducer = createReducer(initialState, (builder) => {
  builder
    .addCase(increment, (state, action) => {
      // 使用了immer, 所以不需要使用原来的方式: return {...state, value: state.value + 1}
      state.value++
    })
    .addCase(decrement, (state, action) => {
      state.value--
    })
    .addCase(incrementByAmount, (state, action) => {
      state.value += action.payload
    })
})

看起来比Redux的actions和reducer要好一些,这儿先不讲第二种方式map object,后面讲到createSlice和createAsyncThunk结合使用时再讲解。

Builder提供了三个方法

  1. addCase: 根据action添加一个reducer case的操作。
  2. addMatcher: 在调用actions前,使用matcher function过滤
  3. addDefaultCase: 默认值,等价于switch的default case;

createSlice

createSlice对actions、Reducer的一个封装,咋一看比较像dva的方式,是一个函数,接收initial state、reducer、action creator和action types,这是使用RTK的标准写法,它内部使用了createAction和createReducer,并集成了immer,完成写法如下:

// initial state interface
export interface InitialStateTypes {
  loading: boolean;
  visible: boolean;
  isEditMode: boolean;
  formValue: CustomerTypes;
  customerList: CustomerTypes[];
  fetchParams: ParamsTypes;
}

// initial state
const initialState: InitialStateTypes = {
  loading: false,
  visible: false,
  isEditMode: false,
  formValue: {},
  customerList: [],
  fetchParams: {},
};

// 创建一个slice
const customerSlice = createSlice({
  name: namespaces, // 命名空间
  initialState, // 初始值
  // reducers中每一个方法都是action和reducer的结合,并集成了immer
  reducers: {
    changeLoading: (state: InitialStateTypes, action: PayloadAction<boolean>) => {
      state.loading = action.payload;
    },
    changeCustomerModel: (state: InitialStateTypes, action: PayloadAction<IndexProps>) => {
      const { isOpen, value } = action.payload;
      state.visible = isOpen;
      if (value) {
        state.isEditMode = true;
        state.formValue = value;
      } else {
        state.isEditMode = false;
      }
    },
  },
  // 额外的reducer,处理异步action的reducer
  extraReducers: (builder: ActionReducerMapBuilder<InitialStateTypes>) => {
    builder.addCase(fetchCustomer.fulfilled, (state: InitialStateTypes, { payload }) => {
      const { content, pageInfo } = payload;
      state.customerList = content;
      state.fetchParams.pageInfo = pageInfo;
    });
  },
});

页面传值取值方式,前提必须是hooks的方式,class方式不支持(本文使用的是useDispatch和useSelector的方式,故class不支持,若要使用class的方式,请使用connect):

import { useDispatch, useSelector } from 'react-redux';
import {
  fetchCustomer,
  changeCustomerModel,
  saveCustomer,
  delCustomer,
} from '@root/store/reducer/customer';

export default () => {
  const dispatch = useDispatch();
  // 取值
  const { loading, visible, isEditMode, formValue, customerList, fetchParams } = useSelector(
    (state: ReducerTypes) => state.customer,
  );

  useEffect(() => {
    // dispatch
    dispatch(fetchCustomer(fetchParams));
  }, [dispatch, fetchParams]);  
}

少了connect的连接,代码优雅不少。

createAsyncThunk

这儿讲RTK本身集成的thunk,想使用redux-saga的自己配置,方式相同。 createAsyncThunk接受Redux action type字符串,返回一个promise callback。它根据传入的操作类型前缀生成Promise的操作类型生命周期,并返回一个thunk action creator。它不跟踪状态或如何处理返回函数,这些操作应该放在reducer中处理。 用法:

export const fetchCustomer = createAsyncThunk(
  `${namespaces}/fetchCustomer`,
  async (params: ParamsTypes, { dispatch }) => {
    const { changeLoading } = customerSlice.actions;
    dispatch(changeLoading(true));
    const res = await server.fetchCustomer(params);
    dispatch(changeLoading(false));

    if (res.status === 0) {
      return res.data;
    } else {
      message.error(res.message);
    }
  },
);

createAsyncThunk可接受三个参数

  1. typePrefix: action types
  2. payloadCreator: { dispatch, getState, extra, requestId ...}, 平常开发只需要了解dispatch和getState就够了,注:这儿的getState能拿到整个store里面的state
  3. options: 可选,{ condition, dispatchConditionRejection}, condition:可在payload创建成功之前取消执行,return false表示取消执行。

讲createReducer时,有两种表示方法,一种是builder callback,即build.addCase(),一种是map object。下面以这种方式讲解。 createAsyncThunk创建成功后,return出去的值,会在extraReducers中接收,有三种状态:

  1. pending: 'fetchCustomer/requestStatus/pending',运行中;
  2. fulfilled: 'fetchCustomer/requestStatus/fulfilled',完成;
  3. rejected: 'fetchCustomer/requestStatus/rejected',拒绝; 代码如下:
const customerSlice = createSlice({
  name: namespaces, // 命名空间
  initialState, // 初始值
  // reducers中每一个方法都是action和reducer的结合,并集成了immer
  reducers: {
    changeLoading: (state: InitialStateTypes, action: PayloadAction<boolean>) => {
      state.loading = action.payload;
    },
    changeCustomerModel: (state: InitialStateTypes, action: PayloadAction<IndexProps>) => {
      const { isOpen, value } = action.payload;
      state.visible = isOpen;
      if (value) {
        state.isEditMode = true;
        state.formValue = value;
      } else {
        state.isEditMode = false;
      }
    },
  },
  // 额外的reducer,处理异步action的reducer
  extraReducers: {
    // padding
    [fetchCustomer.padding]: (state: InitialStateTypes, action: PayloadAction<IndexProps>) => {},
    // fulfilled
    [fetchCustomer.fulfilled]: (state: InitialStateTypes, action: PayloadAction<IndexProps>) => {},
    // rejected
    [fetchCustomer.rejected]: (state: InitialStateTypes, action: PayloadAction<IndexProps>) => {},
  }
});

对应的builder.addCase的方式:

  extraReducers: (builder: ActionReducerMapBuilder<InitialStateTypes>) => {
    builder.addCase(fetchCustomer.padding, (state: InitialStateTypes, { payload }) => {});
    builder.addCase(fetchCustomer.fulfilled, (state: InitialStateTypes, { payload }) => {});
    builder.addCase(fetchCustomer.rejected, (state: InitialStateTypes, { payload }) => {});
  },

createEntityAdapter

字面意思是创建实体适配器,目的为了生成一组预建的缩减器和选择器函数,对包含特定类型的对象进行CRUD操作,可以作为case reducers 传递给createReducer和createSlice,也可以作为辅助函数。createEntityAdapter是根据@ngrx/entity移植过来进行大量修改。其作用就是实现state范式化的思想。 Entity用于表示数据对象的唯一性,一般以id作为key值。 由createEntityAdapter方法生成的entity state结构如下:

{
  // 每个对象唯一的id,必须是string或number
  ids: []
  // 范式化的对象,实体id映射到相应实体对象的查找表,即key为id,value为id所在对象的值,
  entities: {}
}

创建一个createEntityAdapter:

type Book = {
  bookId: string;
  title: string;
};

export const booksAdapter = createEntityAdapter<Book>({
  selectId: (book) => book.bookId,
  sortComparer: (a, b) => a.title.localeCompare(b.title),
});

const bookSlice = createSlice({
  name: 'books',
  initialState: booksAdapter.getInitialState(),
  reducers: {
    // 添加一个book实体
    bookAdd: booksAdapter.addOne,
    // 接受所有books实体
    booksReceived(state, action) {
      booksAdapter.setAll(state, action.payload.books);
    },
  },
});

export const { bookAdd, booksReceived } = bookSlice.actions;
export default bookSlice.reducer;

组件中取值:

  import React, { useEffect } from 'react';
  import { useDispatch, useSelector } from 'react-redux';
  
  const dispatch = useDispatch();
  const entityAdapter = useSelector((state: ReducerTypes) => state);
  const books = booksAdapter.getSelectors((state: ReducerTypes) => state.entityAdapter);

  console.log(entityAdapter);
  // { ids: ['a001', 'a002'], entities: { a001: { bookId: 'a001', title: 'book1' }, a002: { bookId: 'a002', title: 'book2' } } }

  console.log(books.selectById(entityAdapter, 'a001'));
  // { bookId: 'a001', title: 'book1' }

  console.log(books.selectIds(entityAdapter));
  // ['a001', 'a002']

  console.log(books.selectAll(entityAdapter));
  // [{ bookId: 'a001', title: 'book1' }, { bookId: 'a002', title: 'book2' }]

  useEffect(() => {
    dispatch(bookAdd({ bookId: 'a001', title: 'book1' }));
    dispatch(bookAdd({ bookId: 'a002', title: 'book2' }));
  }, []);

从提供的方法中,可以获取到原始的数组值,范式化后的key-value方式,可以获取以存储key的数组ids,就是state范式化。

unit test

公共部分:

  const dispatch = jest.fn();
  const getState = jest.fn(() => ({
    dispatch: jest.fn(),
  }));
  const condition = jest.fn(() => false);
  1. reducers中方法,actions单元测试:
const action = changeCustomerModel({
      isOpen: true,
      value,
    });
    expect(action.payload).toEqual({
      isOpen: true,
      value,
    });
  1. thunk actions(createAsyncThunk)单元测试
    const mockData = {
      status: 0,
      data: {
        content: [
          {
            id: '001',
            code: 'table001',
            name: '张三',
            phoneNumber: '15928797333',
            address: '成都市天府新区',
          },
        ],
      },
    }
    // server.fetchCustomer方法mock数据
    server.fetchCustomer.mockResolvedValue(mockData);
    // 执行thunk action异步方法
    const result = await fetchCustomer(params)(dispatch, getState, { condition });
    // 请求接口数据,断言是否是mock的数据
    expect(await server.fetchCustomer(params)).toEqual(mockData);
    // dispatch设置loading状态为true
    dispatch(changeLoading(true));
    // 断言thunk action执行成功
    expect(fetchCustomer.fulfilled.match(result)).toBe(true);
    
    // 执行extraReducers的fetchCustomer.fulfilled
    customerReducer(
      initState,
      fetchCustomer.fulfilled(
        {
          payload: {
            content: [value],
            pageInfo: initState.fetchParams.pageInfo,
          },
        },
        '',
        initState.fetchParams,
      ),
    );

    // 断言第一次dispatch设置loading为true
    expect(dispatch.mock.calls[1][0]).toEqual({
      payload: true,
      type: 'customer/changeLoading',
    });

    // 请求成功,第二次dispatch设置loading为false
    expect(dispatch.mock.calls[2][0]).toEqual({
      payload: false,
      type: 'customer/changeLoading',
    });
    
    // thunk action return 到extraReducers的值
    expect(dispatch.mock.calls[3][0].payload).toEqual(mockData.data);

后记

写的有点凌乱,就是当做笔记来记录的,有写的不对的地方不吝赐教。

参考文献

  1. redux-toolkit.js.org/introductio…
  2. redux.js.org/recipes/str…