【Vite】离线打包@iconify/vue的图标
所需库@iconify/vue、@iconify/types、@iconify/json
需要创建或修改3个文件,
- 图标检索脚本
@iconify/vue引入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
}
]