Skip to content

Commit

Permalink
Merge pull request #61 from ant-design/docs/data-flow
Browse files Browse the repository at this point in the history
📝 docs: 补充文档
  • Loading branch information
rdmclin2 authored Aug 18, 2023
2 parents b25a440 + b30bf08 commit 4419d30
Showing 1 changed file with 343 additions and 0 deletions.
343 changes: 343 additions & 0 deletions docs/guide/data-management.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,343 @@
---
title: 数据流最佳实践
---

## 数据流最佳实践

编辑器场景不同于网页,存在大量的富交互能力。如何设计一个易于开发与易于维护的数据流架构非常重要。

## 概念要素

| 概念名词 | 解释 |
| -------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
| store | 状态库(store),包含存储应用的状态、动作。允许在应用渲染中访问和修改状态。 |
| state | 状态(state)是指应用程序的数据,存储了应用程序的当前状态,状态的变化**一定会触发应用的重新渲染**,以反映新的状态。 |
| action | 动作(action) 是一个操作函数,它描述了应用程序中发生的交互事件。动作通常是由用户交互、网络请求或定时器等触发。 action 可以是**同步**的,也可以是**异步**的。 |
| reducer | 归约器(reducer) 是一个纯函数,它接收当前状态和动作作为参数,并返回一个新的状态。它用于根据动作类型来更新应用程序的状态。Reducer 是一个纯函数,不存在副作用,因此一定是 **同步** 函数。 |
| selector | 选择器(selector) 是一个函数,用于从应用程序的状态中获取特定的数据。它接收应用程序的状态作为参数,并返回经过计算或转换后的数据。Selector 可以将状态的一部分或多个状态组合起来,以生成派生的数据。Selector 通常用于将应用程序的状态映射到组件的 props,以供组件使用。 |
| slice | 切片(slice) 是一个概念,用于表达数据模型状态的一部分。它指定了一个状态切片(slice),以及与该切片相关的 state、action、reducer 和 selector。使用 Slice 可以将大型的 Store 拆分为更小的、可维护的子类型。 |

## 结构分层

在不同的复杂度下,我们可以将 Store 的结构组织可以由很大的不同:

### 较低复杂度

一般包含 2~5 个 state 、3 ~ 4 个 action。此时的结构一般直接一个 `store.ts` + 一个 `initialState.ts` 即可。

```bash
DataFill/store
├── index.ts
├── initialState.ts
```

### 一般复杂度

一般复杂度存在 5 ~ 15 个 state、 5 ~ 10 个 action,可能会存在 selector 实现派生状态,也有可能存在 reducer 简化部分数据变更的复杂度。此时的结构一般为一个 `store.ts` + 一个 `initialState.ts` + 一个 `selectors.ts`/`reducer.ts`

```bash
IconPicker/store
├── index.ts
├── initialState.ts
├── selectors.ts
└── store.ts
```

```bash
SortableList/store
├── index.ts
├── initialState.ts
├── listDataReducer.ts
└── store.ts
```

### 中等复杂度

中等复杂度存在 15 ~ 30 个 state、 10 ~ 20 个 action,大概率会存在 selector 来聚合派生状态,大概率存在 reducer 简化部分数据变更的复杂度。

此时结构,用单一的 action store 已经较难维护,往往会拆解出来多个 slice 用于管理不同的 action。

下方的代码代表了 `SortableTree` 组件的内部数据流:

```bash
SortableTree/store
├── index.ts
├── initialState.ts
├── selectors.ts
├── slices
├── crudSlice.ts
├── dndSlice.ts
└── selectionSlice.ts
├── store.ts
└── treeDataReducer.ts
```

解读几个关键文件:

1. `selectors.ts`:包含了一些用于从 Store 中获取派生状态的选择器函数。选择器函数可以根据需要聚合和计算派生状态,提供给组件使用;
2. `slices文件夹`:拆解出来的多个模块,用于管理不同的 action。每个模块一般包含一个 Slice,其中包含了相关的 action。
- `crudSlice.ts`:用于管理对树形数据进行增删改查的操作。
- `dndSlice.ts`:用于管理拖拽和排序相关的操作。
- `selectionSlice.ts`:用于管理选中状态相关的操作。
3. `treeDataReducer.ts`:用于管理树形数据的 reducer。它会处理一些与`treeData`相关的数据变化的原子操作,例如添加节点、删除节点、拖动节点等。

### 高等复杂度

高等复杂度存在 30 个以上的 state、 20 个以上的 action。必然需要 slice 做模块化内聚。在每个 slice 中都各自声明了各自的 initState、 action、reducer 与 selector。

下面是一个高复杂度的数据流示例 ([链接](https://github.com/lobehub/lobe-chat/tree/master/src/store/session)):

```bash
LobeChat SessionStore
├── index.ts
├── initialState.ts
├── selectors.ts
├── slices
│ ├── agentConfig
│ │ ├── action.ts
│ │ ├── index.ts
│ │ ├── initialState.ts
│ │ └── selectors.ts
│ ├── chat
│ │ ├── actions
│ │ │ ├── index.ts
│ │ │ ├── message.ts
│ │ │ └── topic.ts
│ │ ├── index.ts
│ │ ├── initialState.ts
│ │ ├── reducers
│ │ │ ├── message.ts
│ │ │ └── topic.ts
│ │ ├── selectors
│ │ │ ├── chat.ts
│ │ │ ├── index.ts
│ │ │ ├── token.ts
│ │ │ ├── topic.ts
│ │ │ └── utils.ts
│ │ └── utils.ts
│ └── session
│ ├── action.ts
│ ├── index.ts
│ ├── initialState.ts
│ ├── reducers
│ │ └── session.ts
│ └── selectors
│ ├── export.ts
│ ├── index.ts
│ └── list.ts
└── store.ts
```

上面这个数据流的目录结构看似复杂,但借助于 slice 的模块化和分形架构的心智,我们可以很容易地找到对应的模块,新增功能与迭代都很易于维护。

## 编辑器场景下的目录结构最佳实践

在编辑器场景下,我们应该默认它终将发展为一个高复杂度的数据流。因此,我们从一开始的目录架构上,就应该将数据流按照 slice 的模式进行拆分,每个 slice 都应该是一个独立的模块,包含自己的 initState、action、reducer 与 selector。

```bash
editorStore
├── index.ts
├── initialState.ts
├── selectors.ts
├── slices
│ ├── crud
│ │ ├── action.ts
│ │ ├── index.ts
│ │ ├── initialState.ts
│ │ └── selectors.ts
└── store.ts
```

### 顶层对象

`index.ts` 是一个聚合导出文件,不再赘述。

#### initialState

`initialState.ts` 中聚合了所有 slice 的 initialState 。

```ts
import { CRUDState, initialCRUDState } from './slices/crud';

export type EditorStoreState = CRUDState & AnotherState;

export const initialState: EditorStoreState = {
...initialCRUDState,
};
```

如果后续扩展为多个 slice,只需按同样的方式做聚合与导出相应的 state 即可。

#### selectors

selectors 将从各自的 slices 中导出,供外部直接调用。

```ts
export { crudSelectors } from './slices/crud';
export { chatSelectors, topicSelectors } from './slices/chat';
export { sessionSelectors } from './slices/session';
```

#### store

再来看看 `store.ts`

```ts
import { devtools } from 'zustand/middleware';
import { shallow } from 'zustand/shallow';
import { createWithEqualityFn } from 'zustand/traditional';
import { StateCreator } from 'zustand/vanilla';

import { EditorStoreState, initialState } from './initialState';
import { CRUDAction, createCRUDSlice } from './slices/crud';

// =============== 聚合 createStoreFn ============ //

export type EditorStore = CRUDAction & EditorStoreState;

const createStore: StateCreator<EditorStore, [['zustand/devtools', never]]> = (...parameters) => ({
...initialState,
...createCRUDSlice(...parameters),
});

// =============== 实装 useStore ============ //

export const useEditorStore = createWithEqualityFn<EditorStore>()(
devtools(createStore, {
name: 'EditorStore',
}),

shallow,
);
```

其中关键的代码是 `createStore` ,它用于聚合所有的 slice,返回一个完整的编辑器 Store 用于 store 初始化创建。

最后使用的 `createWithEqualityFn`函数中包裹了相关的中间件,我们默认使用了 `devtools` 中间件来提供开发者工具支持,并替换默认的比较函数为 `shallow`,用于优化性能。

### Slice 部分

每个 slice 的 initState 与 selector 和不切 slice 是类似的。因此不过多阐述, 重点看下 `slices/crud/action.ts`

```ts
import { StateCreator } from 'zustand/vanilla';

import { EditorStore } from '@/store/edtior';

export interface CrudAction {
// 相关的业务 action ...
}

export const createCrudSlice: StateCreator<
EditorStore,
[['zustand/devtools', never]],
[],
CrudAction
> = (set, get) => ({
// ...
});
```

通过 `import` 语句,接下来,代码定义了一个`CrudAction`接口,用于描述与业务相关的操作。

声明 `createCrudSlice`是一个函数,它接受`set``get`两个参数,并返回一个包含编辑器状态和业务操作的对象。

```ts
StateCreator<EditorStore, [['zustand/devtools', never]], [], CrudAction>;
```

其中的类型定义引入了`StateCreator`作为全量类型,最后的 CrudAction 则代表着这个 slice 中需要实现的 action。 中间的`[['zustand/devtools', never]]` 则意味着这个 slice 被 `devtools` 中间件包裹,因此 `set` 方法将可以接受中间件参数。

这是实现 Slice 模式下,并且保证类型始终友好的关键。

这样书写后,我们可以使用 `get()` 方法拿到全量 `EditorStore` 中的 state 和 action ,而这个 `createCRUDSlice` 只会在声明的 CrudAction 类型与实现不一致时提示错误,不会受到额外的干扰。

这样一来,每个 slice 就具有了感知所有状态和调用所有方法的能力,同时这个 slice 又只需要关注自己所定义的方法和状态,关注点成功被收敛。

## 功能研发

在 slice 分层模式基础下,我们就可以保证每个 slice 的功能模块可控,不会无限膨胀下去,同时可以按需拆成通用、业务或者内部 slice。

在框架达成可控后,我们就可以专注于功能的研发了。其中核心功能的研发工作量存在于两块, action 与 selector,先来讲讲 action 部分。

### action 研发

action 由于就是一个普通的函数方法,支持同步与异步,理论上是没有任何限制的。但是根据我们过去的实践经验来看,action 可以拆分为两类:

- 数据状态变动: 例如修改当前用户的状态信息,修改登录态、标记已查看引导等;
- 业务逻辑编排: 例如一个登录流程,会包含请求、成功判断、失败判断等等;

我们强烈建议将第一类数据状态变动,统一收敛到一个 `dispatchXXX` 的方法中,并将数据变更的逻辑,下沉为一个 `reducer`。在 SortableTree 中,收敛了一个`dispatchTreeData` 的方法:

```ts
const crudSlice = (set, get) => ({
dispatchTreeData: (payload) => {
const { treeData } = get();
const nextTreeData = treeDataReducer(treeData, payload);

set({ treeData: nextTreeData });
},
});
```

这样做的好处有什么?核心是保证了数据变更的原子性。数据变更的逻辑被下沉到了 reducer 中,可以避免数据变更的逻辑被分散到各个 action 中,导致数据变更的不可控。

而这也会相应带来若干好处:

1. **方便做受控能力**:当数据变更原子化之后,通过在 `dispatchTreeData` 最下方添加一个 `onTreeDataChange` ,即可实现数据状态的受控变更,将应用轻松转换为组件;
2. **数据变更语义化**:通过 dispatch 对应 payload 方法的命名,可以将原本比较难构造与变更的数据变更语义化,例如 `dispatchTreeData( { type: 'toggleCollapse', id:'123' })`,可以很容易理解为,将 id 为 123 的节点进行展开/收起操作;
3. **提升状态维护性**:统一 dispatch 的数据变更心智后,我们既可以在 store 外部调用 dispatch 来变更状态,也可以在 store 内部调用,统一的心智将大大降低维护成本;

### reducer 研发

此外,将数据变更逻辑下沉为 reducer 后, reducer 层面也会带来相应的好处。由于 reducer 只是一个纯函数,因此可以非常方便地实现相应的单元测试。而结合 AI ,我们可以实现一句话需求的 reducer 实现,同时也可以实现一键产出测试代码。在 reducer 上的研发与维护成本将会大大降低。

| 功能实现 | 单元测试 |
| ------------------------------------------------------------------------------------------------ | ----------------------------------------------------------------------------------------------------------------------------- |
| ![](https://gw.alipayobjects.com/zos/kitchen/qcmFMlllP/f588a003-6317-4ef2-9728-491c9bda3c05.png) | ![](https://github-production-user-asset-6210df.s3.amazonaws.com/28616219/261315145-01f8542f-e748-4334-b8fa-67b929fa1795.png) |

### selector 研发

selector 一般用于将 store 中的数据映射为页面上需要展示的内容。因此在 store 中存储的数据可以通过 selector 做诸多的转换。 例如:

```ts
import { currentChats, getChatsById } from './chat';
import { chatsTokenCount, systemRoleTokenCount, totalTokenCount } from './token';

export const chatSelectors = {
chatsTokenCount,
currentChats,
getChatsById,
systemRoleTokenCount,
totalTokenCount,
};
```

其中 `chatsTokenCount` 就是一个 selector,它的目的用于计算 message 中的 token 数,它会将 store 中的 chats 数组转化为一个数值提供给外部。

```ts
export const chatsTokenCount = (s: SessionStore): number[] => {
const chats = currentChatsWithHistoryConfig(s);
return encode(chats.map((m) => m.content).join('')).length;
};
```

由于 selector 本身只是一个函数,因此它也能够用非常简单的方式集成单元测试。

那在页面上使用 selector 也非常简单,直接按需引入即可(代码如下)。由于转换后的数据类型是 `number`,因此 react 感知到的状态类型也是 `number`,因此即使 chats 中发生了不影响计算结果的其他的状态变更(例如更新 message 的时间等),这个组件内部也不会触发重新渲染。

```tsx
const Token = memo<>(() => {
const [totalToken, systemRoleToken, chatsToken] = useSessionStore((s) => [
chatSelectors.totalTokenCount(s),
chatSelectors.systemRoleTokenCount(s),
chatSelectors.chatsTokenCount(s),
]);

return (
<Tooltip placement={'bottom'} title={('tokenDetail', { chatsToken, systemRoleToken })}>
<TokenTag value={totalToken + inputTokenCount} />
</Tooltip>
);
});
```

0 comments on commit 4419d30

Please sign in to comment.