数据驱动UI设计(DDD)
背景
这个项目是为了解决中后台B端的研发ROI问题。中后台项目主要是内部使用的PC页面,或者是平台提供给用户后台设置的页面。用户量较前台页面(C端、移动端)较小,而且对性能体验要求相对可控,主要是稳定性和可用性。
B端的页面通常是一个表单查询列表页面,基本样式如下:
领域驱动设计DDD
2004年Eric Evans 发表Domain-Driven Design –Tackling Complexity in the Heart of Software (领域驱动设计),简称Evans DDD。领域驱动设计分为两个阶段:
以一种领域专家、设计人员、开发人员都能理解的通用语言作为相互交流的工具,在交流的过程中发现领域概念,然后将这些概念设计成一个领域模型; 由领域模型驱动软件设计,用代码来实现该领域模型;
由此可见,领域驱动设计的核心是建立正确的领域模型。
如何建立领域模型
领域驱动设计告诉我们,在通过软件实现一个业务系统时,建立一个领域模型是非常重要和必要的,因为领域模型具有以下特点:
- 领域模型是对具有某个边界的领域的一个抽象,反映了领域内用户业务需求的本质;领域模型是有边界的,只反应了我们在领域内所关注的部分;
- 领域模型只反映业务,和任何技术实现无关;领域模型不仅能反映领域中的一些实体概念,如货物,书本,应聘记录,地址,等;还能反映领域中的一些过程概念,如资金转账,等;
- 领域模型确保了我们的软件的业务逻辑都在一个模型中,都在一个地方;这样对提高软件的可维护性,业务可理解性以及可重用性方面都有很好的帮助;
- 领域模型能够帮助开发人员相对平滑地将领域知识转化为软件构造;
- 领域模型贯穿软件分析、设计,以及开发的整个过程;领域专家、设计人员、开发人员通过领域模型进行交流,彼此共享知识与信息;因为大家面向的都是同一个模型,所以可以防止需求走样,可以让软件设计开发人员做出来的软件真正满足需求;
- 要建立正确的领域模型并不简单,需要领域专家、设计、开发人员积极沟通共同努力,然后才能使大家对领域的认识不断深入,从而不断细化和完善领域模型;
- 为了让领域模型看的见,我们需要用一些方法来表示它;图是表达领域模型最常用的方式,但不是唯一的表达方式,代码或文字描述也能表达领域模型;
- 领域模型是整个软件的核心,是软件中最有价值和最具竞争力的部分;设计足够精良且符合业务需求的领域模型能够更快速的响应需求变化;
建模时思考问题的角度
“用户需求”不能等同于“用户”,捕捉“用户心中的模型”也不能等同于“以用户为核心设计领域模型”。 《老子》书中有个观点:有之以为利,无之以为用。在这里,有之利,即建立领域模型;无之用,即包容用户需求。举些例子,一个杯子要装满一杯水,我们在制作杯子时,制作的是空杯子,即要把水倒出来,之后才能装下水;再比如,一座房子要住人,我们在建造房子时,建造的房子是空的,唯有空的才能容纳人的居住。因此,建立领域模型时也要将用户置于模型之外,这样才能包容用户的需求。
所以,我的理解是:
我们设计领域模型时不能以用户为中心作为出发点去思考问题,不能老是想着用户会对系统做什么;而应该从一个客观的角度,根据用户需求挖掘出领域内的相关事物,思考这些事物的本质关联及其变化规律作为出发点去思考问题。
领域模型是排除了人之外的客观世界模型,但是领域模型包含人所扮演的参与者角色,但是一般情况下不要让参与者角色在领域模型中占据主要位置,如果以人所扮演的参与者角色在领域模型中占据主要位置,那么各个系统的领域模型将变得没有差别,因为软件系统就是一个人机交互的系统,都是以人为主的活动记录或跟踪;比如:论坛中如果以人为主导,那么领域模型就是:人发帖,人回帖,人结贴,等等;DDD的例子中,如果是以人为中心的话,就变成了:托运人托运货物,收货人收货物,付款人付款,等等;因此,当我们谈及领域模型时,已经默认把人的因素排除开了,因为领域只有对人来说才有意义,人是在领域范围之外的,如果人也划入领域,领域模型将很难保持客观性。领域模型是与谁用和怎样用是无关的客观模型。归纳起来说就是,领域建模是建立虚拟模型让我们现实的人使用,而不是建立虚拟空间,去模仿现实。
领域驱动设计的经典分层架构:
问题
方案
基于百度AMIS的配置化方案
amis 是一个低代码前端框架,它使用 JSON 配置来生成页面,可以减少页面开发工作量,极大提升效率。
AMIS: https://aisuda.bce.baidu.com/amis/zh-CN/docs/index
<!DOCTYPE html>
<html lang="zh">
<head>
<meta charset="UTF-8" />
<title>amis demo</title>
<meta http-equiv="Content-Type" content="text/html; charset=utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1, maximum-scale=1" />
<meta http-equiv="X-UA-Compatible" content="IE=Edge" />
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/amis/3.4.0/sdk.css" />
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/amis/3.4.0/helper.css" />
<link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/amis/3.4.0/iconfont.css" />
<script src="https://cdn.bootcdn.net/ajax/libs/babel-standalone/7.22.17/babel.min.js"></script>
<style>
html,
body,
.app-wrapper {
position: relative;
width: 100%;
height: 100%;
margin: 0;
padding: 0;
}
</style>
</head>
<body>
<div id="root" class="app-wrapper"></div>
<script src="https://cdn.bootcdn.net/ajax/libs/amis/3.4.0/sdk.js"></script>
<script type="text/javascript">
(function() {
let amis = amisRequire('amis/embed');
let amisJSON = {
type: 'page', title: '表单页面',
body: {
type: 'form', mode: 'horizontal', api: '/saveForm',
body: [ { label: 'Name', type: 'input-text', name: 'name' },
{ label: 'Email', type: 'input-email', name: 'email' } ] }
};
amis.embed('#root', amisJSON); setInterval(function(){ if(window.amisJSON){
amis.embed('#root', window.amisJSON); window.amisJSON = ''; } }, 50); }
)();
</script>
<script type="text/babel">
window.amisJSON = { "type": "page", "toolbar": [ { "type": "form", "panelClassName": "mb-0", "title": "", "body": [ { "type": "select", "label": "区域", "name": "businessLineId", "selectFirst": true, "mode": "inline", "options": [ "北京", "上海" ], "checkAll": false }, { "label": "时间范围", "type": "input-date-range", "name": "dateRange", "inline": true, "value": "-1month,+0month", "inputFormat": "YYYY-MM-DD", "format": "YYYY-MM-DD", "closeOnSelect": true, "clearable": false } ], "actions": [], "mode": "inline", "target": "mainPage", "submitOnChange": true, "submitOnInit": true } ], "body": [ { "type": "grid", "columns": [ { "type": "panel", "className": "h-full", "body": { "type": "tabs", "tabs": [ { "title": "消费趋势", "tab": [ { "type": "chart", "config": { "title": { "text": "消费趋势" }, "tooltip": {}, "xAxis": { "type": "category", "boundaryGap": false, "data": [ "一月", "二月", "三月", "四月", "五月", "六月" ] }, "yAxis": {}, "series": [ { "name": "销量", "type": "line", "areaStyle": { "color": { "type": "linear", "x": 0, "y": 0, "x2": 0, "y2": 1, "colorStops": [ { "offset": 0, "color": "rgba(84, 112, 197, 1)" }, { "offset": 1, "color": "rgba(84, 112, 197, 0)" } ], "global": false } }, "data": [ 5, 20, 36, 10, 10, 20 ] } ] } } ] }, { "title": "账户余额", "tab": "0" } ] } }, { "type": "panel", "className": "h-full", "body": [ { "type": "chart", "config": { "title": { "text": "使用资源占比" }, "series": [ { "type": "pie", "data": [ { "name": "BOS", "value": 70 }, { "name": "CDN", "value": 68 }, { "name": "BCC", "value": 48 }, { "name": "DCC", "value": 40 }, { "name": "RDS", "value": 32 } ] } ] } } ] } ] }, { "type": "crud", "className": "m-t-sm", "api": "https://aisuda.bce.baidu.com/amis/api/mock2/sample", "columns": [ { "name": "id", "label": "ID" }, { "name": "engine", "label": "Rendering engine" }, { "name": "browser", "label": "Browser" }, { "name": "platform", "label": "Platform(s)" }, { "name": "version", "label": "Engine version" }, { "name": "grade", "label": "CSS grade" } ] } ] }
</script>
</body>
</html>
基于Antd Procomponents配置化
ProComponents 是基于 Ant Design 而开发的模板组件,提供了更高级别的抽象支持,开箱即用。可以显著地提升制作 CRUD 页面的效率,更加专注于页面。
Antd: https://ant.design/index-cn/
Antd PRO: https://pro.ant.design/zh-CN/
Antd Procomponents: https://procomponents.ant.design/docs
ProLayout 解决布局的问题,提供开箱即用的菜单和面包屑功能 ProTable 表格模板组件,抽象网络请求和表格格式化 ProForm 表单模板组件,预设常见布局和行为 ProCard 提供卡片切分以及栅格布局能力 ProDescriptions 定义列表模板组件,ProTable 的配套组件 ProSkeleton 页面级别的骨架屏 在使用之前可以查看一下典型的 Demo 来判断组件是否适合你们的业务。ProComponents 专注于中后台的 CRUD, 预设了相当多的样式和行为。这些行为和样式更改起来会比较困难,如果你的业务需要丰富的自定义建议直接使用 Ant Design。
<!DOCTYPE html>
<html>
<head>
<title>antd example</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/antd/4.16.13/antd.min.js"></script>
<Link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/antd/4.16.13/antd.min.css" />
<style>
.container {
width: 800px;
margin: 0 auto;
}
</style>
</head>
<body>
<div id="root"></div>
<script type="text/babel">
const { useState, useEffect, useCallback } = React;
const { Tabs, Form, Input, Select, Button, Table, Modal, message, Pagination } = antd;
const { Item } = Form;
const { Option } = Select;
const mockData = []; // 在这里生成20条mock数据用于测试翻页功能
for (let i = 0; i < 20; i++) {
mockData.push({
id: `id${i}`,
name: `名称${i}`,
attribute: `属性${i}`,
word: `词语${i}`,
status: `状态${i}`,
operator: `操作人${i}`,
operateTime: new Date().toLocaleString(), // 使用当前时间作为操作时间示例
});
}
const BlacklistPage = () => {
const [form] = Form.useForm();
const [visible, setVisible] = useState(false);
const [editingRecord, setEditingRecord] = useState(null);
const [currentPage, setCurrentPage] = useState(1);
const pageSize = 20;
const handleEdit = (record) => {
setEditingRecord(record);
setVisible(true);
};
const handleConfirm = () => {
// 处理确认逻辑,例如提交表单数据等
setVisible(false);
message.success('编辑成功');
};
const handleCancel = () => {
Modal.confirm({
title: '确认取消编辑吗?',
onOk() {
setVisible(false);
},
});
};
const handleDelete = (record) => {
Modal.confirm({
title: '确认删除这条记录吗?',
onOk() {
// 处理删除逻辑,例如从数据源中移除数据等
message.success('删除成功');
},
});
};
const handlePageChange = (page) => {
setCurrentPage(page);
};
const columns = [
{ title: 'ID', dataIndex: 'id', key: 'id' },
{ title: '名称', dataIndex: 'name', key: 'name' },
{ title: '属性', dataIndex: 'attribute', key: 'attribute' },
{ title: '词语', dataIndex: 'word', key: 'word' },
{ title: '状态', dataIndex: 'status', key: 'status' },
{ title: '操作人', dataIndex: 'operator', key: 'operator' },
{ title: '操作时间', dataIndex: 'operateTime', key: 'operateTime' },
{
title: '操作',
key: 'action',
render: (text, record) => (
<div>
<Button type="link" onClick={() => handleEdit(record)}>编辑</Button>
<Button type="link" onClick={() => handleDelete(record)}>删除</Button>
</div>
),
},
];
const paginatedData = () => {
const start = (currentPage - 1) * pageSize;
const end = start + pageSize;
return mockData.slice(start, end);
};
return (
<div>
<h1 className="text-2xl font-bold">黑名单</h1>
<Tabs defaultActiveKey="1">
<Tabs.TabPane tab="黑名单" key="1">
<Form form={form} layout="inline">
<Item name="id" label="ID">
<Input maxLength={30} />
</Item>
<Item name="attribute" label="属性">
<Select>
<Option value="option1">选项1</Option>
<Option value="option2">选项2</Option>
</Select>
</Item>
<Item name="operator" label="操作人">
<Input />
</Item>
<Button type="primary">搜索</Button>
</Form>
<Table columns={columns} dataSource={paginatedData()} />
<Pagination
current={currentPage}
total={mockData.length}
pageSize={pageSize}
onChange={handlePageChange}
/>
</Tabs.TabPane>
<Tabs.TabPane tab="规则" key="2">
{/* 规则Tab的内容可以在这里添加 */}
</Tabs.TabPane>
</Tabs>
<Modal
title="编辑记录"
visible={visible}
onOk={handleConfirm}
onCancel={handleCancel}
>
<Form>
<Item name="name" label="名称">
<Input maxLength={30} />
</Item>
<Item name="category" label="类目">
<Select>
<Option value="category1">类目1</Option>
<Option value="category2">类目2</Option>
</Select>
</Item>
<Item name="attribute" label="属性">
<Select>
<Option value="attribute1">属性1</Option>
<Option value="attribute2">属性2</Option>
</Select>
</Item>
<Item name="word" label="词语">
<Input />
</Item>
<Item>
<Button type="primary" htmlType="submit">
确定
</Button>
<Button onClick={handleCancel}>取消</Button>
</Item>
</Form>
</Modal>
</div>
);
};
ReactDOM.render(<BlacklistPage />, document.getElementById('root'));
</script>
</body>
</html>
基于babel-standalone实现在线配置预览
babel-alone的配置:https://babel.nodejs.cn/docs/babel-standalone/
<!DOCTYPE html>
<html>
<head>
<title>Babel Standalone Example</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react/17.0.2/umd/react.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/react-dom/17.0.2/umd/react-dom.development.js"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/babel-standalone/6.26.0/babel.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/antd/4.16.13/antd.min.js"></script>
<Link rel="stylesheet" href="https://cdn.bootcdn.net/ajax/libs/antd/4.16.13/antd.min.css" />
<style>
.container {
width: 800px;
margin: 0 auto;
}
</style>
</head>
<body>
<div id="output"></div>
<!-- Load Babel -->
<script src="https://unpkg.com/@babel/standalone/babel.min.js"></script>
<!-- Your custom script here -->
<script type="text/babel">
const getMessage = () => "Hello World";
document.getElementById("output").innerHTML = getMessage();
</script>
</body>
</html>
总结
解决了横向业务扩展不断增加新的类型的难题,降低了项目开发和维护的成本;
云课程课程:流程驱动设计与研发实战