如何結合整潔架構和MVP模式提升前端開發體驗(二)

2022-09-07 06:04:17

上一篇文章介紹了整體架構,接下來說說怎麼按照上圖的分層結構實現下面的增刪改查的功能。

程式碼結構

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服務