当前位置: 首页 > news >正文

玩转前端图标系统:从零搭建一套完整的图标选择器组件

背景

在现代前端开发中,图标系统是构建美观用户界面的重要组成部分。一个好的图标系统不仅能提升用户体验,还能提高开发效率。今天,我们将深入探讨如何从零开始搭建一套完整的图标选择器组件系统,涵盖多种图标来源和使用方式。

为什么需要统一的图标系统?

在实际项目开发中,我们经常会遇到以下问题:

  • 项目中使用了多种图标库,管理混乱
  • 图标使用方式不统一,难以维护
  • 图标选择过程繁琐,缺乏可视化工具
  • 图标加载性能不佳,影响用户体验

为了解决这些问题,我们需要构建一套完整的图标管理系统。

核心架构设计

1. 图标获取 (getStyleSheets.ts)

获取多种图标库:

  • 阿里图标库:支持在线和本地两种获取方式
  • Element Plus 图标:集成 Element Plus 内置 SVG 图标
  • Font Awesome:支持 Font Awesome 图标库
  • 本地 SVG 图标:支持项目自定义 SVG 图标
通过解析页面样式表获取在线阿里图标
// 获取在线阿里字体图标
import { nextTick } from 'vue';
const getOnlineAlicdnIconfont = (): Promise<string[]> => {return new Promise((resolve, reject) => {nextTick(() => {// 获取页面所有样式表const styles: any = document.styleSheets;let sheetsList = [] as any[];      // 存储阿里CDN相关的样式表let sheetsIconList = [] as any[];  // 存储解析出的图标类名// 筛选出包含阿里CDN链接的样式表for (let i = 0; i < styles.length; i++) {if (styles[i].href && styles[i].href.indexOf('at.alicdn.com') > -1) {sheetsList.push(styles[i]);}}// 遍历阿里相关的样式表,提取图标类名for (let i = 0; i < sheetsList.length; i++) {for (let j = 0; j < sheetsList[i].cssRules.length; j++) {// 查找以.icon-开头的选择器(阿里图标类名规范)if (sheetsList[i].cssRules[j].selectorText && sheetsList[i].cssRules[j].selectorText.indexOf('.icon-') > -1) {// 提取类名并去除::before伪类sheetsIconList.push(`${sheetsList[i].cssRules[j].selectorText.substring(1, sheetsList[i].cssRules[j].selectorText.length).replace(/\:\:before/gi, '')}`);}}}if (sheetsIconList.length > 0) resolve(sheetsIconList);else reject('未获取到在线阿里图标');});});
};
从本地 iconfont.json 文件读取图标列表
// 获取本地阿里图标函数
import { nextTick } from 'vue';
const getLocalAlicdnIconfont = (): Promise<string[]> => {return new Promise((resolve, reject) => {nextTick(() => {try {// 通过fetch请求本地iconfont.json文件fetch('/src/assets/iconfont/iconfont.json').then(response => response.json()).then(data => {// 将glyphs中的font_class转换为标准图标类名格式const iconList = data.glyphs.map((glyph: any) => `icon-${glyph.font_class}`);console.log('本地阿里图标列表:', iconList);if (iconList.length > 0) {resolve(iconList);} else {reject('未获取到阿里图标,请检查图标文件是否正确');}}).catch(err => {console.error('获取本地阿里图标失败:', err);reject('获取本地阿里图标失败');});} catch (e) {console.error('解析本地阿里图标出错:', e);reject('解析本地阿里图标出错');}});});
};
导入 @element-plus/icons-vue 获取所有 SVG 图标:
// 初始化获取 css 样式,获取 element-plus 自带 svg 图标,增加了 ele- 前缀,使用时:ele-Aim
import { nextTick } from 'vue';
import * as svg from '@element-plus/icons-vue';const getElementPlusIconfont = () => {return new Promise((resolve, reject) => {nextTick(() => {const icons = svg as any;const sheetsIconList = [] as any[];// 遍历所有导入的Element Plus图标for (const i in icons) {// 为每个图标添加ele-前缀,如 ele-EditsheetsIconList.push(`ele-${icons[i].name}`);}if (sheetsIconList.length > 0) resolve(sheetsIconList);else reject('未获取到值,请刷新重试');});});
};
获取 Font Awesome 图标
import { nextTick } from 'vue';
const getAwesomeIconfont = () => {return new Promise((resolve, reject) => {nextTick(() => {const styles: any = document.styleSheets;let sheetsList = [] as any[];      // 存储Font Awesome相关的样式表let sheetsIconList = [] as any[];  // 存储解析出的图标类名// 筛选出包含font-awesome链接的样式表for (let i = 0; i < styles.length; i++) {if (styles[i].href && styles[i].href.indexOf('font-awesome') > -1) {sheetsList.push(styles[i]);}}// 遍历Font Awesome相关的样式表,提取图标类名for (let i = 0; i < sheetsList.length; i++) {for (let j = 0; j < sheetsList[i].cssRules.length; j++) {// 查找以.fa-开头且不包含逗号的选择器(Font Awesome类名规范)if (sheetsList[i].cssRules[j].selectorText &&sheetsList[i].cssRules[j].selectorText.indexOf('.fa-') === 0 &&sheetsList[i].cssRules[j].selectorText.indexOf(',') === -1) {// 只处理包含::before伪类的选择器if (/::before/.test(sheetsList[i].cssRules[j].selectorText)) {// 提取类名并去除::before伪类sheetsIconList.push(`${sheetsList[i].cssRules[j].selectorText.substring(1, sheetsList[i].cssRules[j].selectorText.length).replace(/\:\:before/gi, '')}`);}}}}if (sheetsIconList.length > 0) resolve(sheetsIconList.reverse());else reject('未获取到值,请刷新重试');});});
};
获取本地自带的SVG图标
// 通过读取页面中特定元素的data-icon-name属性获取图标列表
import { nextTick } from 'vue';
const getLocalIconfontNames = () => {return new Promise<string[]>((resolve, reject) => {nextTick(() => {let iconfonts: string[] = [];// 获取页面中ID为local-icon的元素const svgEl = document.getElementById('local-icon');// 从data-icon-name属性中提取图标名称列表if (svgEl?.dataset.iconName) {iconfonts = (svgEl?.dataset.iconName as string).split(',');}if (iconfonts.length > 0) {resolve(iconfonts);} else {reject('No Local Icons');}});});
};

svg图标需要插入到页面中才能获取

// utils/svgBuilder.ts
// 引入 Node.js 文件系统模块,用于读取文件和目录
import { readFileSync, readdirSync } from 'fs';// 定义图标 ID 前缀变量
let idPerfix = '';
// 存储所有图标名称的数组
const iconNames: string[] = [];
// 正则表达式匹配 SVG 标签
const svgTitle = /<svg([^>+].*?)>/;
// 正则表达式清除 SVG 的 width 和 height 属性
const clearHeightWidth = /(width|height)="([^>+].*?)"/g;
// 正则表达式检查是否已有 viewBox 属性
const hasViewBox = /(viewBox="[^>+].*?")/g;
// 正则表达式清除换行符
const clearReturn = /(\r)|(\n)/g;
// 正则表达式清除 fill 属性
const clearFill = /(fill="[^>+].*?")/g;/*** 查找并处理指定目录下的所有 SVG 文件* @param dir - 需要扫描的目录路径* @returns 处理后的 SVG symbol 字符串数组*/
function findSvgFile(dir: string): string[] {const svgRes = [] as any;// 读取目录内容,包括文件类型信息const dirents = readdirSync(dir, {withFileTypes: true,});// 遍历目录中的每一项for (const dirent of dirents) {// 将图标名称添加到数组中iconNames.push(`${idPerfix}-${dirent.name.replace('.svg', '')}`);// 如果是子目录,则递归处理if (dirent.isDirectory()) {svgRes.push(...findSvgFile(dir + dirent.name + '/'));} else {// 如果是文件,读取并处理 SVG 内容const svg = readFileSync(dir + dirent.name).toString()// 清除换行符.replace(clearReturn, '')// 清理 fill 属性.replace(clearFill, 'fill=""')// 替换 SVG 标签为 symbol 标签.replace(svgTitle, ($1: any, $2: any) => {let width = 0;let height = 0;// 移除 width 和 height 属性,并记录它们的值let content = $2.replace(clearHeightWidth, (s1: string, s2: string, s3: number) => {if (s2 === 'width') {width = s3;} else if (s2 === 'height') {height = s3;}return '';});// 如果没有 viewBox 属性,则根据 width 和 height 添加一个if (!hasViewBox.test($2)) {content += `viewBox="0 0 ${width} ${height}"`;}// 返回带新 ID 的 symbol 标签return `<symbol id="${idPerfix}-${dirent.name.replace('.svg', '')}" ${content}>`;})// 将结束标签从 </svg> 改为 </symbol>.replace('</svg>', '</symbol>');svgRes.push(svg);}}return svgRes;
}/*** 构建 SVG 图标集* @param path - SVG 文件所在目录路径* @param perfix - 图标 ID 前缀,默认为 'local'* @returns Vite 插件对象,用于在 HTML 中插入 SVG symbols*/
export const svgBuilder = (path: string, perfix = 'local') => {// 如果路径为空则直接返回if (path === '') return;idPerfix = perfix;// 获取处理后的 SVG 数据const res = findSvgFile(path);// 返回 Vite 插件配置return {name: 'svg-transform',// 在 HTML 转换阶段插入 SVG symbolstransformIndexHtml(html: string) {/* eslint-disable */// 在 <body> 标签后插入 SVG symbol 定义return html.replace('<body>',`<body><svg id="local-icon" data-icon-name="${iconNames.join(',')}" xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position: absolute; width: 0; height: 0">${res.join('')}</svg>`);/* eslint-enable */},};
};

在vue.config.ts里面调用

import { svgBuilder } from '/@/utils/svgBuilder.ts';
import { defineConfig } from 'vite';
const viteConfig = defineConfig((mode: ConfigEnv) => {return {plugins: [svgBuilder('./src/assets/icons/')	// /src/assets/icons 下面的svg 图标]}
})export default viteConfig;
抛出方法
/*** 图标获取接口集合* 提供统一的接口来获取不同来源的图标*/
const initIconfont = {// 阿里字体图标接口ali: () => {return getAlicdnIconfont();},// Element Plus图标接口ele: () => {return getElementPlusIconfont();},// Font Awesome图标接口awe: () => {return getAwesomeIconfont();},// 本地图标接口local: () => {return getLocalIconfontNames();},
};// 导出方法供其他模块使用
export default initIconfont;

注意:在 TypeScript 中直接导入 Node.js 的 fs 模块时,缺少对应的类型声明文件,会导致编译器无法识别模块,需要安装 Node.js 类型定义包:@types/node

npm install @types/node --save-dev

如果项目已存在 tsconfig.json,配置解析 Node 模块:

{"compilerOptions": {"moduleResolution": "node","types": ["node"]}
}

2. 封装图标选择器组件 (IconSelector)

IconSelector/index.vue

<template><div class="icon-selector w100 h100"><el-input v-model="state.fontIconSearch" :placeholder="state.fontIconPlaceholder" :clearable="clearable":disabled="disabled" :size="size" ref="inputWidthRef" @clear="onClearFontIcon" @focus="onIconFocus"@input="onIconInput" @blur="onIconBlur"><template #prepend><SvgIcon :name="state.fontIconPrefix === '' ? prepend : state.fontIconPrefix" class="font20"style="cursor: pointer;" /></template></el-input><el-popover placement="bottom" :width="state.fontIconWidth" transition="el-zoom-in-top"popper-class="icon-selector-popper" trigger="click" :virtual-ref="inputWidthRef" virtual-triggering><template #default><div class="icon-selector-warp"><el-tabs v-model="state.fontIconTabActive" @tab-click="onIconClick"><el-tab-pane lazy label="ali" name="ali"><IconList :list="fontIconSheetsFilterList" :empty="emptyDescription" type="iclass":prefix="state.fontIconPrefix" @get-icon="onColClick" /></el-tab-pane><el-tab-pane lazy label="ele" name="ele"><IconList :list="fontIconSheetsFilterList" :empty="emptyDescription":prefix="state.fontIconPrefix" @get-icon="onColClick" /></el-tab-pane><el-tab-pane lazy label="awe" name="awe"><IconList :list="fontIconSheetsFilterList" :empty="emptyDescription":prefix="state.fontIconPrefix" @get-icon="onColClick" /></el-tab-pane><el-tab-pane lazy label="local" name="local"><IconList :list="fontIconSheetsFilterList" :empty="emptyDescription":prefix="state.fontIconPrefix" @get-icon="onColClick" /></el-tab-pane></el-tabs></div></template></el-popover></div>
</template><script setup lang="ts" name="iconSelector">
import { defineAsyncComponent, ref, reactive, computed, watch } from 'vue';
import type { TabsPaneContext } from 'element-plus';
import initIconfont from '/@/utils/getStyleSheets';
import '/@/theme/iconSelector.scss';// 定义父组件传过来的值
const props = defineProps({// 输入框前置内容prepend: {type: String,default: () => 'ele-Pointer',},// 输入框占位文本placeholder: {type: String,default: () => '请输入内容搜索图标或者选择图标',},// 输入框占位文本size: {type: String,default: () => 'default',},// 禁用disabled: {type: Boolean,default: () => false,},// 是否可清空clearable: {type: Boolean,default: () => true,},// 自定义空状态描述文字emptyDescription: {type: String,default: () => '无相关图标',},// 双向绑定值,默认为 modelValue,modelValue: String,
});// 定义子组件向父组件传值/事件
const emit = defineEmits(['update:modelValue', 'get', 'clear']);// 引入 list.vue 组件
const IconList = defineAsyncComponent(() => import('./list.vue'));// 定义变量内容
const inputWidthRef = ref();
const state = reactive({fontIconPrefix: '',fontIconWidth: 0,fontIconSearch: '',fontIconPlaceholder: '',fontIconTabActive: 'ali',fontIconList: {ali: [],ele: [],awe: [],local: [],localAli: [],},
});// 处理 input 获取焦点时,modelValue 有值时,改变 input 的 placeholder 值
const onIconFocus = () => {if (!props.modelValue) return false;state.fontIconSearch = '';state.fontIconPlaceholder = props.modelValue;
};
// 处理 input 失去焦点时,为空将清空 input 值,为点击选中图标时,将取原先值
const onIconBlur = () => {const list = fontIconTabNameList();setTimeout(() => {const icon = list.filter((icon: string) => icon === state.fontIconSearch);if (icon.length <= 0) state.fontIconSearch = '';}, 300);
};// 处理 input 输入时,进行图标搜索
const onIconInput = (val: any) => {const query = val.trim();if (!query) return '';const list = fontIconTabNameList();let search = state.fontIconSearch.toLowerCase();return list.filter((item: string) => {if (item.toLowerCase().indexOf(search) !== -1) return item;});
};// 图标搜索及图标数据显示
const fontIconSheetsFilterList = computed(() => {const list = fontIconTabNameList();if (!state.fontIconSearch) return list;let search = state.fontIconSearch.trim().toLowerCase();return list.filter((item: string) => {if (item.toLowerCase().indexOf(search) !== -1) return item;});
});
// 根据 tab name 类型设置图标
const fontIconTabNameList = () => {let iconList: any = [];if (state.fontIconTabActive === 'ali') iconList = state.fontIconList.ali;else if (state.fontIconTabActive === 'ele') iconList = state.fontIconList.ele;else if (state.fontIconTabActive === 'awe') iconList = state.fontIconList.awe;else if (state.fontIconTabActive === 'local') iconList = state.fontIconList.local;return iconList;
};
// 处理 icon 双向绑定数值回显
const initModeValueEcho = () => {if (props.modelValue === '') return ((<string | undefined>state.fontIconPlaceholder) = props.placeholder);(<string | undefined>state.fontIconPlaceholder) = props.modelValue;(<string | undefined>state.fontIconPrefix) = props.modelValue;
};
// 处理 icon 类型,用于回显时,tab 高亮与初始化数据
const initFontIconName = () => {let name = 'ali';if (!props.modelValue) {return name;}console.log('props.modelValue::: ', props.modelValue);if (props.modelValue!.indexOf('iconfont') > -1) name = 'ali';else if (props.modelValue!.indexOf('ele-') > -1) name = 'ele';else if (props.modelValue!.indexOf('fa') > -1) name = 'awe';else if (props.modelValue!.indexOf('local') > -1) name = 'local';// 初始化 tab 高亮回显state.fontIconTabActive = name;return name;
};
// 初始化数据
const initFontIconData = async (name: string) => {if (name === 'ali') {// 阿里字体图标使用 `iconfont xxx`if (state.fontIconList.ali.length > 0) return;await initIconfont.ali().then((res: any) => {state.fontIconList.ali = res.map((i: string) => `iconfont ${i}`);});} else if (name === 'ele') {// element plus 图标if (state.fontIconList.ele.length > 0) return;await initIconfont.ele().then((res: any) => {state.fontIconList.ele = res;});} else if (name === 'awe') {// fontawesome字体图标使用 `fa xxx`if (state.fontIconList.awe.length > 0) return;await initIconfont.awe().then((res: any) => {state.fontIconList.awe = res.map((i: string) => `fa ${i}`);});} else if (name === 'local') {if (state.fontIconList.local.length > 0) return;await initIconfont.local().then((res: any) => {state.fontIconList.local = res.map((i: string) => `${i}`);});}// 初始化 input 的 placeholderstate.fontIconPlaceholder = props.placeholder;// 初始化双向绑定回显initModeValueEcho();
};
// 图标点击切换
const onIconClick = (pane: TabsPaneContext) => {initFontIconData(pane.paneName as string);inputWidthRef.value.focus();
};
// 获取当前点击的 icon 图标
const onColClick = (v: string) => {state.fontIconPlaceholder = v;state.fontIconPrefix = v;emit('get', state.fontIconPrefix);emit('update:modelValue', state.fontIconPrefix);inputWidthRef.value.focus();
};
// 清空当前点击的 icon 图标
const onClearFontIcon = () => {state.fontIconPrefix = '';emit('clear', state.fontIconPrefix);emit('update:modelValue', state.fontIconPrefix);
};// 监听双向绑定 modelValue 的变化
watch(() => props.modelValue,() => {initModeValueEcho();initFontIconName();}
);
</script>

该组件具有以下特点:

  • 支持搜索功能,快速定位所需图标
  • 多标签页展示不同来源的图标
  • 双向数据绑定,方便与表单集成

3. 图标列表组件 (list.vue)

list.vue

<template><div class="icon-selector-warp-row"><el-scrollbar ref="selectorScrollbarRef"><el-row :gutter="10" v-if="props.list.length > 0"><el-col :xs="6" :sm="4" :md="4" :lg="4" :xl="4" v-for="(v, k) in list" :key="k" @click="onColClick(v)"><div class="icon-selector-warp-item" :class="{ 'icon-selector-active': prefix === v }"><!-- 使用 SVG 图标 --><SvgIcon :name="v" v-if="type === 'svg'" /><!-- 使用 Class 类名图标 --><i :class="v" v-else-if="type === 'iclass'"></i></div></el-col></el-row><el-empty :image-size="100" v-if="list.length <= 0" :description="empty"></el-empty></el-scrollbar></div>
</template><script setup lang="ts" name="iconSelectorList">
import { PropType } from 'vue';
// 定义图标类型
type IconType = 'svg' | 'iclass';
// 定义父组件传过来的值
const props = defineProps({// 图标列表数据list: {type: Array,default: () => [],},// 自定义空状态描述文字empty: {type: String,default: () => '无相关图标',},// 高亮当前选中图标prefix: {type: String,default: () => '',},// 图标类型:svg - SVG图标, iclass - class类名图标, unicode - unicode编码图标type: {type: String as PropType<IconType>,default: 'svg',}
});// 定义子组件向父组件传值/事件
const emit = defineEmits(['get-icon']);// 当前 icon 图标点击时
const onColClick = (v: unknown | string) => {emit('get-icon', v);
};
</script><style scoped lang="scss">
.icon-selector-warp-row {height: 230px;overflow: hidden;.el-row {padding: 15px;}.el-scrollbar__bar.is-horizontal {display: none;}.icon-selector-warp-item {display: flex;justify-content: center;align-items: center;border: 1px solid var(--el-border-color);border-radius: 5px;margin-bottom: 10px;height: 30px;i {font-size: 20px !important;color: var(--el-text-color-regular);}&:hover {cursor: pointer;background-color: var(--el-color-primary-light-9);border: 1px solid var(--el-color-primary-light-5);i {color: var(--el-color-primary);}}}.icon-selector-active {background-color: var(--el-color-primary-light-9);border: 1px solid var(--el-color-primary-light-5);i {color: var(--el-color-primary);}}
}
</style>

文件目录
在这里插入图片描述

实际应用示例

使用示例:

<template><el-form-item label="图标:" prop="icon" v-if="state.ruleForm.menuType === '0'"><IconSelector placeholder="请选择图标" v-model="state.ruleForm.icon" /></el-form-item>
</template><script setup lang="ts">const IconSelector = defineAsyncComponent(() => import('/@/components/IconSelector/index.vue'));
</script>

效果展示

在这里插入图片描述

总结

通过构建这套完整的图标选择器系统,我们实现了:

  1. 统一管理:集中管理项目中所有图标资源
  2. 灵活扩展:支持多种图标来源和类型
  3. 用户友好:提供直观的图标选择界面
  4. 开发高效:简化图标使用流程,提高开发效率
  5. 性能优化:通过多种技术手段优化加载和渲染性能

这套图标系统不仅解决了项目中的实际问题,也为后续的维护和扩展提供了良好的基础。在实际项目中,你可以根据具体需求对系统进行定制和扩展,打造出最适合自己的图标管理解决方案。

http://www.dtcms.com/a/537216.html

相关文章:

  • 卡尔费休滴定法微量水分测定仪:高精度水分分析的核心技术解析
  • 【重庆政务服务网-注册_登录安全分析报告】
  • 大型网站开发的主流语言网站的标题优化怎么做
  • 3.Xposed框架入门指南:深入解析Hook内部类与匿名类的实现技巧
  • 南皮做网站网站开发 放大图片
  • 【开源负载测试工具Locust的并发测试优势】
  • 历史上的今天 网站如何做影视动画设计专业
  • 网站搭建需要多少钱?嵌入式培训班多少钱
  • JavaScript学习第八天:对象
  • 数据重构!按一级科目拆分序时账,批量生成明细账
  • 适合权重小的网站做的专题西宁市网站建设
  • 清远网站开发sohu电商网站 收费与免费
  • UE5关卡蓝图视图恢复方法
  • JS 自定义事件:从 CustomEvent 到 dispatchEvent!
  • gpt-5和gpt-5-codex到底用哪个好?
  • 如何查看网站的访问量静态网站开发试验报告
  • 【C基本功】类型转换的奇幻漂流
  • 南昌建设人才网站网站域名费用怎么做分录
  • 狄拉克函数与它的性质python函数表示
  • 山东省荣成市建设局网站开鲁网站seo站长工具
  • 海口 网站制作公司找家里做的工作到什么网站
  • Python全栈项目--基于计算机视觉的车牌识别系统
  • 制作空间主页网站学做网站初入门教程
  • 生命周期详解与实践
  • 【开题答辩过程】以《济南市济阳区智能蔬菜大棚管理系统》为例,不会开题答辩的可以进来看看
  • 比较好的网站开发团队有没有网站建设的教程
  • 基于昇腾支持的Llama模型性能测试:GitCode Notebook环境实践
  • 分频器介绍
  • wnmp搭建wordpress哪些网站seo做的好
  • [java] JVM 内存泄漏分析案例