如何結(jié)合整潔架構(gòu)和MVP模式提升前端開(kāi)發(fā)體驗(yàn)(二) - 代碼實(shí)現(xiàn)篇

上一篇文章介紹了整體架構(gòu),接下來(lái)說(shuō)說(shuō)怎么按照上圖的分層結(jié)構(gòu)實(shí)現(xiàn)下面的增刪改查的功能。

代碼結(jié)構(gòu)

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

聲明頁(yè)面數(shù)據(jù)

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: "創(chuàng)建" | "編輯";
    visible: boolean;
    data?: IFetchUserListResult["result"]["rows"][0];
  }>({
    action: "create",
    title: "創(chuàng)建",
    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: '創(chuàng)建' | '編輯';
    visible: boolean;
    data?: IFetchUserListResult['result']['rows'][0];
  }>({
    action: 'create',
    title: '創(chuàng)建',
    visible: false,
    data: undefined,
  });

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

export type Model = ReturnType<typeof useModel>;

看過(guò)幾個(gè)前端整潔架構(gòu)的項(xiàng)目,大部分都會(huì)把 model 分為 業(yè)務(wù)模型(領(lǐng)域模型) 或者 視圖模型。

業(yè)務(wù)模型(領(lǐng)域模型) 可以指用于表達(dá)業(yè)務(wù)內(nèi)容的數(shù)據(jù)。例如淘寶的業(yè)務(wù)模型是【商品】,博客的業(yè)務(wù)模型是【博文】,推特的業(yè)務(wù)模型是【推文】??梢岳斫鉃榻?jīng)典 MVC 中的 Model,包含了名稱(chēng)、描述、時(shí)間、作者、價(jià)格等【真正意義上的】數(shù)據(jù)字段內(nèi)容。

視圖模型 則是 MVVM 興盛后的新概念。要實(shí)現(xiàn)一個(gè)完整的 Web 應(yīng)用,除了數(shù)據(jù)外,還有 UI 交互中非常多的狀態(tài)。例如:彈框是否打開(kāi)、用戶是否正在輸入、請(qǐng)求 Loading 狀態(tài)是否需要顯示、圖表數(shù)據(jù)分類(lèi)是否需要顯示追加字段、和用戶輸入時(shí)文本的大小和樣式的動(dòng)態(tài)改變……這些和具體數(shù)據(jù)字段無(wú)關(guān),但對(duì)前端實(shí)際業(yè)務(wù)場(chǎng)景非常重要的視圖狀態(tài),可以認(rèn)為是一種視圖模型。

業(yè)務(wù)模型(領(lǐng)域模型)的上限太高,站在業(yè)務(wù)的角度去深入的挖掘、歸納,有一個(gè)高大上的名詞:領(lǐng)域驅(qū)動(dòng)開(kāi)發(fā)。不管是前端還是后端,領(lǐng)域驅(qū)動(dòng)開(kāi)發(fā)的成本太高,對(duì)開(kāi)發(fā)人員的要求也高?;舜罅康臅r(shí)間去劃分領(lǐng)域模型,最終結(jié)果可能是弄出各種相互耦合的模型,還不如意大利面式的代碼好維護(hù)。很多整潔結(jié)構(gòu)的項(xiàng)目都是選擇商品,購(gòu)物車(chē)作為例子,因?yàn)檫@些業(yè)務(wù)已經(jīng)被玩透了,比較容易就把業(yè)務(wù)模型弄出來(lái)。

回到文章標(biāo)題中寫(xiě)的 提升前端開(kāi)發(fā)體驗(yàn),顯然面向業(yè)務(wù)領(lǐng)域去劃分模型并不是一種好的開(kāi)發(fā)體驗(yàn)。為了避免意大利面式的代碼,還是選擇進(jìn)行模型的劃分,不過(guò)不是站在業(yè)務(wù)領(lǐng)域的角度區(qū)劃分,而是直接從拿到的設(shè)計(jì)稿或者原型著手(畢竟前端大部分的工作還是面向設(shè)計(jì)稿或者原型編程),直接把頁(yè)面上需要用到的數(shù)據(jù)放到模型中。

比如這篇文章所用的例子,一個(gè)增刪改查的頁(yè)面。查詢條件 filterForm ,列表數(shù)據(jù) userList,分頁(yè)信息 pagination,加載狀態(tài) loading,新增修改彈框 modalInfo。這幾個(gè)字段就是這個(gè)頁(yè)面的模型數(shù)據(jù),不分什么業(yè)務(wù)模型,視圖模型,全部放在一起。

runFetch 這個(gè)變量是為了把副作用依賴(lài)進(jìn)行收口。從交互的角度來(lái)說(shuō),查詢條件或者分頁(yè)信息變了,應(yīng)該觸發(fā)網(wǎng)絡(luò)請(qǐng)求這個(gè)副作用,刷新頁(yè)面數(shù)據(jù)。如果查詢條件由十幾個(gè),那么副作用的依賴(lài)就太多了,代碼既不好維護(hù)也不簡(jiǎn)潔,所以查詢條件或者分頁(yè)數(shù)據(jù)變的時(shí)候,同時(shí)更新 runFetch,副作用只依賴(lài)于 runFetch 即可。

看上面的 model 代碼,其實(shí)就是一個(gè)自定義 hooks。也就是說(shuō)我們?cè)?model 層直接依賴(lài)了框架 react 或者 vue,違反了整潔架構(gòu)的規(guī)范。這是從 開(kāi)發(fā)體驗(yàn)技術(shù)選型 兩方面考慮,要在不引入 mobx,@formily/reactive 等第三方庫(kù)的前提下實(shí)現(xiàn)修改 model 數(shù)據(jù)就能直接觸發(fā)視圖的更新,使用自定義 hooks 是最便捷的。

model 中沒(méi)有限制更新數(shù)據(jù)方式,外部能直接讀寫(xiě)模型數(shù)據(jù)。因?yàn)?model 只是當(dāng)前頁(yè)面中使用,沒(méi)必要為了更新某個(gè)字段單獨(dú)寫(xiě)一個(gè)方法給外部去調(diào)用。同時(shí)也不建議在 model 中寫(xiě)方法,保持 model 的干凈,后續(xù)維護(hù)或者需求變更,會(huì)有更好的開(kāi)發(fā)體驗(yàn),邏輯方法放到后面會(huì)介紹的 service 層中。

react 寫(xiě)的 model不能在 vue 項(xiàng)目中復(fù)用,反之一樣。但是在跨端開(kāi)發(fā)中,model 還是可以復(fù)用的,比如如果技術(shù)棧是 react,web 端和 RN 端是可以復(fù)用 model 層的。如果用了 Taro 或者 uni-app 框架,model 層和 service 層不會(huì)受到不同端適配代碼的污染,在 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 是一個(gè)純類(lèi),通過(guò)構(gòu)造函數(shù)注入 model (如果是 react 技術(shù)棧,presenter 層調(diào)用的時(shí)候使用單例方法,避免每次re-render 都生成新的實(shí)例),service 的方法內(nèi)是相應(yīng)的業(yè)務(wù)邏輯,可以直接讀寫(xiě) model 的狀態(tài)。

service 要盡量保持“整潔”,不要直接調(diào)用特定環(huán)境,端的 API,盡量遵循 依賴(lài)倒置原則。比如 fetch,WebSocket,cookie,localStorage 等 web 端原生 API 以及 APP 端 JSbridge,不建議直接調(diào)用,而是抽象,封裝成單獨(dú)的庫(kù)或者工具函數(shù),保證是可替換,容易 mock 的。還有 Taro,uni-app 等框架的 API 也不要直接調(diào)用,可以放到 presenter 層。還有組件庫(kù)提供的命令式調(diào)用的組件,也不要使用,比如上面代碼中的刪除方法,調(diào)用 api 成功后,不會(huì)直接調(diào)用組件庫(kù)的 Toast 進(jìn)行提示,而是在 presenter 中調(diào)用。

service 保證足夠的“整潔”,model 和 service 是可以直接進(jìn)行單元測(cè)試的,不需要去關(guān)心是 web 環(huán)境還是小程序環(huán)境。

presenter

presenter 調(diào)用 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: "確認(rèn)",
      content: "確認(rèn)刪除當(dāng)前記錄?",
      cancelText: "取消",
      okText: "確認(rèn)",
      onOk: () => {
        service.del(data.id).then(() => {
          message.success("刪除成功");
          service.getUserList();
        });
      },
    });
  };

  const handleCreate = () => {
    model.modalInfo.visible = true;
    model.modalInfo.title = "創(chuàng)建";
    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: '確認(rèn)',
      content: '確認(rèn)刪除當(dāng)前記錄?',
      cancelText: '取消',
      okText: '確認(rèn)',
      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;

因?yàn)?presenter 是一個(gè)自定義 hooks,所以可以使用別的自定義的 hooks,以及其它開(kāi)源的 hooks 庫(kù),比如 ahooks,vueuse 等。presenter 中不要出現(xiàn)太多的邏輯代碼,適當(dāng)?shù)某殡x到 service 中。

view

view 層就是 UI 布局,可以是 jsx 也可以是 vue template。產(chǎn)生的事件由 presenter 處理,使用 model 進(jìn)行數(shù)據(jù)綁定。

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="名稱(chēng)">
                  <Input
                    value={this.model.filterForm.name}
                    placeholder="輸入名稱(chēng)搜索"
                    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 />}
                >
                  創(chuàng)建
                </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="名稱(chēng)">
            <Input
              :value="model.filterForm.name"
              placeholder="輸入名稱(chēng)搜索"
              @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>
            創(chuàng)建
          </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="名稱(chēng)">
                <Input
                  value={model.filterForm.name}
                  placeholder="輸入名稱(chēng)搜索"
                  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 = '創(chuàng)建';
                    s.data = undefined;
                  });
                }}
                icon={<PlusOutlined />}
              >
                創(chuàng)建
              </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;

為何如此分層

一開(kāi)始以這種方式寫(xiě)代碼的時(shí)候,service 跟 presenter 一樣也是一個(gè)自定義 hooks:

import useModel from './useModel';

const useService = () => {
  const model = useModel();
  // 各種業(yè)務(wù)邏輯方法
  const getRemoteData = () => {};

  return { model, getRemoteData };
};

export default useService;

import useService from './useService';

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

  // 調(diào)用 service 方法處理 view 事件

  return {
    model,
    service,
  };
};

export default useController;

useController 就是 usePresenter,這么操作下來(lái),這里就產(chǎn)生了三個(gè)自定義 hooks,為了保證 service 和 presenter 里的 model 是同一份數(shù)據(jù),model 只能在 sevice 中創(chuàng)建,返回給 presenter 使用。

因?yàn)橥祽?,以及有的?yè)面邏輯確實(shí)很簡(jiǎn)單,就把邏輯代碼都寫(xiě)在了 presenter 中,整個(gè) service 只有兩行代碼。刪掉 service 吧,就得調(diào)整代碼,在 presenter 中去引入以及創(chuàng)建 model,如果哪天業(yè)務(wù)變復(fù)雜了,presenter 膨脹了,需要把邏輯抽離到 service 中,又得調(diào)整一次。而且,如果技術(shù)棧是 react ,比較追求性能的話,service 中的方法還得加上 useCallback。所以,最后 service 變成了原生語(yǔ)法的類(lèi),業(yè)務(wù)不復(fù)雜時(shí),presenter 中不調(diào)用就行。

回看整個(gè)文件及結(jié)構(gòu),如下:

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

這是從功能模塊內(nèi)具體的頁(yè)面角度來(lái)劃分,再以文件名來(lái)做分層,不考慮不同頁(yè)面之間進(jìn)行復(fù)用,也幾乎不存在能復(fù)用的。如果某個(gè)頁(yè)面或模塊需要獨(dú)立部署,很容易就能拆分出去。

看過(guò)其它整潔架構(gòu)的落地方案,還有以下兩種分層方式:

面向業(yè)務(wù)領(lǐng)域,微服務(wù)式分層架構(gòu)

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

面向業(yè)務(wù)領(lǐng)域,微服務(wù)式的分層架構(gòu)。不同的 module 是根據(jù)業(yè)務(wù)來(lái)劃分的,而不是某個(gè)具體的頁(yè)面,需要非常熟悉業(yè)務(wù)才有可能劃分好??梢酝瑫r(shí)調(diào)用多個(gè)模塊來(lái)實(shí)現(xiàn)業(yè)務(wù)功能。如果業(yè)務(wù)模塊需要獨(dú)立部署,也很容易就能拆分出去。

單體式分層架構(gòu)

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

就像以前后端的經(jīng)典三層架構(gòu),很難拆分。

數(shù)據(jù)共享,跨組件通訊

父子組件使用 props 即可,子孫組件、兄弟組件(包括相同模塊不同頁(yè)面)或者不同模塊考慮使用狀態(tài)庫(kù)。

狀態(tài)庫(kù)推薦:

vue:Pinia,全局 reactive 或者 ref 聲明變量。

react:jotaizustand ,hox

個(gè)人吐槽:別再用 Redux 以及基于 Redux 弄出來(lái)的各種庫(kù)了,開(kāi)發(fā)體驗(yàn)極差

子孫組件、兄弟組件(包括相同模塊不同頁(yè)面)狀態(tài)共享

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
 

模塊中的不同頁(yè)面需要共享數(shù)據(jù),在模塊根目錄新增 store.ts (子孫組件的話,store.ts 文件放到頂層父組件同級(jí)目錄下即可)

// 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 贈(zèng)送類(lèi)型,0-個(gè)人,1-團(tuán)隊(duì)
     * @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 贈(zèng)送類(lèi)型,0-個(gè)人,1-團(tuán)隊(duì)
     * @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,
    ...
  };
};

我們可以稱(chēng)這種為局部的數(shù)據(jù)共享,因?yàn)槭褂玫牡胤骄褪菃蝹€(gè)模塊內(nèi)的組件,不需要特意地去限制數(shù)據(jù)的讀寫(xiě)。

還有一種場(chǎng)景使用這種方式會(huì)有更好的開(kāi)發(fā)體驗(yàn):一個(gè)表單頁(yè)面,表單填了一半需要跳轉(zhuǎn)頁(yè)面進(jìn)行操作,返回到表單頁(yè)面要維持之前填的表單還在,只需要把 model 中數(shù)據(jù)放到 store 中即可。

模塊之間數(shù)據(jù)共享

其實(shí)就是全局狀態(tài),按以前全局狀態(tài)管理的方式放就行了。因?yàn)樽x寫(xiě)的地方變多了,需要限制更新數(shù)據(jù)的方式以及能方便的跟蹤數(shù)據(jù)的變更操作。
vue 技術(shù)棧建議使用 Pinia,react 還是上面推薦的庫(kù),都有相應(yīng)的 dev-tools 觀察數(shù)據(jù)的變更操作。

完整代碼

vue3

vue2.6

vue2.7

react

taro-vue

taro-react

mock服務(wù)

?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請(qǐng)聯(lián)系作者
【社區(qū)內(nèi)容提示】社區(qū)部分內(nèi)容疑似由AI輔助生成,瀏覽時(shí)請(qǐng)結(jié)合常識(shí)與多方信息審慎甄別。
平臺(tái)聲明:文章內(nèi)容(如有圖片或視頻亦包括在內(nèi))由作者上傳并發(fā)布,文章內(nèi)容僅代表作者本人觀點(diǎn),簡(jiǎn)書(shū)系信息發(fā)布平臺(tái),僅提供信息存儲(chǔ)服務(wù)。

相關(guān)閱讀更多精彩內(nèi)容

友情鏈接更多精彩內(nèi)容