1
头图

The previous article introduced the overall architecture. Next, I will talk about how to implement the following functions of adding, deleting, modifying and checking according to the hierarchical structure in the above figure.

code structure

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

declare page data

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>;

I have seen several projects with clean front-end architecture, and most of them will divide the model into a business model (domain model) or a view model .

A business model (domain model) can refer to the data used to express business content. For example, Taobao's business model is [Commodity], blog's business model is [Blog], and Twitter's business model is [Tweet]. It can be understood as the Model in the classic MVC, which contains the content of the [real] data fields such as name, description, time, author, price, etc.

View model is a new concept after the prosperity of MVVM. To implement a complete web application, in addition to data, there is a lot of state in UI interaction. For example: whether the pop-up box is open, whether the user is inputting, whether the request Loading status needs to be displayed, whether the chart data classification needs to display additional fields, and the dynamic change of the size and style of the text when the user enters... These have nothing to do with specific data fields, but The view state that is very important to the actual front-end business scenario can be considered as a view model.

The upper limit of the business model (domain model) is too high. To dig and summarize in depth from the perspective of the business, there is a high-level term: domain-driven development. Whether it is front-end or back-end, the cost of domain-driven development is too high, and the requirements for developers are also high. Spend a lot of time dividing the domain model, and the end result may be all kinds of coupled models, which are not as easy to maintain as spaghetti code. Many neatly structured projects are choosing products, shopping carts as an example, because these businesses have been played through, it is relatively easy to figure out the business model.

Going back to improving the front-end development experience written in the title of the article, it is obvious that dividing the model for the business domain is not a good development experience. In order to avoid spaghetti code, I still choose to divide the model, but not from the perspective of the business field, but directly from the design draft or prototype obtained (after all, most of the front-end work is still oriented to the design draft or Prototype programming), directly put the data needed on the page into the model.

For example, the example used in this article is a page with additions, deletions, changes, and inspections.查询filterForm ,列表userList ,分页信息pagination ,加载状态loading ,新增修改modalInfo . These fields are the model data of this page, regardless of the business model or view model, they are all put together.

runFetch This variable is used to close side-effect dependencies. From an interactive point of view, if the query conditions or paging information change, the side effect of a network request should be triggered to refresh the page data. If there are more than a dozen query conditions, there will be too many dependencies on side effects, and the code is neither easy to maintain nor concise. Therefore, when query conditions or paging data change, update runFetch at the same time, and side effects only depend on runFetch .

Look at the code above model , which is actually a custom hook. That is to say, we directly depend on the framework react or vue at the model layer, which violates the specification of clean architecture. This is from the perspective of development experience and technical selection . To modify the model data without introducing third-party libraries such as mobx and @formily/reactive, the view update can be directly triggered. Using custom hooks is the most convenient. .

There is no restriction on how to update data in the model, and the model data can be directly read and written externally. Because the model is only used in the current page, there is no need to write a separate method to call externally in order to update a field. At the same time, it is not recommended to write methods in the model, keep the model clean, follow-up maintenance or requirements changes, there will be a better development experience, the logic method will be introduced later in the service layer.

Models written by react cannot be reused in vue projects, and vice versa. However, in cross-end development, the model can still be reused. For example, if the technology stack is React, the model layer can be reused on the web and RN ends. If the Taro or uni-app framework is used, the model layer and service layer will not be polluted by different end adaptation codes, and the adaptation can be done at the presenter layer or the view layer.

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 is a pure class, which is injected into the model through the constructor (if it is the react technology stack, the singleton method is used when the presenter layer is called to avoid generating a new instance each time re-render), and the service method contains the corresponding business logic , you can directly read and write the state of the model.

The service should be kept as "clean" as possible, do not directly call the API of a specific environment, and try to follow the principle of dependency inversion . For example, web-side native APIs such as fetch, WebSocket, cookie, localStorage, and APP-side JSbridge are not recommended to be called directly, but abstracted and encapsulated into separate libraries or tool functions, which are guaranteed to be replaceable and easy to mock. Also, the APIs of frameworks such as Taro and uni-app should not be called directly, but can be placed in the presenter layer. There are also components called imperatively provided by the component library, and do not use them. For example, the delete method in the above code. After the api is successfully called, it will not directly call the Toast of the component library for prompting, but will be called in the presenter.

The service ensures enough "cleanliness", and the model and service can be directly unit tested, without needing to care whether it is a web environment or a small program environment.

presenter

The presenter calls the service method to handle view layer events.

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;

Because presenter is a custom hook, you can use other custom hooks, as well as other open source hooks libraries, such as ahooks, vueuse, etc. There should not be too much logic code in the presenter, and it should be properly extracted into the service.

view

The view layer is the UI layout, which can be either jsx or vue template. The resulting events are handled by the presenter, using the model for data binding.

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;

Why so layered

When initially writing code in this way, the service is a custom hook like the presenter:

 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 is usePresenter. In this way, three custom hooks are generated here. In order to ensure that the model in the service and the presenter is the same data, the model can only be created in the service and returned to the presenter for use.

Because of being lazy, and because some page logic is really simple, the logic code is written in the presenter, and the entire service has only two lines of code. If you delete the service, you have to adjust the code, introduce and create the model in the presenter. If the business becomes complicated and the presenter swells, the logic needs to be extracted into the service, and it has to be adjusted again. Moreover, if the technology stack is React and more performance is pursued, the method in the service has to be added with useCallback. Therefore, in the end, the service becomes a class of native grammar. When the business is not complicated, it is enough not to call it in the presenter.

Look back at the entire file and structure, as follows:

 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

This is divided from the perspective of specific pages in the function module, and then layered by file name. Regardless of the reuse between different pages, there is almost no reuse. If a page or module needs to be deployed independently, it can be easily split out.

I have seen other neat architecture implementation plans, and there are two layering methods as follows:

Business-oriented, micro-service layered architecture

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

Business-oriented, microservice-style layered architecture. Different modules are divided according to the business, not a specific page, and it is possible to divide it well without being very familiar with the business. Multiple modules can be called at the same time to implement business functions. If the business module needs to be deployed independently, it can be easily split out.

Monolithic layered architecture

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

Just like the classic three-tier architecture of the previous backend, it is difficult to split.

Data sharing, cross-component communication

Parent and child components can use props, and child components, sibling components (including different pages of the same module) or different modules consider using the state library.

State library recommendation:

vue: Pinia, global reactive or ref declared variables.

react: jotai , zustand , hox

Personal complaints: Stop using Redux and various libraries based on Redux, the development experience is extremely poor

Descendent components, sibling components (including different pages of the same module) state sharing

 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

Different pages in the module need to share data, add store.ts to the root directory of the module (for descendant components, the store.ts file can be placed in the same level directory of the top-level parent component)

 // 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>;

Use global reactive or ref variables. Pinia can also be used

 // 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>;

use hox

The presenter layer and the view layer can directly introduce useStore, and the service layer can be injected and used like a 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,
    ...
  };
};

We can call this local data sharing , because the place where it is used is the components within a single module, and there is no need to specifically limit the reading and writing of data.

There is also a scenario where this method will have a better development experience: a form page, when the form is half filled, you need to jump to the page to operate, and when you return to the form page, you need to keep the previously filled form still, you only need to put the data in the model Just put it in the store.

Data sharing between modules

In fact, it is the global state , just put it in the way of previous global state management. Because there are more places to read and write, it is necessary to limit the way to update data and to easily track data changes.
The vue technology stack recommends using Pinia, react or the library recommended above, and there are corresponding dev-tools to observe data change operations.

full code

vue3

vue2.6

vue2.7

react

taro-vue

taro-react

mock service


若邪
1.5k 声望64 粉丝

划水摸鱼糊屎