[开发随笔] 前端处理数据字典问题的踩坑与填坑

「数据字典」 是现代 web 系统中比较常用的功能,尤其在面向 「表格(CRUD)」 类业务时,要 扩展某一字段的属性增加某一字段的枚举 ,比如在下图中 「状态」 增加一个 「禁用」 的枚举,只需要在数据字典表中修改对应的枚举即可, 而不用去进行额外的 「开发发版」 操作。

但是,「灵活的系统,往往需要复杂的逻辑」 支撑,后端接口通过 PO -> VO 的转换来保证接口数据即是渲染数据,看似减轻了前端开发者的工作量,但随着项目的推进,组里有同事反馈了在几个场景下的问题:

  1. 「表格数据的编辑」:在编辑场景下,拿到的渲染数据没法再次使用,后端接口要求的是字段原始值而非渲染值
  2. 「跨页面消费字典」 :在当前 SPA 的前端架构下,由于数据字典并没有使用 状态管理 去托管,如果想在其他页面(组件)使用某个曾被请求过的字典数据,还需要重新 CV 代码来消费字典数据

踩坑之路

其实第一个问题的处理方法也很简单,即让 「后端同学」 支持下🤣,既然能 PO -> VO ,那就辛苦 VO -> PO ,从前端发出的请求都是渲染值,需要后端做下翻译在执行其他操作,但后来想到了一个致命的场景,如图所示: 首先是一个用户(user1)正常的请求了表格的数据,并拿到了它的渲染值,而之后,另一个用户(user2)更新了数据字典表,过了一会,user1 要执行编辑操作,如果用户不刷新页面,此刻向服务器传递的数据仍然是旧数据,后端转换的时候肯定找不到对应的数据从而报错影响用户体验,因此 VO -> PO 这一步翻译操作只能在前端执行。 至于问题二,笔者并不希望 「数据字典」 的心智和 「状态管理」 一样复杂,暂时也不希望用状态管理托管数据字典,原因是不希望维护成本翻倍,解决办法只能另想。

填坑之路

查看了一些开源解决方案,比如 「yudao」 ,它的解法是在用户第一次加载页面的时候就去请求全量的数据字典,消费的时候直接从本地拿数据进行处理,优点是简单,但缺点就是这张 「数据字典表」 的大小时不可控的,「几 M」 还好,如果是 「20M」 呢,而且用户也不需要这么多数据,白白浪费带宽,首页白屏时间还会变长,用户体验直接为 0,解决办法也好说,按需加载/请求数据字典就行。 结合第二个问题,希望有一个全局对象供其他页面(组件)使用,「Proxy」 便是一个很好的对象,通过 代理/劫持 来保证每一个字典数据都是 「唯一的」

我们先用 express 来模拟一个后端数据字典的接口。

对于我们要造的 「轮子」 ,首先,我们要创造一个字典数据对象,对象里有两个属性 _name_value 对应字典数据表中的 keydata ,这个对象完成的功能也很简单,有值就返回对应的值,没值就向后端请求对应的字典数据并返回。 此外,数据字典的枚举转换方法也是必须要提供的了,这里要注意 「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」

  1. 「用户名」「学校」 下拉框的值通过数据字典获得
  2. 表单提交需要把 用户名 和 学校这两个字段的 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)
    })()
  }, [])

问题出现的原因在 DictionaryItemgetValue 方法上,如果请求同一个字典两次,如果第一次 「微任务」 的结果还未返回,那么第二次仍然会向服务器发送请求,本着 带宽 能省则省的方针,我们需要对 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的前端手册

让我们一起愉快的玩耍吧🤓

相关推荐
也无晴也无风雨33 分钟前
深入剖析输入URL按下回车,浏览器做了什么
前端·后端·计算机网络
Martin -Tang1 小时前
Vue 3 中,ref 和 reactive的区别
前端·javascript·vue.js
FakeOccupational3 小时前
nodejs 020: React语法规则 props和state
前端·javascript·react.js
放逐者-保持本心,方可放逐3 小时前
react 组件应用
开发语言·前端·javascript·react.js·前端框架
曹天骄4 小时前
next中服务端组件共享接口数据
前端·javascript·react.js
阮少年、4 小时前
java后台生成模拟聊天截图并返回给前端
java·开发语言·前端
郝晨妤5 小时前
鸿蒙ArkTS和TS有什么区别?
前端·javascript·typescript·鸿蒙
AvatarGiser6 小时前
《ElementPlus 与 ElementUI 差异集合》Icon 图标 More 差异说明
前端·vue.js·elementui
喝旺仔la6 小时前
vue的样式知识点
前端·javascript·vue.js
别忘了微笑_cuicui6 小时前
elementUI中2个日期组件实现开始时间、结束时间(禁用日期面板、控制开始时间不能超过结束时间的时分秒)实现方案
前端·javascript·elementui