我们在开发中经常会使用到各种下拉数据, 为了方便后期的更新维护, 也为了在开发过程中更便捷的使用, 我们需要统一管理他们.
需求
css
作为下拉列表的数据, 有固定的格式[{label, value}]
拿到 value 值时, 可以快速高效的找到对应的 label 值
我们的字典可能会在多个页面或组件中应用, 为此我们希望只维护一套数据
对于一些远程的字典数据, 我们希望只请求一次, 然后将数据缓存起来, 并在合适的时间更新
对于业务下拉数据量在300以上时, 我们需要考虑使用虚拟列表的方式来降低渲染的压力
对于业务下拉数据量在1000以上时, 我们则应该使用远程搜索+分页的方式来展示
对于需要加载的字典数据的加载状态也统一管理,方便调用
方案
为了拥有固定的格式,我们首先将数据分成两大类,本地数据 、远程数据 ,对于本地数据而言,我们直接配置为固定的格式即可,而对于远程数据我们则使用请求函数 和转换函数组合的方式来实现。
获取value对应的label值则通过生成Map对象来实现,这样相对于每次循环遍历去查找的时间复杂度更低,当然缺点就是空间占用更多,具体如何实现依据项目和数据情况而定,本文以Map的方式实现。(当然,无论以哪种方式实现,甚至对特定的数据采用更优秀的查找算法,比如字典树,甚至你中途想切换实现方式,你都可以只在一处修改,这便是将其管理起来的其中一种好处。)
基于以上两点,以及我们考虑给每个字典配置唯一的key和一些辅助说明信息,所以我们的配置样例就是这样:
js
// src/base/dict/*.js 字典的配置如有需要也应以模块化配置,这样有利于协作开发
// 字典配置 Demo
const dictConfsDemo = [
{
// 字典的key,保证全局唯一,可使用模块名做为前缀标识,建议使用全大写+下划线(或在getters处转换处理)
key: "SERVER_SATAE", // serverState
// 描述信息,可以记录写与业务相关的东西
desc: "服务器状态, 运行状态下不可删除、修改",
// color是额外的属性,可以在渲染时使用, 也可以再加其他需要的属性
data: [
{ label: "运行", value: 1, color: "green" },
{ label: "停止", value: 0, color: "default" },
],
// 对于需要远程获取的字典,通过配置函数实现,与data配置互斥
methods: () => {
return new Promise((reslove, reject) => {
setTimeOut(() => {
reslove([]);
}, 1000);
});
},
// 自定义转换Map的函数
toMap: () => {},
// 自定义转换List的函数, 一般是针对远程字典数据进行转换的方法。
toList: () => {},
},
];
为了能在多个页面中使用,同时增加缓存和便捷使用的功能,我们选择用vuex来管理。
至于只请求一次并缓存数据,我这里通过state中是否为null进行了标识,如果为null的时候在getter中发起了请求,我不知道这样做是否合理,因为在实际项目中会偶发下拉无数据的情况,要手动刷新才可以,不是是否有同学可以帮我解惑。
为什么要有这个需求,主要是看很多项目中都是每个字典配一套状态管理,因为每个组件都不能确定getters中是否有值,必须要先调用action,再用getters获取数据,这样无疑使得数据获取了多次,占用了一定的网络资源,特别对于数据量较大的字典,用户体验并不友好。
当然这样也有好处,就是每次获取最新的数据,相对于缓存来说复杂度不高,因为一旦缓存就涉及缓存更新等额外的处理。除此之外是否还有别的优点呢?
js
// src/Utils/DictUtil.js
import Store from "xxxx"; // 需要引入最终生成的状态
export function getterItem(type, item) {
const { key, data, methods, toMap, toList } = item;
switch (type) {
case "local":
const map = data.map((v) => [v.value, v]);
return {
map: new Map(map),
list: data,
source: data,
loading: false,
};
case "remote":
return {
map: toMap(data),
list: toList(data),
source: data,
loading: false,
};
default:
// 远程字典的初始状态, 在这里发起字典数据的请求操作, 借助getters的缓存机制,这里只执行一次
Store.dispatch(`GET_${key}`);
return {
map: new Map(),
list: [],
source: [],
loading: true,
// 如果你想有自定义的查找方法,你可以直接返回一个get函数
get: (val) => {}
};
}
}
export function createInitStore() {
return {
state: {
dictLoadings: {},
// 整体的加载状态, 大部分页面都需要多个字典,除了每个字典本身的加载状态还需要一个整体的状态
dictAllLoading: false,
},
getters: {},
mutations: {
// 开始加载
openDictLoading: (state, key) => {
state.dictAllLoading = true;
state.dictLoadings[key] = true;
},
// 加载结束
closeDictLoading: (state, key) => {
delete state.dictLoadings[key];
if (!Object.keys(state.dictLoadings).length) {
state.dictAllLoading = false;
}
},
},
actions: {},
};
}
export function createItemStore(values, item) {
const { key, data, methods } = item;
values.state[key] = data || null;
values.getters[key] = (state) => {
if (data) return getterItem("local", item);
if (state[key] === null) {
return getterItem("", item);
} else {
return getterItem("remote", { ...item, data: state[key] });
}
};
values.mutations[`SET_${key}`] = (state, data) => {
state[key] = data;
};
values.actions[`GET_${key}`] = ({ commit }) => {
commit("openDictLoading", key);
methods()
.then((res) => {
commit("closeDictLoading", key);
commit(`SET_${key}`, Object.freeze(res.data));
})
.catch((err) => console.log(key, err));
};
}
export function createDictStore(dictConfs) {
if (!Array.isArray(dictConfs)) return {};
const store = dictConfs.reduce(createItemStore, createInitStore());
// 清理缓存,重新请求
// 全部的更新是由于我们的项目分不同的环境,每个环境数据不同
// 大部分的字典可能是不会涉及到更新的,但还是有某些需要更新,因此也需要单独更新
// 单个的更新直接commit为null即可,字典对应的新增、删除和修改操作处都需要进行commit
store.actions['RESET_ALL_DICT'] = ({ commit }) => {
dictConfs.filter(item => item.methods).forEach(({key}) => commit(`SET_${key}`, null));
}
return store;
}
有了以上两个部分,基本就可以实现字典的管理了,我们只需要将字典配置dictConfs 和生成函数createDictStore引入到你生成vuex实例的地方,并将最终生成的配置挂载即可。
js
import Vuex from 'vuex';
import { dictConfs } from 'xxx';
import { createDictStore } from 'xxx';
const dict = createDictStore(dictConfs);
const store = new Vuex.Store(dict); // 当然实际用的时候还是要用模块化的
default export store;
关于虚拟列表
在前端开发中,虚拟列表是一种优化大量数据展示的常用技术,特别是在需要展示大量数据的列表或表格场景下,可以显著提升页面的性能和用户体验。虚拟列表的核心思想是只渲染当前视口范围内可见的数据,而不是将所有数据都渲染到页面上,从而减少 DOM 元素数量和渲染开销。
注意项
当基础的虚拟列表功能正常运行后,可以考虑进行一些优化来进一步提升性能和用户体验。以下是一些优化建议:
-
惰性加载数据: 如果列表数据非常庞大,可以考虑采用惰性加载的方式,即在滚动到列表底部时再加载更多数据,而不是一次性加载所有数据。
-
节流滚动事件: 对滚动事件进行节流处理,避免频繁触发滚动事件导致性能问题。可以使用 Lodash 的
throttle
方法或者自定义节流函数来实现。 -
列表项渲染优化: 可以使用
v-for
的:key
属性来优化列表项的渲染性能,确保每个列表项都有唯一的 key 值,以便 Vue 可以正确识别和更新列表项。 -
虚拟滚动条: 考虑使用虚拟滚动条来模拟真实的滚动条,可以提升用户体验并减少页面布局抖动。
-
列表项复用: 考虑实现列表项的复用机制,即当列表项滚出可视区域时,不销毁而是复用已有的列表项,以减少 DOM 元素的创建和销毁开销。
-
数据缓存: 如果列表数据相对稳定且不经常变化,可以考虑使用缓存机制来缓存数据,减少数据请求的频率。
-
性能监控: 使用浏览器的开发者工具或者性能监控工具来监测虚拟列表的性能指标,及时发现和解决性能问题。
通过以上优化措施,可以进一步提升虚拟列表的性能和用户体验,使得大量数据的展示更加流畅和高效。
当然,在实际的项目中使用时我们更推荐直接使用第三方库,例如vue-virtual-scroll-list
手写代码实现
以下是一个vue3的虚拟列表hook函数
js
import { ref, onMounted, onUnmounted } from 'vue';
import throttle from 'lodash/throttle'; // 导入节流函数
export function useVirtualList(data, itemHeight, visibleCount, bufferCount) {
const virtualData = ref([]); // 虚拟列表展示的数据
const totalHeight = ref(0); // 列表总高度
const containerRef = ref(null); // 列表容器的引用
const startIndexRef = ref(0); // 可见区域的起始索引
const endIndexRef = ref(0); // 可见区域的结束索引
// 计算列表总高度
const calculateTotalHeight = () => {
totalHeight.value = data.length * itemHeight;
};
// 计算可见区域的数据
const calculateVisibleData = () => {
const bufferStartIndex = Math.max(startIndexRef.value - bufferCount, 0);
const bufferEndIndex = Math.min(endIndexRef.value + bufferCount, data.length);
virtualData.value = data.slice(bufferStartIndex, bufferEndIndex);
};
// 监听滚动事件(节流处理)
const handleScroll = throttle(() => {
const container = containerRef.value;
if (!container) return;
startIndexRef.value = Math.floor(container.scrollTop / itemHeight);
endIndexRef.value = Math.min(startIndexRef.value + visibleCount, data.length);
calculateVisibleData();
updateScrollbar();
}, 100); // 100ms 内只触发一次滚动事件
// 更新虚拟滚动条位置
const updateScrollbar = () => {
const container = containerRef.value;
const scrollbar = scrollbarRef.value;
if (!container || !scrollbar) return;
const scrollableHeight = totalHeight.value - container.clientHeight;
const scrollbarTop = (container.scrollTop / scrollableHeight) * (container.clientHeight - scrollbar.clientHeight);
scrollbar.style.top = `${scrollbarTop}px`;
};
// 开始拖拽虚拟滚动条
const startDrag = (event) => {
const startY = event.clientY;
const container = containerRef.value;
if (!container) return;
const handleDrag = (e) => {
const deltaY = e.clientY - startY;
const scrollableHeight = totalHeight.value - container.clientHeight;
const scrollbarTop = parseFloat(scrollbar.style.top) + deltaY;
const scrollTop = (scrollbarTop / (container.clientHeight - scrollbar.clientHeight)) * scrollableHeight;
container.scrollTop = scrollTop;
updateScrollbar();
startY = e.clientY;
};
const handleMouseUp = () => {
window.removeEventListener('mousemove', handleDrag);
window.removeEventListener('mouseup', handleMouseUp);
};
window.addEventListener('mousemove', handleDrag);
window.addEventListener('mouseup', handleMouseUp);
};
// 在组件挂载时计算列表总高度并绑定滚动事件
onMounted(() => {
calculateTotalHeight();
calculateVisibleData();
containerRef.value.addEventListener('scroll', handleScroll);
updateScrollbar();
});
// 在组件卸载时移除滚动事件监听
onUnmounted(() => {
containerRef.value.removeEventListener('scroll', handleScroll);
});
return {
virtualData,
totalHeight,
containerRef,
scrollbarRef,
startDrag,
};
}
关于超大量的字典数据
例如项目中存在一个文件目录的下拉列表,某个服务器的目录超十万个,这时即便有虚拟列表,但仅仅只数据的请求耗时和数据本身所占用的缓存都会让页面变得十分卡顿,即便是冻结了数据也依旧会造成很不好的用户体验,因此这个时候最直接的方式便是点击直接打开一个分页搜索的列表,这样不仅解决了性能问题,还能提供更多的搜索条件与数据展示。
实现的方式直接将其封装为一个组件即可,并没有什么难度这里就不具体实现了。下面是一些入参的参考:
-
数据源(DataSource):
- 类型:数组
- 描述:组件需要显示的全部数据源,可能是一个对象数组,每个对象包含标签(label)和值(value)等信息。
-
分页大小(PageSize):
- 类型:数字
- 描述:每页显示的数据条数,用于分页展示数据。
-
远程搜索函数(RemoteSearch):
- 类型:函数
- 参数:搜索关键词(keyword)、当前页码(page)、分页大小(pageSize)
- 返回值:Promise,包含搜索结果的数据
-
搜索延迟(SearchDelay):
- 类型:数字
- 默认值:300
- 描述:输入搜索关键词后,触发搜索的延迟时间(毫秒),用于控制搜索请求的频率。
-
搜索结果格式化函数(ResultFormatter):
- 类型:函数
- 参数:搜索结果数据
- 返回值:格式化后的数据,通常是数组对象,每个对象包含标签(label)和值(value)等信息。
-
初始值(InitialValue):
- 类型:对象
- 默认值:null
- 描述:组件的初始值,包含标签和值等信息。
-
组件样式(Style):
- 类型:对象
- 默认值:{}
- 描述:自定义组件的样式,例如下拉框的宽度、字体颜色等。
-
事件回调(EventHandlers):
- 类型:对象
- 描述:组件需要响应的事件,例如选中某个选项、搜索关键词变化等。
-
加载状态(Loading):
- 类型:布尔值
- 默认值:false
- 描述:组件的加载状态,用于展示加载中的提示或者动画。
-
下拉框最大高度(MaxHeight):
- 类型:数字
- 默认值:300
- 描述:下拉框的最大高度,超过该高度时会出现滚动条。
这些入参可以满足一个功能完整的超大量级数据的分页搜索下拉组件的需求。根据实际情况,还可以添加其他参数,例如选中项改变时的回调函数、输入框的 placeholder 等。
关于数据回显
确保初始值不存在于数据源中时下拉组件正常显示是一个常见的问题。一种常用的解决方法是在组件内部进行判断和处理,以确保初始值存在于数据源中或者处理不存在的情况。以下是一些可能的解决方法:
-
判断初始值是否存在于数据源中: 在组件内部进行判断,如果初始值存在于数据源中,则直接使用初始值;如果不存在,则在下拉选项中添加一个虚拟选项,用于提示用户或者显示默认值。
-
使用占位符或默认值: 如果初始值不存在于数据源中,可以考虑使用一个占位符或默认值作为初始值,在用户未进行任何选择之前显示这个占位符或默认值。
-
强制匹配数据源: 在组件内部对初始值进行匹配操作,如果初始值不在数据源中,则强制将初始值置为空或者选择数据源中的第一个值。
-
提供自定义选项: 在数据源中添加一个特殊的选项,例如"自定义"选项,当初始值不存在于数据源中时,用户可以选择"自定义"并手动输入值。
-
异步加载数据源: 如果数据源是异步加载的,可以等待数据加载完成后再进行初始值的判断和处理,确保初始值存在于数据源中。
本人一般使用的方法是使用占位符并将占位符本身给隐藏掉,具体选择哪种方法取决于组件的设计需求和用户体验考虑。可以根据实际情况综合考虑以上方法,选择最适合的解决方案。
总结
至此,我们基本就完成了字典数据的管理工作,相较于杂乱分布在各个组件之中、每个业务组件内部或者状态管理中编写大量重复代码,将字典数据管理起来极大的提高了代码性能、用户体验、代码可维护性、可读性等。因此不要忽略项目中的每块代码,即便公认没有难度的后台系统也依旧可以挖掘出许多可以提升的点。DayDayUp。共勉。