管理前端项目中的图标

前端项目中,图标管理是一个前期容易被忽略,但后期维护比较困难的一个地方,最后得到的结果就是项目中散落着各种各样的图标,也无法轻易移除。

本文决定讨论一下这个问题,在研究了各种前端图标方案后,决定试试逐渐流行起来的 svg 图标组件方案。

<!--more-->

1. 历史方案

1.1. 原始图片

可以通过imgbackground-image的方式使用图片图标

@mixin image-icon($w, $h, $img) {
    width: $w;
    height: $h;
    background: #{$img} no-repeat center;
    background-size: contain;
}

.icon-user {
    @include (20px, 20px, url("~@/assets/icons/user.png"));
}

每个图片都会发送网络请求,比较浪费资源,

  • 一种方式是通过url-loader等工具将较小的图片通过 base64 内联代码中,节省 http 请求,但会导致输出文件体积增大
  • 更普遍的做法是使用精灵图sprite image,将所有图标放在一张 png 图片中,然后通过background-position来控制图标的展示

优点

  • 实现简单,基本没有兼容问题

缺点

  • 图片无法自由缩放,可能需要设计导出多份 N 倍图避免图片模糊
  • 无法定制颜色,每种状态的图标都需要对应的图片
  • 精灵图需要特殊的工具生成,手动编写维护图片位置工作量比较大

1.2. 字体图标

下面是两个比较著名的字体图标网站

  • fontawesome,一套绝佳的图标字体库和 CSS 框架
  • iconfont,阿里巴巴矢量图标库

选择图标添加到项目,最后会得到一个字体集

@font-face {
    font-family: "iconfont"; /* Project id 3901921 */
    src: url("//at.alicdn.com/t/c/font_3901921_n5nd4y2lpes.woff2?t=1676621012694")
            format("woff2"), url("//at.alicdn.com/t/c/font_3901921_n5nd4y2lpes.woff?t=1676621012694")
            format("woff"),
        url("//at.alicdn.com/t/c/font_3901921_n5nd4y2lpes.ttf?t=1676621012694")
            format("truetype");
}

.iconfont {
    font-family: "iconfont" !important;
    font-size: 16px;
    font-style: normal;
    -webkit-font-smoothing: antialiased;
    -moz-osx-font-smoothing: grayscale;
}

.icon-a-yxy-gender-lined:before {
    content: "\eb19";
}

然后使用对应的样式类就可以了

<i class="iconfont icon-a-yxy-gender-lined" />

字体图标的另外一种使用方式是unicode,这种用法缺少语义性,目前已经很少使用了

<i class=iconfont>&#xe60d;</>

优点

  • 方便控制大小和图标颜色,不用切两张图
  • 使用方便,兼容性好,甚至在 iOS、Android 或 Flutter 项目中都可以使用

缺点

  • 不支持多色彩图标,因为字体颜色是应用于整个图标的
  • 需要开发者记住每个图标的名字,维护起来不是很方便

1.3. svg 图标

参考

首先 svg 可以像一个普通的pngjpg一样作为图片文件去使用

比如在 css 中作为背景图

.icon-user {
    background: url("./assets/user.svg");
}

或者在 html 中作为img标签的 src

<img src="./assets/user.svg" />

手动引入 svg 文件比较繁琐,因此我们需要一些自动引入 svg 图标的方式

社区提供了诸如 webpack 的svg-sprite-loader、vite 的vite-plugin-svg-icons等插件,自动引入 svg 图标

vite-plugin-svg-icons为例

// vite.cofnig.ts
import { createSvgIconsPlugin } from "vite-plugin-svg-icons";

export default defineConfig({
    plugins: [
        createSvgIconsPlugin({
            iconDirs: [path.resolve(process.cwd(), "./src/assets/icons")],
            symbolId: "icon-[dir]-[name]",
            customDomId: "__svg__icons__dom__",
        }),
    ],
});

其原理是加载指定目录下的所有 svg 图标,通过 symbol 来定义一个图形模板对象并插入到页面 body 中

<svg>
    <symbol id="icon-home" viewBox="0 0 16 16">
        <!-- 对应内容 -->
    </symbol>
    <symbol id="icon-user" viewBox="0 0 16 16">
        <!-- 对应内容 -->
    </symbol>
</svg>

然后就可以通过 use 拿到对应 id 的 svg 图标了

 <svg aria-hidden="true" class="svg-icon">
    <use :href="symbolId" :fill="color"/>
  </svg>

重复编写<svg><use :href="#icon-home" /></svg>的方式也略显重复,因此可以封装成组件

<template>
    <svg aria-hidden="true" class="svg-icon">
        <use :href="symbolId" :fill="color" />
    </svg>
</template>

<script lang="ts" setup>
import { computed } from "vue";

type Props = {
    prefix?: string;
    name: string;
    color?: string;
};

const props = withDefaults(defineProps<Props>(), {
    prefix: "icon",
    color: "currentcolor",
});

const symbolId = computed(() => {
    return `#${props.prefix}-${props.name}`;
});
</script>

<style lang="scss" scoped>
.svg-icon {
    width: 1em !important;
    height: 1em !important;
    vertical-align: -0.15em;
    fill: currentColor;
    overflow: hidden;
}
</style>

使用的时候就只需要传图标的名字就行了

<svg-icon name="user"></svg-icon>

优点

  • 矢量图,无损缩放
  • 控制非常方便,定制颜色、尺寸等,可以实现多色彩图标
  • 可以拿来实现 CSS 动画

缺点

  • 需要开发者记住每个图标的名字,维护起来不是很方便,后续也不太知道哪些图标已经废弃使用了
  • svg 存在兼容性,和一些渲染性能问题

2. svg 组件

上面封装的 svg 组件,最大的问题在于需要维护每个图标的名字,开发者需要知道对应图标的名字才能使用,因此需要考虑更灵活和容易维护的 svg 图标组件封装方式。

参考

2.1. 单个组件

封装单个组件并没有什么高科技

<template>
    <svg viewBox="0 0 1024 1024" xmlns="http://www.w3.org/2000/svg">
        <path
            :fill="color"
            d="M340.864 149.312a30.592 30.592 0 0 0 0 42.752L652.736 512 340.864 831.872a30.592 30.592 0 0 0 0 42.752 29.12 29.12 0 0 0 41.728 0L714.24 534.336a32 32 0 0 0 0-44.672L382.592 149.376a29.12 29.12 0 0 0-41.728 0z"
        />
    </svg>
</template>
<script lang="ts" setup>
type Props = {
    color?: string;
};
const props = withDefaults(defineProps<Props>(), {
    color: "currentcolor",
});
</script>

得到的图标组件,就可以像任意一个组件一样使用了

<template>
    <el-button size="small" circle :icon="ArrowRight"></el-button>
</template

<script setup lang="ts">
import { ArrowRight } from '@element-plus/icons-vue'
</script>

也考虑过借鉴vite-plugin-svg-icons等插件的思路,可以聚合所有的 svg 文件,然后通过use来引入对应的 symbol。

但这个方案存在的一些无法克服的问题:

  • 无法实现 svg 代码的按需加载了,所有的 svg 都合并到了一个大的模版中,唯一得到的好处是组件文件里面没有大段的 svg path 代码(好像没有什么必要)
  • 对于多色彩的 svg 图标,如果通过 use 来引入 svg,就无法定制组件的差异化 prop 了。

因此,我认为封装的 svg 组件中,包含原始的 svg 代码,是更合理的方法。

2.2. 批量自动生成组件

一个项目中往往有很多个 svg 组件,手动编写单个组件显得有点重复,因此可以考虑写个脚本将某个文件夹(往往是icons)下的所有 svg 文件都自动转成xx.vue图标组件。

下面展示了相关的伪代码

async function transformSvg2Vue(file) {
    const content = await readFile(file, "utf-8");
    const { filename, componentName } = getName(file);
    const vue = formatCode(
        `
<template>
${content}
</template>
<script lang="ts">
type Props ={
    color?: string
}
const props= withDefaults(defineProps<Props>(), {
    color:'currentcolor',
})
`,
        "vue"
    );
    writeFile(path.resolve(pathComponents, `${filename}.vue`), vue, "utf-8");
}

const files = readFiles("./icons");
for (const file of files) {
    transformSvg2Vue(file);
}

思路非常简单

  • 读取 svg 文件列表
  • 对于单个文件,读取文件内容,拼接 vue 组件模版(react 的话就拼接 FC 组件格式代码)
  • 如果某些组件需要定制 prop,这种利用模版生成组件的方式就需要额外处理一下,也许维护一个组件名和 prop 的映射是一个不错的做法

2.3. 组件 playground

通过脚本生成了一批组件文件,如果能实时预览这些组件就更好了。因此可以搭建一个 playground,用于展示项目中生成的所有组件,通过 vite 可以快速实现这个目标。

首先获取所有的组件文件

// 脚本生成的所有文件都在这个目录下
import * as icons from "./components";

import type { App } from "vue";

export interface InstallOptions {
    prefix?: string;
}
export default (app: App) => {
    for (const [key, component] of Object.entries(icons)) {
        app.component(prefix + key, component);
    }
};

export { icons };

然后直接展示这些 icons 就行了

<template>
    <component
        :is="Icon"
        v-for="(Icon, key) in icons"
        :key="key"
        class="icon"
    />
    <hr />
    <component
        :is="`ElIcon${key}`"
        v-for="key in Object.keys(icons)"
        :key="key"
        class="icon"
    />
</template>

<script lang="ts" setup>
import { icons } from "./icons";
</script>

<style>
.icon {
    height: 48px;
    color: #409eff;
}
</style>

3. 小结

svg 图标组件已经成为前端项目中非常流行的方式了,优点包括

  • 集成了 svg 图标的所有优点,
  • 对开发者非常友好,无需再使用字符串保存每个组件的名字
  • 每个组件都可以自定义 props

而对于封装组件的这一点小成本,也可以通过脚本等方式解决。在下一个项目中试试,就这么愉快的决定了