【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
  }
]
相关推荐
米花丶1 小时前
解决前端监控上报 Script Error实践
前端·javascript
JarvanMo1 小时前
如何在 Flutter 应用中大规模实现多语言翻译并妥善处理 RTL(从右到左)布局?
前端
Haha_bj1 小时前
iOS深入理解事件传递及响应
前端·ios·app
1024小神1 小时前
用html和css实现放苹果的liquidGlass效果
前端
拜晨1 小时前
CG-01: 深入理解 2D 变换的数学原理
前端
im_AMBER1 小时前
Canvas架构手记 07 状态管理 | 组件通信 | 控制反转
前端·笔记·学习·架构·前端框架·react
JarvanMo1 小时前
理解 Flutter 中的 runApp() 与异步初始化
前端
掘金安东尼1 小时前
🧭 前端周刊第442期(24–30 Nov 2025)
前端
h***8561 小时前
Rust在Web中的前端开发
开发语言·前端·rust