前端数据字典集成管理方案 - 基于 Vue.js 实现

9,821 阅读7分钟

背景

多年之后,在一个摆满多肉的工位上,涛哥一定会想起我给他介绍这套方案时平平无奇却不卑不亢的口吻。做业务开发的小伙伴都知道,在任何一个稍具规模的前端项目中,数据字典的管理都是极其重要的,但如何做好,却没有连贯的思路。一个刚入门的前端程序员极可能写出这样的代码:

<select>
  <option value="1">刘备</option>
  <option value="2">关公</option>
  <option value="3">张飞</option>
</select>

{
  filters: {
    role(value) {
      switch (value) {
        case 1:
          return '刘备'
        case 2:
          return '关公'
        case 3:
          return '张飞'
      }
    }
  }
}

然后在系统其他用到的地方,复制过去即可。渐渐地,系统中就会充斥着随意复制粘贴的同质数据,一旦需要增删改,必然所有相关地方都会波及,否则就会出现各种数据不一致的问题。这类问题产生的原因是什么,如何解决这个或说这类问题?回答如下:

  1. 问题产生的原因在于开发者没有以数据结构为中心去分析系统的架构和演化,只满足于当前模块的需求实现;
  2. 要解决这类问题,务必遵循数据统一管理原则,特别对于同质化数据,必须尽最大可能保证单一数据源,其他的一切展示形态例如选择器、过滤器等必须溯源至单一数据源。

基于此,在下一节将给出完整的设计方案。整个方案会基于 Vue 2.x 框架做实现。

设计

我们的设计遵循单一数据源原则。数据源我们称作数据字典,数据字典按来源分有两种:前后端约定由前端自行维护的静态数据字典和由后端维护并提供接口访问的动态数据字典;界面展示与交互形态包含两种:过滤器和选择器。

思维导图

数据字典

数据字典即单一数据源头,目录分配为:src/model/dicts。数据字典包含两类,一类是前端与后端协商定下的键/值,一类是动态维护由后端给前端提供API接口。静态字典以 static-业务线前缀.js 命名,动态字典以 **dynamic-业务线前缀.js **命名。字典键值按业务线前缀加具体描述的形式划分,详细数据格式见实现部分。

过滤器

过滤器采用全局形式,整个系统公用。因为字典键值已经按业务线前缀做了命名空间隔离,所以不存在冲突的可能性。注册方式以 Vue 插件形式提供。

组件引用

组件引用的场景包含:搜索表单、过滤器和录入表单。搜索表单与录入表单可封装成独立组件,过滤器则直接取字典键值即可。

实现

业务线以三国演义为例,前缀:sgyy。

数据字典

静态字典

新建一个 src/model/dicts 目录,对于静态字典在此目录下新建 static-sgyy.js

// @NOTICE 命名以对应业务线缩写作为前缀,规避业务冲突
export default {
  dictArr: {
    sgyyWei: [
      { key: '1', value: '曹操' }
      { key: '2', value: '司马懿' }
    ],
    sgyyShu: [ 
      { key: '1', value: '刘备' },
      { key: '2', value: '关公' },
  		{ key: '3', value: '张飞' }
    ],
    sgyyWu: [ 
      { key: '1', value: '孙权' },
      { key: '2', value: '周瑜' }
    ]
  }
}


在目录下新建 index.js 文件作为字典整合导出层

let subDictKeys = [
  './static-sgyy',
]

export default {
  dictArr: {
    common: [
      { key: '1', value: '共用字典字段' }
    ]
  },
  
  // 对外提供转换为键值对功能
  getDict: function (key) {
    var arr = this.dictArr[key]
    var dict = {}
    for (var i = 0; i < arr.length; i++) {
      dict[arr[i].key + ''] = arr[i].value
    }
    return dict
  },
  
  // 按原始数组形式对外
  getDictArr: function (key) {
    return this.dictArr[key]
  }
}

subDictKeys.forEach(item => {
  let subDicts = require(item+'')
  dicts.dictArr = mix(dicts.dictArr, subDicts.dictArr)
})

function mix(o, n) {
  var obj = o || {}

  for (var p in n) {
    if (n.hasOwnProperty(p) && (!o.hasOwnProperty(p))) {
      o[p] = n[p]
    }
  }

  return obj
}


动态字典

在 src/model/dicts 目录下,新建 dynamic-sgyy.js 文件

export default {
  sgyyAsyncWeiHero: {
    url: constant.baseUri + '/heros/wei',
    optionKey: { // 指定后端返回的数据以哪两个字段作为键/值,这里一般需要与后端开发协商好
      label: 'name',
      value: 'code'
    }
  }
}

过滤器

过滤器实现仅针对静态数据字典,动态字典无法做成过滤器,需要 API 接口返回对应描述,前端直接展示描述字典即可。过滤器实现如下

import dicts from '@/model/dicts/index'

export default {
  install(Vue) {
    var dictArr = dicts.dictArr
    for (var key in dictArr) {
      if (dictArr.hasOwnProperty(key)) {
        (function (key) {
          Vue.filter(key, function (n) {
            var item = dicts.getDict(key)
            return item[n + ''] || '-'
          })
        }(key))
      }
    }
  }
}

组件引用

静态字典组件

我们把静态字典对应的选择器封装成通用组件,并将其注册为全局组件。代码如下

<template>
  <el-select :placeholder="calcPlaceholder" clearable
    :value="value + ''"
    @change="changeFn"
  >
    <el-option
      v-for="item in dictArr"
      :value="item.key"
      :key="item.key"
      :label="item.value"
    />
  </el-select>
</template>

<script>

const PLC_MAP = {
  SEARCH: '全部', 
  DATAOPR: '请选择'
}

export default {
  name: 'StaticDictSelect',

  model: {
    prop: 'value',
    event: 'change'
  },

  props: {
    value: {
      type: String | Number,
      default: ''
    },
    dictArr: {
      type: Array,
      default: []
    },
    actionType: { // 'SEARCH' => 搜索操作 | 'DATAOPR' => 数据操作
      type: 'SEARCH' | 'DATAOPR',
      default: 'DATAOPR'
    },
    placeholder: ''
  },

  computed: {
    calcPlaceholder() {
      return this.placeholder || PLC_MAP[this.actionType]
    }
  },

  data() {
    return {
      PLC_MAP: {
        SEARCH: '全部',
        SELECT: '请选择'
      }
    }
  },

  methods: {
    changeFn(value) {
      this.$emit('change', value)
    }
  }
}
</script>

<style lang="scss" scoped></style>

组件调用方式如下

<template>
  <gp-page class="page">
    <gp-search>
      <el-form inline :model="table.params" >
        <el-form-item label="支付状态">
          <gp-static-dict-select actionType="SEARCH"
            v-model="table.params.wei"
            :dictArr="searchDicts.wei"
          />
        </el-form-item>
      </el-form>
      <div slot="right">
        <el-button type="primary" @click="search(1)">查询</el-button>
        <el-button @click="resetSearchFilter">清空条件</el-button>
      </div>
    </gp-search>

    <gp-table
      :tableData="table"
      :loading="table.loading"
      @change="search"
    >
      <el-table-column label="姓名" prop="name" min-width="200" 
        show-overflow-tooltip
      >
        <template slot-scope="scope">
          {{ scope.row.wei | sgyyWei }}
				</template>
      </el-table-column>
    </gp-table>
  </gp-page>
</template>

<script>
import dicts from '@M/dicts/index'

const INIT_SEARCH_FILTER = {
  wei: '',
}

export default {
  name: 'page',

  data() {
    return {
      searchDicts: {
        wei: dicts.getDictArr('sgyyWei'),
      },
      table: {
        params: {
          ...INIT_SEARCH_FILTER
        },
        loading: true,
        total: 0,
        page: 1,
        size: 10,
        list: []
      },
      viewOrderId: ''
    }
  },

  filters: {},

  methods: {
    async search(page) {
      // 获取列表数据
      const res = await this.$post('/search', {
        ...this.table.params
      })
      /* 后续处理略 */
    },
    
    resetSearchFilter() {
      this.table.params = {
        ...INIT_SEARCH_FILTER
      }
    }
  },

  mounted() {
    this.search()
  }
}
</script>

<style lang="scss" scoped></style>

动态字典组件

动态字典组件可以选择封装为通用组件并注册为全局组件,代码如下

<template>
  <el-select
    class="dynamic-dict-select"
    :placeholder="calcPlaceholder"
    :no-data-text="noDataText || '无数据'"
    :size="actionType === 'SEARCH' ? 'small' : ''"
    v-model="innerValue"
    :filterable="filterable"
    :clearable="clearable"
    :disabled="disabled"
    :loading="loading"
    :remote="remote"
    :remote-method="remoteMethod"
    @change="change"
    @visible-change="visibleChange"
  >
    <el-option 
      v-for="item in options" 
      :key="item.value" 
      :label="item.label" 
      :value="item.value"
     />
  </el-select>
</template>

<script>
const PLC_MAP = {
  SEARCH: '全部',
  DATAOPR: '请选择'
}

export default {
  name: 'DynamicDictSelect',
  props: {
    actionType: {
      // 'SEARCH' => 搜索操作 | 'DATAOPR' => 数据操作
      type: 'SEARCH' | 'DATAOPR',
      default: 'DATAOPR'
    },
    bindSelItem: {}, // {url: '', optionKey: {label: 'labelKey', value: 'labelKey'}}
    appendParam: {}, // 附加参数,如果有传则处理,未传则不处理
    validFlagNew: {
      //传入0查所有,1查正常(有效)
      type: Number,
      default: 0
    },
    value: {},
    label: {}, //获取 label
    filterable: {
      type: Boolean,
      default: true
    },
    clearable: {
      type: Boolean,
      default: true
    },
    disabled: false,
    load: {
      type: Boolean,
      default: false
    },
    placeholder: {
      type: String,
      default: ''
    },
    remote: {
      type: Boolean,
      default: true
    },
    paging: {
      type: Boolean,
      default: true
    }
  },
  data() {
    return {
      loading: false,
      innerValue: '',
      options: [],
      noDataText: '',
      chooseArr: []
    }
  },

  watch: {
    appendParam: {
      handler(newAppendParam, oldAppendParam) {
        this.findData('', true)
      },
      immediate: true,
      deep: true
    }
  },

  computed: {
    calcPlaceholder() {
      return this.placeholder || PLC_MAP[this.actionType]
    }
  },

  created() {
    this.remoteMethod = this.$utils.debounce(this.remoteMethod, 300)
    if (this.value) {
      this.innerValue = JSON.parse(JSON.stringify(this.value))
    }
    this.findData()
  },
  methods: {
    visibleChange(visible) {
      if (visible) {
        this.findData()
      }
    },
    async findData(keywords, forceUpdate) {
      let pageIndex = 1
      let pageSize = 50

      const options = this.options
      if (options.length > 0 && !forceUpdate) {
        return
      }

      this.chooseArr = []
      let isRemote = this.remote

      if (this.loading) {
        return
      }

      if (!isRemote && this.options.length > 0) {
        return
      }

      this.loading = true

      let params = {
        keywords: keywords || '',
        name: keywords || ''
      }

      // 处理附加参数
      let appendParam = this.appendParam
      for (let key in appendParam) {
        if (appendParam.hasOwnProperty(key)) {
          params[key] = appendParam[key]
        }
      }

      params.validFlagNew = this.validFlagNew

      if (this.paging) {
        params.pageNum = pageIndex
        params.pageSize = pageSize
        params.rows = pageSize
      }

      let bindSelItem = this.bindSelItem

      // 解决先赋值再加载列表的问题
      let cache = ''
      if (this.innerValue) {
        cache = JSON.parse(JSON.stringify(this.innerValue))
        this.innerValue = ''
      }

      const result = await this.$post(bindSelItem.url, {
        data: params
      })
      
      if (result.err) {
        this.noDataText = result.errmsg
      } else {
        const data = result.data || {}
        const arr = []
        let list = []

        if (data.list) {
          list = data.list
        } else {
          list = data
        }

        list = list.splice(0, pageSize)

        list.forEach(item => {
          this.chooseArr.push(item)
          arr.push({
            label: item[bindSelItem.optionKey['label']],
            value: item[bindSelItem.optionKey['value']]
          })
        })
        this.options = arr

        setTimeout(() => {
          this.$emit('load-data', JSON.parse(JSON.stringify(arr)))
        }, 10)

        // 设置延迟,解决当有值的情况下,列表未加载出来且下拉列表处于显示状态的时候,不能正确显示label的问题
        setTimeout(() => {
          if (cache) {
            this.innerValue = cache
          }
        }, 25)

        this.noDataText = ''
      }
    },
    change(e) {
      this.$emit('input', e)
      this.getLabel(e)
      this.$emit('change', e)
    },
    getLabel(value) {
      let label = ''
      let obj = {}
      for (let i = 0; i < this.options.length; i++) {
        const option = this.options[i]
        if (option.value == value) {
          label = option.label
          obj = this.chooseArr[i]
          break
        }
      }
      this.$emit('update:label', label)
      this.$emit('listenToChildListLabel', label)
      this.$emit('listenToChildListEvent', obj)
    },

    remoteMethod(query) {
      this.findData(query, true)
    }
  },

  watch: {
    value(value) {
      this.innerValue = value
      this.getLabel(value)
    }
  }
}
</script>

<style lang="scss">
.gp-dynamic-dict-select {
  .el-select__caret {
    &.el-input__icon:not(.el-icon-circle-close) {
      &:before {
        content: '\E6E1';
      }
    }
  }
}
</style>

动态字典组件引用方式实例如下

<template>
  <div class="page">
    <layout-search>
      <el-form
        ref="sgyySearchForm"
        inline
        label-width="100px"
        :model="searchForm"
      >
        <el-form-item label="魏国武将" prop="weiHero">
          <gp-dynamic-dict-select
            actionType="SEARCH"
            v-model="searchForm.weiHero"
            :bindSelItem="bindDynamicSel.sgyyAsyncWeiHero"
          />
        </el-form-item>
      </el-form>
      <div slot="right">
        <el-button type="primary" @click="search(1)">查询</el-button>
      </div>
    </layout-search>

    <el-dialog :visible="visble">
      <el-form ref="sgyyDataForm" inline label-width="100px" :model="dataForm">
        <el-form-item label="魏国武将" prop="weiHero">
          <gp-dynamic-dict-select
            actionType="DATAOPR"
            v-model="dataForm.weiHero"
            :bindSelItem="bindDynamicSel.sgyyAsyncWeiHero"
          />
        </el-form-item>
      </el-form>
    </el-dialog>
  </div>
</template>
<script>
import bindDynamicSel from "@M/dicts/dynamic-sgyy";

export default {
  data() {
    return {
      searchForm: {
        weiHero: "",
      },
      bindDynamicSel: bindDynamicSel,
      visble: false,
      dataForm: {
        weiHero: "",
      },
    };
  },
  methods: {
    async search(page) {
      const searchPage = page || 1;
      const result = await this.$post(constant.baseURI + "/search/heros", {
        data: {
          ...this.searchForm,
          pageNum: searchPage,
          pageSize: 20,
        }
      })
      /* 后续处理略 */
    },
  },
};
</script>

<style lang="scss" scoped></style>

优化

当前方案的设计是静态字典与动态字典独立维护,独立使用,有没有可能将动静两套归并为一套呢?其次,当前方案中,通用组件所需调用方传入的属性很多,比如静态字典组件需要调用方注入字典数组,而动态字典组件则需要指定 bindSelItem,有没有可能再做一层封装,调用方只需要指定 key 值就行?当然可以!

总结

这套数据字典集成管理方案实现了对系统字典数据的统一集约化管理,做到了所有界面形态包括选择器与过滤器都只依赖单一数据源。最终,无论系统界面或交互形态如何变化,基础数据层始终保持稳定。产品迭代进化时,也只需要更改单一数据源头即可,易如反掌,从此告别全局搜索替换。
最后按照惯例,附上源码:github.com/sq580kzfed/…