Vue3 中如何设计一套好用的 Icon 和 IconPicker 组件

在后台管理系统里,图标是一个很容易被低估的基础能力。

最开始我们可能只是在菜单里写几个 Ant Design 图标:

vue 复制代码
<HomeOutlined />
<SettingOutlined />

但随着项目复杂度上来,问题会很快出现:菜单图标来自后端配置怎么办?本地 SVG 怎么接入?Iconify 这类海量图标库怎么用?权限菜单里需要一个图标选择器怎么办?离线场景和在线搜索如何兼顾?

这篇文章结合一个 Vue3 + TypeScript + Ant Design Vue 管理后台项目的实现,聊聊如何设计一套工程上更可维护的 IconIconPicker 组件。

文章不会只堆代码,而是重点讲组件边界、数据协议、加载策略和交互设计。

先明确目标

一个通用图标体系至少要解决这几个问题:

  1. 统一渲染入口:业务侧不关心图标来源,只传一个图标值。
  2. 支持多种来源:Ant Design 图标、本地 SVG、Iconify、本地离线图标包、在线搜索结果。
  3. 支持后端配置:菜单、权限、页面配置里图标通常是字符串,而不是 Vue 组件。
  4. 按需加载:不能为了一个图标选择器,把几万枚图标全部打进首屏。
  5. 选择器可用:支持搜索、分类、分页、预览、选中态和手动输入。
  6. 接口简单稳定 :业务组件最好只面对 icon: string 这一种模型。

所以核心思路是:先设计一套字符串协议,再让 IconIconPicker 围绕这套协议协作。

第一步:用字符串协议统一图标来源

图标体系的关键不是先写组件,而是先统一图标值的表达方式。

可以约定如下协议:

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',
];

这样做有几个好处:

  1. 字符串可以直接存数据库,适合菜单、权限、页面配置。
  2. 前缀就是图标来源,不需要额外维护复杂对象。
  3. Icon 可以根据前缀自动判断渲染方式。
  4. IconPicker 选出来的值可以原样交给 Icon 渲染。

也就是说,IconPicker 的产物就是 Icon 的入参,这两个组件天然闭环。

Icon 组件:只负责渲染,不负责选择

Icon 组件应该尽量小,它只解决一个问题:给我一个字符串,我把它渲染出来。

推荐的 Props 设计如下:

ts 复制代码
interface IconProps {
  icon: string;
  kind?: 'iconify' | 'antdv-next' | 'antdvNext' | 'antd' | 'svg';
  size?: number | string;
  style?: StyleValue;
}

这里有两个设计点:

  1. 默认通过 icon 前缀自动识别类型。
  2. 保留 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:/, ''));

这种方式的优点是:

  1. SVG 可以跟随 currentColor,自然继承文字颜色。
  2. 渲染成本低。
  3. 与设计系统里的自定义图标兼容性好。

渲染 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;
}

这里同时支持 modelValuevalue,可以兼容不同表单体系。

事件则保持简单:

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 通常包含这些区域:

  1. 输入框触发器:展示当前图标值,支持手动输入。
  2. 搜索框:筛选当前分类图标,也可以触发在线搜索。
  3. 分类栏:All、RI、MDI、ION、Ant Design、本地 SVG。
  4. 图标网格:展示图标预览,支持 tooltip 和选中态。
  5. 分页栏:避免一次渲染过多图标。

组件结构可以类似这样:

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]);
});

这让业务侧有两种接入方式:

  1. 把 SVG 文件放进约定目录,由选择器自动识别。
  2. 通过 svgIcons 显式传入当前页面临时定义的 symbol。

示例:

vue 复制代码
<IconPicker
  v-model="pickedIcon"
  :svg-icons="['icon-demo-orbit', 'icon-demo-pulse']"
/>

分类、搜索与分页

IconPicker 的数据流可以拆成三步:

  1. listByCategory:先根据分类拿到候选集合。
  2. filtered:再根据关键词过滤。
  3. 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 的价值在于海量在线搜索。

在线搜索建议只在满足条件时触发,例如:

  1. 当前分类是 all
  2. 关键词长度至少 2 个字符。
  3. 输入停止 300ms 后再请求。
ts 复制代码
const shouldSearchOnline = computed(() => {
  const query = keyword.value.trim();
  return query.length >= 2 && category.value === 'all';
});

请求层面建议同时处理三件事:

  1. setTimeout 做防抖。
  2. Map 缓存相同关键词结果。
  3. 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 组件,只能传字符串。

字符串协议天然适合:

  1. 后端存储。
  2. 权限配置。
  3. 表单编辑。
  4. 复制粘贴。
  5. 国际化和多端复用。

为什么 Icon 和 IconPicker 要拆开?

因为它们职责完全不同。

Icon 是渲染组件,要足够轻,使用频率很高。

IconPicker 是选择组件,会涉及大量数据、搜索、分页、网络请求,不能污染 Icon 的复杂度。

拆开以后,业务里可以自由组合:

vue 复制代码
<IconPicker v-model="form.icon" />
<IconView :icon="form.icon" />

为什么离线和在线都要支持?

只支持在线搜索,内网环境或弱网环境会很难用。

只支持离线图标包,图标覆盖面又不够。

比较稳妥的方案是:

  1. 常用集合离线化,比如 rimdiion
  2. 关键词搜索时补充在线结果。
  3. 在线搜索结果也仍然返回 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 图标组件,重点不是"能不能显示图标",而是能不能在真实业务里长期维护。

我认为比较稳的方案是:

  1. 用字符串协议统一所有图标来源。
  2. Icon 只负责按协议渲染。
  3. IconPicker 只负责选择并输出协议字符串。
  4. 常用 Iconify 集合做离线动态加载。
  5. 搜索结果用分页控制渲染规模。
  6. 在线搜索加防抖、缓存和请求取消。
  7. 本地 SVG 用 svg: 协议接入,保持和其他来源一致。

IconPicker 选出的值可以直接交给 Icon 渲染时,这套体系就已经形成闭环。后续无论是菜单配置、权限管理、表单设计器,还是低代码页面配置,都可以围绕同一个 icon: string 模型扩展。

这就是基础组件真正有价值的地方:不是把某个 UI 做出来,而是把复杂度收敛到一个稳定、可复用、可演进的工程模型里。

相关推荐
Dreamland工坊1 小时前
AI 视频到可用资产:浏览器端抽帧与导出全链路方案选型
前端
kungggyoyoyo1 小时前
从0开发一套geo优化软件:数据模型与API设计
前端·vue.js·后端
李明卫杭州1 小时前
Web Components 完全指南:从 Custom Elements 到 Shadow DOM
前端
Darling噜啦啦1 小时前
BEM 命名规范 + CSS Reset 实战:从微信按钮页面看专业前端开发
前端·css·代码规范
Dirty_Mouse1 小时前
基于langgraph + sentry的自动化前端性能监控日报 (直接上github链接)
前端
悟空瞎说1 小时前
React 项目一键部署至 GitHub Pages 实操教程
前端
To_OC1 小时前
写完这个微信风格按钮页面,我终于吃透了BEM命名+CSS重置
前端·css·html
万少1 小时前
如果你要自动化操作浏览器,Kimi-WebBridge可能适合你
前端·javascript·后端
倾颜2 小时前
React 自定义 Hook 实战:把 AI Chat 的会话流和滚动体验从组件中拆出来
前端·react.js·next.js