基于iconify开发vite插件,打包本地svg图标
背景
在项目中使用svg图标是一个非常常见的需求,目前常用的方案有iconfont矢量图标库提供的方案,基于svg-sprite-loader
插件处理的方案,但是这些方案会生成大量的dom节点,性能底。因此iconify会是更好的选择,想要基于iconify使用v-for的方式动态渲染图标必须要本地打包图标。
iconify 官网
使用方式
iconify有多种使用方式,结合iconify提供的 Icon
组件可完成动态渲染功能。
<!-- 使用方式 {前缀}-{图标集合}-{图标名} -->
<i-ep-edit />
<i-mdi-account-box style="font-size: 2em; color: red"/>
<!-- 结合element plus -->
<el-icon><i-ep-menu /></el-icon>
<Icon icon="ep:menu" />
渲染动态图标
<!-- custom:${item.icon} => custom 是本地图标的自定义前缀,在vite.config中配置 -->
// import {Icon} from 'iconify/vue'
<el-icon>
<Icon :icon="`custom:${item.icon}`" width="28" height="28" />
</el-icon>
使用iconify打包离线图标库和本地的svg图标
效果
在main.js中导入
import "virtual:customIcon";
vite.config.ts配置
import ViteIconifyBundle from './plugins/vite-iconify-bundle'
import path from 'node:path'
export default defineConfig({
plugins: [
ViteIconifyBundle({
// 打包自定义svg图标
svg: [
{
dir: path.resolve(__dirname, './src/assets/svg'),
monotone: false,
prefix: 'custom', // 自定义本地图标前缀
},
],
// 从 iconify 打包 icon 集合
icons: [
"mdi:home",
"mdi:account",
"mdi:login",
"mdi:logout",
"octicon:book-24",
"octicon:code-square-24"
]
// 自定义 JSON 文件
json: [
// Custom JSON file
// 'json/gg.json',
// Iconify JSON file (@iconify/json is a package name, /json/ is directory where files are, then filename)
require.resolve('@iconify/json/json/tabler.json'),
// Custom file with only few icons
{
filename: require.resolve('@iconify/json/json/line-md.json'),
icons: ['home-twotone-alt', 'github', 'document-list', 'document-code', 'image-twotone'],
},
],
}),
]
})
完整代码
import type {PluginOption} from "vite";
import {promises as fs} from "node:fs";
import {importDirectory, cleanupSVG, parseColors, isEmptyColor, runSVGO} from "@iconify/tools";
import {getIcons, stringToIcon, minifyIconSet} from "@iconify/utils";
import type {IconifyJSON, IconifyMetaData} from "@iconify/types";
interface BundleScriptCustomSVGConfig {
// 存放SVG文件的目录
dir: string;
// 是否为多色图标
monotone: boolean;
// Icon 集合的前缀
prefix: string;
}
interface BundleScriptCustomJSONConfig {
// JSON文件的路径
filename: string;
// 要导入的图标名称数组,如果不写则全部导入
icons?: string[];
}
interface BundleScriptConfig {
// 自定义导入的svg图标集合
svg?: BundleScriptCustomSVGConfig[];
// 要从@iconify/json打包的图标集
icons?: string[];
// 要打包的JSON文件列表
json?: (string | BundleScriptCustomJSONConfig)[];
}
const component = "@iconify/vue";
// Set to true to use require() instead of import
const commonJS = false;
let iconCount: number = 0;
export default function (sources: BundleScriptConfig): PluginOption {
const virtualModuleId = "virtual:customIcon";
const resolvedVirtualModuleId = "\0" + virtualModuleId;
return {
name: "vite-iconify-bundle", // 必须的,将会在 warning 和 error 中显示
resolveId(id) {
if (id === virtualModuleId) {
return resolvedVirtualModuleId;
}
},
async load(id) {
if (id === resolvedVirtualModuleId) {
const iconSet = await createBundleTask(sources);
return iconSet;
}
}
};
}
async function createBundleTask(sources: BundleScriptConfig) {
let bundle = commonJS
? "const { addCollection } = require('" + component + "');\n\n"
: "import { addCollection } from '" + component + "';\n\n";
// 将sources.icons转换为sources.json
if (sources.icons) {
const sourcesJSON = sources.json ? sources.json : (sources.json = []);
// Sort icons by prefix
const orgainzedList = organizeIconsList(sources.icons);
for (const prefix in orgainzedList) {
const filename = require.resolve(`@iconify/json/json/${prefix}.json`);
sourcesJSON.push({
filename,
icons: orgainzedList[prefix]
});
}
}
// 打包 JSON 文件
if (sources.json) {
for (let i = 0; i < sources.json.length; i++) {
const item = sources.json[i];
// 加载 icon集合
const filename = typeof item === "string" ? item : item.filename;
let content = JSON.parse(await fs.readFile(filename, "utf-8")) as IconifyJSON;
// Filter icons
if (typeof item !== "string" && item.icons?.length) {
const filteredContent = getIcons(content, item.icons);
if (!filteredContent) {
throw new Error(`Cannot find required icons in ${filename}`);
}
content = filteredContent;
}
// 移除元数据并且添加到bundle
removeMetaData(content);
minifyIconSet(content);
bundle += "addCollection(" + JSON.stringify(content) + ");\n";
console.log(`Bundled icons from ${filename}`);
}
}
// 自定义 SVG 文件
if (sources.svg) {
for (let i = 0; i < sources.svg.length; i++) {
const source = sources.svg[i];
// 导入图标
const iconSet = await importDirectory(source.dir, {
prefix: source.prefix
});
// 验证,清理,修复颜色并优化
await iconSet.forEach(async (name, type) => {
if (type !== "icon") {
return;
}
// 获取SVG实例以进行分析
const svg = iconSet.toSVG(name);
if (!svg) {
// 无效的图标
iconSet.remove(name);
return;
}
// 清除并且优化图标
try {
// Clean up icon code
await cleanupSVG(svg);
if (source.monotone) {
// Replace color with currentColor, add if missing
// If icon is not monotone, remove this code
await parseColors(svg, {
defaultColor: "currentColor",
callback: (attr, colorStr, color) => {
return !color || isEmptyColor(color) ? colorStr : "currentColor";
}
});
}
// Optimise
await runSVGO(svg);
} catch (err) {
// Invalid icon
console.error(`Error parsing ${name} from ${source.dir}:`, err);
iconSet.remove(name);
return;
}
iconCount = iconSet.count();
// Update icon from SVG instance
iconSet.fromSVG(name, svg);
});
// Export to JSON
const content = iconSet.export();
bundle += "addCollection(" + JSON.stringify(content) + ");\n";
}
}
console.log(`\n图标打包完成! 总共打包了${iconCount}个图标,大小:(${bundle.length} bytes)\n`);
// Save to file
return bundle;
}
/**
* Sort icon names by prefix
* @param icons
* @returns icons
*/
function organizeIconsList(icons: string[]) {
const sorted: Record<string, string[]> = Object.create(null);
icons.forEach(icon => {
const item = stringToIcon(icon);
if (!item) {
return;
}
const prefix = item.prefix;
const prefixList = sorted[prefix] ? sorted[prefix] : (sorted[prefix] = []);
const name = item.name;
if (prefixList.indexOf(name) === -1) {
prefixList.push(name);
}
});
return sorted;
}
// 从icon集合移除元数据
function removeMetaData(iconSet: IconifyJSON) {
const props: (keyof IconifyMetaData)[] = ["info", "chars", "categories", "themes", "prefixes", "suffixes"];
props.forEach(prop => {
delete iconSet[prop];
});
}