
上一篇文章介紹了整體架構(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 聲明變量。
個(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ù)的變更操作。