上一篇文章介紹了整體架構,接下來説説怎麼按照上圖的分層結構實現下面的增刪改查的功能。
代碼結構
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:jotai ,zustand ,hox
個人吐槽:別再用 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服務