基于代码扫描的 Icon 优化实践

在中大型前端项目中,图标库(Icon Library)经常是一个被忽视的"隐形大头":

  • 设计同学给了一整套 SVG 图标,全部集成进组件库;
  • 实际业务只用到其中一小部分;
  • 结果是:没怎么用,但每个页面都在为这堆"沉睡图标"付出体积成本

这篇文章分享的是我在项目里做的一次 Icon 优化实践:通过一段 Node 脚本,自动收集项目中实际使用到的图标组件 ,生成统一入口文件,再在 Vue 应用中批量注册。

同时兼顾了两类使用方式:

  • 组件模板中直接写 <ZeroIconXXX />
  • 菜单、配置中用字符串 "ZeroIconXXX" 表示 icon

文中的组件前缀统一用 ZeroIconXXX,可以理解为公司内部约定的一套 Icon 命名规范。


一、背景与问题

项目中有一套统一的 SVG Icon 组件库(假设包名为):

js 复制代码
import { ZeroIconHome, ZeroIconSetting, ... } from "@company/svg-icon"

公司有统一约定:所有图标组件名都以 ZeroIcon 开头,例如:

  • ZeroIconHome
  • ZeroIconAdvertising
  • ZeroIconAnalysis

在业务中,图标主要有两种使用方式:

  1. 模板/组件中直接使用组件:

    vue 复制代码
    <ZeroIconHome />
  2. 配置中通过字符串标记 icon,例如菜单配置:

    ts 复制代码
    const menu = [
      {
        name: '首页',
        icon: 'ZeroIconHome',
      },
    ]

如果我们简单粗暴地:

js 复制代码
import * as Icons from "@company/svg-icon"

或者手写一大坨:

js 复制代码
import {
  ZeroIconHome,
  ZeroIconAdvertising,
  ZeroIconAnalysis,
  // ...
} from "@company/svg-icon"

会出现几个问题:

  • 打包会把整个 Icon 库搬进来,几乎没有体积控制
  • 手动维护这些 import 很容易出错:
    • 配置里新增了菜单 icon,忘记加 import;
    • 某些 icon 已经没人用了,但 import 一直留在那儿。

于是我希望有这样一个能力:

自动分析项目代码,找出所有实际出现的 ZeroIconXXX,生成一份 svgList.js,只从 Icon 库中导入这些组件。


二、Icon 优化方案:自动收集 + 生成入口

整体思路是写一个构建前执行的 Node 脚本,主要承担四件事:

  1. 扫描业务代码,找出所有出现过的 ZeroIconXXX
  2. 扫描 Icon 库中真实存在的组件名,做一次交集过滤;
  3. 生成统一的入口文件 svgList.js
  4. 如果一个图标都没扫到,直接阻断部署,起到兜底作用。

1. 扫描项目代码中的 ZeroIcon 使用

核心脚本示例:

js 复制代码
const glob = require("glob")
const fs = require("fs")
const { EOL } = require("os")
const path = require("path")

try {
  // 1)扫描 icon 组件库里的所有 .vue 组件
  const svgFiles = glob.sync(\`node_modules/@company/svg-icon/components/**/*.vue\`)
  const allSVG = svgFiles.map((file) => path.parse(file).name)

  // 2)扫描业务代码(根据你的项目实际目录调整)
  const files = glob.sync(\`packages/**/*.{js,jsx,ts,tsx,vue}\`, {
    ignore: [\`**/node_modules/**\`, \`**/dist/**\`],
  })

  // 收集所有出现过的 ZeroIcon 名称
  const icons = []

  files.forEach((file) => {
    const content = fs.readFileSync(file, "utf8")
    // 通过正则匹配所有 ZeroIconXXX
    const matches = content.match(/ZeroIcon[a-zA-Z0-9]+/g)
    if (matches) {
      matches.forEach((e) => icons.push(e))
    }
  })

  // 去重
  const uniqueIcons = Array.from(new Set(icons))

  // 和实际存在的 SVG 组件求交集,避免引用不存在的名字
  const filteredIcons = uniqueIcons.filter((icon) => allSVG.includes(icon))

  // 稳定排序,方便 diff 和 review
  filteredIcons.sort((a, b) => a.toLowerCase().localeCompare(b.toLowerCase()))

到这里为止,filteredIcons 数组里就是:

「当前项目中真实用到,且图标库里确实存在的 ZeroIcon 组件名」。

2. 没有任何匹配时,直接阻止部署

js 复制代码
  // 如果没有匹配的图标,抛出异常阻塞部署进程
  if (filteredIcons.length === 0) {
    throw new Error("No matching icons found in the SVG files.")
  }

这一点很实用:

  • 如果路径写错、Icon 库不可用、脚本出 bug 等导致匹配结果为空,
  • 在 CI / 部署阶段就会失败,
  • 避免线上出现"所有图标都不见了"这种事故。

3. 自动生成 svgList.js 入口文件

下面这段代码,将 filteredIcons 拼接为一个 import + export 文件:

js 复制代码
  let listStatement = ""
  let index = 1

  filteredIcons.forEach((e) => {
    if (index === filteredIcons.length) {
      listStatement += \`  \${e}\`
    } else {
      listStatement += \`  \${e},\${EOL}\`
    }
    index++
  })

  const statement =
    \`import {\${EOL}\${listStatement}\${EOL}} from "@company/svg-icon"\` +
    \`\${EOL}export default {\${EOL}\${listStatement}\${EOL}}\${EOL}\`

  fs.writeFileSync("packages/prism/src/components/SvgIcon/svgList.js", statement)
} catch (e) {
  // 任何异常直接退出,CI 中会失败
  process.exit(1)
}

生成的 svgList.js 大致长这样:

js 复制代码
import {
  ZeroIconHome,
  ZeroIconAdvertising,
  ZeroIconAnalysis,
  // ...
} from "@company/svg-icon"

export default {
  ZeroIconHome,
  ZeroIconAdvertising,
  ZeroIconAnalysis,
  // ...
}

到此我们就得到了一个**"只包含当前项目实际使用图标"的统一入口文件**。


三、如何在项目中使用生成的 svgList.js

有了 svgList.js 之后,接下来就是在应用启动的时候,把这些 Icon 组件做一次全局注册

下面是一个在 Vue 3 项目中的示例(路径根据你项目调整):

js 复制代码
// packages/pacvue/src/frame.js

// 1. 导入 svgList.js
import SvgList from "@/components/SvgIcon/svgList.js"
import { createApp } from "vue"
import App from "./App.vue"

class InitAppClass {
  constructor() {
    this.app = createApp(App)
    this.registerComponent(this.app) // 调用注册方法
  }

  // 2. 统一注册组件
  registerComponent(app) {
    // 遍历 svgList.js 导出的所有图标组件
    for (const [key, component] of Object.entries(SvgList)) {
      // 全局注册每个图标组件
      app.component(key, component)
    }
  }

  mount(selector) {
    this.app.mount(selector)
  }
}

export default InitAppClass

在业务代码中,使用体验是完全原来的写法

vue 复制代码
<template>
  <ZeroIconHome />
  <ZeroIconAdvertising />
</template>

你并不需要在每个组件里重复写:

js 复制代码
import { ZeroIconHome } from "@company/svg-icon"

所有 Icon 组件的注册统一收敛在 frame.js 中,既保持了代码干净,又利用 svgList.js 做到了"只注册真正用到的那一批"。


四、特殊处理:菜单 / 配置中的 icon(menuIconList)

上面的脚本有一个很好的特性:

我们是直接对文件内容字符串 做正则匹配,凡是出现 ZeroIconXXX 的地方都会被识别------不仅是模板里的 <ZeroIconXXX />,字符串 "ZeroIconXXX" 一样能匹配。

这就刚好覆盖了菜单/配置中使用 icon 的场景。

假设我们单独维护了一个 static-icons.js

js 复制代码
// static-icons.js
const menuIconList = [
  "ZeroIconHome",
  "ZeroIconAdvertising",
  "ZeroIconReport",
  "ZeroIconOptimization",
  "ZeroIconCompetitorAnalysis",
  // ...
]

module.exports = {
  menuIconList,
}

只要满足两点:

  1. static-icons.js 位于 packages 目录(或者被 glob 扫描到的目录);
  2. menuIconList 中存的是完整的组件名字符串,如 "ZeroIconHome"

那么在脚本扫描时:

js 复制代码
const content = fs.readFileSync(file, "utf8")
const matches = content.match(/ZeroIcon[a-zA-Z0-9]+/g)

就会把这些菜单 icon 一并识别出来:

  • 不需要在菜单配置文件里额外 import 图标;
  • 不需要对 menuIconList 做特殊逻辑处理;
  • 只要遵守统一命名规范(ZeroIconXXX),就能自动被收集进 svgList

这也是"统一命名规范"非常重要的一点:

它让工具脚本只用一条简单的正则,就能覆盖所有 icon 使用场景。


五、这种方案带来的好处

1. 包体积可控:只导入真正用到的 Icon

和全量导入 Icon 库相比,这种方式相当于构建了一份白名单

  • 项目中没出现过的 ZeroIconXXX,不会被 import;
  • 新增业务用到了新 icon,脚本下一次扫描就会自动加进来;
  • 删除一些功能时,对应 icon 只要不再出现在代码里,也会自动从 svgList.js 中消失。

整个过程不需要人工维护一份"icon 白名单"文件。

2. 对业务代码零侵入

对业务开发同学来说,只需要遵守两点简单约定:

  1. 使用组件时统一写成 <ZeroIconXXX />
  2. 在配置中表示 icon 时统一写 "ZeroIconXXX"

你不需要:

  • 在每个组件里手工 import icon 组件;
  • 在任何地方维护一个"手写的 icon 清单"。

所有优化都通过这段脚本 + 应用启动时的统一注册来完成。

3. 可视化、可审核

生成的 svgList.js 本身就是一份"当前项目实际使用 icon 清单":

  • 在 PR 中可以直观看到本次改动引入了哪些新 icon;
  • 长期来看,可以统计:
    • 哪些 icon 被频繁使用;
    • 哪些 icon 长期无人引用,可以考虑在图标库层面做清理。

4. 构建阶段兜底,降低线上风险

通过:

js 复制代码
if (filteredIcons.length === 0) {
  throw new Error("No matching icons found in the SVG files.")
}

配合 process.exit(1),可以保证:

  • 一旦 Icon 库路径出问题 / 脚本失效;
  • 或者误改了目录结构导致没匹配到任何 icon;

CI / 部署流程会直接失败,避免"上线之后才发现所有图标全挂了"的情况。


六、与 import Tree Shaking 的区别与优势

Tree Shaking 能不能解决这个问题?

答案是:理论上能帮助一部分,但不足以完全覆盖。

1. Tree Shaking 的适用场景与局限

Tree Shaking 的基本前提是:

  • 分析静态 ES Module 依赖(`import { A, B } from 'xxx'`);
  • 找出"从未被使用的导出",在打包时剔除。

它在以下场景表现不错:

  • 工具函数库;
  • UI 组件库(每个组件明确按需 import)。

但对本文的场景,它存在几类天然盲区:

  1. 配置中用字符串标记 icon:

    ts 复制代码
    const menu = [{ icon: 'ZeroIconHome' }]

    Tree Shaking 无法从字符串反推出这是一个组件依赖。

  2. 库本身导出方式不够"纯":

    比如存在 CommonJS 导出、`export *` 等,Tree Shaking 效果会明显打折扣。

  3. 动态 import / 间接引用等复杂用法,也会增加 Tree Shaking 分析难度。

2. 当前方案的特点

我们的方案从另外一个角度切入:

不去"猜哪些没用",而是 明确列出"所有用到的 ZeroIconXXX",只导入这些

具体特点是:

  • 使用正则在代码文本层面识别 icon,而不仅仅是 import 语句;
  • 能覆盖:
    • `` 这样的模板用法;
    • 代码中的变量引用;
    • 菜单/配置中的 `"ZeroIconHome"` 字符串;
  • 不依赖于 Icon 库内部是 ESM 还是 CJS。

3. 二者的关系

  • Tree Shaking 更像一个通用的"编译级优化器";
  • 本文这个脚本方案更像是一个为 Icon 这种高碎片化资源定制的专项优化

两者不冲突:

  • 你完全可以继续依赖 Tree Shaking 做其它库的优化;
  • 在 Icon 这种资源上,再叠加一层"代码扫描 + 白名单导入",效果更确定、更可控。

总体来说,这套基于 ZeroIconXXX 命名规范的 Icon 优化方案,实现了:

  • 减小包体积:只打包项目实际用到的图标;
  • 无侵入接入:业务代码几乎不需要改动;
  • 可维护性更好:统一入口 + 自动生成 + 支持菜单配置场景。

如果你们项目也有类似:

  • 统一前缀的 Icon 组件;
  • 菜单 / 配置中用字符串标记图标;

不妨也尝试用一段简单的脚本,把 Icon 这块的"隐形体积"收拾一下。

相关推荐
磊磊磊磊磊2 小时前
用AI做了个排版工具,分享一下如何高效省钱地用AI!
前端·后端·react.js
喵爱吃鱼2 小时前
flex 0 flex 1 flex none flex auto 应该在什么场景下使用
前端
雾散声声慢2 小时前
解决 iOS 上 Swiper 滑动图片闪烁问题:原因分析与最有效的修复方式
前端·css·ios
Crystal3282 小时前
冒泡排序 bubble sort
前端·javascript·面试
阿蓝灬2 小时前
clientWidth vs offsetWidth
前端·javascript
一代明君Kevin学长2 小时前
快速自定义一个带进度监控的文件资源类
java·前端·后端·python·文件上传·文件服务·文件流
2501_924064113 小时前
优测工具如何测试接口最大并发量及实践方法
性能优化·接口测试·最大并发量·优测工具·压测方案
4Forsee3 小时前
【Android】动态操作 Window 的背后机制
android·java·前端
用户90443816324603 小时前
从40亿设备漏洞到AI浏览器:藏在浏览器底层的3个“隐形”原理
前端·javascript·浏览器