Redux toolkit 如何优雅的实现范式化

522 阅读7分钟

关于「你不知道的 Redux」这个专栏

在前端进阶中,redux 或者说是状态管理 是一个比较好的切入点,在实际项目中应用广泛,可选的解决方案非常多,生态非常丰富,学习他可以帮助我们提升编程设计思想,而且代码非常易读,代码量也不大,不像 webpack,react 源码那样庞大,学习收益非常高,同时也是面试中的高频考点。

专栏会持续更新 redux 整个生态的相关知识,包括 react-redux ,redux 中间件,immer 等等,甚至会考虑其他的解决方案,比如最近的 zustand 等。

什么是 Redux 范式化

Redux 范式化是一种将嵌套的 State 数据结构转换为扁平化的数据结构的方法,以便更轻松地管理和更新应用程序的状态。它通过将嵌套的数据结构拆分为多个单独的对象,并使用唯一的 ID 来引用它们,从而实现了数据的扁平化。

我们用代码具体解释一下: 假设后端返回我们一段博客文章的数据,数据结构如下:

const blogPosts = [
  {
    id: "post1",
    author: { username: "user1", name: "User 1" },
    body: "......",
    comments: [
      {
        id: "comment1",
        author: { username: "user2", name: "User 2" },
        comment: ".....",
      },
      {
        id: "comment2",
        author: { username: "user3", name: "User 3" },
        comment: ".....",
      },
    ],
  },
  {
    id: "post2",
    author: { username: "user2", name: "User 2" },
    body: "......",
    comments: [
      {
        id: "comment3",
        author: { username: "user3", name: "User 3" },
        comment: ".....",
      },
      {
        id: "comment4",
        author: { username: "user1", name: "User 1" },
        comment: ".....",
      },
      {
        id: "comment5",
        author: { username: "user3", name: "User 3" },
        comment: ".....",
      },
    ],
  },
  // and repeat many times
];

上面非范式化数据在前端展示和更新上可能存在如下问题:

  1. 数据冗余较多,例如存在多个 author 对象,如果 author 对象中的某个 name 发生变化,那么需要遍历所有的 post 对象进行更新,如果有遗漏就可能会出现数据不一致的情况。
  2. 数据嵌套较深,如果我们要更新某条评论,我们需要先找到对应的 post,然后再找到对应的 comment,最后才能更新 comment 的内容
  3. 因为 redux 的 store 是不可变的,加入我们更新某条评论,会造成整个 post 组件的更新,更有甚者会造成整个 postList 重新渲染,这样会造成不必要的性能损耗。

Redux 范式化有哪些优点

接下来我们看一下范式化的数据结构:

{
    posts : {
        byId : {
            "post1" : {
                id : "post1",
                author : "user1",
                body : "......",
                comments : ["comment1", "comment2"]
            },
            "post2" : {
                id : "post2",
                author : "user2",
                body : "......",
                comments : ["comment3", "comment4", "comment5"]
            }
        },
        allIds : ["post1", "post2"]
    },
    comments : {
        byId : {
            "comment1" : {
                id : "comment1",
                author : "user2",
                comment : ".....",
            },
            "comment2" : {
                id : "comment2",
                author : "user3",
                comment : ".....",
            },
            "comment3" : {
                id : "comment3",
                author : "user3",
                comment : ".....",
            },
            "comment4" : {
                id : "comment4",
                author : "user1",
                comment : ".....",
            },
            "comment5" : {
                id : "comment5",
                author : "user3",
                comment : ".....",
            },
        },
        allIds : ["comment1", "comment2", "comment3", "comment4", "comment5"]
    },
    users : {
        byId : {
            "user1" : {
                username : "user1",
                name : "User 1",
            },
            "user2" : {
                username : "user2",
                name : "User 2",
            },
            "user3" : {
                username : "user3",
                name : "User 3",
            }
        },
        allIds : ["user1", "user2", "user3"]
    }
}

这样的数据结构在实际使用中,会有如下便利:

  1. 数据冗余较少,不会出现数据不一致的情况
  2. 数据更加扁平,数据查找和更新更加方便
  3. List 组件和 Item 组件之间可以通过 id 传值,不需要通过 props 传递整个对象,较少可能不必要的渲染

其实,我们可以把范式化类比为关系型数据库中的表,而非范式化类比为非关系型数据库中的文档。范式化的数据结构更加适合前端的展示和更新,而非范式化的数据结构更加适合后端的存储和查询。

id为关系型数据库中的主键,byId为关系型数据库中的行,allIds为关系型数据库中的主键列。

如下表中多个实体之间的关系,就类似于关系型数据库之间 一对多,多对多的关系。

{
    entities: {
        authors : { byId : {}, allIds : [] },
        books : { byId : {}, allIds : [] },
        authorBook : {
            byId : {
                1 : {
                    id : 1,
                    authorId : 5,
                    bookId : 22
                },
                2 : {
                    id : 2,
                    authorId : 5,
                    bookId : 15,
                },
                3 : {
                    id : 3,
                    authorId : 42,
                    bookId : 12
                }
            },
            allIds : [1, 2, 3]

        }
    }
}

Redux Toolkit 数据范式化的最佳实践

接下来我们来看一下,RTK 如何实现数据范式化的最佳实践。 在 RTK 中,使用createEntityAdapter来创建一个entityAdapter对象,来实现数据范式化。 首先看一下 entityAdapter 的范式化数据结构定义:

export interface EntityState<T> {
  ids: readonly EntityId[];
  entities: Record<EntityId, T>;
}

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

查看下面简单的例子, 如果对 rtk 不熟悉的同学,可以先看一下我之前的文章Redux Toolkit 可能是目前 Redux 的最佳实践

在下面代码中,我们使用createEntityAdapter创建了一个usersAdapter对象,然后使用usersAdapter.getInitialState()创建了一个初始的 state,然后在reducers中,我们使用usersAdapter.addOne来添加一条数据,usersAdapter.setAll来添加多条数据,usersAdapter.updateOne来更新一条数据,usersAdapter.removeOne来删除一条数据 。我们对常见的增删改查操作,都进行了封装,做了大量简化。

同时我们可以通过usersAdapter.getSelectors来创建一个selectors对象,然后通过selectAllUsers来获取所有的用户数据,通过selectUserById来获取指定 id 的用户数据。

const usersAdapter = createEntityAdapter();

const initialState = usersAdapter.getInitialState();

export const fetchUsers = createAsyncThunk("users/fetchUsers", async () => {
  const response = await client.get("/fakeApi/users");
  return response.data;
});

const usersSlice = createSlice({
  name: "users",
  initialState,
  reducers: {
    userAdded: usersAdapter.addOne, //往 users 中添加一条数据,ids添加一个ID,entities添加一条数据
    userUpdated: usersAdapter.updateOne, //更新一条数据
    userRemoved: usersAdapter.removeOne, //删除一条数据
  },
  extraReducers(builder) {
    builder.addCase(fetchUsers.fulfilled, usersAdapter.setAll);
  },
});

export default usersSlice.reducer;

export const { selectAll: selectAllUsers, selectById: selectUserById } =
  usersAdapter.getSelectors((state) => state.users);

接下来我们看看createEntityAdapter相关技术实现

  1. 接受一个options对象,options对象中包含selectIdsortComparer两个属性,selectId用来获取实体的idsortComparer用来对实体进行排序。
  2. 比较关键的是 selectorsFactory 和 stateAdapter,selectorsFactory 用来创建 selectors 对象,stateAdapter 用来创建 state 对象。
export function createEntityAdapter<T>(
  options: {
    selectId?: IdSelector<T>;
    sortComparer?: false | Comparer<T>;
  } = {}
): EntityAdapter<T> {
  const { selectId, sortComparer }: EntityDefinition<T> = {
    sortComparer: false,
    selectId: (instance: any) => instance.id,
    ...options,
  };

  const stateFactory = createInitialStateFactory<T>();
  const selectorsFactory = createSelectorsFactory<T>();
  const stateAdapter = sortComparer
    ? createSortedStateAdapter(selectId, sortComparer)
    : createUnsortedStateAdapter(selectId);

  return {
    selectId,
    sortComparer,
    ...stateFactory,
    ...selectorsFactory,
    ...stateAdapter,
  };
}

这里我们不考虑排序的情况, 我们来看一下createUnsortedStateAdapter的实现。代码实现简单易懂

  1. 接受 selectId 方法,用来获取实体的 id
  2. addOneMutably 方法,用来往 ids 数组中添加一个 id,往 entities Object 中添加一条数据
  3. 返回之前嵌套的 createStateOperator 方法为 immer 的 produce 方法,用来创建一个 reducer 函数,用来更新 state(后续有计划展开聊聊 immer)
export function createUnsortedStateAdapter<T>(
  selectId: IdSelector<T>
): EntityStateAdapter<T> {
  type R = EntityState<T>;

  function addOneMutably(entity: T, state: R): void {
    const key = selectIdValue(entity, selectId);

    if (key in state.entities) {
      return;
    }

    state.ids.push(key);
    state.entities[key] = entity;
  }

  function addManyMutably(
    newEntities: readonly T[] | Record<EntityId, T>,
    state: R
  ): void {
    newEntities = ensureEntitiesArray(newEntities);

    for (const entity of newEntities) {
      addOneMutably(entity, state);
    }
  }

  return {
    addOne: createStateOperator(addOneMutably),
    addMany: createStateOperator(addManyMutably),
  };
}

我们再看看 createSelectorsFactory的实现

  1. createDraftSafeSelector 是一个封装了 immer 和 reselect 的 createSelector 方法,接受多个 selector 方法,其中,最后一个 selector 方法是一个回调函数,把前面 selector 方法作为入参数,最后返回一个新的数据。
  2. 拿 selectIds 举例,selectState 先从整个 store 中获取到 users 的 state,然后再从 users 的 state 中获取到 ids
  3. selectAll 稍微复杂一些,还是先经过 selectState,然后再从 users 的 state 中获取到 ids 和 entities,然后再根据 ids 和 entities 获取到所有的数据的数组结构
export function createSelectorsFactory<T>() {
  function getSelectors<V>(
    selectState?: (state: V) => EntityState<T>
  ): EntitySelectors<T, any> {
    const selectIds = (state: EntityState<T>) => state.ids;

    const selectEntities = (state: EntityState<T>) => state.entities;

    const selectAll = createDraftSafeSelector(
      selectIds, // 从 state 中获取 ids
      selectEntities, // 从 state 中获取 entities
      (ids, entities): T[] => ids.map((id) => entities[id]!) // 根据 ids 和 entities 获取所有的数据的数组结构
    );

    const selectId = (_: unknown, id: EntityId) => id;

    const selectById = (entities: Dictionary<T>, id: EntityId) => entities[id];

    const selectTotal = createDraftSafeSelector(selectIds, (ids) => ids.length);

    const selectGlobalizedEntities = createDraftSafeSelector(
      selectState as Selector<V, EntityState<T>>,
      selectEntities
    );

    return {
      selectIds: createDraftSafeSelector(selectState, selectIds),
      selectEntities: selectGlobalizedEntities,
      selectAll: createDraftSafeSelector(selectState, selectAll),
      selectTotal: createDraftSafeSelector(selectState, selectTotal),
      selectById: createDraftSafeSelector(
        selectGlobalizedEntities,
        selectId,
        selectById
      ),
    };
  }

  return { getSelectors };
}

总结一下

本文主要介绍了 Redux 范式化的好处,可以减少数据冗余,可以提高数据的查询效率,同时可以减少 UI 层重复渲染。

然后,我们介绍了在 RTK 中 通过 createEntityAdapter 落地 Redux 范式化, 大大提升了我们的开发效率,同时也减少了很多 reducer 和 selector 模版代码, 非常推荐大家使用。

最后,我们对 createEntityAdapter 相关的技术实现进行了简单的分析,希望能够帮助大家更好的理解 createEntityAdapter 的实现原理。

如果觉得对你的理解有帮助,请点赞收藏,后续会继续聊聊 Redux 生态相关的技术实现。

如果对于部分内容不理解,可以在评论区留言,我会尽快回复。