[MobX State Tree資料元件化開發][2]:例項-TodoList

awaw00發表於2019-01-30

?系列文章目錄?

上一篇文章MST基礎中簡單地介紹了MST的幾個基本概念和相關API,本文將帶大家搭配React實現一個TodoList。

準備工作

為了省去枯燥的專案搭建過程,本文選擇使用stackblitz平臺來編輯我們的程式碼。

TS+React+MST Starter

同學們可以點選上面的地址fork一個starter專案,專案中已經配置好MST以及React相關的依賴,並且包含了一個簡單的Counter demo,後面將在這個starter的基礎上進行開發。

專案結構&規範說明

從上面的地址進入後,你會得到一個包含以下目錄結構的初始專案。

[MobX State Tree資料元件化開發][2]:例項-TodoList

其中,目錄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的靜態型別約束覆蓋到整個應用,開發過程中可以享受到型別帶來的便利:

[MobX State Tree資料元件化開發][2]:例項-TodoList

ModelInjector元件的實現比較簡單,可以在專案中自行檢視。

確定目標

在開始動手編碼之前,必須明確要做的這個東西是什麼樣的。

我們要做的這款TodoList大家應該比較熟悉:

[MobX State Tree資料元件化開發][2]:例項-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
  • 可組合
  • 可複用

當然,本文只是一個開胃菜,還有更多優雅的特性等待後面的文章中慢慢去挖掘。

喜歡本文歡迎關注和收藏,轉載請註明出處,謝謝支援。

相關文章