状态管理

icejs 内置了状态管理方案,并在此基础上进一步遵循 “约定优于配置” 原则,进行抽象和封装,使得状态管理变得非常容易。

全局应用状态

定义 Model

约定全局状态位于 src/models 目录,目录结构如下:

  1. src
  2. ├── models // 全局状态
  3. | ├── counter.ts
  4. └── user.ts
  5. └── store.ts

假设我们需要全局管理用户状态,定义模型如下:

  1. // src/models/user.ts
  2. export const delay = (time) => new Promise((resolve) => setTimeout(() => resolve(), time));
  3. export default {
  4. // 定义 model 的初始 state
  5. state: {
  6. name: '',
  7. id: '',
  8. },
  9. // 定义改变该模型状态的纯函数
  10. reducers: {
  11. update(prevState, payload) {
  12. return {
  13. ...prevState,
  14. ...payload,
  15. };
  16. },
  17. },
  18. // 定义处理该模型副作用的函数
  19. effects: (dispatch) => ({
  20. async getUserInfo() {
  21. await delay(1000);
  22. dispatch.user.update({
  23. name: 'taobao',
  24. id: '123',
  25. });
  26. },
  27. }),
  28. };

初始化 Store

  1. // src/store.ts
  2. import { createStore } from 'ice';
  3. import user from './models/user';
  4. import project from './models/project';
  5. const store = createStore(
  6. {
  7. user,
  8. project,
  9. },
  10. {
  11. // options
  12. },
  13. );
  14. export default store;

详细文档请参考 API-createStore

在 View 中使用模型状态

  1. + import store from '@/store';
  2. const HomePage = () => {
  3. + const [userState, userDispatchers] = store.useModel('user');
  4. return (
  5. <>
  6. <span>{userState.id}</span>
  7. <span>{userState.name}</span>
  8. </>
  9. );
  10. }

页面级状态

页面状态只能在该页面下的组件中使用,无法跨页面使用。

非嵌套页面

大部分场景中一个 pages/Foo 目录对应一个路由,即非嵌套页面,此时约定页面状态在 src/pages/*/models 中定义。

目录组织如下:

  1. src
  2. ├── models // 全局状态
  3. └── user.ts
  4. └── pages
  5. | ├── Home // Home 页面
  6. +| ├── models // 页面状态
  7. +| | ├── foo.ts
  8. +| | └── bar.ts
  9. +| ├── store.ts
  10. | └── index.tsx
  11. └── app.ts

定义模型如下:

  1. // src/pages/Home/models/foo.ts
  2. export default {
  3. state: {
  4. title: 'Hello',
  5. },
  6. };

初始化 Store 实例:

  1. // src/pages/Home/store.ts
  2. import { createStore } from 'ice';
  3. import foo from './models/foo';
  4. const store = createStore({ foo });
  5. export default store;

在页面组件中使用模型状态:

  1. // 引用页面状态
  2. import pageStore from '@/pages/Home/store';
  3. const HomePage = () => {
  4. const [pageState, pageDispatchers] = pageStore.useModel('foo');
  5. return (
  6. <>{pageState.title}</> // Hello
  7. );
  8. };

嵌套页面

某些复杂场景会出现嵌套页面的情况,即 src/pages/Home包含多个路由页面,目录组织如下:

  1. src
  2. └── pages
  3. ├── Home // Home 页面包含了 A、B 等多个路由页面
  4. ├── HomeA
  5. └── index.tsx
  6. ├── HomeB
  7. └── index.tsx
  8. ├── Layout // 页面布局
  9. └── index.tsx
  10. ├── models // 页面状态
  11. ├── Foo.ts
  12. └── Bar.ts
  13. └── store.ts
  14. └── app.ts

对于嵌套页面,框架会将 store 的 Provider 包裹在 Layout/index.tsx 上,此时所有嵌套的页面以及组件都可以访问到这里的 store。Layout/index.tsx 内容如下:

  1. // Layout 中可以定义这些嵌套页面共用的布局,如果没有共用布局则直接渲染 children 即可
  2. export default ({ children }) => {
  3. return <>{children}</>;
  4. };

同时配置在 src/routes.ts 中:

  1. // src/routes.ts
  2. +import HomeLayout from '@/pages/Home/Layout';
  3. +import HomeA from '@/pages/Home/HomeA';
  4. +import HomeB from '@/pages/Home/HomeB';
  5. import About from '@/pages/About';
  6. export default [
  7. {
  8. path: '/',
  9. component: BasicLayout,
  10. children: [
  11. {
  12. path: '/home',
  13. + component: HomeLayout,
  14. + children: [
  15. + {
  16. + path: '/a',
  17. + component: HomeA
  18. + }
  19. + ]
  20. },
  21. {
  22. path: '/about',
  23. component: About,
  24. }
  25. ]
  26. }
  27. ]

高阶用法

设置初始状态

假设我们有 models/user.tsmodels/counter.ts 两个模型,可以通过 runApp() 中的 store.initialStates 设置初始状态:

  1. runApp({
  2. app: {},
  3. store: {
  4. // 可选,初始化状态
  5. initialStates: {
  6. user: { name: 'foo' },
  7. counter: { count: 0 }
  8. },
  9. },
  10. });

如果初始的状态数据需要异步获取,则需要结合 app.getInitialData() 实现:

  1. runApp({
  2. app: {
  3. getInitialData: async (ctx) => {
  4. const { username, count } = await request.get('/api/data');
  5. return {
  6. // initialStates 是约定好的字段,会透传给 store 的初始状态
  7. initialStates: {
  8. user: { name: username },
  9. counter: { count }
  10. }
  11. }
  12. }
  13. },
  14. });

注意:页面级状态目前不支持设置 initialStates

TypeScript 类型提示

编写类型有助于更好的代码提示,类型定义步骤如下:

  • 创建 Store 实例
  1. // src/store.ts
  2. import { createStore, IStoreModels, IStoreDispatch, IStoreRootState } from 'ice';
  3. import user from './models/user';
  4. import porject from './models/porject';
  5. +interface IAppStoreModels extends IStoreModels {
  6. + user: typeof user;
  7. + project: typeof project;
  8. +};
  9. +const appModels: IAppStoreModels = {
  10. + user,
  11. + project,
  12. +};
  13. export default createStore(appModels);
  14. +export type IRootDispatch = IStoreDispatch<typeof appModels>;
  15. +export type IRootState = IStoreRootState<typeof appModels>;
  • 定义状态模型
  1. // src/models/user.ts
  2. +import { IRootState, IRootDispatch } from '@/store';
  3. const user = {
  4. state: [],
  5. reducers: {},
  6. + effects: ((dispatch: IRootDispatch) => ({
  7. + like(payload, rootState: IRootState) {
  8. + dispatch.project.foo(payload); // 调用其他 model 的 effects/reducers
  9. + rootState.project.title; // 获取其他 model 的 state
  10. + }
  11. + })
  12. };

Model 定义详细说明

如上示例所述,icejs 约定在 src/modelssrc/pages/*/models 目录下的文件为项目定义的模型文件,每个文件需要默认导出一个对象。

通常模型定义包括 state、reducers、effects 三部分:

  1. export default {
  2. state: {},
  3. reducers: {},
  4. effects: {},
  5. };

state

model 的初始 state

  1. export default {
  2. + state: { count: 0 }
  3. }

reducers

  1. reducers: { [string]: (prevState, payload) => any }

一个改变该模型状态的函数集合。这些方法以模型的上一次 prevState 和一个 payload 作为入参,在方法中使用可变的方式来更新状态。这些方法应该是仅依赖于 prevState 和 payload 参数来计算下一个 nextState 的纯函数。对于有副作用的函数,请使用 effects。

  1. export default {
  2. state: { count: 0, list: [] },
  3. + reducers: {
  4. + increment (prevState, payload) {
  5. + const newList = prevState.list.slice();
  6. + newList.push(payload);
  7. + const newCount = prevState.count + 1;
  8. + return { ...prevState, count: newCount, list: newList }
  9. + },
  10. + decrement (prevState) {
  11. + return { ...prevState, count: prevState.count - 1 }
  12. + }
  13. + }
  14. }

effects

  1. effects: (dispatch) => ({ [string]: (payload, rootState) => void })

一个可以处理该模型副作用的函数集合。这些方法以 payload 和 rootState 作为入参,适用于进行异步调用、模型联动等场景。

  1. export default {
  2. state: { count: 0 },
  3. reducers: {
  4. increment (prevState) {
  5. return {
  6. ...prevState,
  7. count: prevState.count + 1
  8. }
  9. },
  10. decrement (prevState) {
  11. return {
  12. ...prevState,
  13. count: prevState.count - 1
  14. }
  15. }
  16. },
  17. + effects: (dispatch) => ({
  18. + async asyncDecrement() {
  19. + await delay(1000); // 进行一些异步操作
  20. + this.increment(); // 调用模型 reducers 内的方法来更新状态
  21. + },
  22. + }),
  23. };

Model 之间通信

注意:如果两个 model 不属于同一个 store 实例,是无法通信的

  1. // src/models/user
  2. export default {
  3. state: {
  4. name: '',
  5. tasks: 0,
  6. },
  7. effects: () => ({
  8. async refresh() {
  9. const data = await fetch('/user');
  10. + // 通过 this.foo 调用自身的 reducer
  11. + this.setState(data);
  12. },
  13. }),
  14. };
  15. // src/models/tasks
  16. export default {
  17. state: [],
  18. effects: (dispatch) => ({
  19. async refresh() {
  20. const data = await fetch('/tasks');
  21. this.setState(data);
  22. },
  23. async add(task) {
  24. await fetch('/tasks/add', task);
  25. + // 调用另一个 model user 的 effects
  26. + await dispatch.user.refresh();
  27. + // 通过 this.foo 调用自身的 effects
  28. + await this.refresh();
  29. },
  30. }),
  31. };

在 effects 里的 action 方法中可以通过 dispatch[model][action] 拿到其他模型所定义的方法。

如果遇到 this.foo 的 ts 类型错误,请参考文档 icestore QA 进行修复

setState 是 icestore 内置的一个 reducer,可以直接使用

Model 中使用 immer 更改 state

Redux 默认的函数式写法在处理一些复杂对象的 state 时会非常繁琐,因此 icejs 同时支持了使用 immer 来操作 state:

  1. export default {
  2. state: {
  3. tasks: ['A Task', 'B Task'],
  4. detail: {
  5. name: 'Bob',
  6. age: 3,
  7. },
  8. },
  9. reducers: {
  10. addTasks(prevState, payload) {
  11. - return {
  12. - ..prevState,
  13. - tasks: [ ...prevState.tasks, payload ],
  14. - },
  15. + prevState.tasks.push(payload);
  16. },
  17. updateAge(prevState, payload) {
  18. - return {
  19. - ..prevState,
  20. - detail: {
  21. - ...prevState.detail,
  22. - age: payload,
  23. - },
  24. - },
  25. + prevState.detail.age = payload
  26. }
  27. }
  28. }

注意:因为 immer 无法支持字符串或数字这样的简单类型,因此如果 state 符合这种情况(极少数)则不支持通过 immer 操作,必须使用 Redux 默认的函数式写法(返回一个新值):

  1. const count = {
  2. state: 0,
  3. reducers: {
  4. add(prevState) {
  5. - state += 1;
  6. + return state += 1;
  7. },
  8. },
  9. }

获取 effects 的状态 loading/error

通过 useModelEffectsState API 即可获取到 effects 的 loading 和 error 状态。

  1. import store from '@/store';
  2. function FunctionComponent() {
  3. const [state, dispatchers] = store.useModel('counter');
  4. + const effectsState = store.useModelEffectsState('counter');
  5. useEffect(() => {
  6. dispatchers.asyncDecrement();
  7. }, []);
  8. + console.log(effectsState.asyncDecrement.isLoading); // loading
  9. + console.log(effectsState.asyncDecrement.error); // error
  10. }

在 Class Component 中使用

useModel 相关的 API 基于 React 的 Hooks 能力,仅能在 Function Component 中使用,通过 withModel API 可以实现在 Class Component 中使用。

  1. import store from '@/store';
  2. class TodoList extends React.Component {
  3. render() {
  4. const { todos } = this.props;
  5. const [state, dispatchers] = todos;
  6. // ...
  7. }
  8. }
  9. export default store.withModel('todos')(TodoList);
  10. // 绑定多个 model
  11. // export default withModel('user')(withModel('todos')(TodoList));

同时,也可以使用 withModelDispatchers 以及 withModelEffectsState API。

完整 API 文档

Redux Devtools

icejs 中默认集成了 Redux Devtools,不需要额外的配置就可以通过 Redux Devtools 调试:

状态管理 - 图1

如果想要定义 Devtools 的参数,可以查看上面 createStore 的 options 说明。

在其他地方使用 store

满足以下几种情况,框架都会自动帮助开发者包裹 store.Provider

  • SPA 全局 store:src/ 下有 store.tsapp.ts
  • SPA 页面级 store:src/pages/Home 下有 store.tsindex.tsx
  • SPA 嵌套页面级 store:src/pages/Home 下有 store.tsLayout/index.tsx(优先级低于上面)
  • MPA 组件类型的 entry:src/pages/Home 下有 store.tsindex.tsx
  • MPA 单页类型的 entry:src/pages/Home 下有 store.ts, app.ts, Layout/index.tsx

如果不满足上述情况,则需要开发者自行包裹 store.Provider。比如希望在 src/pages/Home/Foo/ 下创建一个 store:

  1. src/pages/Home/Foo/models/ 下定义 model
  2. src/pages/Home/Foo/store.ts 中初始化 store
  3. 新增步骤:src/pages/Home/Foo/index.tsx 中包裹 store.Provider
  1. // src/pages/Home/Foo/index.tsx
  2. import store from './store';
  3. const { Provider } = store;
  4. export default () => {
  5. return (
  6. <Provider>
  7. <Child />
  8. </Provider>
  9. );
  10. };
  11. function Child() {
  12. const [state, actions] = store.useModel('foo');
  13. return <></>;
  14. }

使用其他状态管理方案

icejs 默认使用 @ice/store 作为状态管理方案,如需使用其他方案,需要在 build.json 中通过选项关闭默认方案:

  1. {
  2. "store": false
  3. }

此时项目不会再引入 @ice/store 相关的各种能力,包含上述的自动包裹 Provider 等,此时就可以灵活的引入其他状态管理方案了。

版本变更说明

内置的 immer 从 6.x 升级到最新版本 9.x

icejs 2.0.0 版本升级

不再自动初始化 store

1.9.7 版本标记废弃,2.0.0 版本完全移除

推荐开发者自行创建 store.ts 并在其中初始化 store,这样可以更灵活的定制一些参数,相对之前方案带来的改变:

  • 开发者需要在 models/ 同层目录自行创建 store.ts 并初始化 store 实例:
  1. src
  2. └── models
  3. | ├── foo.ts
  4. | └── bar.ts
  5. +|── store.ts
  6. └── app.ts
  1. // src/store.ts
  2. import { createStore } from 'ice';
  3. import user from './models/user';
  4. import project from './models/project';
  5. const store = createStore({
  6. user,
  7. project,
  8. });
  9. export default store;
  • 引入 store 的路径发生了变化:
  1. // 全局状态
  2. - import { store } from 'ice';
  3. + import store from '@/store';
  4. // 页面级状态
  5. - import { store } from 'ice/Home';
  6. + import store from '@/pages/Home/store';

Model 中不再支持 actions: {} 写法

1.7.0 版本标记废弃,2.0.0 版本完全移除

将原先的 actions: {} 拆分为 effects: () => {}reducers: {} 两个字段:

  1. const counter = {
  2. state: { value: 0 },
  3. - actions: {
  4. - increment:(state) => ({ value: state.value + 1 }),
  5. - async asyncIncrement(state, payload, actions, globalActions) {},
  6. - }
  7. + reducers: {
  8. + increment:(prevState) => ({ value: prevState.value + 1 }),
  9. + },
  10. + effects: (dispatch) => ({
  11. + async asyncIncrement(payload, rootState) {},
  12. + }),
  13. }

不再支持 store.getInitialStates()

1.7.0 版本标记废弃,2.0.0 版本完全移除

推荐使用 store.initialStates

路由切换后重新初始化页面状态

icejs 1.0 中有一个「错误」的设计,切换页面再次进入原页面后页面状态不会重新初始化,如需重新初始化需要主动配置:

  1. {
  2. "store": {
  3. "resetPageState": true
  4. }
  5. }

icejs 2.0 版本将此默认行为进行了修正,切换页面再次进入原页面后会重新初始化页面状态,如果希望跟 1.0 表现一致,则需要主动配置:

  1. {
  2. "store": {
  3. "disableResetPageState": true
  4. }
  5. }