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

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

代码结构

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: IFetchUserListResult["result"]["rows"] }>({
    value: [],
  });
  const pagination = reactive({
    size: 10,
    page: 1,
    total: 0,
  });
  const loading = ref(false);

  const runFetch = ref(0);

  const modalInfo = reactive<{
    action: "create" | "edit";
    title: "创建" | "编辑";
    visible: boolean;
    data?: IFetchUserListResult["result"]["rows"][0];
  }>({
    action: "create",
    title: "创建",
    visible: false,
    data: undefined,
  });

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

export type Model = ReturnType<typeof useModel>;

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<
    IFetchUserListResult['result']['rows']
  >([]);

  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' | 'edit';
    title: '创建' | '编辑';
    visible: boolean;
    data?: IFetchUserListResult['result']['rows'][0];
  }>({
    action: 'create',
    title: '创建',
    visible: false,
    data: undefined,
  });

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

export type Model = ReturnType<typeof useModel>;

看过几个前端整洁架构的项目,大部分都会把 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 (
              <Tag color="blue" key={s}>
                {s}
              </Tag>
            );
          });
        },
      },
      {
        title: "住址",
        dataIndex: "address",
        key: "address",
        width: 300,
      },
      {
        title: "操作",
        key: "action",
        width: 200,
        customRender(data) {
          return (
            <span>
              <Button
                type="link"
                onClick={() => {
                  presenter.handelEdit(data.record);
                }}
              >
                编辑
              </Button>
              <Button
                type="link"
                danger
                onClick={() => {
                  presenter.handleDel(data.record);
                }}
              >
                删除
              </Button>
            </span>
          );
        },
      },
    ];
    return { model, presenter, culumns };
  },
  render() {
    return (
      <div>
        <div class={styles.index}>
          <div class={styles.filter}>
            <Row gutter={[20, 0]}>
              <Col span={8}>
                <Form.Item label="名称">
                  <Input
                    value={this.model.filterForm.name}
                    placeholder="输入名称搜索"
                    onChange={(e) => {
                      this.presenter.handleFormChange("name", e.target.value);
                    }}
                    onPressEnter={this.presenter.handleSearch}
                  />
                </Form.Item>
              </Col>
            </Row>
            <Row>
              <Col span={24} style={{ textAlign: "right" }}>
                <Button type="primary" onClick={this.presenter.handleSearch}>
                  查询
                </Button>
                <Button
                  style={{ marginLeft: "10px" }}
                  onClick={this.presenter.handleReset}
                >
                  重置
                </Button>
                <Button
                  style={{ marginLeft: "10px" }}
                  type="primary"
                  onClick={() => {
                    this.presenter.handleCreate();
                  }}
                  icon={<PlusOutlined />}
                >
                  创建
                </Button>
              </Col>
            </Row>
          </div>
          <Table
            columns={this.culumns}
            dataSource={this.model.userList.value}
            loading={this.model.loading.value}
            pagination={false}
          />
          <Pagination
            current={this.model.pagination.page}
            total={this.model.pagination.total}
            showQuickJumper
            hideOnSinglePage
            style={{ marginTop: "20px" }}
            pageSize={this.model.pagination.size}
            onChange={this.presenter.handlePageChange}
          />
        </div>
        <EditModal
          visible={this.model.modalInfo.visible}
          data={this.model.modalInfo.data}
          title={this.model.modalInfo.title}
          onCancel={() => {
            this.model.modalInfo.visible = false;
          }}
          onOk={() => {
            this.model.modalInfo.visible = false;
            this.presenter.refresh();
          }}
        />
      </div>
    );
  },
});
export default Index;

vue template

// vue template
<template>
  <div :class="styles.index">
    <div :class="styles.filter">
      <Row :gutter="[20, 0]">
        <Col :span="8">
          <FormItem label="名称">
            <Input
              :value="model.filterForm.name"
              placeholder="输入名称搜索"
              @change="handleFormChange"
              @press-enter="presenter.handleSearch"
            />
          </FormItem>
        </Col>
      </Row>
      <Row>
        <Col span="24" style="text-align: right">
          <Button type="primary" @click="presenter.handleSearch"> 查询 </Button>
          <Button style="margin-left: 10px" @click="presenter.handleReset">
            重置
          </Button>
          <Button
            style="margin-left: 10px"
            type="primary"
            @click="presenter.handleCreate"
          >
            <template #icon>
              <PlusOutlined />
            </template>
            创建
          </Button>
        </Col>
      </Row>
    </div>
    <Table
      :columns="columns"
      :dataSource="model.userList.value"
      :loading="model.loading.value"
      :pagination="false"
    >
      <template #bodyCell="{ column, record }">
        <template v-if="column.key === 'tags'">
          <Tag v-for="tag in record.tags" :key="tag" color="blue">{{
            tag
          }}</Tag>
        </template>
        <template v-else-if="column.key === 'action'">
          <span>
            <Button type="link" @click="() => presenter.handelEdit(record)">
              编辑
            </Button>
            <Button
              type="link"
              danger
              @click="
                () => {
                  presenter.handleDel(record);
                }
              "
            >
              删除
            </Button>
          </span>
        </template>
      </template>
    </Table>
    <Pagination
      :current="model.pagination.page"
      :total="model.pagination.total"
      showQuickJumper
      hideOnSinglePage
      style="margin-top: 20px"
      :pageSize="model.pagination.size"
      @change="
        (page, pageSize) => {
          presenter.handlePageChange(page, pageSize);
        }
      "
    />
    <EditModal
      :visible="model.modalInfo.visible"
      :data="model.modalInfo.data"
      :title="model.modalInfo.title"
      :onCancel="
        () => {
          model.modalInfo.visible = false;
        }
      "
      :onOk="
        () => {
          model.modalInfo.visible = false;
          presenter.refresh();
        }
      "
    />
  </div>
</template>
<script setup lang="ts">
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/index.vue";

const FormItem = Form.Item;

const presenter = usePresenter();
const { model } = presenter;
const columns: 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",
  },
  {
    title: "住址",
    dataIndex: "address",
    key: "address",
    width: 300,
  },
  {
    title: "操作",
    key: "action",
    width: 200,
  },
];
const handleFormChange = (e: any) => {
  presenter.handleFormChange("name", e.target.value);
};
</script>

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) => (
          <Tag color="blue" key={s}>
            {s}
          </Tag>
        ));
      },
    },
    {
      title: '住址',
      dataIndex: 'address',
      key: 'address',
      width: 300,
    },
    {
      title: 'Action',
      key: 'action',
      width: 200,
      render(value, record) {
        return (
          <span>
            <Button
              type="link"
              onClick={() => {
                presenter.handelEdit(record as any);
              }}
            >
              编辑
            </Button>
            <Button
              type="link"
              danger
              onClick={() => {
                presenter.handleDel(record as any);
              }}
            >
              删除
            </Button>
          </span>
        );
      },
    },
  ];
  return (
    <div>
      <div className={styles.index}>
        <div className={styles.filter}>
          <Row gutter={[20, 0]}>
            <Col span={8}>
              <Form.Item label="名称">
                <Input
                  value={model.filterForm.name}
                  placeholder="输入名称搜索"
                  onChange={(e) => {
                    presenter.handleFormChange('name', e.target.value);
                  }}
                  onPressEnter={presenter.handleSearch}
                />
              </Form.Item>
            </Col>
          </Row>
          <Row>
            <Col span={24} style={{ textAlign: 'right' }}>
              <Button type="primary" onClick={presenter.handleSearch}>
                查询
              </Button>
              <Button
                style={{ marginLeft: '10px' }}
                onClick={presenter.handleReset}
              >
                重置
              </Button>
              <Button
                style={{ marginLeft: '10px' }}
                type="primary"
                onClick={() => {
                  model.setModalInfo((s) => {
                    s.visible = true;
                    s.title = '创建';
                    s.data = undefined;
                  });
                }}
                icon={<PlusOutlined />}
              >
                创建
              </Button>
            </Col>
          </Row>
        </div>
        <Table
          columns={culumns as any}
          dataSource={model.userList}
          loading={model.loading}
          pagination={false}
          rowKey="id"
        />
        <Pagination
          current={model.pagination.page}
          total={model.pagination.total}
          showQuickJumper
          hideOnSinglePage
          style={{ marginTop: '20px' }}
          pageSize={model.pagination.size}
          onChange={(page, pageSize) => {
            presenter.handlePageChange(page, pageSize);
          }}
        />
      </div>

      <EditModal
        visible={model.modalInfo.visible}
        data={model.modalInfo.data}
        title={model.modalInfo.title}
        onCancel={() => {
          model.setModalInfo((s) => {
            s.visible = false;
          });
        }}
        onOk={() => {
          model.setModalInfo((s) => {
            s.visible = false;
          });
          presenter.refresh();
        }}
      />
    </div>
  );
}
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: {
    exchangeGoodId: number;
    exchangeId: number;
    goodId: number;
    houseCode: string;
    projectCode: string;
    userId: string;
    needScore: number;
    bigImg: string; // 锦旗大图,空的
    makingImg: string; // 制作后的图片
    /**
     * @description 赠送类型,0-个人,1-团队
     * @type {(0 | 1)}
     */
    sendType: 0 | 1;
    staffId: string;
    staffName: string;
    staffAvatar: string;
    staffRole: string;
    sendName: string;
    makingId: string; // 提交后后端返回 ID
  };
}>({ data: {} } as any); // 制作锦旗需要的信息

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

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

export type Store = ReturnType<typeof useStore>;

使用全局 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<{
    exchangeGoodId: number;
    exchangeId: number;
    goodId: number;
    houseCode: string;
    projectCode: string;
    userId: string;
    needScore: number;
    bigImg: string; // 锦旗大图,空的
    makingImg: string; // 制作后的图片
    /**
     * @description 赠送类型,0-个人,1-团队
     * @type {(0 | 1)}
     */
    sendType: 0 | 1;
    staffId: string;
    staffName: string;
    staffAvatar: string;
    staffRole: string;
    sendName: string;
    makingId: string; // 提交后后端返回 ID
  }>({} as any); // 制作锦旗需要的信息

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

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

export type Store = ReturnType<typeof useStore>;

使用 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服务

©著作权归作者所有,转载或内容合作请联系作者
  • 序言:七十年代末,一起剥皮案震惊了整个滨河市,随后出现的几起案子,更是在滨河造成了极大的恐慌,老刑警刘岩,带你破解...
    沈念sama阅读 212,294评论 6 493
  • 序言:滨河连续发生了三起死亡事件,死亡现场离奇诡异,居然都是意外死亡,警方通过查阅死者的电脑和手机,发现死者居然都...
    沈念sama阅读 90,493评论 3 385
  • 文/潘晓璐 我一进店门,熙熙楼的掌柜王于贵愁眉苦脸地迎上来,“玉大人,你说我怎么就摊上这事。” “怎么了?”我有些...
    开封第一讲书人阅读 157,790评论 0 348
  • 文/不坏的土叔 我叫张陵,是天一观的道长。 经常有香客问我,道长,这世上最难降的妖魔是什么? 我笑而不...
    开封第一讲书人阅读 56,595评论 1 284
  • 正文 为了忘掉前任,我火速办了婚礼,结果婚礼上,老公的妹妹穿的比我还像新娘。我一直安慰自己,他们只是感情好,可当我...
    茶点故事阅读 65,718评论 6 386
  • 文/花漫 我一把揭开白布。 她就那样静静地躺着,像睡着了一般。 火红的嫁衣衬着肌肤如雪。 梳的纹丝不乱的头发上,一...
    开封第一讲书人阅读 49,906评论 1 290
  • 那天,我揣着相机与录音,去河边找鬼。 笑死,一个胖子当着我的面吹牛,可吹牛的内容都是我干的。 我是一名探鬼主播,决...
    沈念sama阅读 39,053评论 3 410
  • 文/苍兰香墨 我猛地睁开眼,长吁一口气:“原来是场噩梦啊……” “哼!你这毒妇竟也来了?” 一声冷哼从身侧响起,我...
    开封第一讲书人阅读 37,797评论 0 268
  • 序言:老挝万荣一对情侣失踪,失踪者是张志新(化名)和其女友刘颖,没想到半个月后,有当地人在树林里发现了一具尸体,经...
    沈念sama阅读 44,250评论 1 303
  • 正文 独居荒郊野岭守林人离奇死亡,尸身上长有42处带血的脓包…… 初始之章·张勋 以下内容为张勋视角 年9月15日...
    茶点故事阅读 36,570评论 2 327
  • 正文 我和宋清朗相恋三年,在试婚纱的时候发现自己被绿了。 大学时的朋友给我发了我未婚夫和他白月光在一起吃饭的照片。...
    茶点故事阅读 38,711评论 1 341
  • 序言:一个原本活蹦乱跳的男人离奇死亡,死状恐怖,灵堂内的尸体忽然破棺而出,到底是诈尸还是另有隐情,我是刑警宁泽,带...
    沈念sama阅读 34,388评论 4 332
  • 正文 年R本政府宣布,位于F岛的核电站,受9级特大地震影响,放射性物质发生泄漏。R本人自食恶果不足惜,却给世界环境...
    茶点故事阅读 40,018评论 3 316
  • 文/蒙蒙 一、第九天 我趴在偏房一处隐蔽的房顶上张望。 院中可真热闹,春花似锦、人声如沸。这庄子的主人今日做“春日...
    开封第一讲书人阅读 30,796评论 0 21
  • 文/苍兰香墨 我抬头看了看天上的太阳。三九已至,却和暖如春,着一层夹袄步出监牢的瞬间,已是汗流浃背。 一阵脚步声响...
    开封第一讲书人阅读 32,023评论 1 266
  • 我被黑心中介骗来泰国打工, 没想到刚下飞机就差点儿被人妖公主榨干…… 1. 我叫王不留,地道东北人。 一个月前我还...
    沈念sama阅读 46,461评论 2 360
  • 正文 我出身青楼,却偏偏与公主长得像,于是被迫代替她去往敌国和亲。 传闻我的和亲对象是个残疾皇子,可洞房花烛夜当晚...
    茶点故事阅读 43,595评论 2 350

推荐阅读更多精彩内容