设计store

Store 的职责是将组件使用的业务逻辑和状态封装到单独的模块中,这样组件就可以专注于 UI 渲染。第 10 章介绍 state 时已经提到,state 可以分为三类:与领域直接相关的领域状态数据、反映应用行为(登录状态、当前是否有 API 请求等)的应用状态数据和代表 UI 状态的 UI 状态数据。后两种 state 一般不会涉及太多逻辑,仅仅是关于应用、UI 的一些松散状态的读取和简单修改,封装这两种 state 的 store 实现也很直观。领域 state 的数据结构较复杂,且往往涉及较多的逻辑处理。领域 state 可以使用普通对象来描述,也可以使用 class 来描述,例如描述一个待办任务 todo:

// 使用普通对象
var todo = { id: 1, title: "Todo1", finished: false }

// 使用class
class Todo {
    id;
    title;
    finished;
}

使用 class 比使用普通对象描述 state 有一些优势:

  1. class 内可以定义方法,可以自己保存上下文信息而不依赖外部,因此 class 描述的 state 比普通对象描述的 state 更容易被单独使用。

  2. class 内可以方便地混合使用可观测属性和非可观测属性,例如,在 Todo class 中,我们希望只有 title 和 finished 是可观测的,那么只需要在这两个属性前使用 @observable,id 继续作为不可观测属性使用。

  3. class 描述的 state 辨识度高且容易进行类型校验。

所以,稍复杂的领域 state 都建议大家使用 class 来描述。

一个领域 store 对应应用中的一个简单的领域概念,这个领域的 state 和 state 的管理都由这个 store 负责。具体来讲,领域 store 的职责有:

  1. 实例化领域 state,并且保证领域 state 知道它属于哪一个 store。

  2. 每一个领域 store 在应用中只能有一个实例对象,例如,应用中不能有两个 todoList store。

  3. 更新领域 state,无论是通过服务器端获取,还是来自纯客户端的修改。

根据上面的介绍,我们可以为 BBS 创建 5 个 store:AppStore、AuthStore、UIStore、PostsStore、CommentsStore。AppStore 和 AuthStore 是应用状态 store,UIStore 是 UI store,PostsStore 和 CommentsStore 是领域 store。另外,还可以创建 PostModel 和 CommentModel 两个 class,代表领域 state。下面就来逐一分析每个 store。

AppStore

AppStore 管理的 state 包括应用当前的请求数量 requestQuantity 和应用的错误信息 error:

class AppStore {
  @observable requestQuantity = 0;
  @observable error = null;

  //...
}

requestQuantity 间接决定界面上是否需要显示 Loading 效果,因此可以创建一个 computed value 直接标识是否需要显示 Loading 效果:

class AppStore {
    @computed get isLoading() {
        return this.requestQuantity > 0;
    }

    // ...
}

最后,为 AppStore 添加修改 state 的 action,完整代码如下:

import {observable, computed, action} from "mobx";

class AppStore {
    @observable requestQuantity = 0;
    @observable error = null;

    @computed get isLoading() {
        return this.requestQuantity > 0;
    }

    // 当前进行的请求数量加1
    @action increaseRequest() {
        this.requestQuantity++;
    }

    // 当前进行的请求数量减1
    @action decreaseRequest() {
        if (this.requestQuantity > 0)
            this.requestQuantity--;
    }

    // 设置错误信息
    @action setError(error) {
        this.error = error;
    }

    // 删除错误信息
    @action.bound removeError() {
        this.error = null;
    }
}

export default AppStore;

这里,removeError 使用 @action.bound 绑定 this,因为存在 removeError 不是通过 AppStore 实例调用的场景,而是直接作为组件的回调函数被使用。为了保证 removeError 中的 this 一直指向的是 AppStore 的实例对象,所以使用了 @action.bound。

AuthStore

AuthStore 负责用户的登录认证,使用到的 state 包括 userId、username 和 password,除了包含直接修改这几个 state 的 action 外,还定义了登录和注销两个 action,这两个 action 涉及网络请求,所以又会改变 AppStore 的 state,我们通过构造函数把 AppStore 的实例和登录 API 传递给 AuthStore,代码如下:

import {observable, action} from "mobx";

class AuthStore {
    api;
    appStore;
    @observable userId = sessionStorage.getItem("userId");
    @observable username = sessionStorage.getItem("username") || "jack";
    @observable password = "123456";

    constructor(api, appStore) {
        this.api = api;
        this.appStore = appStore;
    }

    @action setUsername(username) {
        this.username = username;
    }

    @action setPassword(password) {
        this.password = password;
    }

    @action login() {
        this.appStore.increaseRequest();
        const params = {username: this.username, password: this.password};
        return this.api.login(params).then(action(data => {
            this.appStore.decreaseRequest();
            if (!data.error) {
                this.userId = data.userId;
                sessionStorage.setItem("userId", this.userId);
                sessionStorage.setItem("username", this.username);
                return Promise.resolve();
            } else {
                this.appStore.setError(data.error);
                return Promise.reject();
            }
        }));
    }

    @action.bound logout() {
        this.userId = undefined;
        this.username = 'jack';
        this.password = '123456';
        sessionStorage.removeItem("userId");
        sessionStorage.removeItem("username");
    }
}

export default AuthStore;

UIStore

UIStore 负责新建帖子和编辑帖子两个 UI 状态的控制,代码很简单,不多做解释:

import {observable, action} from "mobx";

class UIStore {
    @observable addDialogOpen = false;
    @observable editDialogOpen = false;

    // 设置新建帖子编辑框的状态
    @action setAddDialogStatus(status) {
        this.addDialogOpen = status
    }

    // 设置修改帖子编辑框的状态
    @action setEditDialogStatus(status) {
        this.editDialogOpen = status
    }
}

export default UIStore;

PostsStore

PostsStore 负责帖子对应的领域,我们单独创建一个 class PostModel,用来描述帖子对应的 state:

import {observable, action} from "mobx";

class PostModel {
    store; // PostModel 实例对象所属的 store
    id;
    @observable title;
    @observable content;
    @observable vote;
    @observable author;
    @observable createdAt;
    @observable updatedAt;

    constructor(store, id, title, content, vote, author, createdAt, updatedAt) {
        this.store = store;
        this.id = id;
        this.title = title;
        this.content = content;
        this.vote = vote;
        this.author = author;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }

    // 根据JSON对象更新帖子
    @action updateFromJS(json) {
        this.title = json.title;
        this.content = json.content;
        this.vote = json.vote;
        this.author = json.author;
        this.createdAt = json.createdAt;
        this.updatedAt = json.updatedAt;
    }

    // 静态方法,创建新的PostModel实例
    static fromJS(store, object) {
        return new PostModel(
            store,
            object.id,
            object.title,
            object.content,
            object.vote,
            object.author,
            object.createdAt,
            object.updatedAt
        );
    }

}

export default PostModel;

PostModel 包含帖子标题、内容、作者等可观测属性,action updateFromJS 用于根据服务器端返回的数据更新 PostModel 实例,静态方法 fromJS 用于根据服务器端返回的数据构造 PostModel 的实例,这两个方法在 PostsStore 中都要用到。

PostsStore 中保存一个可观测的数组 posts,posts 的元素是 PostModel 的实例,PostsStore 的 action 包括获取帖子列表、获取帖子详情、新建帖子和修改帖子,代码如下:

import {observable, action, toJS} from "mobx";
import PostModel from "../models/PostModel";

class PostsStore {
    api;
    appStore;
    authStore;
    @observable posts = [];  // 数组的元素是PostModel的实例

    constructor(api, appStore, authStore) {
        this.api = api;
        this.appStore = appStore;
        this.authStore = authStore;
    }

    // 根据帖子id,获取当前store中的帖子
    getPost(id) {
        return this.posts.find(item => item.id === id);
    }

    // 从服务器获取帖子列表
    @action fetchPostList() {
        this.appStore.increaseRequest();
        return this.api.getPostList().then(
            action(data => {
                this.appStore.decreaseRequest();
                if (!data.error) {
                    this.posts.clear();
                    data.forEach(post => this.posts.push(PostModel.fromJS(this, post)));
                    return Promise.resolve();
                } else {
                    this.appStore.setError(data.error);
                    return Promise.reject();
                }
            })
        );
    }

    // 从服务器获取帖子详情
    @action fetchPostDetail(id) {
        this.appStore.increaseRequest();
        return this.api.getPostById(id).then(
            action(data => {
                this.appStore.decreaseRequest();
                if (!data.error && data.length === 1) {
                    const post = this.getPost(id);
                    // 如果store中当前post已存在,更新post;否则,添加post到store
                    if (post) {
                        post.updateFromJS(data[0]);
                    } else {
                        this.posts.push(PostModel.fromJS(this, data[0]));
                    }
                    return Promise.resolve();
                } else {
                    this.appStore.setError(data.error);
                    return Promise.reject();
                }
            })
        );
    }

    // 新建帖子
    @action createPost(post) {
        const content = {...post, author: this.authStore.userId, vote: 0};
        this.appStore.increaseRequest();
        return this.api.createPost(content).then(
            action(data => {
                this.appStore.decreaseRequest();
                if (!data.error) {
                    this.posts.unshift(PostModel.fromJS(this, data));
                    return Promise.resolve();
                } else {
                    this.appStore.setError(data.error);
                    return Promise.reject();
                }
            })
        );
    }

    // 更新帖子
    @action updatePost(id, post) {
        this.appStore.increaseRequest();
        return this.api.updatePost(id, post).then(
            action(data => {
                this.appStore.decreaseRequest();
                if (!data.error) {
                    const oldPost = this.getPost(id);
                    if (oldPost) {
                        /* 更新帖子的API,返回数据中的author只包含authorId,
                           因此需要从原来的post对象中获取完整的author数据。
                           toJS是MobX提供的函数,用于把可观测对象转换成普通的JS对象。 */
                        data.author = toJS(oldPost.author);
                        oldPost.updateFromJS(data);
                    }
                    return Promise.resolve();
                } else {
                    this.appStore.setError(data.error);
                    return Promise.reject();
                }
            })
        );
    }
}

export default PostsStore;

CommentsStore

CommentsStore 负责评论对应的领域,与 PostsStore 相同,先创建 class CommentModel,描述评论对应的 state:

import {observable} from "mobx";

class CommentModel {
    store;
    id;
    @observable content;
    @observable author;
    @observable createdAt;
    @observable updatedAt;

    constructor(store, id, content, author, createdAt, updatedAt) {
        this.store = store;
        this.id = id;
        this.content = content;
        this.author = author;
        this.createdAt = createdAt;
        this.updatedAt = updatedAt;
    }

    static fromJS(store, object) {
        return new CommentModel(
            store,
            object.id,
            object.content,
            object.author,
            object.createdAt,
            object.updatedAt
        );
    }
}

export default CommentModel;

CommentsStore 中保存一个可观测的数组 comments,comments 的元素是 CommentModel 的实例。CommentsStore 中定义的 action 包括获取某个帖子的评论列表和新建评论,代码如下:

import {observable, action} from "mobx";
import CommentModel from "../models/CommentModel";

class CommentsStore {
    api;
    appStore;
    authStore;
    @observable comments = [];   // 数组的元素是CommentModel的实例

    constructor(api, appStore, authStore) {
        this.api = api;
        this.appStore = appStore;
        this.authStore = authStore;
    }

    // 获取评论列表
    @action fetchCommentList(postId) {
        this.appStore.increaseRequest();
        return this.api.getCommentList(postId).then(action(data => {
            this.appStore.decreaseRequest();
            if (!data.error) {
                this.comments.clear();
                data.forEach(item => this.comments.push(CommentModel.fromJS(this, item)));
                return Promise.resolve();
            } else {
                this.appStore.setError(data.error);
                return Promise.reject();
            }
        }));
    }

    // 新建评论
    @action createComment(content) {
        this.appStore.increaseRequest();
        return this.api.createComment(content).then(action(data => {
            this.appStore.decreaseRequest();
            if (!data.error) {
                this.comments.unshift(CommentModel.fromJS(this, data));
                return Promise.resolve();
            } else {
                this.appStore.setError(data.error);
                return Promise.reject();
            }
        }));
    }
}

export default CommentsStore;

合并store

通常会把使用到的多个 store 再次合并成一个根 store,利用 mobx-react 提供的高阶组件 Provider 将根 store 注入组件树中。我们在 stores/index.js 中完成这项工作:

import AppStore from "./AppStore";
import AuthStore from "./AuthStore";
import PostsStore from "./PostsStore";
import CommentsStore from "./CommentsStore";
import UIStore from "./UIStore";
import authApi from "../api/authApi";
import postApi from "../api/postApi";
import commentApi from "../api/commentApi";

// 每个 store 在应用中只存在一个实例对象
const appStore = new AppStore();
const authStore = new AuthStore(authApi, appStore);
const postsStore = new PostsStore(postApi, appStore, authStore);
const commentsStore = new CommentsStore(commentApi, appStore, authStore);
const uiStore = new UIStore();

const stores = {
  appStore,
  authStore,
  postsStore,
  commentsStore,
  uiStore
};

export default stores;

在 stores/index.js 中,对每一个 store 都进行实例化,然后合并成一个对象导出。这样既保证每一个 store 只有一个实例,又便于外部组件的使用。