优化前端图标:SVG Sprite与Vite插件的探索之旅

引言

在项目开发中,由于尚未建立统一的UI规范,前端开发同学在拿到UI设计稿后,通常会将图标以SVG格式保存到静态资源文件夹中,并通过<img>标签加载。然而,这种方法存在诸多问题:对于同一个图标的不同状态(如默认、悬停、激活等),需要保存多份SVG文件,导致资源冗余、网络请求过多,同时样式管理也变得繁琐且难以维护。

为了解决这些问题,SVG Sprite 提供了一种高效且性能友好的解决方案。通过将多个SVG图标合并为一个文件,并利用<symbol><use>元素实现按需加载,SVG Sprite不仅能减少HTTP请求,还能通过CSS灵活控制图标样式,极大地简化了开发流程。

在实际应用中,现代前端构建工具如 Webpack 和 Vite 等已经提供了丰富的 SVG Sprite 插件(如 svg-sprite-loadervite-plugin-svg-sprite),用于高效管理和优化 SVG 图标。这些插件通过将多个 SVG 图标合并为一个精灵文件,减少了网络请求,同时支持通过 CSS 灵活控制样式。这些插件的实现思路为我提供了很好的借鉴。因此,我将以 SVG Sprite 的需求为基础,结合现有插件的实现方式,记录自己开发 Vite 插件的学习过程,探索如何更好地集成 SVG 图标管理。

需求分析

为了优化项目中的SVG图标管理,提出以下需求:

  1. SVG图标合并与导出

    将项目中的SVG图标合并为一个SVG Sprite文件,减少HTTP请求,并支持导出功能。

  2. 开发模式下的热更新

    支持在开发模式下对SVG文件的新增、修改和更新等操作,实现热更新,提升开发效率。

  3. 虚拟模块加载

    支持通过虚拟模块加载SVG Sprite文件,简化开发流程。

  4. SVG Sprite内容注入到 index.html

    支持将 SVG Sprite 内容直接嵌入到 index.html 中,减少外部资源加载时间

实现细节

定义插件的参数

typescript 复制代码
interface SVGSpriteGenOptions {
    /**
     * 包含的SVG文件路径或模式(glob)。用于指定需要处理的SVG文件。
     */
    include: string[];

    /**
     * 排除的SVG文件路径或模式(glob)。用于指定不处理的SVG文件。
     */
    exclude: string[];

    /**
     * 是否启用SVGO优化配置,或直接传入自定义的SVGO配置。
     * - 如果为`true`,将使用默认的SVGO优化配置且可以加载svgo的根目录下配置文件。
     * - 如果为`false`,将跳过SVGO优化。
     * - 如果传入`Config`对象,则使用自定义的SVGO配置且可以加载svgo的根目录下配置文件。
     */
    svgoConf: boolean | Config;

    /**
     * SVG Sprite中`<symbol>`元素的ID命名规则。
     * - 可以是字符串模板,例如`"icon-[name]"`,其中`[name]`会被替换为SVG文件名。
     */
    symbolId: string;

    /**
     * SVG Sprite文件的输出路径。
     */
    output: string;

    /**
     * 模式选择:
     * - `virtual`:以虚拟模块的形式加载SVG Sprite。
     * - `inline`:将SVG Sprite内容直接注入到`index.html`中。
     */
    mode: 'virtual' | 'inline';
}

生成 SVG Sprite 工具函数

ts 复制代码
import { optimize, loadConfig, type Config } from 'svgo';
import fg from 'fast-glob';
import { resolve,basename } from 'path';
import { readFileSync } from 'fs';

const generateSVGSprite = async (options: SVGSpriteGenOptions & { root: string }) => {
    // 默认的 SVGO 配置,用于优化 SVG 文件
    let svgoConfig: Config = {
        plugins: [
            {
                name: 'preset-default',
                params: {
                    overrides: {
                        removeViewBox: false // 不移除 viewBox 属性
                    }
                }
            }
        ]
    };

    // 加载 SVGO 配置文件(如果存在)
    const svgoLoadConfig = await loadConfig();
    if (typeof options.svgoConf === 'object') {
        svgoConfig = Object.assign(svgoConfig, options.svgoConf); // 合并用户自定义的 SVGO 配置
    }

    // 使用 fast-glob 查找匹配的 SVG 文件
    const SVGFiles = fg.sync(options.include, {
        cwd: options.root, // 以项目根目录为基准
        ignore: options.exclude, // 排除指定的文件或目录
        onlyFiles: true // 只匹配文件
    });

    // 生成 SVG 精灵内容
    const spriteContent = SVGFiles.map((file) => {
        const filePath = resolve(options.root, file); // 获取文件的绝对路径
        const SVGContent = readFileSync(filePath, 'utf-8'); // 读取 SVG 文件内容
        const optimizedSVG = options.svgoConf
            ? optimize(SVGContent, Object.assign(svgoConfig, svgoLoadConfig)).data // 如果启用了 SVGO,优化 SVG 内容
            : SVGContent; // 否则直接使用原始内容
        const id = options.symbolId.replace('[name]', basename(file, '.svg')); // 替换 symbolId 中的 [name] 为文件名
        return `<symbol id="${id}" viewBox="0 0 24 24">${optimizedSVG}</symbol>`; // 生成 symbol 元素
    }).join('\n'); // 将所有 symbol 元素连接成字符串

    // 返回完整的 SVG 精灵文件内容
    return `<svg xmlns="http://www.w3.org/2000/svg" style="display:none;">${spriteContent}</svg>`;
};

Vite 插件开发

ts 复制代码
import {createFilter, normalizePath, type ResolvedConfig, type Plugin, ViteDevServer} from 'vite';
import {optimize, loadConfig, type Config} from 'svgo';
import fg from 'fast-glob';
import {readFileSync, writeFileSync} from 'fs';

// 定义插件名称和虚拟模块 ID
const VITE_PLUGIN_NAME = 'vite-plugin-svg-sprite-gen';
const VIRTUAL_MODULE_ID = 'virtual:svg-sprite-gen';
const RESOLVED_VIRTUAL_MODULE_ID = '\0' + VIRTUAL_MODULE_ID;

// 插件的主函数
export default function SVGSpriteGen(options?: Partial<SVGSpriteGenOptions>): Plugin {
    // 默认配置
    const defaultOptions: SVGSpriteGenOptions = {
        include: ['**/*.svg'], // 默认包含所有 SVG 文件
        exclude: ['**/node_modules/**', '**/dist/**'], // 默认排除 node_modules 和 dist 目录
        svgoConf: true, // 启用 SVGO 优化
        symbolId: '[name]', // 默认 symbolId 使用文件名
        output: 'sprite.svg', // 默认输出文件名
        mode: 'virtual' // 默认模式为虚拟模块
    };

    // 合并用户传入的配置和默认配置
    const SVGSpriteGenOpts = Object.assign(defaultOptions, options);
    const {include, exclude, output, mode} = SVGSpriteGenOpts;

    // 创建文件过滤器,用于匹配和排除文件
    const filter = createFilter(include, exclude);
    let config: ResolvedConfig; // Vite 的配置对象
    let SVGSpriteElement = ''; // 存储生成的 SVG 精灵内容

    // 文件变化时的处理函数
    const handleFileChange = async (file: string, server: ViteDevServer) => {
        if (filter(normalizePath(file))) { // 如果文件匹配过滤器
            SVGSpriteElement = await generateSVGSprite({...SVGSpriteGenOpts, root: config.root}); // 重新生成 SVG 精灵
            // 重新触发虚拟模块的加载
            const module = server.moduleGraph.getModuleById(RESOLVED_VIRTUAL_MODULE_ID);
            if (module) {
                server.moduleGraph.invalidateModule(module);
            }
            server.ws.send({ // 触发页面重新加载
                type: 'full-reload',
                path: '*'
            });
        }
    };

    return {
        name: VITE_PLUGIN_NAME, // 插件名称
        async configResolved(_config) {
            config = _config; // 保存 Vite 的配置对象
        },
        configureServer(server) {
            // 监听文件变化
            server.watcher.add(include);
            server.watcher.on("add", (file) => handleFileChange(file, server)); // 文件新增
            server.watcher.on("change", (file) => handleFileChange(file, server)); // 文件修改
            server.watcher.on("unlink", (file) => handleFileChange(file, server)); // 文件删除
        },
        async buildStart() {
            // 构建开始时生成 SVG 精灵
            SVGSpriteElement = await generateSVGSprite({...SVGSpriteGenOpts, root: config.root});
            if (config.command === 'build') {
                // 在构建模式下,将 SVG 精灵作为资产输出
                this.emitFile({
                    type: 'asset',
                    source: SVGSpriteElement,
                    fileName: output
                });
            } else {
                // 在开发模式下,将 SVG 精灵写入到输出目录
                writeFileSync(resolve(config.build.outDir, output), SVGSpriteElement);
            }
        },
        transformIndexHtml(html) {
            // 在 HTML 文件中注入 SVG 精灵
            if (mode === 'inline') {
                return html.replace('</body>', `${SVGSpriteElement}</body>`);
            }
            return html;
        },
        resolveId(id) {
            // 解析虚拟模块 ID
            if (id === VIRTUAL_MODULE_ID) {
                return RESOLVED_VIRTUAL_MODULE_ID;
            }
        },
        load(id) {
            // 加载虚拟模块的内容
            if (id === RESOLVED_VIRTUAL_MODULE_ID) {
                return `const SVGSprite = \`${SVGSpriteElement}\`;
                        const parser = new DOMParser();
                        const SVGDoc = parser.parseFromString(SVGSprite, 'image/svg+xml');
                        const SVGNode = SVGDoc.documentElement;
                        document.body.appendChild(SVGNode);
                        export default SVGSprite;`;
            }
        },
        transform(code, id) {
            // 如果文件匹配过滤器,则不处理代码
            if (filter(id)) return null;
            return code;
        }
    };
}

FAQ

  1. 在使用虚拟模块时,因为 TypeScript 编译器无法识别这个虚拟模块,从而导致类型检查失败。
    vite-env.d.ts 文件中添加以下内容:

    ts 复制代码
    /// <reference types="vite/client" />
    
    declare module 'virtual:svg-sprite-gen' {
        const SVGSprite: string;
        export default SVGSprite;
    }

后记

SVG:可缩放矢量图形
SVG:<g> 标签
SVG:<symbol> 标签
SVG:<use> 标签
未来必热:SVG Sprites技术介绍-张鑫旭


感谢阅读,敬请斧正!

相关推荐
2301_764441332 分钟前
小说文本分析工具:基于streamlit实现的文本分析
前端·python·信息可视化·数据分析·nlp
jackl的科研日常16 分钟前
“个人陈述“的“十要“和“十不要“
前端
一个处女座的程序猿O(∩_∩)O20 分钟前
Vue 中 this 使用指南与注意事项
前端·javascript·vue.js
程序员大澈37 分钟前
7个 Vue 路由守卫的执行顺序
javascript·vue.js
程序员大澈1 小时前
4个 Vue mixin 的原理拆解
javascript·vue.js
程序员大澈1 小时前
3个 Vue $set 的应用场景
javascript·vue.js
大有数据可视化1 小时前
数字孪生像魔镜,映照出无限可能的未来
前端·html·webgl
程序员大澈1 小时前
3个 Vue nextTick 原理的关键点
javascript·vue.js
一个处女座的程序猿O(∩_∩)O1 小时前
使用 Docker 部署前端项目全攻略
前端·docker·容器
bin91531 小时前
DeepSeek 助力 Vue3 开发:打造丝滑的表格(Table)之添加列宽调整功能,示例Table14_10空状态的固定表头表格
前端·javascript·vue.js·ecmascript·deepseek