【Vite】离线打包@iconify/vue的图标

【Vite】离线打包@iconify/vue的图标

所需库@iconify/vue@iconify/types@iconify/json

需要创建或修改3个文件,

  1. 图标检索脚本
  2. @iconify/vue引入
  3. package.json文件

图标检索脚本

scripts\extract-icons.cjs

js 复制代码
// scripts/extract-icons.cjs
/* eslint-disable @typescript-eslint/no-require-imports */
const fs = require('fs')
const path = require('path')

// ============ 配置区域 ============
const CONFIG = {
  srcDir: 'src',
  outputPath: 'src/assets/icons-slim/icons.json',
  iconifyJsonDir: 'node_modules/@iconify/json/json',
  extensions: ['.vue', '.ts', '.tsx', '.js', '.jsx']
}

// Tailwind CSS 响应式/状态前缀黑名单
/* prettier-ignore */
const TAILWIND_PREFIXES = new Set([
  'sm', 'md', 'lg', 'xl', '2xl', // 响应式断点
  'hover', 'focus', 'active', 'disabled', 'visited', 'checked', 'indeterminate',  // 状态变体
  'focus-within', 'focus-visible', 'group-hover', 'group-focus', 'peer-hover', 'peer-focus',
  'before', 'after', 'first-letter', 'first-line', 'marker', 'selection', 'file', 'placeholder', // 伪元素
  'first', 'last', 'only', 'odd', 'even', 'first-of-type', 'last-of-type', 'only-of-type',  // 伪类
  'empty', 'enabled', 'default', 'required', 'valid', 'invalid', 'in-range', 'out-of-range',
  'read-only', 'read-write', 'open',
  'dark', 'light',  // 深色模式
  'ltr', 'rtl',  // 方向
  'print',  // 打印
  'motion-safe', 'motion-reduce',  // 运动
  'contrast-more', 'contrast-less',  // 对比度
  'not', 'has', 'is', 'where', 'supports', 'aria', 'data'  // 其他常见
])

// 其他需要排除的前缀
/* prettier-ignore */
const EXCLUDED_PREFIXES = new Set([
  'http', 'https', 'ftp', 'mailto', 'tel', 'data', 'file', 'ws', 'wss', // 协议
  'v-bind', 'v-on', 'v-if', 'v-else', 'v-for', 'v-show', 'v-model', 'v-slot', // Vue 指令
  'nth-child', 'nth-of-type', 'nth-last-child', 'nth-last-of-type', // CSS 相关
  'type', 'lang', 'scoped', 'module', 'setup', 'name', 'key', 'ref', 'class', 'style' // 常见误匹配
])

// ============ 工具函数 ============
function scanFiles(dir, fileList = []) {
  for (const file of fs.readdirSync(dir)) {
    const filePath = path.join(dir, file)
    const stat = fs.statSync(filePath)

    if (stat.isDirectory()) {
      if (!['node_modules', '.git', 'dist', '.nuxt', '.output'].includes(file)) {
        scanFiles(filePath, fileList)
      }
    } else if (CONFIG.extensions.includes(path.extname(file))) {
      fileList.push(filePath)
    }
  }
  return fileList
}

function preprocessContent(content, filePath) {
  if (filePath.endsWith('.vue')) {
    content = content.replace(/<style[^>]*>[\s\S]*?<\/style>/gi, '')
  }
  return content
    .replace(/\/\/.*$/gm, '')
    .replace(/\/\*[\s\S]*?\*\//g, '')
    .replace(/<!--[\s\S]*?-->/g, '')
}

function isValidIcon(prefix, name) {
  const lowerPrefix = prefix.toLowerCase()
  if (TAILWIND_PREFIXES.has(lowerPrefix) || EXCLUDED_PREFIXES.has(lowerPrefix)) return false
  if (/^\d/.test(prefix) || prefix.length < 2) return false
  if (name.includes('$') || name.includes('{') || name.length < 2) return false
  if (/^\d+$/.test(name)) return false

  const cssValues = ['none', 'auto', 'inherit', 'initial', 'unset', 'block', 'inline', 'flex', 'grid', 'hidden', 'visible']
  return !cssValues.includes(name.toLowerCase())
}

// ============ 主逻辑 ============
function extractIcons() {
  console.log('🔍 开始扫描项目中使用的图标...\n')

  const files = scanFiles(path.resolve(process.cwd(), CONFIG.srcDir))
  console.log(`📁 找到 ${files.length} 个文件需要扫描`)

  const iconPatterns = [
    /\bicon\s*=\s*["'`]([a-z][a-z0-9-]*):([a-z][a-z0-9-]+)["'`]/gi,
    /:icon\s*=\s*["'`]['"`]([a-z][a-z0-9-]*):([a-z][a-z0-9-]+)['"`]["'`]/gi,
    /\bicon\s*:\s*["'`]([a-z][a-z0-9-]*):([a-z][a-z0-9-]+)["'`]/gi,
    /(?:addIcon|getIcon|loadIcon)\s*\(\s*["'`]([a-z][a-z0-9-]*):([a-z][a-z0-9-]+)["'`]/gi,
    /\bname\s*=\s*["'`]([a-z][a-z0-9-]*):([a-z][a-z0-9-]+)["'`]/gi
  ]

  // 扫描收集图标
  const iconsByPrefix = new Map()

  for (const file of files) {
    const content = preprocessContent(fs.readFileSync(file, 'utf-8'), file)

    for (const pattern of iconPatterns) {
      pattern.lastIndex = 0
      let match
      while ((match = pattern.exec(content)) !== null) {
        const [, prefix, iconName] = match
        if (isValidIcon(prefix, iconName)) {
          if (!iconsByPrefix.has(prefix.toLowerCase())) {
            iconsByPrefix.set(prefix.toLowerCase(), new Set())
          }
          iconsByPrefix.get(prefix.toLowerCase()).add(iconName.toLowerCase())
        }
      }
    }
  }

  // 验证并提取图标
  const iconifyJsonDir = path.resolve(process.cwd(), CONFIG.iconifyJsonDir)
  const outputCollections = []
  const collectionStats = [] // 记录每个图标集的统计信息
  const missingIcons = []
  let totalFound = 0

  for (const [prefix, iconNames] of iconsByPrefix) {
    const jsonPath = path.join(iconifyJsonDir, `${prefix}.json`)
    if (!fs.existsSync(jsonPath)) continue

    const fullCollection = JSON.parse(fs.readFileSync(jsonPath, 'utf-8'))
    const slimCollection = {
      prefix: fullCollection.prefix,
      icons: {},
      ...(fullCollection.width && { width: fullCollection.width }),
      ...(fullCollection.height && { height: fullCollection.height })
    }

    let foundInCollection = 0

    for (const iconName of iconNames) {
      if (fullCollection.icons[iconName]) {
        slimCollection.icons[iconName] = fullCollection.icons[iconName]
        totalFound++
        foundInCollection++
      } else if (fullCollection.aliases?.[iconName]) {
        slimCollection.aliases = slimCollection.aliases || {}
        slimCollection.aliases[iconName] = fullCollection.aliases[iconName]
        const parentName = fullCollection.aliases[iconName].parent
        if (fullCollection.icons[parentName]) {
          slimCollection.icons[parentName] = fullCollection.icons[parentName]
        }
        totalFound++
        foundInCollection++
      } else {
        missingIcons.push(`${prefix}:${iconName}`)
      }
    }

    if (Object.keys(slimCollection.icons).length > 0) {
      outputCollections.push(slimCollection)
      collectionStats.push({ prefix, count: foundInCollection })
    }
  }

  // 写入文件
  const outputPath = path.resolve(process.cwd(), CONFIG.outputPath)
  fs.mkdirSync(path.dirname(outputPath), { recursive: true })
  fs.writeFileSync(outputPath, JSON.stringify(outputCollections, null, 2))

  // 输出报告
  const slimSize = fs.statSync(outputPath).size
  console.log('\n' + '='.repeat(50))
  console.log('📊 提取报告')
  console.log('='.repeat(50))
  console.log(`✅ 成功提取: ${totalFound} 个图标,来自 ${outputCollections.length} 个图标集\n`)

  // 打印图标集详情
  console.log('📦 图标集详情:')
  collectionStats
    .sort((a, b) => b.count - a.count)
    .forEach(({ prefix, count }) => {
      console.log(`   - ${prefix}: ${count} 个图标`)
    })

  if (missingIcons.length > 0) {
    console.log(`\n⚠️  未找到的图标: ${missingIcons.length} 个`)
    missingIcons.slice(0, 10).forEach(name => console.log(`   - ${name}`))
    if (missingIcons.length > 10) console.log(`   ... 还有 ${missingIcons.length - 10} 个`)
  }

  console.log(`\n📦 精简图标集大小: ${(slimSize / 1024).toFixed(2)} KB`)
  console.log(`✅ 已生成: ${CONFIG.outputPath}`)
}

extractIcons()

@iconify/vue引入离线的icon

src\plugins\iconify.ts

ts 复制代码
// plugins/iconify.ts
import type { App } from 'vue'
import { Icon, addCollection } from '@iconify/vue'
import type { IconifyJSON } from '@iconify/types'
import iconCollections from '@/assets/icons-slim/icons.json'

export default {
  install(app: App) {
    const icons = iconCollections as any as IconifyJSON[]
    for (const collection of icons) {
      addCollection(collection)
    }
    app.component('Icon', Icon)
  }
}

注:在main.ts中这样用

ts 复制代码
import { createApp } from 'vue'
import App from './App.vue'
import installIconify from '@/plugins/iconify'

const app = createApp(App)

// 注册iconify图标组件
app.use(installIconify)

app.mount('#app')

执行脚本

package.json

json 复制代码
{
  "name": "smart-community-h5",
  "private": true,
  "version": "0.0.0",
  "type": "module",
  "scripts": {
    "extract-icons": "node scripts/extract-icons.cjs",
    "dev": "vite",
    "serve": "vite",
    "build": "vite build",
    "build:shegong": "vite build --mode shegong",
    "preview": "vite preview",
    "lint": "eslint --ext .ts,.vue src",
    "lint:fix": "eslint --ext .ts,.vue src --fix"
  },
  "dependencies": {
    "@antv/g6": "5.0.49",
    "@iconify/vue": "5.0.0",
    "@microsoft/fetch-event-source": "2.0.1",
    "axios": "1.11.0",
    "dayjs": "1.11.18",
    "echarts": "6.0.0",
    "js-cookie": "3.0.5",
    "mitt": "^3.0.1",
    "naive-ui": "2.42.0",
    "nanoid": "5.1.6",
    "nprogress": "0.2.0",
    "pdfjs-dist": "5.4.296",
    "pinia": "^3.0.3",
    "tailwindcss-palette-generator": "1.6.1",
    "vue": "^3.5.18",
    "vue-i18n": "11.1.11",
    "vue-router": "^4.5.1",
    "vue3-autocounter": "1.0.8",
    "ys-md-rendering": "0.2.4"
  },
  "devDependencies": {
    "@eslint/eslintrc": "3.3.1",
    "@eslint/js": "9.34.0",
    "@iconify/json": "2.2.412",
    "@iconify/types": "2.0.0",
    "@tailwindcss/vite": "^4.1.12",
    "@types/js-cookie": "3.0.6",
    "@types/node": "24.3.0",
    "@types/nprogress": "0.2.3",
    "@typescript-eslint/eslint-plugin": "8.40.0",
    "@typescript-eslint/parser": "8.40.0",
    "@vitejs/plugin-vue": "^6.0.1",
    "@vue/tsconfig": "^0.7.0",
    "eslint": "9.33.0",
    "eslint-config-prettier": "10.1.8",
    "eslint-plugin-import": "2.32.0",
    "eslint-plugin-prettier": "5.5.4",
    "eslint-plugin-vue": "10.4.0",
    "prettier": "^3.6.2",
    "prettier-plugin-tailwindcss": "^0.6.14",
    "sass": "1.90.0",
    "tailwindcss": "^4.1.12",
    "typescript": "~5.8.3",
    "typescript-eslint": "8.41.0",
    "unplugin-auto-import": "20.0.0",
    "unplugin-icons": "22.3.0",
    "unplugin-vue-components": "29.0.0",
    "vite": "^7.1.2",
    "vite-svg-loader": "5.1.0",
    "vue-eslint-parser": "10.2.0",
    "vue-tsc": "^3.0.5"
  }
}

通过pnpm run extract-icons检索项目中的所有icon

当然也可以通过"build": "npm run extract-icons && vite build"的方式,在每次build前都打包

生成的json示例效果如下

src\assets\icons-slim\icons.json

json 复制代码
[
  {
    "prefix": "material-symbols",
    "icons": {
      "check-rounded": {
        "body": "<path fill=\"currentColor\" d=\"m9.55 15.15l8.475-8.475q.3-.3.7-.3t.7.3t.3.713t-.3.712l-9.175 9.2q-.3.3-.7.3t-.7-.3L4.55 13q-.3-.3-.288-.712t.313-.713t.713-.3t.712.3z\"/>"
      },
      "search": {
        "body": "<path fill=\"currentColor\" d=\"m19.6 21l-6.3-6.3q-.75.6-1.725.95T9.5 16q-2.725 0-4.612-1.888T3 9.5t1.888-4.612T9.5 3t4.613 1.888T16 9.5q0 1.1-.35 2.075T14.7 13.3l6.3 6.3zM9.5 14q1.875 0 3.188-1.312T14 9.5t-1.312-3.187T9.5 5T6.313 6.313T5 9.5t1.313 3.188T9.5 14\"/>"
      },
      "check": {
        "body": "<path fill=\"currentColor\" d=\"m9.55 18l-5.7-5.7l1.425-1.425L9.55 15.15l9.175-9.175L20.15 7.4z\"/>"
      },
      "description": {
        "body": "<path fill=\"currentColor\" d=\"M8 18h8v-2H8zm0-4h8v-2H8zm-2 8q-.825 0-1.412-.587T4 20V4q0-.825.588-1.412T6 2h8l6 6v12q0 .825-.587 1.413T18 22zm7-13h5l-5-5z\"/>"
      },
      "open-in-new": {
        "body": "<path fill=\"currentColor\" d=\"M5 21q-.825 0-1.412-.587T3 19V5q0-.825.588-1.412T5 3h7v2H5v14h14v-7h2v7q0 .825-.587 1.413T19 21zm4.7-5.3l-1.4-1.4L17.6 5H14V3h7v7h-2V6.4z\"/>"
      },
      "add-comment-outline": {
        "body": "<path fill=\"currentColor\" d=\"M11 14h2v-3h3V9h-3V6h-2v3H8v2h3zm-9 8V4q0-.825.588-1.412T4 2h16q.825 0 1.413.588T22 4v12q0 .825-.587 1.413T20 18H6zm3.15-6H20V4H4v13.125zM4 16V4z\"/>"
      },
      "chevron-left": {
        "body": "<path fill=\"currentColor\" d=\"m14 18l-6-6l6-6l1.4 1.4l-4.6 4.6l4.6 4.6z\"/>"
      }
    },
    "width": 24,
    "height": 24
  },
  {
    "prefix": "ep",
    "icons": {
      "right": {
        "body": "<path fill=\"currentColor\" d=\"M754.752 480H160a32 32 0 1 0 0 64h594.752L521.344 777.344a32 32 0 0 0 45.312 45.312l288-288a32 32 0 0 0 0-45.312l-288-288a32 32 0 1 0-45.312 45.312z\"/>"
      }
    },
    "width": 1024,
    "height": 1024
  },
  {
    "prefix": "bxs",
    "icons": {
      "right-arrow": {
        "body": "<path fill=\"currentColor\" d=\"M5.536 21.886a1 1 0 0 0 1.033-.064l13-9a1 1 0 0 0 0-1.644l-13-9A1 1 0 0 0 5 3v18a1 1 0 0 0 .536.886\"/>"
      }
    },
    "width": 24,
    "height": 24
  },
  {
    "prefix": "icon-park-solid",
    "icons": {
      "down-one": {
        "body": "<path fill=\"currentColor\" stroke=\"currentColor\" stroke-linejoin=\"round\" stroke-width=\"4\" d=\"M36 19L24 31L12 19z\"/>"
      }
    },
    "width": 48,
    "height": 48
  },
  {
    "prefix": "teenyicons",
    "icons": {
      "right-outline": {
        "body": "<path fill=\"none\" stroke=\"currentColor\" stroke-linecap=\"square\" d=\"m5 14l7-6.5L5 1\"/>"
      }
    },
    "width": 15,
    "height": 15
  }
]
相关推荐
崔庆才丨静觅10 小时前
hCaptcha 验证码图像识别 API 对接教程
前端
passerby606111 小时前
完成前端时间处理的另一块版图
前端·github·web components
掘了11 小时前
「2025 年终总结」在所有失去的人中,我最怀念我自己
前端·后端·年终总结
崔庆才丨静觅11 小时前
实用免费的 Short URL 短链接 API 对接说明
前端
崔庆才丨静觅12 小时前
5分钟快速搭建 AI 平台并用它赚钱!
前端
崔庆才丨静觅12 小时前
比官方便宜一半以上!Midjourney API 申请及使用
前端
Moment12 小时前
富文本编辑器在 AI 时代为什么这么受欢迎
前端·javascript·后端
崔庆才丨静觅13 小时前
刷屏全网的“nano-banana”API接入指南!0.1元/张量产高清创意图,开发者必藏
前端
剪刀石头布啊13 小时前
jwt介绍
前端
爱敲代码的小鱼13 小时前
AJAX(异步交互的技术来实现从服务端中获取数据):
前端·javascript·ajax