夜雨聆风学习资料网

ARTICLE · 1079182

YsIconPicker 组件源码详解:忆笙智云Vue3 图标选择器的完整实现(三图标库、搜索过滤、Tooltip 预览)

YsIconPicker 组件源码详解:忆笙智云Vue3 图标选择器的完整实现(三图标库、搜索过滤、Tooltip 预览)

打开开源版后台,左侧菜单每一项前面都有个小图标:首页是房子、系统管理是齿轮、日志是文档。这些图标是哪来的?进"菜单管理"编辑一个菜单,你会看到"菜单图标"那一栏不是一个让你填类名的文本框,而是一个能点开的图标选择器——弹出一大片图标网格,分几个标签页,顶上能搜,鼠标划过还能预览图标名字。

这个选择器就是 YsIconPicker。它看着是个"小工具组件",实际内部要同时管三件事:三套来源完全不同的图标库、按需懒加载、以及搜索+预览+选中的完整交互。麻雀虽小,五脏俱全。这篇把它的源码完整拆开,代码全部来自 忆笙智云YsIconPicker/(index.vue + list.vue)和配套的 src/utils/getStyleSheets.ts,对着读即可。

一、先搞清它服务的场景:为什么要"选图标"而不是"填图标"

在后台系统里,图标是配置数据,不是写死的代码。菜单的图标、部门的图标,都由管理员在界面上配,存进数据库的 icon 字段,前端渲染菜单时再把这个字段取出来动态渲染成图标。既然是"配"出来的,就带来两个需求:

  1. 不能让人凭记忆填类名。 要求管理员背出 ele-User、ri-home-line 这种类名不现实,必须给一个可视化列表,点一下就选上。
  2. 图标来源不止一处。 项目里同时用了 Element Plus 自带的 SVG 图标、Remix Icon 字体图标、以及一套自定义的 iconfont。管理员不应该关心某个图标来自哪个库,但组件得把三套都收进一个界面里。

理解了这两点,你就明白了 YsIconPicker 为什么长成那样——它不是"随便挑几个图标排一排",而是要把三套异构的图标数据源统一成一个可搜索、可预览、可选中的列表。这决定了它的复杂度。

三套图标库的真实情况(都能在仓库里翻到):

图标库
前缀
来源
本质
Element Plus
ele-@element-plus/icons-vue
 包
Vue 组件 / SVG
Remix Icon
ri-src/theme/remixicon/remixicon.css
字体图标
自定义 iconfont
cn cn-src/theme/iconfont/iconfont.json
字体图标

注意最后一列——三套图标的技术本质并不一样:Element Plus 是一堆 Vue 组件,另外两套是字体文件。它们没法用同一种方式渲染,这正是组件里到处在做"按前缀分流"的根本原因,后面会反复看到。

二、组件全景:三个文件的协作

YsIconPicker 不是一个文件,而是三块拼起来的:

image-20260920151655200

分工很清楚:

  • 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
String
—
选中的图标类名,如 ele-User
prepend
String
ele-Pointer
未选图标时,输入框左侧显示的默认图标
placeholder
String
请输入内容搜索图标或者选择图标
占位提示
size
String
default
输入框尺寸,透传给 el-input
title
String
请选择图标
弹层顶部标题
disabled
Boolean
false
禁用整个选择器
clearable
Boolean
true
是否显示清空按钮
emptyDescription
String
无相关图标
搜不到结果时的空态文案

这里 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"的标准范式,三个要点:

  1. 用一个共享的浮层,而不是每个图标一个。 通过 tooltipStyle 动态改它的 left/top,视觉上就像"跟着鼠标走"。
  2. getBoundingClientRect 测出目标位置。 这是浮层定位的通用手段——拿到元素相对视口的坐标,再算浮层该放哪。
  3. 做视口边界回弹。 靠近屏幕边缘时把浮层往里收(横向),上方没空间就翻到下方(纵向)。不做这层处理,靠边的图标弹出的 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

相关学习资料