数据字典这东西,做过企业级应用或后台管理系统的朋友应该都不陌生。尤其当你接触过类似若依这类快速开发框架,会发现它的数据字典功能几乎无处不在——性别、状态、类型、级别,各种下拉选项、标签显示,背后全是字典在支撑。但坦白讲,很多前端同学对数据字典的理解停留在“后端返回一个数组,我渲染一下”的层面,一旦脱离后端接口的支撑,纯前端需要维护一套固定选项的时候就容易写成一堆散落的硬编码,改起来想死的心都有。
这篇文章我就想跟你聊聊,怎么用原生的 JavaScript,在完全不依赖任何框架的前提下,实现一个足够日常业务使用的“简单版数据字典”。这个“简单版”不意味着简陋,而是指它没有复杂的后端同步、没有权限控制、没有分布式配置这些重东西,但把字典该有的核心能力——分类管理、键值映射、中文翻译、选项列表生成——都给你做扎实。我会从最开始的需求分析讲起,到具体的代码设计,再到实际业务里的挂载使用,中间穿插我这些年实际踩过的一些坑。不管你是刚接触前端不久的新手,还是已经写了好几年业务代码但一直没系统梳理过字典方案的老手,这篇文章都值得你花十分钟看看。
1. 先从“为什么需要数据字典”说起:硬编码维护是一场灾难
在动手写代码之前,我得先花点篇幅讲讲数据字典到底解决了什么问题,因为如果这个问题想不清楚,代码写出来大概率也是空中楼阁。
1.1 没有字典的时候,业务代码长什么样
我见过太多项目,在没有字典概念的情况下,前端代码里到处散落着类似这样的逻辑:
javascript复制// 根据用户状态显示对应的标签文本
function getUserStatusText(status) {
switch (status) {
case 0:
return '禁用';
case 1:
return '启用';
case 2:
return '锁定';
default:
return '未知';
}
}
还有这种:
javascript复制// 根据订单类型生成下拉框选项
const orderTypeOptions = [
{ label: '普通订单', value: 1 },
{ label: '拼团订单', value: 2 },
{ label: '秒杀订单', value: 3 }
];
这种写法的问题在哪?在单个页面、单个场景下看着好像挺清爽,但只要你做一个稍微像样点的后台管理系统,就会立刻暴露出三个非常痛的问题。
第一,状态含义散落各处,根本无法统一维护。你在这个页面用 switch,在那个页面可能就用了三元表达式;这个模块里状态值是 0/1/2,另一个模块里可能同一个状态含义用的值完全不一样。时间一长,代码库就是一片混沌,谁也不敢轻易动这些状态值,因为根本不知道全局还有多少处在引用它们。
第二,改动成本极高。产品经理永远是善变的。今天说用户状态要有“锁定”和“禁用”两种,明天可能就要拆成五种,每种还要加个颜色区分。你作为一个前端,只能一个页面一个页面地找、一个文件一个文件地改,改漏一个,线上就会出现“状态显示异常”的bug,然后被测试妹子追着骂。
第三,前后端联调时认知不一致。后端同学返回的字段含义、取值范围,前端同学经常只能靠接口文档去猜。文档更新不及时的时候,前端根本不知道 status 字段到底有哪几种值,每种值是什么含义,只能等接口真正返回了奇怪的数据才发现“哦,原来还有这个值”。而数据字典本质上就是一套业务元数据的约定,它把“状态字段有哪些合法取值、每个取值对应什么业务含义”这件事固定下来,前后端都遵循这套约定,沟通成本能降低一大截。
1.2 数据字典在前端领域的核心定义
数据字典这个词,在不同的技术语境下有微妙的不同。后端的数据字典,通常指的是存在数据库里的一张张表,通过字典类型编码来区分不同业务场景下的数据项集合。而到了前端,我更愿意把它理解为:
一套独立的、可复用的“数据项映射集合”。它以字典类型为分类维度,存储若干键值对(通常是 value -> label),并提供根据类型获取选项列表、根据值和类型翻译文本的统一能力。
打个不那么严谨但很好懂的比方:数据字典就像是一本新华字典。你查一个字(value),它能告诉你这个字怎么读、什么意思(label)。而前端系统里有很多种“字”——用户状态是一类、订单类型是一类、性别是一类——每一类都对应字典里的一个“偏旁部首分区”,我们通过字典类型编码来定位到具体的分区。
之所以强调在前端实现一套独立的数据字典,是因为很多场景下,字典数据并不一定都来源于后端接口。系统的静态配置项、本地需要立即响应的临时选项、某些无需后端参与的纯前端组件配置,这些如果每次都要走后端接口拿,延迟高不说,还给后端平白增加压力。所以一个合格的前端数据字典,应该具备本地直配和远端获取两条腿走路的能力。这篇文章咱们先聚焦在本地直配这条更基础、更通用的路径上,把原理讲透,后面你再去扩展远程加载就非常简单了。
需要模型API调用? 免费领10W Token,多模型网关一键接入 Claude、DeepSeek 等主流模型。
2. 核心数据结构设计:撑起整套字典机制的地基
数据字典的代码量其实不大,但数据结构的设计,决定了后续所有 API 调用是否顺手。这块我至少推翻过两次重写,就是想找到一个足够通用、又足够简单的形态。
2.1 字典类型与字典项:一对多的二维模型
数据字典最核心的模型就是“字典类型”和“字典项”的一对多关系。用文字描述就是:系统里有若干种字典类型(比如性别、状态、类型),每一种字典类型下面有若干个字典项(比如性别下有男、女、保密)。前端的数据结构应该天然地反映这种二维关系。
我见过不少初学者把字典设计成一个扁平的数组,类似这样:
javascript复制const dictList = [
{ type: 'gender', value: 1, label: '男' },
{ type: 'gender', value: 2, label: '女' },
{ type: 'user_status', value: 0, label: '禁用' },
{ type: 'user_status', value: 1, label: '启用' }
];
这种设计有什么问题?第一,取某种类型下的所有字典项时,必须通过循环过滤 dictList.filter(item => item.type === 'gender'),数据量大了之后性能会有损耗。第二,它没法集中存储字典类型本身的额外信息,比如这个字典类型的名称、说明、是否启用等元数据。第三,代码意图不够直白,字典的层级关系没有体现在数据结构里。
所以我在项目里采用的设计是按字典类型分组,用对象或者 Map 作为最外层容器,每个字典类型对应一个独立的字典项数组。这样当业务方需要“性别”字典时,直接通过 key 取用即可,不用遍历过滤:
javascript复制const dictionary = {
gender: {
name: '性别',
items: [
{ value: 1, label: '男', color: '#409EFF' },
{ value: 2, label: '女', color: '#F56C6C' },
{ value: 0, label: '保密', color: '#909399' }
]
},
user_status: {
name: '用户状态',
items: [
{ value: 0, label: '禁用', color: '#F56C6C' },
{ value: 1, label: '启用', color: '#67C23A' },
{ value: 2, label: '锁定', color: '#E6A23C' }
]
}
};
注意这里我特意给每个字典项加了一个 color 字段,这是在企业后台里非常常见的一个需求——状态标签要给不同的颜色区分。如果不放在字典里,等你做到标签展示那一步,还得再写一次颜色映射逻辑,那和硬编码没区别了。把 color 收编进字典项,等于是在数据结构上彻底解决了“选项与样式分离”的问题。
2.2 我为什么最终选择了 Map 而不是普通对象
上面的示例我用的是普通对象字面量。但实际代码里,我更推荐使用 ES6 的 Map 来承载这套数据。原因有两点。
一是语义化更强。字典类型编码作为一个 key,本质上是“键”,不是“属性”。Map 的键值对语义比普通对象更贴近这种表达。当你需要遍历所有字典类型时,Map 直接提供了 keys()、values()、entries() 方法,不需要再 Object.keys(obj).map(k => obj[k]) 绕一圈。
二是键的兼容性更好。对象字面量的 key 会默认被转换成字符串。假如你的字典类型编码里存在数字类型(虽然我很不建议,但保不齐有历史项目这么干),普通对象就会悄悄把 1 和 '1' 混为一谈,导致潜在 bug。而 Map 的 key 可以是任意类型,严格区分数字 1 和字符串 '1',从根上杜绝了这种隐患。
所以我的建议是:最终方案请优先用 Map 实现核心存储。不过为了文章读起来更直观,接下来我在代码示例里会混用对象结构和 Map,实际写代码时你可以自行选择。
2.3 字典项内部字段的取舍设计
字典项内部除了必备的 value 和 label 之外,我建议你在设计阶段就预留几个可选字段,避免后期频繁重构。
value:字典项的值,可以是数字、字符串,我习惯统一用数字或字符串常量。label:字典项的展示文本,也就是前端页面上用户能看到的内容。color:标签展示时的颜色值,可选。tagType:如果项目用的是 Element Plus 或 Ant Design,标签组件的类型(success、info、warning、danger)可以和color二选一,看团队习惯。disabled:布尔值,标识当前选项是否禁用,用于下拉选择场景。children:如果你的字典项需要支持树形结构(比如省市区、商品分类),预留这个字段可以实现递归渲染。ext:任何自定义的扩展字段,塞进去就行。
关于这些字段,有一个很重要但又很容易被忽略的原则:value 一旦确定,线上稳定运行后尽量不要修改。因为历史数据里可能已经大量存储了旧 value,一旦修改,所有关联数据都会面临清洗迁移的问题。label 倒是可以随便改,反正它是给人看的,随时可以优化措辞。
3. 核心代码实现:注册、读取、翻译、渲染一步到位
理论铺垫了一堆,现在进入正片环节。我用纯 JavaScript 写一个 DictManager 类,把字典的注册、查询、翻译、选项提取这些核心能力全部收拢在里面。
3.1 基础框架:注册与初始化
先定义一个基础的工具类,它的职责是维护字典数据并提供增删改查的入口:
javascript复制class DictManager {
constructor() {
this.dictMap = new Map();
}
/**
* 注册一个字典类型
* @param {string} type - 字典类型编码
* @param {string} name - 字典类型名称
* @param {Array} items - 字典项数组,元素需包含 value 和 label
* @returns {DictManager} - 支持链式调用
*/
register(type, name, items = []) {
if (this.dictMap.has(type)) {
console.warn(`[DictManager] 字典类型 "${type}" 已存在,将被覆盖。`);
}
if (!Array.isArray(items)) {
throw new Error(`[DictManager] 字典类型 "${type}" 的 items 必须是一个数组。`);
}
this.dictMap.set(type, {
type,
name,
items
});
return this;
}
/**
* 批量注册多个字典类型
* @param {Object} dictConfig - 字典配置对象
*/
registerBatch(dictConfig) {
Object.entries(dictConfig).forEach(([type, config]) => {
const { name, items } = config;
this.register(type, name, items);
});
return this;
}
/**
* 判断某个字典类型是否存在
* @param {string} type
* @returns {boolean}
*/
has(type) {
return this.dictMap.has(type);
}
}
这里有几个值得抠的细节。
register 方法里我做了重复注册的 warn 提示。为什么不是直接报错?因为在实际项目中,字典定义可能要分散在不同的模块里完成,而且开发环境下热更新会导致模块被反复执行。如果直接 throw new Error,页面还没跑起来就先崩了。而如果你需要严格模式,完全可以在业务侧监听这个 warn 再决定要不要拦截。
registerBatch 支持传入对象形式,这是为了方便你用一个独立的字典配置文件统一管理所有字典,到时候只需要 import 进来一次注册即可。关于这个配置文件,我通常会在项目里单独建一个 constants/dict.js,视觉上就是一张大表,后端同学看了都能直接对齐,团队协作效率很高。
有了这两个注册入口,你就能在一个集中式模块里把全站字典都维护起来了。接下来才真正进入业务调用部分。
3.2 字典查询与选项获取
查询能力是字典最基础、使用频率最高的能力。你需要提供这样几个方法:
javascript复制class DictManager {
// ... 上面已经实现的部分
/**
* 获取指定字典类型的完整定义
* @param {string} type
* @returns {Object|undefined}
*/
getDict(type) {
return this.dictMap.get(type);
}
/**
* 获取指定字典类型下的字典项数组
* @param {string} type
* @returns {Array}
*/
getItems(type) {
const dict = this.getDict(type);
return dict ? dict.items : [];
}
/**
* 获取指定字典类型下的选项列表,供下拉框/单选组使用
* @param {string} type
* @param {Object} options - { disabledValues: [], includeAll: false, allLabel: '全部' }
* @returns {Array}
*/
getOptions(type, options = {}) {
const { disabledValues = [], includeAll = false, allLabel = '全部', allValue = '' } = options;
let items = this.getItems(type).map(item => ({ ...item }));
if (disabledValues.length > 0) {
items = items.map(item => {
if (disabledValues.includes(item.value)) {
item.disabled = true;
}
return item;
});
}
if (includeAll) {
items.unshift({ label: allLabel, value: allValue });
}
return items;
}
}
getOptions 是最常用的一个方法。业务里的下拉控件五花八门,有的查询条件下拉要加一个“全部”选项,有的编辑表单下拉需要把某些项禁用(比如状态为“已完成”的订单不允许再修改成其他状态)。如果每次到业务组件里再去组装这些前缀项和禁用项,代码会显得特别啰嗦。所以我干脆把两个出现频率最高的扩展需求做进了 getOptions 里。
includeAll 默认情况下是 false,因为只有查询条件里才需要“全部”,而没有这个选项的编辑表单你忘了传参也不会受到影响。unshift 用在这里是直接把“全部”塞到第一个位置,保证排序上一定在最前面。
特别提醒一下,getOptions 返回的是浅拷贝后的新数组,并且字典项对象也通过 { ...item } 做了浅拷贝。这么做是很有必要的——你想想看,如果直接把内部数据返回给外部组件,组件里但凡有人手欠写了 option.xxx = 'yyy',你的原始字典数据就被污染了。经过拷贝之后,外部怎么改都不会影响字典源数据,相当于天然加了一层防护。
3.3 核心翻译功能:从 value 到 label 的解析
字典最经典的场景就是“数据库存的是 0/1/2,页面上要显示中文状态”。这个翻译能力也是做数据字典的人最熟悉的方法,通常叫 translate 或者 getLabelByValue,它是字典机制的灵魂。
javascript复制class DictManager {
// ... 前面实现的部分
/**
* 根据字典类型和值翻译文本
* 支持传入数组对多个值进行批量翻译
* @param {string} type
* @param {string|number|Array} value
* @returns {string|Array}
*/
translate(type, value) {
const items = this.getItems(type);
const findLabel = (val) => {
// 这里采用严格全等比较
const matched = items.find(item => item.value === val);
return matched ? matched.label : String(val);
};
if (Array.isArray(value)) {
return value.map(item => findLabel(item));
}
return findLabel(value);
}
}
这里面有个设计决策需要给你解释清楚。findLabel 中找不到匹配项时,我选择了返回原始值本身,而不是返回一个类似 未知、-- 这样的兜底文案。为什么?因为在表格展示场景中,如果数据确实有脏值,把 val 直接渲染出来反而更容易让你发现问题——它能帮助你判断是不是字典配置漏了项,还是后端数据有问题。如果一律返回“未知”,所有异常值都被抹平了,出了问题你连查的方向都没有。
当然,这个策略不是绝对的。遇到那种纯面向用户的阅读场景,你不想让用户看到原始 code,那就额外封装一个方法,在 translate 的结果基础上再做一层兜底替换,把逻辑留给上层调用者决定。核心的 translate 保持“诚实”,这是我觉得比较健康的默认行为。
3.4 高级查询:过滤、排序与自定义数据处理
基础功能顺手了之后,你一定会遇到这些更实际的需求:有的下拉框只要某几个特定选项,有的下拉框选项顺序要按业务调整,有的是想根据字典数据计算一个数字的总和或用字典项做“位掩码”。
我把这些统一封装成一个 query 方法,参数灵活,意图清晰:
javascript复制class DictManager {
// ... 前面实现的部分
/**
* 更灵活的字典项查询方法
* @param {string} type
* @param {Object} query - { filter: Function, sort: Function, map: Function }
* @returns {Array}
*/
query(type, { filter = null, sort = null, map = null } = {}) {
let items = this.getItems(type).map(item => ({ ...item }));
if (typeof filter === 'function') {
items = items.filter(filter);
}
if (typeof sort === 'function') {
items = items.sort(sort);
}
if (typeof map === 'function') {
items = items.map(map);
}
return items;
}
}
这套 API 有点像数组的 filter、sort、map 的复合。比如你想拿“订单状态”字典里所有不是“已取消”且启用状态的选项,并重新编排顺序,把“待发货”放第一位:
javascript复制const dict = new DictManager();
const customizedOptions = dict.query('order_status', {
filter: (item) => item.value !== 5 && !item.disabled,
sort: (a, b) => {
if (a.value === 2) return -1; // 把待发货(2)排到最前
if (b.value === 2) return 1;
return a.sort - b.sort;
},
map: (item) => ({ text: item.label, code: item.value })
});
把这种高阶玩法也沉淀到工具类里的好处是,调用方无需知道内部如何过滤排序,只管传入意图函数即可,代码可读性大大提升,业务组件里不会堆积乱七八糟的数组处理逻辑。
4. 从全局挂载到安全的模块化导出:不同场景下的接入姿势
你手上已经有一份很好的工具类了,但它需要一个真正体面的“接入”方式,业务代码才能方便地调用。常见的有好几种方案,我挨个分析下各自的适用场景和优劣。
4.1 全局对象方式:适合快速 Demo 或非模块化传统项目
如果你是在传统多页应用里用 <script> 标签引 JS,或者临时写个 Demo,最简单的方式是把它挂到 window 上:
javascript复制window.$dict = new DictManager();
window.$dict.registerBatch({
gender: {
name: '性别',
items: [
{ value: 1, label: '男' },
{ value: 2, label: '女' }
]
}
});
// 业务里这样做
const genderText = window.$dict.translate('gender', 1); // 男
这种方式的优点是真·零门槛,任何页面打开都能直接用。缺点也明显:全局变量满天飞、无法按需加载、变量命名容易冲突。不过在小项目里,它确实足够简单粗暴。
4.2 模块化单例模式:最推荐的现代前端方案
现代前端项目基本都是模块化了,我更推荐创建一个独立的字典模块,并且直接导出一个初始化好的单例:
javascript复制// dict/index.js
import { DictManager } from './DictManager';
import { dictConfig } from './config';
const dict = new DictManager();
dict.registerBatch(dictConfig);
export default dict;
然后在任意业务组件中使用:
javascript复制import dict from '@/dict';
const statusLabel = dict.translate('user_status', status);
const statusOptions = dict.getOptions('user_status');
模块化单例的好处有这几个:一是所有字典操作入口统一,后续如果要扩展方法,只需要改 DictManager 类这一个文件,所有引用的地方立刻生效;二是按需引入的灵活性被保留,你完全可以只在需要的模块里 import,不会像全局对象一样把整个世界都暴露出去;三是可以配合构建工具的 tree-shaking,当 DictManager 类里某个方法没用时它会通过摇树优化给你移除掉,从而压缩最终打包体积。
如果你用的是 Vue 或 React,还可以顺手在原型上挂一下,让组件内部调用少写一个 import 步骤。
- Vue 2:
Vue.prototype.$dict = dict - Vue 3:
app.config.globalProperties.$dict = dict - React:通常直接用
import引用即可,不推荐挂在组件实例上。
值得提醒的是:全局注册实例方便是方便,但也容易把自己搞糊涂。团队里一旦有人用了 this.$dict,另一个人偏偏写 import dict,代码风格就割裂了。所以一个团队要定死一种用法。我的倾向是,即便挂在原型上,内部也是同一个单例对象,操作结果无差别,只是语法糖的区别。
4.3 彻底的安全封装:只暴露你需要的能力
还有一种做法是把内部 Map 彻底藏起来,只对外暴露方法,防止业务代码意外篡改数据。就拿前面的代码来说,dict.dictMap 是公开属性,谁都可以 dict.dictMap.clear() 一把梭把字典全清空。如果是团队小、开发规范强,一般问题不大;但如果你在写一个开源工具或者公共包,我强烈建议你写一层闭包或只有 getter 的类:
javascript复制function createDictStore() {
let dictMap = new Map();
return {
register(type, name, items) {
if (dictMap.has(type)) {
console.warn(`[DictManager] 字典类型 "${type}" 已存在,将被覆盖。`);
}
dictMap.set(type, { type, name, items });
},
getItems(type) {
const dict = dictMap.get(type);
return dict ? dict.items.map(item => ({ ...item })) : [];
},
translate(type, value) {
const items = this.getItems(type);
if (Array.isArray(value)) {
return value.map(v => {
const matched = items.find(item => item.value === v);
return matched ? matched.label : String(v);
});
}
const matched = items.find(item => item.value === value);
return matched ? matched.label : String(value);
},
_debug() {
// 仅供开发调试使用,生产环境可以整体移除
return { dictMap };
}
};
}
export default createDictStore();
这种方式比较适合做成第三方库分发给多人使用,好在数据访问路径收得窄,能显著降低被误操作的概率。但缺点也很明显,调试的时候想偷偷看内部结构都看不全。
就普通企业内部项目而言,我建议你采用 4.2 的模块化单例 + 规范化命名就够了,不需要为了安全而过度设计。代码的安全,更多靠团队约定和 code review,而不是靠一种数据结构上的“物理隔离”。
5. 异步字典加载:当数据必须来自后端接口时怎么处理
本地配置的字典适合系统预设的静态选项,但现实是企业项目永远有一批字典数据需要由后端管理维护,比如后台上可动态增删的“文章分类”“渠道来源”。这种情况下,前端就不能把它写死在本地配置里,而是要等后端把数据推过来。
异步字典最核心的难点其实只有两个:什么时候去加载,以及加载过程中有组件提前来取数据怎么办。
5.1 异步加载的三种流程设计
第一个问题是“什么时候加载”。常见有三种设计思路。
方式一:应用启动时全量预加载。
在系统初始化的时候,一次性请求所有需要用到的字典数据,然后塞进本地 store。这种方式实现最简单、后续访问字典零延迟,代价就是首次白屏时间变长、字典口径必须提前全部和后端对齐。
javascript复制// App.vue 或 main.js 中
const res = await fetch('/api/dict/all');
const dictData = await res.json();
dict.registerBatch(dictData);
方式二:路由切换时按需加载。
进入某个页面之前,先判断当前页面需要哪些字典类型,只请求缺失的那几个。这种方式省流量,但每个路由的处理逻辑会多一些。“页面路由 meta 里配置需要的字典编码,在全局路由守卫里动态补齐”,是一个不错的落地方案。
方式三:组件内首次使用时懒加载。
业务组件调用某个字典的 translate 时,发现本地没有这个类型,于是首次触发异步加载,后续再访问直接走缓存。这是最“懒”的,但对工具类的要求也最高——你必须处理并发情况,也就是多个组件同时请求同一个字典类型,不能重复发 N 个请求。
5.2 以懒加载为例:带缓存 Promise 的实现
我个人认为,异步字典最实用的设计是**“全局注册函数注入 + 缓存 Promise”**。我先解释一个关键点:当某个字典类型还没加载好,你用一个 Promise 把它“记住”,后续每次请求这个类型都拿同一个 Promise 去 then,就不会重复请求了。这个技巧很多同学第一次见,但它简单到可怕。
javascript复制class AsyncDictManager extends DictManager {
constructor(loader) {
super();
// loader: 接收 dictionaryType,返回 Promise<Array>
this.loader = typeof loader === 'function' ? loader : null;
this.pendingPromises = new Map();
}
async ensureLoaded(type) {
if (this.has(type)) return;
if (!this.loader) {
throw new Error(`[AsyncDictManager] 字典类型 "${type}" 不存在,且没有配置 loader。`);
}
// 关键:如果该类型的请求已经发出去了,直接复用同一个 Promise
if (!this.pendingPromises.has(type)) {
const promise = this.loader(type).then((items) => {
const name = type; // 如果你希望字典名称就是编码本身,可自行调整
this.register(type, name, items);
}).finally(() => {
this.pendingPromises.delete(type);
});
this.pendingPromises.set(type, promise);
}
return this.pendingPromises.get(type);
}
async translateAsync(type, value) {
await this.ensureLoaded(type);
return this.translate(type, value);
}
async getOptionsAsync(type, options) {
await this.ensureLoaded(type);
return this.getOptions(type, options);
}
}
这个 AsyncDictManager 是上述基础版的子类,不重写父类的核心方法,只在父类之上加了一层“延迟加载”。它的工作逻辑是:
- 调用
translateAsync('order_status', 1); - 内部先确认这个字典类型是否已经加载,加载过就直接翻译;
- 没有加载过就走
ensureLoaded——发现没有发起过该类型的 Promise,就调用loader函数去发请求,并把 Promise 存到pendingPromises里; - 另一个组件这时候也来
translateAsync('order_status', 2),它发现pendingPromises里已经有同一个类型的 Promise 了,就不再发第二次请求,直接拿到同一个 Promise 去等待结果返回; - 加载完成后从
pendingPromises移除这个类型,下次再访问就直接走this.has(type)的那个分支,即刻返回。
这套带“缓存 Promise”的设计,是异步字典方案里我认为最优雅、成本最低的一版。你唯一需要做的就是为它提供一个 loader 函数,把后端的接口转换成统一格式返回即可。比如:
javascript复制const dict = new AsyncDictManager(async (type) => {
const res = await fetch(`/api/dict/items?type=${type}`);
const data = await res.json();
// 假定后端返回的 data 是 [{ value: 1, label: '正常' }, ...]
return data;
});
5.3 远端字典项映射到本地结构的注意事项
后端返回的字典项格式千奇百怪,有的是 dictValue + dictLabel,有的是 code + name,所以 loader 里最好统一做一次“适配器转换”,转换成 DictManager 内部统一使用的 value + label + color + disabled 结构。别把这件事拖到业务里做,否则每个使用方都适配一遍就乱套了。
此外,为了避免远程字典数据把本地配置冲掉,注册之前你可以先判断本地是否已经注册过这个类型。在企业系统里,我通常采用“就近优先”的规则:本地如果已经有配置,就用本地的;没有再从远端拿。这样可以应对“部分字典固定、部分字典动态”的混合场景。
6. 框架生态整合落地:以 Element Plus 的字典标签和下拉框为例
文章开头我提到,“简单版”的目的就是为了拿到业务里去用。如果只讲纯 JS 类和方法,很多读者会卡在“写完了不知道在哪用”这一步。所以我这里挑 Vue3 + Element Plus 这个最常见的组合,走一遍真实业务里的落地动作,React 的思维也完全一致。
6.1 二次封装字典标签组件,让状态列告别 v-if 连写
没有封装字典之前,表格里要根据 status 显示不同颜色标签,你是这么写的:
html复制<el-table-column label="状态">
<template #default="{ row }">
<el-tag v-if="row.status === 0" type="info">禁用</el-tag>
<el-tag v-else-if="row.status === 1" type="success">启用</el-tag>
<el-tag v-else-if="row.status === 2" type="warning">锁定</el-tag>
<span v-else>{{ row.status }}</span>
</template>
</el-table-column>
假如有十几种状态,这个模板直接塞满半个页面文件,不仅难看还难维护。用上字典之后,我封装了一个 DictTag 组件,全代码没几行:
vue复制<!-- components/DictTag.vue -->
<template>
<el-tag v-if="tagType !== 'default'" :type="tagType" :color="color" disable-transitions>
{{ label }}
</el-tag>
<el-tag v-else :color="color" disable-transitions>{{ label }}</el-tag>
</template>
<script setup>
import { computed } from 'vue';
import { $dict } from '@/dict';
const props = defineProps({
type: { type: String, required: true },
value: { type: [String, Number], required: true },
// 支持传入 color 或 tagType,优先使用 tagType
color: { type: String, default: '' },
tagType: { type: String, default: 'default' }
});
const label = computed(() => $dict.translate(props.type, props.value));
</script>
然后表格列可以简化成一行:
html复制<el-table-column label="状态">
<template #default="{ row }">
<dict-tag type="user_status" :value="row.status" />
</template>
</el-table-column>
如果你想从字典项里自动读取颜色/标签类型,而不是在组件上手动传参,那就在封装组件时到字典项里顺便把 color/tagType 一起搜出来。因为注册 dictionary 的时候,字典项可能已经携带了 color: '#67C23A',你可以这样增强 DictTag 的逻辑:
javascript复制const matchedItem = computed(() => {
const items = $dict.getItems(props.type);
return items.find(item => item.value === props.value) || {};
});
const label = computed(() => matchedItem.value.label ?? String(props.value));
const color = computed(() => props.color || matchedItem.value.color || '');
const tagType = computed(() => props.tagType || matchedItem.value.tagType || 'default');
这样一旦字典里颜色配置好了,页面上不需要任何手动指定。产品经理哪天要把“启用”改成蓝色,你只需要改字典配置一个地方,全站所有表格自动生效。这就是数据字典最直观的威力。
6.2 下拉选项自动回显以及 v-model 联动技巧
在新增编辑表单时,下拉框要读字典选项,并且当前编辑行的值要默认匹配上。用 getOptions 就很简单:
javascript复制const formStatus = ref(1);
const statusOptions = computed(() => $dict.getOptions('user_status', {
includeAll: false,
disabledValues: []
}));
模板:
html复制<el-form-item label="状态">
<el-select v-model="formStatus" placeholder="请选择用户状态">
<el-option
v-for="opt in statusOptions"
:key="opt.value"
:label="opt.label"
:value="opt.value"
:disabled="opt.disabled"
/>
</el-select>
</el-form-item>
这个串联其实极其顺畅。表单数据的初始值仍然是从后端返回的 status 数字,而 el-select 因为 v-model 的关系会自动匹配到对应的 option label 显示出来,无需你额外写“根据数值找 label 回填”的逻辑。
如果需求变成“选择了某个状态之后,下一个下拉框联动变化”,比如选择了“已完成”的订单类型后,后续原因下拉框只能选择固定几项,你可以在监听函数里用 dict.query 动态过滤,或者调 getOptions 时传入 disabledValues 来动态禁用不需要的选项:
javascript复制watch(selectedType, (newType) => {
const options = $dict.getOptions('next_step', {
disabledValues: newType === 5 ? [2, 3] : []
});
nextStepOptions.value = options;
});
这种联动的本质,就是你手上的字典数据和组件状态变量之间的普通 JS 逻辑,并不需要什么神奇的魔法。熟练了之后,你会发现用字典管理选项列表会让联动代码简单很多——因为字典是统一的数据源,改一处全盘生效,不需要同时去同步多个分散的选项定义。
7. 避坑指南与工程化建议:这些坑我都帮你踩过了
最后这部分,我想老实交代一些在实际项目中“简单版数据字典”极容易踩的坑,以及我在真实项目里总结出的一些工程化建议。
7.1 键值类型不一致是头号杀手
最常见又最隐蔽的问题就是类型不一致。字典项里定义 value: 1(数字),后端接口返回的字段是字符串 '1',然后你用 dict.translate('status', row.status) 去翻译,结果死活匹配不上,返回的永远是原始值。问题根源就在这里。
作为一个老手,我给你三条防御性建议。
- 项目里统一规范 value 的类型,最好全部使用字符串。因为后端接口传输 JSON 时,数字类型的字段很容易被各种语言或中间件翻来覆去地转成字符串,反而是字符串永远稳如泰山。
- 在
translate内部,可以做一次“宽松比较兼容”,也就是当严格全等找不到时,自动尝试用String(val) === String(item.value)再匹配一轮。 - 如果字典项比较多、更新频率也不低,可以加一次自检逻辑(比如在开发模式下遍历所有字典项,检查有没有重复 value),这种未雨绸缪能在开发阶段就能发现问题。
7.2 字典项复制引用污染与浅拷贝的必要性
我前面在 getOptions 和 query 里都提到过拷贝,这里再强调一次。假设某个组件拿到 options 后给某一项临时加了 disabled: true,如果没有拷贝,这个改动会直接污染字典源数据。下次另一个组件同样想读取这个选项时,它会被莫名其妙地禁用。线上排查时这种问题最难发现——你根本想不到是一次脏数据修改导致后续所有地方全错了。
给数据字典提供方法时,默认返回副本,是一个成本极低但收益极高的习惯。
7.3 字典更新的实时性问题
本地静态配置的字典,打包后就固定了,页面不刷新不会变,这其实对大部分固定枚举是 ok 的。但如果是后端动态维护的字典,前端全量预加载的方案就会遇到一个问题:后端管理员在后台改了某个字典项,用户那边不刷新页面看不到变化。
针对这个问题,可以设计一个定时的字典刷新任务,或者提供“页面切 focus 时静默刷新”钩子。具体怎么权衡刷新频率,取决于字典数据的敏感度和后端接口压力,我的惯例是,在用户进入数据管理这类需要用到最新字典的页面之前,显式调用一次 ensureLoaded 的强制更新版本,只更新那一个变化的类型而不是全量拉取。
7.4 命名规范与集中管理是灵魂
数据字典的 type 命名,是整个体系最容易失控的地方。今天一个人建了 user_status,明天另一个人建了 userStatus 或 user-status,同一种字典产生了三个编码,各自引用互不相通,后期合并只能哭。
所以从第一天起就要立好规范。我建议采用:领域前缀 + 下划线 + 业务含义,比如 sys_user_status、order_state、product_type,所有字典编码集中放到一个 dict/constants.js 文件里统一导出,业务代码引用常量而不写裸字符串。
javascript复制// dict/constants.js
export const DICT = {
USER_STATUS: 'sys_user_status',
ORDER_STATE: 'order_state',
PRODUCT_TYPE: 'product_type'
};
// 使用时
import { DICT } from '@/dict/constants';
const text = dict.translate(DICT.USER_STATUS, row.status);
虽然字符串常量本身没有什么技术含量,但这种“代码即文档”的规范,会大大降低后期维护的理解成本,也是我做过几个中大型项目后最想强调的一点。
7.5 从简单版走向企业版:下一步的演进方向
这篇文章做的是简单版的数据字典,但你要知道,企业级的字典系统里通常还包括:
- 接口自动同步:后端通过配置文件或接口地址,把字典数据主动 push 到前端 store。
- 字段多语言支持:同一个 value 在不同语言环境下显示不同 label。
- 树形字典:用于无限极分类下拉,需要支持递归查找与回显。
- 权限过滤:某些字典项只对指定角色可见。
- 字典审计:记录字典变更日志,方便追溯谁在什么时候改了什么标签。
这些本质上都是在“简单版核心”之上叠加外围能力。你把本章实现的数据结构、加载机制、API 形式吃透了,后面演进到企业版时,唯一要改的是扩展外层容器、增加异步策略,不会伤筋动骨。
我在实际项目里,也会给 DictManager 写上完整的 JSDoc 注释和简单的单元测试,尤其是 translate、getOptions、异步去重这类核心方法,因为后面项目一扩,你根本不记得当初为什么这个方法要这么实现,注释和测试就是最好的“记忆备份”。
数据字典看着不起眼,但它是整个系统中贯穿全局的地基设施之一。今天把这套“简单版”的方案吃透,往后无论你面对 Vue、React 还是原生小程序,无论你要接的是若依这类后端框架还是自研接口,都能很自然地迁移和扩展。希望你不用再走我当年处处硬编码、处处改到崩溃的老路。
