首先我们先来看一个简单的需求,假设我们要计算一个按钮是否可用。规则如下⬇️
| 普通用户 | Vip用户 | 管理员 | |
|---|---|---|---|
| 工作日 | ✅ | ✅ | ❌ |
| 非工作日 | ❌ | ❌ | ✅ |
一、逻辑判断
首先我们很自然可以想到使用if else 的逻辑判断来模拟需求的每一个分支
const IsWorkDay // 是否是工作日
const UserType // 用户类型
// 模拟所有逻辑分支
if (IsWorkDay) {
if (UserType === '普通用户') return true
if (UserType === 'Vip用户') return true
if (UserType === '管理员') return false
} else {
if (UserType === '普通用户') return false
if (UserType === 'Vip用户') return false
if (UserType === '管理员') return true
}
这是一个很典型的面向过程的写法。我们按照需求依次输出逻辑分支判断。最终得到结果
当然上面的代码还可以进行逻辑分支合并,减少代码量
// 合并过后
if (UserType !== '管理员' && IsWorkDay) return true
if (UserType === '管理员' || !IsWorkDay) return true
return false
实际上上述代码的还是非常易于阅读,如果我们的需求真这么简单,这就是最好的写法:)
二、穷举所有状态,存表查询
如果我们换一个视角来看这个需求,我们的目的是获取多个 不同的维度值 下的结果。
如果我们把所有不同维度值的组合全部获取,并用某种算法对不同的组合生成 对应的唯一 的值,该指向最终的结果
这样一来,我们要做的事情就变成了 穷举所有状态 和 实现生成唯一值的算法
再把生成的唯一值和结果关联起来,可以使用任意 数据结构,也可以使用 数据库表
这里用数据结构的方式示意:
// 关联表对象
const map = {}
const UserType = ['普通用户', 'Vip用户' , '管理员']
const IsWorkDay = ['工作日', '非工作日]
// 生存map表的唯一键
function genMapKey(...dims: string[]) {
return dims.sort().join('-')
}
// 生成按钮状态
function genButtonStatus(dims: string[], state: boolean) {
map[getMapKey(...dims)] = state
}
// 查询按钮状态
function queryButtonStatus(...dims: string[]) {
return map[genMapKey(...dims)]
}
// 穷举所有的状态
// todo:此处还能优化吗?
genButtonStatus(['普通用户', '工作日'], true)
genButtonStatus(['Vip用户', '工作日'], true)
genButtonStatus(['管理员', '工作日'], false)
genButtonStatus(['普通用户', '非工作日'], false)
genButtonStatus(['Vip用户', '非工作日'], false)
genButtonStatus(['管理员', '非工作日'], true)
// 查询的时候
return queryButtonStatus('普通用户', '工作日')
对于上述代码,很明显我们维护的重点从逻辑分支的书写变成了 穷举状态 和 算法
一般来说,算法不会经常变化,那么剩下的工作就是穷举状态而已。那么这样写又有什么问题呢?
- 增加了代码阅读的成本
- 穷举状态的代码很难维护:一旦出现了bug,难以定位是那条分支定义错了
三、自动穷举所有状态
从上面的代码可以看出,让我们自行穷举所有状态,是非常反人类的。并且这部分代码读起来让人头昏脑胀,四肢发麻...
还记得最开始的那个需求表吗?我们是否可以直接读取表的内容然后自动生成状态呢?
// 我们以md格式为例
假设需求存在rules.md文档,内容如下:
| | 普通用户 | Vip用户 | 管理员 |
| --- | --- | --- | --- |
| 工作日 | ✅ | ✅ | ❌ |
| 非工作日 | ❌ | ❌ | ✅ |
我们直接读取这个文件
// 省略实现,直接用nodejs即可
const content = readRuleMd()
const rows = content.split('\n').filter(Boolean).reduce((rows, row) => {
rows.push(row.split('|').filter(Boolean))
return rows
}, [])
rows[0].forEach((item, idx) => {
if (idx === 0) return;
rows.slice(1).forEach((row, index) => {
// 注册状态和值
genButtonStatus(item, row[0]) = row[idx] === '✅'
});
});
很明显,自动将需求表中的内容穷举并存表。维护起来非常方便,甚至这部分文档的内容可以转交给产品同学来定义,一旦出问题了,产品背锅:)
题外话,如果这个需求规则表的内容是后端接口返回,这是不是就是一个动态的呢
下面我们将讨论其他几种值的情况,并将以一个真实需求为例
真实需求
这是一个值计算,其中白色块就是对应的结果,返回一个数组
如果我们使用if else 的写法,相信我,写出来的代码没人想维护
那么如果使用存表查询呢?
首先我们还是可以采用前面的算法函数。至于状态规则定义,实际上也是大同小异。无非是新增了一些维度信息
const dim1 = ['条件1', '条件2' , '条件3']
const dim2 = ['根实体', '子实体']
const dim3 = ['树形实体', '非树形实体']
const dim4 = ['子实体一对多']
const dim5 = ['详情页', '列表页' , '其他页面']
genButtonStatus(['条件1', '详情页', '根实体', '树形实体'], [2])
genButtonStatus(['条件1', '详情页', '根实体', '非树形实体'], [1])
// 等等...
同样,我们也可以写一个自动注册的函数来穷举所有的状态定义。此处省略(我懒...
但是你会发现,维度的增加并没有给整体代码添加复杂度,多的只是状态表里面的几行数据而已
状态聚合
在前端开发中,有一种场景非常的常见,就是表单。 对于表单来说,状态是必不可少的一环:
- 是否必填:提示用户的文案内容是什么
- 是否禁用:原因是什么
- 是否可见:和权限相关
- 是否能修改:流程相关
- 值:当前值、服务器上的最新值、修改前的值等等
- 等等。。。
表单的每一个节点都是上述的状态的集合。如果我们有一种机制,可以把这些状态(值)的计算单独定义,并聚合到一个数据结构中,那么整个表单的状态管理将变的异常轻松。同时状态的抽离会让UI和逻辑解耦,那么跨端复用也将有方向可行。
而对于外部使用者来说,也可以很轻松的获取到整个表单的任意节点的任意状态
毋庸置疑,树是一个非常适合该场景的数据结构。表单对象本身就是一颗树,里面的每一个属性都是一个节点。
/**
* 以下代码仅供参考,并没有真实调试过
* 项目中真实代码远比这个复杂
*/
export class FormStatusTreeManager<T extends object> {
value: T
form: FormStatusTreeItem<T>
getFormItem(path: string) {
// 查找value中是否存在
const item = ...
if (item) return item
return new FormStatusTreeItem(path)
}
}
/**
* 节点的状态
*/
export class FormStatusTreeItem <T>{
// 状态表
private statusMap = {}
private value?: any
private serverValue?: any
// 子节点
private children?; FormStatusTreeItem[]
constructor(path: string) {
this.path = path
// 建立父子级节点关系
}
public get disabled(ctx: any) {
const key = this.genDisabledStatusMapKey(ctx)
return this.statusMap[key]
}
public genDisabledStatus(...args: any) {}
public genDisabledStatusMapKey(...args: any) {}
// 其他属性类似
}
const form = new FormStatusTreeManager()
// 自动创建状态树
form.getItem('name')
form.getItem('code')
form.getItem('user.id')
...
// 应用对应的规则
// 请不要纠结这些维度值,我们完全可以维护一个上下文来处理
form.getItem('name').genDisabledStatus(['后台管理员'], false)
form.getItem('name').genDisabledStatus(['id的值为空'], true)
// 正常使用
const item = form.getItem('name')
<Input
value={item.value}
disaebld={item.value}
readonly={item.readonly}
error={item.error}
/>
function onSubmit() {
// 直接从form上获取对应的信息
// 包括form的校验等
}
如果大家使用过现在的各个UI库的form组件,就会发现他们也是在做状态聚合。
每当我们主动使用<Form.Item>的时候,每个<Form.Item> 就是创建了一个表单节点,所有的状态都可以绑定到对应的节点上,内部自然就可以构建成一个完整的树。
但是这种通过标签的声明式方式,会导致很难去做抽象,你必须要在他的基础上。再去做一套 FormCreator,然后通过一大堆的配置去生成标签节点树。同时你也无法主动获取当前某个节点的状态,除非UI库本身提供了这样的功能(实际上没有提供
所以我更推荐的一种做法是 FormStatus 这一层自己做,把所有的状态计算,逻辑等内聚到类里面,把UI库当成一个纯UI Component 来使用,放弃他提供的Form组件能力。
当积累到一定程度的时候,FormStatus的各个rule最后都可以可视化掉(你看看这个像什么...
正经总结
- 状态(值)计算简单的时候,直接面向过程写
- 复杂的、动态的状态(值)计算,可以参考存表查询的方式来做
- 多个不同维度状态描述同一个节点,我们可以往上层抽象。最后聚合出来一个解决方案
有问题欢迎和我讨论