如何结合整洁架构和MVP模式提升前端开发体验(二) – 代码实现篇

如何结合整洁架构和MVP模式提升前端开发体验(二) – 代码实现篇插图

上一篇文章介绍了整体架构,接下来说说怎么按照上图的分层结构实现下面的增删改查的功能。

如何结合整洁架构和MVP模式提升前端开发体验(二) – 代码实现篇插图1

代码结构

vue

userManage
 └── List
     ├── api.ts
     ├── EditModal
     │   ├── index.tsx
     │   ├── index.vue
     │   ├── model.ts
     │   ├── presenter.tsx
     │   └── service.ts
     ├── index.module.less
     ├── index.tsx
     ├── index.vue
     ├── model.ts
     ├── presenter.tsx
     └── service.ts

react

userManage
 └── List
     ├── api.ts
     ├── EditModal
     │   ├── index.tsx
     │   ├── model.ts
     │   ├── presenter.tsx
     │   └── service.ts
     ├── index.module.less
     ├── index.tsx
     ├── model.ts
     ├── presenter.tsx
     └── service.ts

model

声明页面数据

vue

// vue
import { reactive, ref } from "vue";
import { IFetchUserListResult } from "./api";

export const useModel = () => {
  const filterForm = reactive({ name: "" });
  const userList = reactive({
    value: [],
  });
  const pagination = reactive({
    size: 10,
    page: 1,
    total: 0,
  });
  const loading = ref(false);

  const runFetch = ref(0);

  const modalInfo = reactive({
    action: "create",
    title: "创建",
    visible: false,
    data: undefined,
  });

  return {
    filterForm,
    userList,
    pagination,
    loading,
    runFetch,
    modalInfo,
  };
};

export type Model = ReturnType;

react

// react
import { useImmer as useState } from 'use-immer';
import { IFetchUserListResult } from './api';

export const useModel = () => {
  const [filterForm, setFilterForm] = useState({ name: '' });

  const [userList, setUserList] = useState([]);

  const [pagination, setPagination] = useState({ size: 10, page: 1, total: 0 });

  const [loading, setLoading] = useState(false);

  const [runFetch, setRunFetch] = useState(0);

  const [modalInfo, setModalInfo] = useState({
    action: 'create',
    title: '创建',
    visible: false,
    data: undefined,
  });

  return {
    filterForm,
    setFilterForm,
    userList,
    setUserList,
    pagination,
    setPagination,
    loading,
    setLoading,
    runFetch,
    setRunFetch,
    modalInfo,
    setModalInfo,
  };
};

export type Model = ReturnType;

看过几个前端整洁架构的项目,大部分都会把 model 分为 业务模型(领域模型) 或者 视图模型

业务模型(领域模型) 可以指用于表达业务内容的数据。例如淘宝的业务模型是【商品】,博客的业务模型是【博文】,推特的业务模型是【推文】。可以理解为经典 MVC 中的 Model,包含了名称、描述、时间、作者、价格等【真正意义上的】数据字段内容。

视图模型 则是 MVVM 兴盛后的新概念。要实现一个完整的 Web 应用,除了数据外,还有 UI 交互中非常多的状态。例如:弹框是否打开、用户是否正在输入、请求 Loading 状态是否需要显示、图表数据分类是否需要显示追加字段、和用户输入时文本的大小和样式的动态改变……这些和具体数据字段无关,但对前端实际业务场景非常重要的视图状态,可以认为是一种视图模型。

业务模型(领域模型)的上限太高,站在业务的角度去深入的挖掘、归纳,有一个高大上的名词:领域驱动开发。不管是前端还是后端,领域驱动开发的成本太高,对开发人员的要求也高。花了大量的时间去划分领域模型,最终结果可能是弄出各种相互耦合的模型,还不如意大利面式的代码好维护。很多整洁结构的项目都是选择商品,购物车作为例子,因为这些业务已经被玩透了,比较容易就把业务模型弄出来。

回到文章标题中写的 提升前端开发体验,显然面向业务领域去划分模型并不是一种好的开发体验。为了避免意大利面式的代码,还是选择进行模型的划分,不过不是站在业务领域的角度区划分,而是直接从拿到的设计稿或者原型着手(毕竟前端大部分的工作还是面向设计稿或者原型编程),直接把页面上需要用到的数据放到模型中。

比如这篇文章所用的例子,一个增删改查的页面。查询条件 filterForm ,列表数据 userList,分页信息 pagination,加载状态 loading,新增修改弹框 modalInfo。这几个字段就是这个页面的模型数据,不分什么业务模型,视图模型,全部放在一起。

runFetch 这个变量是为了把副作用依赖进行收口。从交互的角度来说,查询条件或者分页信息变了,应该触发网络请求这个副作用,刷新页面数据。如果查询条件由十几个,那么副作用的依赖就太多了,代码既不好维护也不简洁,所以查询条件或者分页数据变的时候,同时更新 runFetch,副作用只依赖于 runFetch 即可。

看上面的 model 代码,其实就是一个自定义 hooks。也就是说我们在 model 层直接依赖了框架 react 或者 vue,违反了整洁架构的规范。这是从 开发体验技术选型 两方面考虑,要在不引入 mobx,@formily/reactive 等第三方库的前提下实现修改 model 数据就能直接触发视图的更新,使用自定义 hooks 是最便捷的。

model 中没有限制更新数据方式,外部能直接读写模型数据。因为 model 只是当前页面中使用,没必要为了更新某个字段单独写一个方法给外部去调用。同时也不建议在 model 中写方法,保持 model 的干净,后续维护或者需求变更,会有更好的开发体验,逻辑方法放到后面会介绍的 service 层中。

react 写的 model不能在 vue 项目中复用,反之一样。但是在跨端开发中,model 还是可以复用的,比如如果技术栈是 react,web 端和 RN 端是可以复用 model 层的。如果用了 Taro 或者 uni-app 框架,model 层和 service 层不会受到不同端适配代码的污染,在 presenter 层或者 view 层做适配即可。

service

vue

// vue
import { delUser, fetchUserList } from "./api";
import { Model } from "./model";

export default class Service {
  private model: Model;

  constructor(model: Model) {
    this.model = model;
  }

  async getUserList() {
    if (this.model.loading.value) {
      return;
    }
    this.model.loading.value = true;
    const res = await fetchUserList({
      page: this.model.pagination.page,
      size: this.model.pagination.size,
      name: this.model.filterForm.name,
    }).finally(() => {
      this.model.loading.value = false;
    });
    if (res) {
      this.model.userList.value = res.result.rows;
      this.model.pagination.total = res.result.total;
    }
  }

  changePage(page: number, pageSize: number) {
    if (pageSize !== this.model.pagination.size) {
      this.model.pagination.page = 1;
      this.model.pagination.size = pageSize;
      this.model.runFetch.value += 1;
    } else {
      this.model.pagination.page = page;
      this.model.runFetch.value += 1;
    }
  }

  changeFilterForm(name: string, value: any) {
    (this.model.filterForm as any)[name] = value;
  }

  resetForm() {
    this.model.filterForm.name = "";
    this.model.pagination.page = 1;
    this.model.runFetch.value += 1;
  }

  doSearch() {
    this.model.pagination.page = 1;
    this.model.runFetch.value += 1;
  }

  edit(data: Model["modalInfo"]["data"]) {
    this.model.modalInfo.action = "edit";
    this.model.modalInfo.data = JSON.parse(JSON.stringify(data));
    this.model.modalInfo.visible = true;
    this.model.modalInfo.title = "编辑";
  }

  async del(id: number) {
    this.model.loading.value = true;
    await delUser({ id: id }).finally(() => {
      this.model.loading.value = false;
    });
  }
}

react

// react
import { delUser, fetchUserList } from './api';
import { Model } from './model';

export default class Service {
  private static _indstance: Service | null = null;

  private model: Model;

  static single(model: Model) {
    if (!Service._indstance) {
      Service._indstance = new Service(model);
    }
    return Service._indstance;
  }

  constructor(model: Model) {
    this.model = model;
  }

  async getUserList() {
    if (this.model.loading) {
      return;
    }
    this.model.setLoading(true);
    const res = await fetchUserList({
      page: this.model.pagination.page,
      size: this.model.pagination.size,
      name: this.model.filterForm.name,
    }).catch(() => {});
    if (res) {
      this.model.setUserList(res.result.rows);
      this.model.setPagination((s) => {
        s.total = res.result.total;
      });
      this.model.setLoading(false);
    }
  }

  changePage(page: number, pageSize: number) {
    if (pageSize !== this.model.pagination.size) {
      this.model.setPagination((s) => {
        s.page = 1;
        s.size = pageSize;
      });
      this.model.setRunFetch(this.model.runFetch + 1);
    } else {
      this.model.setPagination((s) => {
        s.page = page;
      });
      this.model.setRunFetch(this.model.runFetch + 1);
    }
  }

  changeFilterForm(name: string, value: any) {
    this.model.setFilterForm((s: any) => {
      s[name] = value;
    });
  }

  resetForm() {
    this.model.setFilterForm({} as any);
    this.model.setPagination((s) => {
      s.page = 1;
    });
    this.model.setRunFetch(this.model.runFetch + 1);
  }

  doSearch() {
    this.model.setPagination((s) => {
      s.page = 1;
    });
    this.model.setRunFetch(this.model.runFetch + 1);
  }

  edit(data: Model['modalInfo']['data']) {
    this.model.setModalInfo((s) => {
      s.action = 'edit';
      s.data = data;
      s.visible = true;
      s.title = '编辑';
    });
  }

  async del(id: number) {
    this.model.setLoading(true);
    await delUser({ id }).finally(() => {
      this.model.setLoading(false);
    });
  }
}

service 是一个纯类,通过构造函数注入 model (如果是 react 技术栈,presenter 层调用的时候使用单例方法,避免每次re-render 都生成新的实例),service 的方法内是相应的业务逻辑,可以直接读写 model 的状态。

service 要尽量保持“整洁”,不要直接调用特定环境,端的 API,尽量遵循 依赖倒置原则。比如 fetch,WebSocket,cookie,localStorage 等 web 端原生 API 以及 APP 端 JSbridge,不建议直接调用,而是抽象,封装成单独的库或者工具函数,保证是可替换,容易 mock 的。还有 Taro,uni-app 等框架的 API 也不要直接调用,可以放到 presenter 层。还有组件库提供的命令式调用的组件,也不要使用,比如上面代码中的删除方法,调用 api 成功后,不会直接调用组件库的 Toast 进行提示,而是在 presenter 中调用。

service 保证足够的“整洁”,model 和 service 是可以直接进行单元测试的,不需要去关心是 web 环境还是小程序环境。

presenter

presenter 调用 service 方法处理 view 层事件。

vue

// vue
import { watch } from "vue";
import { message, Modal } from "ant-design-vue";
import { IFetchUserListResult } from "./api";
import Service from "./service";
import { useModel } from "./model";

const usePresenter = () => {
  const model = useModel();
  const service = new Service(model);
  watch(
    () => model.runFetch.value,
    () => {
      service.getUserList();
    },
    { immediate: true },
  );

  const handlePageChange = (page: number, pageSize: number) => {
    service.changePage(page, pageSize);
  };

  const handleFormChange = (name: string, value: any) => {
    service.changeFilterForm(name, value);
  };

  const handleSearch = () => {
    service.doSearch();
  };

  const handleReset = () => {
    service.resetForm();
  };

  const handelEdit = (data: IFetchUserListResult["result"]["rows"][0]) => {
    service.edit(data);
  };

  const handleDel = (data: IFetchUserListResult["result"]["rows"][0]) => {
    Modal.confirm({
      title: "确认",
      content: "确认删除当前记录?",
      cancelText: "取消",
      okText: "确认",
      onOk: () => {
        service.del(data.id).then(() => {
          message.success("删除成功");
          service.getUserList();
        });
      },
    });
  };

  const handleCreate = () => {
    model.modalInfo.visible = true;
    model.modalInfo.title = "创建";
    model.modalInfo.data = undefined;
  };

  const refresh = () => {
    service.getUserList();
  };

  return {
    model,
    handlePageChange,
    handleFormChange,
    handleSearch,
    handleReset,
    handelEdit,
    handleDel,
    handleCreate,
    refresh,
  };
};

export default usePresenter;

react

// react
import { message, Modal } from 'antd';
import { useEffect } from 'react';
import { IFetchUserListResult } from './api';
import { useModel } from './model';
import Service from './service';

const usePresenter = () => {
  const model = useModel();
  const service = Service.single(model);

  useEffect(() => {
    service.getUserList();
  }, [model.runFetch]);

  const handlePageChange = (page: number, pageSize: number) => {
    service.changePage(page, pageSize);
  };

  const handleFormChange = (name: string, value: any) => {
    service.changeFilterForm(name, value);
  };

  const handleSearch = () => {
    service.doSearch();
  };

  const handleReset = () => {
    service.resetForm();
  };

  const handelEdit = (data: IFetchUserListResult['result']['rows'][0]) => {
    service.edit(data);
  };

  const handleDel = (data: IFetchUserListResult['result']['rows'][0]) => {
    Modal.confirm({
      title: '确认',
      content: '确认删除当前记录?',
      cancelText: '取消',
      okText: '确认',
      onOk: () => {
        service.del(data.id).then(() => {
          message.success('删除成功');
          service.getUserList();
        });
      },
    });
  };

  const refresh = () => {
    service.getUserList();
  };

  return {
    model,
    handlePageChange,
    handleFormChange,
    handleSearch,
    handleReset,
    handelEdit,
    handleDel,
    refresh,
  };
};

export default usePresenter;

因为 presenter 是一个自定义 hooks,所以可以使用别的自定义的 hooks,以及其它开源的 hooks 库,比如 ahooks,vueuse 等。presenter 中不要出现太多的逻辑代码,适当的抽离到 service 中。

view

view 层就是 UI 布局,可以是 jsx 也可以是 vue template。产生的事件由 presenter 处理,使用 model 进行数据绑定。

vue jsx

// vue jsx
import { defineComponent } from "vue";
import {
  Table,
  Pagination,
  Row,
  Col,
  Button,
  Form,
  Input,
  Tag,
} from "ant-design-vue";
import { PlusOutlined } from "@ant-design/icons-vue";
import usePresenter from "./presenter";
import styles from "./index.module.less";
import { ColumnsType } from "ant-design-vue/lib/table";
import EditModal from "./EditModal";

const Index = defineComponent({
  setup() {
    const presenter = usePresenter();
    const { model } = presenter;
    const culumns: ColumnsType = [
      {
        title: "姓名",
        dataIndex: "name",
        key: "name",
        width: 150,
      },
      {
        title: "年龄",
        dataIndex: "age",
        key: "age",
        width: 150,
      },
      {
        title: "电话",
        dataIndex: "mobile",
        key: "mobile",
        width: 150,
      },
      {
        title: "tags",
        dataIndex: "tags",
        key: "tags",
        customRender(data) {
          return data.value.map((s: string) => {
            return (
              
                {s}
              
            );
          });
        },
      },
      {
        title: "住址",
        dataIndex: "address",
        key: "address",
        width: 300,
      },
      {
        title: "操作",
        key: "action",
        width: 200,
        customRender(data) {
          return (
            
              
              
            
          );
        },
      },
    ];
    return { model, presenter, culumns };
  },
  render() {
    return (
      
{ this.presenter.handleFormChange("name", e.target.value); }} onPressEnter={this.presenter.handleSearch} />
{ this.model.modalInfo.visible = false; }} onOk={() => { this.model.modalInfo.visible = false; this.presenter.refresh(); }} />
); }, }); export default Index;

vue template

// vue template



react

// react
import { Table, Pagination, Row, Col, Button, Form, Input, Tag } from 'antd';
import { ColumnsType } from 'antd/lib/table';
import { PlusOutlined } from '@ant-design/icons';
import usePresenter from './presenter';
import styles from './index.module.less';
import EditModal from './EditModal';

function Index() {
  const presenter = usePresenter();
  const { model } = presenter;
  const culumns: ColumnsType = [
    {
      title: '姓名',
      dataIndex: 'name',
      key: 'name',
      width: 150,
    },
    {
      title: '年龄',
      dataIndex: 'age',
      key: 'age',
      width: 150,
    },
    {
      title: '电话',
      dataIndex: 'mobile',
      key: 'mobile',
      width: 150,
    },
    {
      title: 'tags',
      dataIndex: 'tags',
      key: 'tags',
      render(value) {
        return value.map((s: string) => (
          
            {s}
          
        ));
      },
    },
    {
      title: '住址',
      dataIndex: 'address',
      key: 'address',
      width: 300,
    },
    {
      title: 'Action',
      key: 'action',
      width: 200,
      render(value, record) {
        return (
          
            
            
          
        );
      },
    },
  ];
  return (
    
{ presenter.handleFormChange('name', e.target.value); }} onPressEnter={presenter.handleSearch} />
{ presenter.handlePageChange(page, pageSize); }} />
{ model.setModalInfo((s) => { s.visible = false; }); }} onOk={() => { model.setModalInfo((s) => { s.visible = false; }); presenter.refresh(); }} />
); } export default Index;

为何如此分层

一开始以这种方式写代码的时候,service 跟 presenter 一样也是一个自定义 hooks:

import useModel from './useModel';

const useService = () => {
  const model = useModel();
  // 各种业务逻辑方法
  const getRemoteData = () => {};

  return { model, getRemoteData };
};

export default useService;

import useService from './useService';

const useController = () => {
  const service = useService();
  const { model } = service;

  // 调用 service 方法处理 view 事件

  return {
    model,
    service,
  };
};

export default useController;

useController 就是 usePresenter,这么操作下来,这里就产生了三个自定义 hooks,为了保证 service 和 presenter 里的 model 是同一份数据,model 只能在 sevice 中创建,返回给 presenter 使用。

因为偷懒,以及有的页面逻辑确实很简单,就把逻辑代码都写在了 presenter 中,整个 service 只有两行代码。删掉 service 吧,就得调整代码,在 presenter 中去引入以及创建 model,如果哪天业务变复杂了,presenter 膨胀了,需要把逻辑抽离到 service 中,又得调整一次。而且,如果技术栈是 react ,比较追求性能的话,service 中的方法还得加上 useCallback。所以,最后 service 变成了原生语法的类,业务不复杂时,presenter 中不调用就行。

回看整个文件及结构,如下:

userManage
 └── List
     ├── api.ts
     ├── EditModal
     │   ├── index.tsx
     │   ├── index.vue
     │   ├── model.ts
     │   ├── presenter.tsx
     │   └── service.ts
     ├── index.module.less
     ├── index.tsx
     ├── index.vue
     ├── model.ts
     ├── presenter.tsx
     └── service.ts

这是从功能模块内具体的页面角度来划分,再以文件名来做分层,不考虑不同页面之间进行复用,也几乎不存在能复用的。如果某个页面或模块需要独立部署,很容易就能拆分出去。

看过其它整洁架构的落地方案,还有以下两种分层方式:

面向业务领域,微服务式分层架构

src
 │
 ├── module
 │   ├── product
 │   │   ├── api
 │   │   │   ├── index.ts
 │   │   │   └── mapper.ts
 │   │   ├── model.ts
 │   │   └── service.ts
 │   └── user
 │       ├── api
 │       │   ├── index.ts
 │       │   └── mapper.ts
 │       ├── model.ts
 │       └── service.ts
 └── views

面向业务领域,微服务式的分层架构。不同的 module 是根据业务来划分的,而不是某个具体的页面,需要非常熟悉业务才有可能划分好。可以同时调用多个模块来实现业务功能。如果业务模块需要独立部署,也很容易就能拆分出去。

单体式分层架构

src
 ├── api
 │   ├── product.ts
 │   └── user.ts
 ├── models
 │   ├── productModel.ts
 │   └── userModel.ts
 ├── services
 │   ├── productService.ts
 │   └── userService.ts
 └── views

就像以前后端的经典三层架构,很难拆分。

数据共享,跨组件通讯

父子组件使用 props 即可,子孙组件、兄弟组件(包括相同模块不同页面)或者不同模块考虑使用状态库。

状态库推荐:

vue:Pinia,全局 reactive 或者 ref 声明变量。

react:jotaizustandhox

个人吐槽:别再用 Redux 以及基于 Redux 弄出来的各种库了,开发体验极差

子孙组件、兄弟组件(包括相同模块不同页面)状态共享

pennant
 ├── components
 │   ├── PenantItem
 │   │   ├── index.module.less
 │   │   └── index.tsx
 │   └── RoleWrapper
 │       ├── index.module.less
 │       └── index.tsx
 ├── Detail
 │   ├── index.module.less
 │   ├── index.tsx
 │   ├── model.ts
 │   ├── presenter.tsx
 │   └── service.ts
 ├── MakingPennant
 │   ├── index.module.less
 │   ├── index.tsx
 │   ├── model.ts
 │   ├── presenter.tsx
 │   └── service.ts
 ├── OptionalList
 │   ├── index.module.less
 │   ├── index.tsx
 │   ├── model.ts
 │   ├── presenter.tsx
 │   └── service.ts
 ├── PresentedList
 │   ├── index.module.less
 │   ├── index.tsx
 │   ├── model.ts
 │   ├── presenter.tsx
 │   └── service.ts
 ├── SelectGiving
 │   ├── GivingItem
 │   │   ├── index.module.less
 │   │   └── index.tsx
 │   ├── index.module.less
 │   ├── index.tsx
 │   ├── model.ts
 │   ├── presenter.tsx
 │   └── service.ts
 ├── Share
 │   ├── index.module.less
 │   ├── index.tsx
 │   └── model.ts
 └── store.ts

模块中的不同页面需要共享数据,在模块根目录新增 store.ts (子孙组件的话,store.ts 文件放到顶层父组件同级目录下即可)

// vue
import { reactive, ref } from "vue";

const userScore = ref(0); // 用户积分
const makingInfo = reactive({ data: {} } as any); // 制作锦旗需要的信息

export const useStore = () => {
  const detory = () => {
    userScore.value = 0;
    makingInfo.data = {} as any;
  };

  return {
    userScore,
    makingInfo,
    detory,
  };
};

export type Store = ReturnType;

使用全局 reactive 或者 ref 变量。也可以使用 Pinia

// react
import { createModel } from 'hox';
import { useState } from '@/hooks/useState';

export const useStore = createModel(() => {
  const [userScore, setUserScore] = useState(0); // 用户积分

  const [makingInfo, setMakingInfo] = useState({} as any); // 制作锦旗需要的信息

  const detory = () => {
    setUserScore(0);
    setMakingInfo({} as any);
  };

  return {
    userScore,
    setUserScore,
    makingInfo,
    setMakingInfo,
    detory,
  };
});

export type Store = ReturnType;

使用 hox

presenter 层和 view 层可以直接引入 useStore,service 层可以像 model 一样注入使用:

import { useStore } from '../store';
import { useModel } from './model';
import Service from './service';

export const usePresenter = () => {
  const store = useStore();
  const model = useModel();
  const service = Service.single(model, store);

  ...

  return {
    model,
    ...
  };
};

我们可以称这种为局部的数据共享,因为使用的地方就是单个模块内的组件,不需要特意地去限制数据的读写。

还有一种场景使用这种方式会有更好的开发体验:一个表单页面,表单填了一半需要跳转页面进行操作,返回到表单页面要维持之前填的表单还在,只需要把 model 中数据放到 store 中即可。

模块之间数据共享

其实就是全局状态,按以前全局状态管理的方式放就行了。因为读写的地方变多了,需要限制更新数据的方式以及能方便的跟踪数据的变更操作。
vue 技术栈建议使用 Pinia,react 还是上面推荐的库,都有相应的 dev-tools 观察数据的变更操作。

完整代码

vue3

vue2.6

vue2.7

react

taro-vue

taro-react

mock服务

文章来源于互联网:如何结合整洁架构和MVP模式提升前端开发体验(二) - 代码实现篇

THE END
分享
二维码