在后台管理系统里,图标是一个很容易被低估的基础能力。
最开始我们可能只是在菜单里写几个 Ant Design 图标:
vue
<HomeOutlined />
<SettingOutlined />
但随着项目复杂度上来,问题会很快出现:菜单图标来自后端配置怎么办?本地 SVG 怎么接入?Iconify 这类海量图标库怎么用?权限菜单里需要一个图标选择器怎么办?离线场景和在线搜索如何兼顾?
这篇文章结合一个 Vue3 + TypeScript + Ant Design Vue 管理后台项目的实现,聊聊如何设计一套工程上更可维护的 Icon 和 IconPicker 组件。
文章不会只堆代码,而是重点讲组件边界、数据协议、加载策略和交互设计。
先明确目标
一个通用图标体系至少要解决这几个问题:
- 统一渲染入口:业务侧不关心图标来源,只传一个图标值。
- 支持多种来源:Ant Design 图标、本地 SVG、Iconify、本地离线图标包、在线搜索结果。
- 支持后端配置:菜单、权限、页面配置里图标通常是字符串,而不是 Vue 组件。
- 按需加载:不能为了一个图标选择器,把几万枚图标全部打进首屏。
- 选择器可用:支持搜索、分类、分页、预览、选中态和手动输入。
- 接口简单稳定 :业务组件最好只面对
icon: string这一种模型。
所以核心思路是:先设计一套字符串协议,再让 Icon 和 IconPicker 围绕这套协议协作。
第一步:用字符串协议统一图标来源
图标体系的关键不是先写组件,而是先统一图标值的表达方式。
可以约定如下协议:
ts
type IconValue =
| `antdv-next:${string}`
| `antd:${string}`
| `svg:${string}`
| `ri:${string}`
| `mdi:${string}`
| `ion:${string}`
| string;
例如:
ts
const icons = [
'antdv-next:HomeOutlined',
'svg:icon-demo-orbit',
'ri:home-line',
'mdi:account-circle-outline',
'ion:planet-outline',
];
这样做有几个好处:
- 字符串可以直接存数据库,适合菜单、权限、页面配置。
- 前缀就是图标来源,不需要额外维护复杂对象。
Icon可以根据前缀自动判断渲染方式。IconPicker选出来的值可以原样交给Icon渲染。
也就是说,IconPicker 的产物就是 Icon 的入参,这两个组件天然闭环。
Icon 组件:只负责渲染,不负责选择

Icon 组件应该尽量小,它只解决一个问题:给我一个字符串,我把它渲染出来。
推荐的 Props 设计如下:
ts
interface IconProps {
icon: string;
kind?: 'iconify' | 'antdv-next' | 'antdvNext' | 'antd' | 'svg';
size?: number | string;
style?: StyleValue;
}
这里有两个设计点:
- 默认通过
icon前缀自动识别类型。 - 保留
kind作为兜底,让调用方在特殊情况下强制指定类型。
组件内部可以先做一次类型归一化:
ts
type NormalizedIconKind = 'iconify' | 'antdv-next' | 'svg';
type IconKind = NormalizedIconKind | 'antdvNext' | 'antd';
function normalizeKind(kind?: IconKind): NormalizedIconKind | undefined {
if (!kind) return undefined;
if (kind === 'antdvNext' || kind === 'antd') return 'antdv-next';
return kind;
}
然后根据 icon 计算最终类型:
ts
const iconText = computed(() => props.icon.trim());
const resolvedKind = computed<NormalizedIconKind>(() => {
const forcedKind = normalizeKind(props.kind);
if (forcedKind) return forcedKind;
if (iconText.value.startsWith('antdv-next:') || iconText.value.startsWith('antd:')) {
return 'antdv-next';
}
if (iconText.value.startsWith('svg:')) {
return 'svg';
}
return 'iconify';
});
这个设计让调用方可以非常轻量:
vue
<IconView icon="antdv-next:HomeOutlined" :size="18" />
<IconView icon="svg:icon-demo-orbit" :size="18" />
<IconView icon="ri:home-line" :size="18" />
渲染 Ant Design 图标
Ant Design 图标本质上是 Vue 组件,可以通过动态导入拿到:
ts
const antdvKey = computed(() => {
return iconText.value
.replace(/^antdv-next:/, '')
.replace(/^antd:/, '');
});
const antdvComp = shallowRef<Component>();
watch([resolvedKind, antdvKey], async ([kind, key]) => {
if (kind !== 'antdv-next') {
antdvComp.value = undefined;
return;
}
const icons = await import('@antdv-next/icons');
antdvComp.value = icons[key] || icons.QuestionOutlined;
}, { immediate: true });
模板中直接使用动态组件:
vue
<component
:is="antdvComp"
v-if="resolvedKind === 'antdv-next'"
class="app-icon"
:style="baseStyle"
/>
这里使用 shallowRef 是合理的,因为图标组件本身不需要深层响应式代理。
渲染本地 SVG Symbol
本地 SVG 推荐走 symbol 模式:
vue
<svg
v-else-if="resolvedKind === 'svg'"
class="app-icon app-icon-svg"
:style="baseStyle"
aria-hidden="true"
>
<use :href="`#${svgId}`" />
</svg>
对应的图标值是:
ts
svg:icon-demo-orbit
组件内部只需要去掉 svg: 前缀:
ts
const svgId = computed(() => iconText.value.replace(/^svg:/, ''));
这种方式的优点是:
- SVG 可以跟随
currentColor,自然继承文字颜色。 - 渲染成本低。
- 与设计系统里的自定义图标兼容性好。
渲染 Iconify 图标
Iconify 的优势是图标库巨大,并且天然支持 prefix:name 格式,比如:
ts
ri:home-line
mdi:account-circle-outline
ion:planet-outline
Icon 组件中可以直接使用 @iconify/vue:
vue
<IconifyIcon
v-else-if="canRenderIconify"
class="app-icon"
:icon="iconifyIcon"
:style="baseStyle"
/>
如果项目希望支持离线图标包,可以把常用集合单独安装下来,例如:
ts
import { addCollection } from '@iconify/vue';
export type LocalIconifyPrefix = 'ri' | 'mdi' | 'ion';
const localPrefixes = new Set<string>(['ri', 'mdi', 'ion']);
const localIconifyLoadPromises = new Map<LocalIconifyPrefix, Promise<IconsJson>>();
export function isLocalIconifyPrefix(prefix: string): prefix is LocalIconifyPrefix {
return localPrefixes.has(prefix);
}
export function loadLocalIconifySet(prefix: LocalIconifyPrefix) {
const cached = localIconifyLoadPromises.get(prefix);
if (cached) return cached;
const promise = (async () => {
let module: unknown;
if (prefix === 'ri') {
module = await import('@iconify-json/ri/icons.json');
} else if (prefix === 'mdi') {
module = await import('@iconify-json/mdi/icons.json');
} else {
module = await import('@iconify-json/ion/icons.json');
}
const iconsJson = resolveIconsJson(module);
addCollection(iconsJson as Parameters<typeof addCollection>[0]);
return iconsJson;
})();
localIconifyLoadPromises.set(prefix, promise);
return promise;
}
这段代码里有一个很重要的点:用 Map 缓存加载 Promise,而不是缓存结果。
这样即使多个图标同时触发同一个图标包加载,也只会发起一次动态导入。
Icon 中再根据前缀决定是否加载本地集合:
ts
const iconifyPrefix = computed(() => {
const [prefix] = iconifyIcon.value.split(':');
return prefix || '';
});
const localIconifyReady = ref(true);
watch([resolvedKind, iconifyPrefix], async ([kind, prefix]) => {
if (kind !== 'iconify' || !isLocalIconifyPrefix(prefix)) {
localIconifyReady.value = true;
return;
}
localIconifyReady.value = false;
await loadLocalIconifySet(prefix);
if (resolvedKind.value === 'iconify' && iconifyPrefix.value === prefix) {
localIconifyReady.value = true;
}
}, { immediate: true });
这可以避免图标包尚未注册时就立刻渲染,减少闪烁和失败状态。
IconPicker 组件:负责选择,不负责解释渲染

如果说 Icon 是渲染层,那么 IconPicker 就是数据选择层。
它的输出仍然应该是字符串:
vue
<IconPicker v-model="iconValue" />
<IconView :icon="iconValue" />
推荐的 Props 设计如下:
ts
interface IconPickerProps {
modelValue?: string;
value?: string;
placeholder?: string;
pageSize?: number;
svgIcons?: string[];
svgPrefix?: string;
onlineLimit?: number;
}
这里同时支持 modelValue 和 value,可以兼容不同表单体系。
事件则保持简单:
ts
const emit = defineEmits<{
(e: 'update:modelValue', v: string): void;
(e: 'update:value', v: string): void;
(e: 'change', v: string): void;
}>();
选中图标时统一派发:
ts
function emitUpdate(value: string) {
emit('update:value', value);
emit('update:modelValue', value);
emit('change', value);
}
选择器的整体结构
一个好用的 IconPicker 通常包含这些区域:
- 输入框触发器:展示当前图标值,支持手动输入。
- 搜索框:筛选当前分类图标,也可以触发在线搜索。
- 分类栏:All、RI、MDI、ION、Ant Design、本地 SVG。
- 图标网格:展示图标预览,支持 tooltip 和选中态。
- 分页栏:避免一次渲染过多图标。
组件结构可以类似这样:
vue
<a-popover v-model:open="open" trigger="click" placement="bottomLeft">
<template #default>
<a-input v-model:value="inputValue" allow-clear>
<template #suffix>
<button type="button" @mousedown.prevent.stop="togglePopover">
<IconView :icon="inputValue || 'ion:apps-outline'" :size="18" />
</button>
</template>
</a-input>
</template>
<template #content>
<a-input v-model:value="keyword" />
<a-segmented v-model:value="category" :options="categoryOptions" />
<div class="icon-grid">
<button v-for="name in pageItems" :key="name" @click="apply(name)">
<IconView :icon="name" :size="20" />
</button>
</div>
<a-pagination v-model:current="page" :page-size="pageSize" :total="filteredTotal" />
</template>
</a-popover>
这里有一个细节:触发按钮使用 @mousedown.prevent.stop,可以避免点击后输入框 blur 或 Popover 状态抖动。
离线图标集合加载
IconPicker 不是一打开页面就加载所有图标,而是在打开弹层时再加载:
ts
function loadOfflineIconSets() {
void loadIconifySet('ri');
void loadIconifySet('mdi');
void loadIconifySet('ion');
void loadAntdvIcons();
}
function onOpenChange(next: boolean) {
if (next) {
category.value = detectCategoryByIcon(boundValue.value.trim());
page.value = 1;
loadOfflineIconSets();
focusSearch();
}
open.value = next;
}
这样首屏不会因为选择器而膨胀。用户真正打开选择器时,再异步加载候选项。
分类列表可以分开维护:
ts
const riNames = ref<string[]>([]);
const mdiNames = ref<string[]>([]);
const ionNames = ref<string[]>([]);
const antdvIconNames = ref<string[]>([]);
function iconifyNames(prefix: string, json: IconsJson) {
const names = [
...Object.keys(json.icons || {}),
...Object.keys(json.aliases || {}),
];
return names.map((name) => `${prefix}:${name}`);
}
Ant Design 图标也可以动态导入,然后过滤出真正的图标组件:
ts
async function loadAntdvIcons() {
const icons = await import('@antdv-next/icons');
antdvIconNames.value = Object.keys(icons)
.filter((name) => /(Outlined|Filled|TwoTone)$/.test(name))
.map((name) => `antdv-next:${name}`);
}
本地 SVG 自动发现
除了通过 Props 传入本地 SVG,也可以使用 Vite 的 import.meta.glob 自动发现:
ts
const localSvgModules = import.meta.glob('../../assets/icons/**/*.svg');
function extractSvgSymbolName(path: string) {
const matched = path.match(/\/icons\/(.*)\.svg$/);
if (!matched || !matched[1]) return '';
const normalized = matched[1].replace(/\//g, '-');
const prefix = props.svgPrefix || '';
return normalized.startsWith(prefix) ? normalized : `${prefix}${normalized}`;
}
const svgAll = computed(() => {
const fromLocal = Object.keys(localSvgModules)
.map(extractSvgSymbolName)
.filter(Boolean)
.map(normalizeSvgName);
const fromProps = (props.svgIcons || [])
.map(normalizeSvgName)
.filter(Boolean);
return dedupe([...fromProps, ...fromLocal]);
});
这让业务侧有两种接入方式:
- 把 SVG 文件放进约定目录,由选择器自动识别。
- 通过
svgIcons显式传入当前页面临时定义的 symbol。
示例:
vue
<IconPicker
v-model="pickedIcon"
:svg-icons="['icon-demo-orbit', 'icon-demo-pulse']"
/>
分类、搜索与分页
IconPicker 的数据流可以拆成三步:
listByCategory:先根据分类拿到候选集合。filtered:再根据关键词过滤。pageItems:最后根据分页截取当前页。
ts
const listByCategory = computed<string[]>(() => {
switch (category.value) {
case 'ri':
return riAll.value;
case 'mdi':
return mdiAll.value;
case 'ion':
return ionAll.value;
case 'antdv-next':
return antdvAll.value;
case 'svg':
return svgAll.value;
default:
return allOfflineIcons.value;
}
});
const filtered = computed(() => {
const query = keyword.value.trim().toLowerCase();
if (!query) return listByCategory.value;
return listByCategory.value.filter((item) =>
item.toLowerCase().includes(query),
);
});
const pageItems = computed(() => {
const start = (page.value - 1) * props.pageSize;
return filtered.value.slice(start, start + props.pageSize);
});
当分类或关键词变化时,分页要回到第一页:
ts
watch([category, keyword], () => {
page.value = 1;
});
这个逻辑虽然简单,但很关键。否则用户在第 10 页搜索一个关键词,很容易看到空列表,以为没有结果。
在线搜索:防抖、缓存和取消请求
离线图标适合常用集合,但 Iconify 的价值在于海量在线搜索。
在线搜索建议只在满足条件时触发,例如:
- 当前分类是
all。 - 关键词长度至少 2 个字符。
- 输入停止 300ms 后再请求。
ts
const shouldSearchOnline = computed(() => {
const query = keyword.value.trim();
return query.length >= 2 && category.value === 'all';
});
请求层面建议同时处理三件事:
- 用
setTimeout做防抖。 - 用
Map缓存相同关键词结果。 - 用
AbortController取消上一次未完成请求。
ts
const onlineIcons = ref<string[]>([]);
const onlineCache = new Map<string, string[]>();
const onlineAbortController = ref<AbortController | null>(null);
let onlineTimer: ReturnType<typeof setTimeout> | null = null;
function scheduleOnlineSearch() {
if (onlineTimer) {
clearTimeout(onlineTimer);
onlineTimer = null;
}
if (!shouldSearchOnline.value) {
resetOnlineState();
return;
}
const query = keyword.value.trim();
onlineTimer = setTimeout(() => {
fetchOnlineIcons(query);
}, 300);
}
真正请求时:
ts
async function fetchOnlineIcons(query: string) {
const normalized = query.trim().toLowerCase();
if (!normalized) return;
if (onlineCache.has(normalized)) {
onlineIcons.value = onlineCache.get(normalized) || [];
return;
}
onlineAbortController.value?.abort();
const controller = new AbortController();
onlineAbortController.value = controller;
try {
const url = `https://api.iconify.design/search?query=${encodeURIComponent(normalized)}&limit=${props.onlineLimit}`;
const response = await fetch(url, { signal: controller.signal });
const data = await response.json() as { icons?: string[] };
const icons = Array.isArray(data.icons)
? data.icons.filter((item): item is string => typeof item === 'string' && Boolean(item))
: [];
onlineCache.set(normalized, icons);
if (!controller.signal.aborted) {
onlineIcons.value = icons;
}
} finally {
if (onlineAbortController.value === controller) {
onlineAbortController.value = null;
}
}
}
组件卸载时也要清理定时器和请求:
ts
onBeforeUnmount(() => {
if (onlineTimer) clearTimeout(onlineTimer);
onlineAbortController.value?.abort();
});
这个细节决定了 IconPicker 在复杂页面里的稳定性。否则用户快速输入、切换页面或关闭弹层时,很容易出现过期请求覆盖新结果的问题。
交互细节:选择器要像表单组件一样工作
IconPicker 本质上是一个表单控件,所以它要尊重表单控件的使用习惯。
支持手动输入
不要把 IconPicker 做成只能点选。实际业务中,开发者可能已经知道图标名,直接粘贴会更快:
ts
function onInputChange() {
emitUpdate(editableValue.value);
}
这样可以支持:
ts
ri:home-line
mdi:account-circle-outline
custom-prefix:custom-icon
打开时自动定位分类
如果当前值是 mdi:account,打开选择器时默认切到 MDI 分类会更自然:
ts
function detectCategoryByIcon(iconName: string): Category {
if (iconName.startsWith('ri:')) return 'ri';
if (iconName.startsWith('mdi:')) return 'mdi';
if (iconName.startsWith('ion:')) return 'ion';
if (iconName.startsWith('svg:')) return 'svg';
if (iconName.startsWith('antdv-next:') || iconName.startsWith('antd:')) {
return 'antdv-next';
}
return 'all';
}
图标来源要可视化
图标多了以后,只看名字不够。可以给不同来源配置不同颜色和标签:
ts
const iconMetaConfig = {
ri: { label: 'Remix', color: '#3b82f6' },
mdi: { label: 'MDI', color: '#10b981' },
ion: { label: 'Ion', color: '#8b5cf6' },
'antdv-next': { label: 'Antdv', color: '#ef4444' },
svg: { label: 'SVG', color: '#f59e0b' },
online: { label: 'Online', color: '#64748b' },
};
在 tooltip、hover 色条、分类标签里复用这套元信息,用户就能快速判断图标来源。
网格不要一次渲染太多
图标选择器很容易一次渲染几千个按钮。如果没有虚拟滚动,至少要分页:
ts
const pageItems = computed(() => {
const start = (page.value - 1) * props.pageSize;
return filtered.value.slice(start, start + props.pageSize);
});
默认 pageSize 可以设成 36。比如 6 列网格,每页 6 行,视觉上比较稳定。
菜单系统里的图标解析
在菜单、标签页、全局搜索这些地方,图标通常来自路由 meta 或后端权限配置。
如果只用少量 Ant Design 图标,可以额外维护一个轻量 resolveIcon:
ts
const iconMap: Record<string, Component> = {
HomeOutlined,
SettingOutlined,
UserOutlined,
};
function normalizeIconName(name: string): string {
const trimmed = name.trim();
if (trimmed.startsWith('antdv-next:')) return trimmed.slice('antdv-next:'.length);
if (trimmed.startsWith('antd:')) return trimmed.slice('antd:'.length);
return trimmed;
}
export function resolveIcon(name?: string): Component | undefined {
if (!name) return undefined;
return iconMap[normalizeIconName(name)];
}
这种方式适合菜单树:只注册菜单常用图标,避免布局组件引入过多内容。
如果希望菜单也支持 Iconify 和 SVG,则可以直接在菜单项里使用统一的 Icon 组件,而不是 resolveIcon。
组件设计里的几个取舍
为什么不直接传组件?
因为后台系统里的图标经常来自后端。后端不可能传 Vue 组件,只能传字符串。
字符串协议天然适合:
- 后端存储。
- 权限配置。
- 表单编辑。
- 复制粘贴。
- 国际化和多端复用。
为什么 Icon 和 IconPicker 要拆开?
因为它们职责完全不同。
Icon 是渲染组件,要足够轻,使用频率很高。
IconPicker 是选择组件,会涉及大量数据、搜索、分页、网络请求,不能污染 Icon 的复杂度。
拆开以后,业务里可以自由组合:
vue
<IconPicker v-model="form.icon" />
<IconView :icon="form.icon" />
为什么离线和在线都要支持?
只支持在线搜索,内网环境或弱网环境会很难用。
只支持离线图标包,图标覆盖面又不够。
比较稳妥的方案是:
- 常用集合离线化,比如
ri、mdi、ion。 - 关键词搜索时补充在线结果。
- 在线搜索结果也仍然返回 Iconify 标准字符串。
这样既保证基础可用,又保留扩展空间。
最终使用效果
业务页面可以这样写:
vue
<script setup lang="ts">
import { ref } from 'vue';
import IconView from '@/components/Icon/index.vue';
import IconPicker from '@/components/IconPicker/index.vue';
const iconValue = ref('ri:map-pin-time-line');
</script>
<template>
<IconPicker v-model="iconValue" />
<IconView :icon="iconValue" :size="32" />
</template>
如果需要本地 SVG:
vue
<svg aria-hidden="true" style="position:absolute;width:0;height:0">
<symbol id="icon-demo-orbit" viewBox="0 0 1024 1024">
<!-- svg paths -->
</symbol>
</svg>
<IconView icon="svg:icon-demo-orbit" :size="20" />
如果需要让 IconPicker 识别页面内临时 symbol:
vue
<IconPicker
v-model="iconValue"
:svg-icons="['icon-demo-orbit', 'icon-demo-pulse']"
/>

总结
设计一套 Vue3 图标组件,重点不是"能不能显示图标",而是能不能在真实业务里长期维护。
我认为比较稳的方案是:
- 用字符串协议统一所有图标来源。
Icon只负责按协议渲染。IconPicker只负责选择并输出协议字符串。- 常用 Iconify 集合做离线动态加载。
- 搜索结果用分页控制渲染规模。
- 在线搜索加防抖、缓存和请求取消。
- 本地 SVG 用
svg:协议接入,保持和其他来源一致。
当 IconPicker 选出的值可以直接交给 Icon 渲染时,这套体系就已经形成闭环。后续无论是菜单配置、权限管理、表单设计器,还是低代码页面配置,都可以围绕同一个 icon: string 模型扩展。
这就是基础组件真正有价值的地方:不是把某个 UI 做出来,而是把复杂度收敛到一个稳定、可复用、可演进的工程模型里。