redux黑魔法@reduxjs/tootik

前言

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

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. https://redux-toolkit.js.org/introduction/quick-start
  2. https://redux.js.org/recipes/structuring-reducers/normalizing-state-shape
©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 194,491评论 5 459
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 81,856评论 2 371
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 141,745评论 0 319
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 52,196评论 1 263
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 61,073评论 4 355
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 46,112评论 1 272
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 36,531评论 3 381
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 35,215评论 0 253
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 39,485评论 1 290
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 34,578评论 2 309
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 36,356评论 1 326
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 32,215评论 3 312
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 37,583评论 3 299
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 28,898评论 0 17
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 30,174评论 1 250
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 41,497评论 2 341
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 40,697评论 2 335

推荐阅读更多精彩内容