ARTICLE · 1079182
YsIconPicker 组件源码详解:忆笙智云Vue3 图标选择器的完整实现(三图标库、搜索过滤、Tooltip 预览)
打开开源版后台,左侧菜单每一项前面都有个小图标:首页是房子、系统管理是齿轮、日志是文档。这些图标是哪来的?进"菜单管理"编辑一个菜单,你会看到"菜单图标"那一栏不是一个让你填类名的文本框,而是一个能点开的图标选择器——弹出一大片图标网格,分几个标签页,顶上能搜,鼠标划过还能预览图标名字。
这个选择器就是 YsIconPicker。它看着是个"小工具组件",实际内部要同时管三件事:三套来源完全不同的图标库、按需懒加载、以及搜索+预览+选中的完整交互。麻雀虽小,五脏俱全。这篇把它的源码完整拆开,代码全部来自 忆笙智云YsIconPicker/(index.vue + list.vue)和配套的 src/utils/getStyleSheets.ts,对着读即可。
一、先搞清它服务的场景:为什么要"选图标"而不是"填图标"
在后台系统里,图标是配置数据,不是写死的代码。菜单的图标、部门的图标,都由管理员在界面上配,存进数据库的 icon 字段,前端渲染菜单时再把这个字段取出来动态渲染成图标。既然是"配"出来的,就带来两个需求:
不能让人凭记忆填类名。 要求管理员背出 ele-User、ri-home-line这种类名不现实,必须给一个可视化列表,点一下就选上。图标来源不止一处。 项目里同时用了 Element Plus 自带的 SVG 图标、Remix Icon 字体图标、以及一套自定义的 iconfont。管理员不应该关心某个图标来自哪个库,但组件得把三套都收进一个界面里。
理解了这两点,你就明白了 YsIconPicker 为什么长成那样——它不是"随便挑几个图标排一排",而是要把三套异构的图标数据源统一成一个可搜索、可预览、可选中的列表。这决定了它的复杂度。
三套图标库的真实情况(都能在仓库里翻到):
ele- | @element-plus/icons-vue | ||
ri- | src/theme/remixicon/remixicon.css | ||
cn cn- | src/theme/iconfont/iconfont.json |
注意最后一列——三套图标的技术本质并不一样:Element Plus 是一堆 Vue 组件,另外两套是字体文件。它们没法用同一种方式渲染,这正是组件里到处在做"按前缀分流"的根本原因,后面会反复看到。
二、组件全景:三个文件的协作
YsIconPicker 不是一个文件,而是三块拼起来的:

分工很清楚:
index.vue是主组件,负责对外接口(props/events)、输入框、Popover 弹层、以及决定"当前该加载哪个图标库"。list.vue是列表子组件,负责把图标渲染成网格、处理滚动、自己画一个 Tooltip 做预览。getStyleSheets.ts是数据源工具,负责从三种完全不同的来源里,把图标名"抠"成统一的字符串数组。
把"取数据""展示列表""对外壳"三层分开,是这个组件最值得学的结构决策。 图标数据源的解析逻辑很脏(正则、fetch、遍历),单独抽出去之后,主组件就干净了;列表渲染逻辑(网格、滚动、tooltip)单独成件,主组件也不用管。这种分层让每一块都只做一件事。
三、index.vue 模板:输入框 + 虚拟触发的 Popover
先看主组件的模板骨架,它的交互模式有点特别——不是"点按钮弹框",而是"输入框和弹层绑在一起":
<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" @blur="onIconBlur" > <!-- 前置插槽:显示当前已选图标,让用户直观看到选了什么 --> <template #prepend> <SvgIcon :name="state.fontIconPrefix === '' ? prepend : state.fontIconPrefix" class="font14" v-if="state.fontIconPrefix === '' ? prepend?.indexOf('ele-') > -1 : state.fontIconPrefix?.indexOf('ele-') > -1" /> <!-- 非 ele- 前缀的图标用字体图标方式渲染 --> <i v-else :class="state.fontIconPrefix === '' ? prepend : state.fontIconPrefix" class="font14"></i> </template> </el-input> <!-- Popover 弹层:用 virtual-ref 绑定到输入框上,实现"贴着输入框弹出" --> <el-popover placement="right" :width="state.fontIconWidth" transition="el-zoom-in-top" popper-class="icon-selector-popper" trigger="click" :virtual-ref="inputWidthRef" virtual-triggering ref="popoverRef" @before-enter="onPopoverShow" @after-leave="onPopoverHide" > <template #default> <div class="icon-selector-warp"> <div class="icon-selector-warp-title">{{ title }}</div> <div class="icon-selector-tabs-container"> <!-- 三个 Tab:ele / remixicon / iconfont,切哪个加载哪个 --> <el-tabs v-model="state.fontIconTabActive" @tab-click="onIconClick" class="icon-selector-tabs"> <el-tab-pane lazy label="ele" name="ele"> <IconList :list="fontIconSheetsFilterList" :empty="emptyDescription" :prefix="state.fontIconPrefix" :width="state.fontIconWidth" @get-icon="onColClick" /> </el-tab-pane> <el-tab-pane lazy label="remixicon" name="remixicon"> <IconList :list="remixiconFilterList" :icon-names="state.remixiconDetails" :empty="emptyDescription" :prefix="state.fontIconPrefix" :width="state.fontIconWidth" @get-icon="onColClick" /> </el-tab-pane> <el-tab-pane lazy label="iconfont" name="iconfont"> <IconList :list="iconfontFilterList" :icon-names="state.iconfontDetails" :empty="emptyDescription" :prefix="state.fontIconPrefix" :width="state.fontIconWidth" @get-icon="onColClick" /> </el-tab-pane> </el-tabs> </div> </div> </template> </el-popover> </div></template>有几个设计点值得慢慢看:
第一,输入框一物两用。 它是搜索框(v-model="state.fontIconSearch"),又是"当前选中值"的显示区(通过 placeholder 或者前置插槽展示)。搜索词和显示值共用一个变量,是这类控件的常见做法——反正同一时刻只会展示一种。
第二,#prepend 前置插槽渲染当前图标。 这里就能看到前面说的"按前缀分流":如果当前值是 ele- 开头,走 <SvgIcon>;否则走 <i :class="..."> 当字体图标渲染。同一段渲染逻辑在组件里出现多次(列表里、tooltip 里、这里),核心都是判断 ele-。
第三,Popover 用了 virtual-ref + virtual-triggering。 这是 Element Plus 的一个稍冷门但很有用的能力:不给 Popover 传可见的触发元素,而是传入一个 ref(这里是 inputWidthRef,即输入框组件实例),让弹层"虚拟地"绑定到它上面。好处是——弹层的展开由组件逻辑控制(onIconFocus 等),而不是绑死在某个按钮的点击上;同时弹层位置依然能正确对齐到输入框。这种"逻辑触发、位置虚拟绑定"的组合,是做一个"贴着输入框弹出"的弹层最顺手的方案。
第四,三个 Tab 都带 lazy。 这是性能关键。"懒"意味着 Tab 的内容在第一次被激活时才渲染。用户不切到 remixicon 标签,那几百个 Remix 图标根本不会渲染出来。配合后面要讲的"数据也是切到才加载",整套是三重的按需。
四、Props:八个参数控制外观与行为
const props = defineProps({ prepend: { type: String, default: () =>'ele-Pointer' }, // 前置默认图标 placeholder: { type: String, default: () =>'请输入内容搜索图标或者选择图标' }, size: { type: String, default: () =>'default' }, // 输入框尺寸 title: { type: String, default: () =>'请选择图标' }, // 弹层标题 disabled: { type: Boolean, default: () =>false }, // 禁用 clearable: { type: Boolean, default: () =>true }, // 是否可清空 emptyDescription: { type: String, default: () =>'无相关图标' }, // 空态文案 modelValue: String, // 双向绑定值});v-model | ele-User | ||
prepend | ele-Pointer | ||
placeholder | 请输入内容搜索图标或者选择图标 | ||
size | default | ||
title | 请选择图标 | ||
disabled | false | ||
clearable | true | ||
emptyDescription | 无相关图标 |
这里 modelValue: String 是必看的——它只接受字符串,且约定把类型编码在字符串前缀里(ele- / ri- / cn )。整个组件靠"看前缀判断图标是什么类型、该走哪个 Tab"来运转。这个"用前缀编码类型"的设计有好处也有代价:好处是实现简单、一个字符串就能自解释;代价是类型信息藏在字符串里,不够显式。理解这一点,后面很多逻辑就顺了。
五、数据源解析:getStyleSheets.ts 怎么把三套图标"抠"出来
这是整个组件最"硬核"的部分。三套图标的来源天差地别,得用三种方法分别拿:
/** * 获取字体图标 document.styleSheets * @method ele 获取 element plus 自带图标 * @method remixicon 获取 remixicon 图标 * @method iconfont 获取 iconfont 图标 */const initIconfont = { ele: () => getElementPlusIconfont(), remixicon: () => getRemixiconIconfont(), iconfont: () => getIconfontJson(),};exportdefault initIconfont;第一种:Element Plus 图标——遍历组件包。
import * as svg from'@element-plus/icons-vue';const getElementPlusIconfont = () => {returnnewPromise((resolve, reject) => { nextTick(() => {const icons = svg as any;const sheetsIconList = [];// 把每个图标组件的 name 取出来,统一加上 ele- 前缀for (const i in icons) { sheetsIconList.push(`ele-${icons[i].name}`); }if (sheetsIconList.length > 0) resolve(sheetsIconList);else reject('未获取到值,请刷新重试'); }); });};因为图标是"组件",直接 import 进来遍历对象的 name 属性就行,等一个 nextTick 保证组件已注册。这是三种里最"正规"的一种——不需要网络、不需要解析,纯内存操作。
第二种:Remix Icon——fetch CSS 然后正则抠类名。
const getRemixiconIconfont = async () => {returnnewPromise((resolve, reject) => { nextTick(async () => {// 从本地 remixicon.css 加载图标数据const response = await fetch('/theme/remixicon/remixicon.css')const cssContent = await response.text()// 用正则匹配 .ri-xxx:before { 这种规则,提取类名const iconRegex = /\.ri-[a-zA-Z0-9-]+:before\s*\{/gconst iconList = []const iconfontDetails = {} as Record<string, string>;let matchwhile ((match = iconRegex.exec(cssContent)) !== null) {const fullMatch = match[0]// 砍掉开头的 "." 和结尾的 ":before",只剩类名const className = fullMatch.substring(1, fullMatch.length - 9) iconList.push(className) iconfontDetails[className] = className; } resolve({ iconList, iconfontDetails }); }); });};这是三种里最"取巧"的一种。Remix Icon 是字体图标,它没有现成的"图标清单"接口,但有 CSS 文件,而每个图标在 CSS 里都对应一条 .ri-xxx:before { content: "\xxx"; } 的规则。所以就直接把 CSS 当文本读进来,用正则把所有 .ri-xxx:before 抠出来——substring(1, length - 9) 里的 1 是去掉开头的点,9 是 :before 的长度(7 个字符)+ 一个 { 及可能的空格。这种"从样式表反推图标清单"的思路,是处理第三方字体图标库的通用手法,你换成 Font Awesome、Iconfont 的在线 CSS,套路是一样的。
第三种:自定义 iconfont——读 JSON 里的 glyphs。
const getIconfontJson = () => {returnnewPromise(async (resolve, reject) => {try {const response = await fetch('/src/theme/iconfont/iconfont.json');const data = await response.json();// iconfont 的 JSON 结构里有一个 glyphs 数组if (data.glyphs && Array.isArray(data.glyphs)) {const iconfontDetails = {} as Record<string, string>;const iconList = data.glyphs.map((glyph: any) => {// 建立 "类名 -> 中文名" 的映射,供 tooltip 显示 iconfontDetails[`cn cn-${glyph.font_class}`] = glyph.name;return`cn cn-${glyph.font_class}`; }); resolve({ iconList, iconfontDetails }); } else { resolve({ iconList: [], iconfontDetails: {} }); } } catch (error) {console.error('Failed to load iconfont.json:', error); reject('Failed to load iconfont.json'); } });};阿里的 iconfont 在下载时会附带一个 iconfont.json,里面 glyphs 数组每一项有 font_class(类名后缀)和 name(中文描述)。这里不仅提取了类名(拼成 cn cn-xxx),还顺手建了一个 类名 → 中文名 的映射 iconfontDetails——因为 iconfont 的类名是拼音(比如 cn-quanbushouqi),人看不懂,但配上中文名"全部收起"就直观了。这个映射就是后面 tooltip 能显示中文名的原因。
注意第二种和第三种都返回了 iconfontDetails 结构,但第一种(ele)没有——因为 Element Plus 的图标类名本身就是英文单词(ele-User、ele-Setting),一眼能看懂,不需要额外映射。这个不一致恰好反映了"数据源特性不同,处理方式就不同"。
六、懒加载:切到哪个 Tab 才加载哪个库
三套图标合计几百上千个,显然不能在组件初始化时全加载。initFontIconData 负责按需加载:
/** * 初始化图标数据 * @param name 图标类型名称 */const initFontIconData = async (name: string) => {if (name === 'ele') {// element plus 图标if (state.fontIconList.ele.length > 0) return; // 已加载就跳过await initIconfont.ele().then((res: any) => { state.fontIconList.ele = res; }); } elseif (name === 'remixicon') {if (state.fontIconList.remixicon.length > 0) return; // 已加载就跳过await initIconfont.remixicon().then((res: any) => { state.fontIconList.remixicon = res.iconList; state.remixiconDetails = res.iconfontDetails; }); } elseif (name === 'iconfont') {if (state.fontIconList.iconfont.length > 0) return; // 已加载就跳过await initIconfont.iconfont().then((res: any) => { state.fontIconList.iconfont = res.iconList; state.iconfontDetails = res.iconfontDetails; }); }// 初始化 input 的 placeholder 与双向绑定回显 state.fontIconPlaceholder = props.placeholder; initModeValueEcho();};三个分支结构一样,都是"先判断这个库加载过没有,加载过就直接 return"。这个判断是懒加载的精髓——用户来回切 Tab 时,不会每次都重新 fetch / 重新解析,切第二次是瞬时的。fontIconList 这个 state 对象,本质就是三套图标的缓存。
加载时机有两个来源:
// 打开时:根据当前值判断该加载哪个库onMounted(() => { initFontIconData(initFontIconName()); initResize();});// 切 Tab 时:加载对应库const onIconClick = (pane: TabsPaneContext) => { initFontIconData(pane.paneName as string); inputWidthRef.value.focus();};组件挂载时先按当前值加载一次(保证编辑态打开就能看到已选图标所在的库),之后每次切 Tab 再按需加载。
七、回显:一个字符串前缀,决定高亮哪个 Tab
编辑一个已有图标的数据时,选择器得"知道"该把哪个 Tab 高亮、输入框显示什么。全靠 initFontIconName 这个"看前缀猜类型"的函数:
/** * 判断图标类型并设置 Tab 高亮 * @returns 图标类型名称 */const initFontIconName = () => {let name = 'ele';// 前缀判断:ele- / ri- / cn 分别对应三个库if (props.modelValue && props.modelValue.indexOf('ele-') > -1) name = 'ele';elseif (props.modelValue && props.modelValue.indexOf('ri-') > -1) name = 'remixicon';elseif (props.modelValue && props.modelValue.startsWith('cn ')) name = 'iconfont';// 初始化 tab 高亮回显 state.fontIconTabActive = name;return name;};这就是前面说的"用前缀编码类型"的集中体现。组件不认识任何"类型字段",它就认字符串长相:带 ele- 是 Element Plus,带 ri- 是 Remix Icon,cn 开头是 iconfont。
请在微信客户端打开
/** * 处理图标双向绑定数值回显 */const initModeValueEcho = () => {// 没值就显示占位文字if (props.modelValue === '') return ((state.fontIconPlaceholder) = props.placeholder);// 有值就把值同时放进 placeholder 和 prefix state.fontIconPlaceholder = props.modelValue; state.fontIconPrefix = props.modelValue;};这里有个实现细节值得注意:**有值时,它把图标名塞进了 placeholder**。也就是说,选中图标后,输入框里显示的其实是 placeholder 文案(因为输入框是搜索框,真正的内容 fontIconSearch 是空的)。配合 #prepend 插槽里渲染的真实图标,用户看到的是"左边一个图标 + 中间灰色显示类名"。这么做的原因是输入框的主要职责是搜索,不能真把图标名当搜索词写进去,否则输入框一聚焦就会去过滤。用 placeholder 承载"显示"、用 prepend 承载"图形",两个需求都满足了,只是把显示职责挪了个位置。
八、选中与清空:两个动作,一个 emit 契约
/** * 选中图标点击 * @param v 图标名称 */const onColClick = (v: string) => { state.fontIconPlaceholder = v; // 更新显示 state.fontIconPrefix = v; // 更新当前选中图标 emit('get', state.fontIconPrefix); // 抛"选中"事件 emit('update:modelValue', state.fontIconPrefix); // 同步 v-model};/** 清空当前选中的图标 */const onClearFontIcon = () => { state.fontIconPrefix = ''; emit('clear', state.fontIconPrefix); // 抛"清空"事件 emit('update:modelValue', state.fontIconPrefix); // 值置空};选中和清空各抛两个事件:一个是业务事件(get / clear),一个是支撑 v-model 的 update:modelValue。这种"业务事件 + 双向绑定事件"成对出现的模式,和前面 YsEditableSelect 的 change + update:modelValue 是一致的——组件既要能被 v-model 无感使用,又要在关键动作时给父组件一个明确的钩子去做联动。 光有 v-model 是不够的,因为你分不清"值变了"是选中来的还是清空来的;有了 get/clear,父组件就能区分对待。
九、list.vue:把图标渲染成自适应网格
列表子组件的核心诉求是"不管容器多宽,图标都整齐排列"。它没有用 el-row/el-col 那套栅格,而是自己算每行放几个:
// 每个图标项固定宽度const iconItemWidth = 37;// 计算每行可容纳的图标数量const iconsPerRow = computed(() => {// 减去内边距(左右各15px)、加上一个 gap(5px)、再减其他宽度 22const availableWidth = props.width - 30 + 5 - 22;// 至少 3 个,最多 20 个,其余按宽度取整returnMath.max(3, Math.min(20, Math.floor(availableWidth / iconItemWidth)));});// 计算总行数const totalRows = computed(() => {returnMath.ceil(props.list.length / iconsPerRow.value);});配合模板里的 flex 布局:
<!-- 用 flex + wrap 实现响应式排列,图标宽度固定、自动换行 --><div class="icons-container" v-if="props.list.length > 0"> <div v-for="(v, k) in list" :key="k" class="icon-item" :class="{ 'icon-selector-active': prefix === v }" <!-- 高亮已选中项 --> @click="onColClick(v as string)" @mouseenter="showTooltip(v as string, $event)" @mouseleave="hideTooltip" > <!-- 按类型分流渲染:ele- 用 SvgIcon,其余当字体图标 --> <SvgIcon :name="v" v-if="(v as string).startsWith('ele-')" /> <i :class="v" v-else-if="(v as string).startsWith('ri') || (v as string).startsWith('cn ')"></i> </div></div><el-empty :image-size="100" v-if="list.length <= 0" :description="empty"></el-empty>iconsPerRow 的算法是把容器宽度一层层扣掉内边距和间隙,再除以固定图标宽,并且用 Math.max(3, Math.min(20, ...)) 把结果夹在 3 到 20 之间——防止容器过窄时一行放不下(保底 3 个),也防止超宽屏一行堆太多(封顶 20 个)。用 flex-wrap 让浏览器自己换行,再用 computed 算出行数用于信息展示,是这个网格的实现要点。它比"动态 el-col 的 span"更简单,也不依赖栅格的 24 等分限制。
icon-item 还有个 flex-shrink: 0,防止图标在容器变窄时被压扁。这种细节决定了网格在各种尺寸下是否"齐"。
十、自定义 Tooltip:为什么没用 el-tooltip
鼠标划过图标时,会弹出一个显示"图标中文名 + 点击选择"的小卡片。有意思的是,它**没有用 Element Plus 的 el-tooltip,而是自己画了一个 div.icon-tooltip**。为什么?因为图标是密集网格,用 el-tooltip 会给每个图标挂一个弹层实例,几百个图标就是几百个实例,性能吃不消。自己画一个"全局唯一、跟着鼠标位置移动"的浮层,成本低得多:
const showTooltip = (iconName: string, event: MouseEvent) => { tooltipIcon.value = iconName; tooltipVisible.value = true;// 拿到被划过图标的位置const target = event.target as HTMLElement;const rect = target.getBoundingClientRect();const tooltipWidth = 200;const tooltipHeight = 60;const arrowHeight = 8;// 默认放在图标正上方let left = rect.left + rect.width / 2 - tooltipWidth / 2;let top = rect.top - tooltipHeight - arrowHeight;// 边界处理:横向超出视口就往里收const viewportWidth = window.innerWidth;if (left < 10) left = 10;elseif (left + tooltipWidth > viewportWidth - 10) left = viewportWidth - tooltipWidth - 10;// 边界处理:上方空间不够就翻到下方if (top < 10) top = rect.bottom + arrowHeight; tooltipStyle.value = { left: `${left}px`, top: `${top}px` };};这段定位逻辑是"自己画 tooltip"的标准范式,三个要点:
用一个共享的浮层,而不是每个图标一个。 通过 tooltipStyle动态改它的left/top,视觉上就像"跟着鼠标走"。getBoundingClientRect测出目标位置。 这是浮层定位的通用手段——拿到元素相对视口的坐标,再算浮层该放哪。做视口边界回弹。 靠近屏幕边缘时把浮层往里收(横向),上方没空间就翻到下方(纵向)。不做这层处理,靠边的图标弹出的 tooltip 会被屏幕切掉一半。
自己画 tooltip 比用 el-tooltip 多写了几十行,但换来的是"无论列表里多少图标,浮层始终只有一个"。在"密集小元素 + 浮层"的场景里,共享单实例浮层几乎是性能上的必选项,这个取舍很典型。
十一、底部信息栏:滚动比例算当前行
列表底部有一条信息栏,显示"图标总数"和"当前位置 x/y"。总数好办,list.length 就是;"当前行"则需要根据滚动位置换算:
// 处理滚动事件const handleScroll = ({ scrollTop }: { scrollTop: number }) => { updateCurrentRow(scrollTop);};const updateCurrentRow = (scrollTop = 0) => { nextTick(() => {const scrollbar = selectorScrollbarRef.value;if (scrollbar && scrollbar.wrapRef) {const containerHeight = scrollbar.wrapRef.clientHeight; // 可视高度const scrollHeight = scrollbar.wrapRef.scrollHeight; // 内容总高const calculatedTotalRows = Math.ceil(props.list.length / iconsPerRow.value);// 关键:用"滚动比例"换算当前行,保证滚到底时正好是最后一行const scrollRatio = scrollHeight > containerHeight ? scrollTop / (scrollHeight - containerHeight) : 0;const row = Math.floor(scrollRatio * (calculatedTotalRows - 1)) + 1;// 夹在 [1, 总行数] 之间 currentRow.value = Math.max(1, Math.min(row, calculatedTotalRows)); } });};这里最值得学的是用"滚动比例"而不是"滚动像素除以行高"来算行号。按像素除行高的做法有个通病:因为图标有 gap、容器有 padding,单行实际高度不是整数,累加起来误差会越来越大,滚到底时算出的行号往往不是最后一行(差个一两行)。改用比例法——scrollTop / (scrollHeight - containerHeight) 得到 0~1 的滚动进度,再乘 (总行数 - 1) + 1——就保证"滚到顶是第 1 行、滚到底正好是最后一行"。这是个很实用的技巧,凡是"滚动位置映射到序号"的需求都能用。
十二、图标渲染:SvgIcon 的三种分支
不管是列表里的图标、tooltip 里的图标、还是输入框前置的图标,最终都交给 SvgIcon 组件渲染。它内部按 name 的不同长相分三种情况:
<template> <!-- 分支一:ele- 开头,当 Vue 组件动态渲染 --> <i v-if="isShowIconSvg" class="el-icon" :style="setIconSvgStyle"> <component :is="getIconName" /> </i> <!-- 分支二:http/data:image 等开头,当图片渲染 --> <div v-else-if="isShowIconImg" :style="setIconImgOutStyle"> <img :src="getIconName" :style="setIconSvgInsStyle" /> </div> <!-- 分支三:其余(ri- / cn),当字体图标渲染 --> <i v-else :class="getIconName" :style="setIconSvgStyle" /></template>// 判断是否为 element plus 的 svg 图标(ele- 前缀)const isShowIconSvg = computed(() => {return props?.name?.startsWith('ele-');});// 判断是否为在线链接、本地图片等const isShowIconImg = computed(() => {return linesString.find((str) => props.name?.startsWith(str));});三种情况对应三种渲染方式:
ele-开头 →component :is动态组件。因为 Element Plus 图标是 Vue 组件,用:is把名字当组件名渲染。(全局注册时,每个图标都被注册成了ele-Xxx。)http/data:image//src等开头 →<img>。支持"图标是个图片地址"的情况,扩展性好。其余 → <i :class>。这是字体图标的渲染方式,ri-home-line、cn cn-xingbie都走这里。
这三分支就是"用字符串前缀编码类型"设计价值的最终落点——一个 name 字符串进来,SvgIcon 一看长相就知道该怎么渲染,调用方什么都不用管。整个 YsIconPicker 里那些 startsWith('ele-') 的判断,本质都是在适配这个渲染契约。
十三、它的边界与可改进之处
诚实地说几个局限:
一、类型靠字符串前缀,不够健壮。 如果将来换一套图标库、前缀规则变了,组件里散落的 startsWith 判断都得跟着改。更健壮的做法是存 { type, name } 结构,但那样 icon 数据库字段就得存 JSON,迁移成本高。这是"简单"和"健壮"的老权衡,项目选了简单。
二、remixicon 和 iconfont 依赖运行时 fetch。 它们的图标清单是组件运行时通过网络路径拿的(/theme/remixicon/remixicon.css、/src/theme/iconfont/iconfont.json),一旦这些路径在部署环境里不可达(比如构建后文件没进正确的目录、或路径前缀对不上),这两个 Tab 就是空的。Element Plus 那套因为走 import,不受影响。部署时务必确认这些静态资源能被访问到,这是它最容易出环境问题的地方。
三、没有"最近使用""收藏"这类便利功能。 图标多了以后,每次都要搜。要提升体验,可以加个"最近选过"的小分组,存 localStorage 即可。
四、列表没做虚拟滚动。 单库几百个图标(加上 DOM 不轻),开启 lazy 后只渲染当前 Tab,勉强够用;如果某个库上千个,还是会卡,得上虚拟滚动。
五、没有"自定义上传图标"。 样式文件里其实预留了 .icon-uploader 的样式,但组件没实现上传逻辑,属未完成的能力。
小结
YsIconPicker 表面是个"选图标的小工具",内部却是一堂很完整的组件设计课:三套异构数据源用三种方法解析、三重按需加载(Tab lazy + 数据懒加载 + 已加载缓存)、共享单实例 Tooltip 的性能取舍、比例法算滚动行、以及"字符串前缀编码类型 + 渲染端分流"这套贯穿始终的约定。 它不完美,有些地方选择"简单优先",但每个取舍都有它的道理,读懂了你自己写类似的多源选择器时就有章法了。
开源地址:
Gitee: https://gitee.com/lqclf/ys-lowcode-open GitHub: https://github.com/lqclf/ys-code-ai-open
在线体验:
地址: https://admin.yscode.cn/ 账号: ysadmin 密码: Ysadmin123456
官网地址:https://yscode.cn