[MobX State Tree数据组件化开发][2]:实例-TodoList

上一篇文章MST基础中简单地介绍了MST的几个基本概念和相关API,本文将带大家搭配React实现一个TodoList。

准备工作

为了省去枯燥的项目搭建过程,本文选择使用stackblitz平台来编辑我们的代码。

TS+React+MST Starter

同学们可以点击上面的地址fork一个starter项目,项目中已经配置好MST以及React相关的依赖,并且包含了一个简单的Counter demo,后面将在这个starter的基础上进行开发。

项目结构&规范说明

从上面的地址进入后,你会得到一个包含以下目录结构的初始项目。

其中,目录components用于存放React组件,目录models用于存放MST Model。

整个应用的Root Model在models/index.ts文件中定义:

import { types } from 'mobx-state-tree';
import { Counter } from './Counter';

export const Root = types
  .model('Root', {
    counter: types.optional(Counter, {}),
  });
复制代码

定义好的Root Model会在项目的入口index.tsx文件中被引入,并创建实例对象,然后使用mobx-react提供的Provider组件将Root Model的实例对象传递到应用的Context中:

import React from 'react';
import { render } from 'react-dom';
import { Provider } from 'mobx-react';
import { Root } from './models';
import { ModelInjector } from './components/ModelInjector';
import './style.css';

import { Counter } from './components/Counter';

const root = Root.create({});

const ConnectedCounter = () => (
  <ModelInjector>
    {(root) => <Counter model={root.counter}/>}
  </ModelInjector>
);

function App () {
  return (
    <Provider root={root}>
      <ConnectedCounter/>
    </Provider>
  );
}

render(<App />, document.getElementById('root'));
复制代码

项目提供了一个名为ModelInjector的组件,index.tsx代码中,使用ModelInjector组件对Counter组件进行了一个包装,将root.counter这个节点Model作为props传给了Counter组件。在本文以及后续的文章中,将会沿用这样的方式在组件与Model之间建立连接。

这样做的好处是,可以让TypeScript的静态类型约束覆盖到整个应用,开发过程中可以享受到类型带来的便利:

ModelInjector组件的实现比较简单,可以在项目中自行查看。

确定目标

在开始动手编码之前,必须明确要做的这个东西是什么样的。

我们要做的这款TodoList大家应该比较熟悉:

这款TodoList来自TodoMVC

由于本文的主题不在UI的实现上,我们可以复用他的DOM结构和CSS,这会省去不少功夫。

定义Model

明确要做什么之后,就可以着手开始分析这个应用的状态结构了。

TodoItem

从最基础的开始。TodoList的基本单位就是TodoItem,TodoItem具备的属性是他的id(用于编辑、删除时进行跟踪)、title,以及是否完成的标识done,所以可以得出:

// models/TodoItem.ts
import { types, Instance } from 'mobx-state-tree';

export const TodoItem = types
  .model('TodoItem', {
    id: types.string,
    title: types.string,
    done: types.boolean,
  })
  .actions(self => ({
    switchDone(done?: boolean) {
      if (typeof done === 'boolean') {
        self.done = done;
      } else {
        self.done = !self.done;
      }
    }
  }));

export type TodoItemInstance = Instance<typeof TodoItem>;
复制代码

新建models/TodoItem.ts文件,写入上面的代码。

细心的同学会发现,上面代码中还export了一个type定义TodoItemInstance。这个type表示的是TodoItem这个Model的实例类型,可以在定义React组件的props类型时使用。

TodoForm

应用还需要一个输入框,在新增的时候输入新TodoItem的title;以及一个可隐藏的输入框用来编辑已有TodoItem的title。

这两个输入框的功能相似,都是维护输入框的值并处理值的更新。不同的是编辑输入框会与某一个TodoItem关联,而新增输入框没有关联对象。

可以使用一个TodoForm的Model来维护两个输入框的状态:

// models/TodoForm.ts
import { types, Instance } from 'mobx-state-tree';
import { TodoItemInstance } from './TodoItem';

export const TodoForm = types
  .model('TodoForm', {
    value: types.optional(types.string, ''),
    targetTodoId: types.optional(types.maybeNull(types.string), null),
  })
  .views(self => ({
    get trimedValue () {
      return self.value.trim();
    },
    get valid() {
      return this.trimedValue.length > 0;
    }
  }))
  .actions(self => ({
    setTarget(target: TodoItemInstance) {
      self.value = target.title;
      self.targetTodoId = target.id;
    },
    update(value: string) {
      self.value = value;
    },
    reset() {
      self.value = '';
      self.targetTodoId = null;
    }
  }));

export type TodoFormInstance = Instance<typeof TodoForm>;
复制代码

TodoForm中,使用value维护输入框的值,targetTodoId表示当前关联的TodoItem的id。并提供了用于关联TodoItemsetTarget方法,更新值的update方法以及重置状态的reset方法。

这里使用了types.optional为状态设置了初始值。

另外还提供了两个计算值:trimedValue以及valid

这里需要注意的是,在valid的定义中,trimedValue引用的是this而不是self,这是由于valid以及trimedValue两者的定义写在同一个views方法中,views方法结束前,TypeScript的类型系统并不能观察到self对应的类型中包含valid或者trimedValue,所以需要使用this来代替self

除了views之外,actions或者volatile也需要注意上面这个问题。

TodoList

完成上面的两个Model之后,剩下的都是一些与列表相关的状态了。将TodoItem与TodoForm进行组合,构成整个TodoList应用的基本Model:

// models/TodoList.ts
import { types } from 'mobx-state-tree';
import { TodoItem } from './TodoItem';
import { TodoForm } from './TodoForm';

export const TodoList = types
  .model('TodoList', {
    adderForm: types.optional(TodoForm, {}),
    editorForm: types.optional(TodoForm, {}),
    list: types.array(TodoItem),
  });
复制代码

其中adderFormeditorForm分别表示新增Todo编辑Todo的表单Model,list用于管理Todo列表。

仔细观察目标的成品图,他还包括三个筛选按钮AllActiveCompleted,用于筛选展现的Todo列表的类型,这里可以将三种类型定义为枚举TodoFilterType,新建enums.ts文件,输入代码:

// enums.ts
export enum TodoFilterType {
  All = 'All',
  Active = 'Active',
  Completed = 'Completed'
}
复制代码

然后为TodoList新增一个filterType的状态:

// models/TodoList.ts
import { types } from 'mobx-state-tree';
import { TodoItem } from './TodoItem';
import { TodoForm } from './TodoForm';
import { TodoFilterType } from '../enums';

export const TodoList = types
  .model('TodoList', {
    adderForm: types.optional(TodoForm, {}),
    editorForm: types.optional(TodoForm, {}),
    list: types.array(TodoItem),
    filterType: types.optional(types.string, TodoFilterType.All),
  });
复制代码

有了这几个基础状态,就可以得到其他几个衍生状态:

// models/TodoList.ts
...
export const TodoList = types
  .model('TodoList', {
    ...
  })
  .views(self => ({
    // 已完成的Todo列表
    get doneList() {
      return self.list.filter(i => i.done);
    },
    // 未完成的Todo列表
    get activeList() {
      return self.list.filter(i => !i.done);
    },
    // 是否全部完成
    get isAllDone() {
      return this.doneList.length === self.list.length;
    },
    // 剩余未完成的Todo数量
    get activeCount() {
      return this.activeList.length;
    },
    // 当前展现的Todo列表
    get showList() {
      switch (self.filterType) {
        case TodoFilterType.Active:
          return this.activeList;
        case TodoFilterType.Completed:
          return this.doneList;
        default:
          return self.list;
      }
    },
    // 是否显示主体UI(没有Todo数据的时候只显示一个新增输入框)
    get isShowMain() {
      return self.list.length > 0;
    },
    // 是否包含已完成的Todo,用于控制右下角[Clear completed]按钮的展现和隐藏
    get hasDoneTodos() {
      return this.doneList.length > 0;
    }
  }))
复制代码

最后,再补上更新状态的actions:

// models/TodoList.ts
import { types, cast, Instance } from 'mobx-state-tree';
import uuid from 'uuid/v1';
...
export const TodoList = types
  .model('TodoList', {
    ...
  })
  .views(self => ({
    ...
  })
  .actions(self => ({
    // 切换全部完成/全部未完成
    switchAllDone(done?: boolean) {
      if (typeof done !== 'boolean') {
        done = !self.isAllDone;
      }
      self.list.forEach(item => {
        item.switchDone(done);
      });
    },
    // 切换列表过滤类型
    setFilterType(filterType: TodoFilterType) {
      self.filterType = filterType;
    },
    // 新增Todo
    addTodo() {
      if (self.adderForm.valid) {
        self.list.push(cast({
          id: uuid(),
          title: self.adderForm.trimedValue,
          done: false
        }));
        self.adderForm.reset();
      }
    },
    // 更新Todo
    updateTodo() {
      if (self.editorForm.valid) {
        const item = self.list.find(i => i.id === self.editorForm.targetTodoId);
        if (item) {
          item.title = self.editorForm.trimedValue;
        }
        self.editorForm.reset();
      }
    },
    // 删除Todo
    removeTodo(todoId: string) {
      const index = self.list.findIndex(i => i.id === todoId);
      if (index >= 0) {
        self.list.splice(index, 1);
      }
    },
    // 清除已完成Todos
    clearDone() {
      self.list = cast(self.list.filter(i => !i.done));
    }
  }));
  
export type TodoListInstance = Instance<typeof TodoList>;
复制代码

上面的代码在给一些状态赋值的时候,用到了MST提供的cast方法,这个方法仅在TypeScript中有意义,因为他仅仅是将入参的类型转换成对应的状态的类型,使得代码的类型能通过TypeScript的检测(因为在TypeScript看来,没有cast的时候,等号左侧和右侧的两个值并不是类型匹配的)。

另外,在新增Todo的时候,使用了uuid库提供的方法生成Todo的唯一id。注意在项目中安装uuid依赖。

本实例中还依赖了classnames库,也需要一并安装。由于uuid以及classnames库都不包含类型定义文件(*.d.ts),在项目中新增了一个modules.d.ts文件,代码如下:

// modules.d.ts
declare module 'classnames';
declare module 'uuid/v1';
复制代码

更新Root

要在应用中使用上面定义的Model,还需要将他们加入到状态树中,更新models/index.ts文件:

// models/index.ts
import { types } from 'mobx-state-tree';
import { TodoList } from './TodoList';

export const Root = types
  .model('Root', {
    todoList: types.optional(TodoList, {}),
  });
复制代码

至此,这个TodoList实例的状态树就构造完成了。

实现UI组件

UI方面并不是本系列文章的重点,并且本文TodoList的UI实现比较简单,套用了TodoMVC的DOM结构和CSS,本文中只对几个关键的点做一下说明,完整的代码见文末。

尽可能地使用observer装视组件

使用mobx-react包提供的observer装饰器装饰后的组件能响应observable的变化,并做了诸多的性能优化,尽可能为你的组件加上observer,除非你想要自定义shouldComponentUpdate来控制组件更新时机。

尽可能地编写无状态组件

有的同学可能看到过类似这样的说法:

用Redux管理全局状态,用组件State管理局部状态。

笔者不认同这种说法,根据笔者的经验来看,当项目复杂到一定的程度,使用State管理的状态会难受到让你抓狂:某个深层次的组件State只能通过改变上层组件传递的props来进行更新

更何况,现在无状态(state less)组件越来越受到大家的认可,react hooks的出现也顺应了组件无状态的这个发展趋势。

当应用都由无状态组件构成,应用的状态都存储在触手可及的地方(如Redux或MST),想要在某些时刻修改某个状态值就变得轻而易举。

这也是上文中,将输入框的值维护在TodoForm中的一个重要原因。

在线运行&完整代码

完整代码可点击此处查看,或者直接查看运行结果

小结

本文使用MST搭配React构建了一个完整的TodoList应用,不知道同学们有没有体会到MST的魅力:

  • 简单
  • 最小化State
  • 可组合
  • 可复用

当然,本文只是一个开胃菜,还有更多优雅的特性等待后面的文章中慢慢去挖掘。

喜欢本文欢迎关注和收藏,转载请注明出处,谢谢支持。

猜你喜欢

转载自juejin.im/post/5c4fb3ecf265da613a546378