上一篇文章介绍了整体架构,接下来说说怎么按照上图的分层结构实现下面的增删改查的功能。

代码结构

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

如何结合整洁架构和MVP模式提升前端开发体验(二) - 代码实现篇的更多相关文章

  1. 如何结合整洁架构和MVP模式提升前端开发体验 - 整体架构篇

    本文不详细介绍什么是整洁架构以及 MVP 模式,自行查看文章结尾相关链接文章. 整洁架构粗略介绍 下图为整洁架构最原始的结构图: Entities/Models:实体层,官方说法就是封装了企业里最通用 ...

  2. 如何结合整洁架构和MVP模式提升前端开发体验(三) - 项目工程化配置、规范篇

    工程化配置 还是开发体验的问题,跟开发体验有关的项目配置无非就是使用 eslint.prettier.stylelint 统一代码风格. formatting and lint eslint.pret ...

  3. [原创]使用命令行工具提升cocos2d-x开发效率(二)之CocosBuilder篇

    如果你正在使用CocosBuilder或者是其他基于CocosBuilder源码改装而成的工具为你的游戏搭建场景或者UI,那你一定要看看这篇文章:)   你是否已经厌倦了无聊的手工publish操作? ...

  4. MVP模式在Android开发中的应用

    一.MVP介绍      随着UI创建技术的功能日益增强,UI层也履行着越来越多的职责.为了更好地细分视图(View)与模型(Model)的功能,让View专注于处理数据的可视化以及与用户的交互.同一 ...

  5. [译]Google官方关于Android架构中MVP模式的示例

    概述 该示例(TODO-MVP)是后续各种示例演变的基础,它主要演示了在不带架构性框架的情况下实现M-V-P模式.其采用手动依赖注入的方式来提供本地数据源和远程数据源仓库.异步任务通过回调处理. 注意 ...

  6. Google官方关于Android架构中MVP模式的示例续-DataBinding

    基于前面的TODO示例,使用Data Binding库来显示数据并绑定UI元素的响应动作. 这个示例并未严格遵循 Model-View-ViewModel 或 Model-View-Presenter ...

  7. [原创]使用命令行工具提升cocos2d-x开发效率(一)之TexturePacker篇

    TexturePacker是一个常用的制作sprite sheet的工具,它提供了很多实用的功能. 一般我们制作sprite sheet都是使用他的gui版本,纯手工操作,就像下面这张图示的一样. 刚 ...

  8. 通过Swagger文档生成前端service文件,提升前端开发效率

    在企业级的项目开发过程中,一般会采用前后端分离的开发方式,前后端通过api接口进行通信,所以接口文档就显得十分的重要. 目前大多数的公司都会引入Swagger来自动生成文档,大大提高了前后端分离开发的 ...

  9. 说说Android的MVP模式

    http://toughcoder.NET/blog/2015/11/29/understanding-Android-mvp-pattern/ 安卓应用开发是一个看似容易,实则很难的一门苦活儿.上手 ...

随机推荐

  1. 分享|智慧环保-生态文明信息化解决方案(附PDF)

    内容摘要: 生态文明建设被提到前所未有的战略高度,我们既要绿水青山,也要金山银山.宁要绿水青山,不要金山银山,而且绿水青山就是金山银山.要正确处理好经济发展同生态环境保护的关系,牢固树立保护生态环境就 ...

  2. python常见的错误提示处理

    python常见的错误有 NameError变量名错误 IndentationError代码缩进错误 AttributeError对象属性错误 TypeError类型错误 IOError输入输出错误 ...

  3. mariadb安装配置(主从配置)

    主服务器192.168.206.183 从服务器192.168.206.193 1.创建并编辑 /etc/yum.repos.d/MariaDB.repo文件(主从都要做) [mariadb] nam ...

  4. Electron学习(三)之简单交互操作

    写在前面 最近一直在做批量测试工具的开发,打包的exe,执行也是一个黑乎乎的dos窗口,真的丑死了,总感觉没个界面,体验不好,所以就想尝试写桌面应用程序. 在技术选型时,Java窗体实现使用JavaF ...

  5. Http实战之Wireshark抓包分析

    Http实战之Wireshark抓包分析 Http相关的文章网上一搜一大把,所以笔者这一系列的文章不会只陈述一些概念,更多的是通过实战(抓包+代码实现)的方式来跟大家讨论Http协议中的各种细节,帮助 ...

  6. 挑战30天写操作系统-day1-从计算机结构到汇编程序入门

    先动手操作 软盘映像文件制作:先采用二进制编辑器编辑我们所需要的映像文件helloos.img 二进制编辑器下载链接:Bz - c.mos (vcraft.jp) 制作好之后,可以选择写入软盘,通过软 ...

  7. SQL练习六--More JOIN operations

    movie Field name Type Notes id INTEGER An arbitrary unique identifier title CHAR(70) The name of the ...

  8. Python究竟属不属于嵌入式语言?

    写在前面: 几十年来,大家普遍的认为C与C++才是标准的嵌入式语言,那么现在大火的Python算是一种嵌入式语言吗? 在给出我的答案之前我们要先明确几个问题? 什么是Python? 编程语言的定义? ...

  9. day10 Map_查找与遍历

    Map 查找表 Map体现的结构是一个多行两列的表格,其中左列称为key,右列称为value. Map总是成对保存数据,并且总是根据key获取对应的value.因此我们可以将查询的条件作为key查询对 ...

  10. 串口应用:遵循uart协议发送N位数据(状态优化为3个,适用任意长度的输入数据,取寄存器中的一段(用变量作为边界))

    上一节中成功实现了发送多个字节的数据.把需要发送的数据分成多段遵循uart协议的数据依次发送.上一节是使用状态机实现的,每发一次设定为一个状态,所以需要发送的数据越多,状态的个数越多,代码越长,因而冗 ...