「数据字典」 是现代 web 系统中比较常用的功能,尤其在面向 「表格(CRUD)」 类业务时,要 扩展某一字段的属性 或 增加某一字段的枚举 ,比如在下图中 「状态」 增加一个 「禁用」 的枚举,只需要在数据字典表中修改对应的枚举即可, 而不用去进行额外的 「开发发版」 操作。
但是,「灵活的系统,往往需要复杂的逻辑」 支撑,后端接口通过 PO -> VO 的转换来保证接口数据即是渲染数据,看似减轻了前端开发者的工作量,但随着项目的推进,组里有同事反馈了在几个场景下的问题:
- 「表格数据的编辑」:在编辑场景下,拿到的渲染数据没法再次使用,后端接口要求的是字段原始值而非渲染值
- 「跨页面消费字典」 :在当前 SPA 的前端架构下,由于数据字典并没有使用 状态管理 去托管,如果想在其他页面(组件)使用某个曾被请求过的字典数据,还需要重新 CV 代码来消费字典数据
踩坑之路
其实第一个问题的处理方法也很简单,即让 「后端同学」 支持下🤣,既然能 PO -> VO ,那就辛苦 VO -> PO ,从前端发出的请求都是渲染值,需要后端做下翻译在执行其他操作,但后来想到了一个致命的场景,如图所示: 首先是一个用户(user1)正常的请求了表格的数据,并拿到了它的渲染值,而之后,另一个用户(user2)更新了数据字典表,过了一会,user1 要执行编辑操作,如果用户不刷新页面,此刻向服务器传递的数据仍然是旧数据,后端转换的时候肯定找不到对应的数据从而报错影响用户体验,因此 VO -> PO 这一步翻译操作只能在前端执行。 至于问题二,笔者并不希望 「数据字典」 的心智和 「状态管理」 一样复杂,暂时也不希望用状态管理托管数据字典,原因是不希望维护成本翻倍,解决办法只能另想。
填坑之路
查看了一些开源解决方案,比如 「yudao」 ,它的解法是在用户第一次加载页面的时候就去请求全量的数据字典,消费的时候直接从本地拿数据进行处理,优点是简单,但缺点就是这张 「数据字典表」 的大小时不可控的,「几 M」 还好,如果是 「20M」 呢,而且用户也不需要这么多数据,白白浪费带宽,首页白屏时间还会变长,用户体验直接为 0,解决办法也好说,按需加载/请求数据字典就行。 结合第二个问题,希望有一个全局对象供其他页面(组件)使用,「Proxy」 便是一个很好的对象,通过 代理/劫持 来保证每一个字典数据都是 「唯一的」。
我们先用 express 来模拟一个后端数据字典的接口。
对于我们要造的 「轮子」 ,首先,我们要创造一个字典数据对象,对象里有两个属性 _name 和 _value 对应字典数据表中的 key 和 data ,这个对象完成的功能也很简单,有值就返回对应的值,没值就向后端请求对应的字典数据并返回。 此外,数据字典的枚举转换方法也是必须要提供的了,这里要注意 「label」 和 「value」 是可以互转的
typescript
class DictionaryItem {
private _name: string;
private _value: any = undefined;
constructor(name: string) {
this._name = name;
}
public async getValue(): Promise<{ label: string; value: string }[]>{
if(this._value !== undefined){
return this._value
}
try {
this._value = (
await request('/api/v1/dict', {params: {name: this._name}})
)['data'];
return this._value;
} catch (error) {
return []
}
}
/**
* 枚举转换函数
*
* @author siroi
* @param value 枚举值或枚举标签
* @param type 转换类型,可选值为 'label' 或 'value',默认为 'value'
* @returns 返回转换后的值或 undefined
*/
public async enumTrans(
value: string | number,
type: 'label' | 'value' = 'value',
) {
try {
const data = await this.getValue();
const current = data.find((item) => {
return item[type === 'label' ? 'value' : 'label'] === value;
});
if (type === 'value') {
return current?.value;
} else {
return current?.label;
}
} catch (error) {
return null;
}
}
}
接着创建一个 Proxy 对象, 这里留意下,在最早的版本中,通过 Proxy 代理后的值应该是直接返回数据,但是后来发现好多操作还是得暴露给开发者,因此返回的是对应 DictionaryItem 实例化的对象,而且通过 Proxy 也不用保证实例化的对象为单例🤓
vbnet
type Store = Record<string, DictionaryItem>;
const _store: Store = {};
const store = new Proxy(_store, {
get: function (target, key: string) {
if (!Reflect.has(target, key)) {
const item = new DictionaryItem(key);
Reflect.set(target, key, item);
return item;
} else {
return Reflect.get(target, key);
}
},
});
由于项目使用的是 「React」, 因此也给开发者提供一个自定义的 hook,用来获取字典值。最后导出这三个属性、方法。
ini
/**
* 获取指定名称的存储数据
*
* @param name 存储名称
* @returns 存储数据数组
*/
const useGetStore = (name: string) => {
const [data, setData] = useState<Record<string, string | number>[]>([]);
useEffect(() => {
const getStoreData = async () => {
if (name !== '') {
const _data = await store[name].getValue();
setData(_data);
}
};
getStoreData();
}, [name]);
return [data];
};
export {
DictionaryItem,
useGetStore as useGetWarehouseBeta,
store as warehouseBeta,
};
举个栗子
整个 轮子 暴露了两个 「取数」 方法和 一个 「值转换」 方法,用这个轮子实现一个如下的 「DEMO」
- 「用户名」 , 「学校」 下拉框的值通过数据字典获得
- 表单提交需要把 用户名 和 学校这两个字段的 label 进行返回
获取值和表单提交转换的方法如下:
ini
const [ name ] = useGetWarehouseBeta('name');
const [ school ] = useGetWarehouseBeta('school');
const onFinish = async (data: {username: string, school: string}) => {
const transName = await warehouseBeta['name'].enumTrans(data.username, 'label');
const transSchool = await warehouseBeta['school'].enumTrans(data.school, 'label');
//....
};
return <>
<Form.Item<FieldType>
label="用户名"
name="username"
rules={[{ required: true, message: 'Please select your username!' }]}
>
<Select options={name}/>
</Form.Item>
<Form.Item<FieldType>
label="学校"
name="school"
rules={[{ required: true, message: 'Please select your school!' }]}
>
<Select options={school} />
</Form.Item>
</>
最终效果如下: 最终提交的数据为:
json
{ "username": 2, "school": "Tsinghua University", "remember": true, "transName": "test2", "transSchool": "清华大学", "flag": 0.32423895797449254}
填坑之路再填坑
第一版的方案在功能上已经解决了问题一和二,但是某次无意的看 network 发现,针对某一字典仍然会被请求多次。问题复现很简单,还是上面的例子,在页面加载时,执行获取值和翻译值的操作
scss
useEffect(()=> {
(async () => {
const o_data = await warehouseBeta['name'].getValue();
const data = await warehouseBeta['name'].enumTrans('test2');
console.log('=======>', data, o_data)
})()
}, [])
问题出现的原因在 DictionaryItem 的 getValue 方法上,如果请求同一个字典两次,如果第一次 「微任务」 的结果还未返回,那么第二次仍然会向服务器发送请求,本着 带宽 能省则省的方针,我们需要对 DictionaryItem 进行改造。
改造思路很简单,就是给 getValue 「加锁」 ,虽然 js 本身是没有锁这个概念的,但是:「Promise」 的 「pending」 转态,本身就是一把 「锁」 ! 改造后的代码如下:
kotlin
class DictionaryItem {
private _name: string;
private _value: any = undefined;
private _query: any[] = [];
//* 请求数据的 loading
private _loading: boolean = false;
constructor(name: string) {
this._name = name;
}
// 其他方法不重复写了
/**
* 获取值列表, 只向服务端请求一次
* @author siroi
* @returns 返回一个Promise,resolve值为一个对象数组,每个对象包含label和value属性
* @version 2.0
*/
public async getValue(): Promise<{ label: string; value: string }[]> {
if (this._loading) {
//* 如果在请求的过程中还有被调用,则返回一个Promise,
//* 保证被 pending 等待请求完成后 resolve
return new Promise((res) => {
this._query.push((_v: any) => res(_v)); //* 只要没被 resolve 就一直 pending
});
}
try {
if (this._value !== undefined) {
return this._value;
} else {
//* 调用开始设置
this._loading = true;
this._value = (
await request('/api/v1/dict', {params: {name: this._name}})
)['data'];
//* 结束后执行
this._loading = false;
while (this._query.length > 0) {
const item = this._query.pop();
if (typeof item === 'function') {
//! 判断是函数后执行
item(this._value);
}
}
return this._value;
}
} catch (error) {
//! 服务器报错,返回 空数组
return [];
}
}
}
改造前:
改造后:
填坑完毕😼(「Promise」 真好用)
hi, there👋
i am siroi
一个接触前端 2.5 年的新人,欢迎关注我的微信公众号:siroi的前端手册
让我们一起愉快的玩耍吧🤓