前言

在前端开发中,大家都接触到设计图。Figma 或者蓝图,在线设计或者看图的网站。UI 设计又很喜欢那种花里胡哨的图标,很难找。神奇的是你可以把它们保存为 SVG 图标。但是 SVG 的引入又是一大串长长的代码,更麻烦的是有些图标悬浮上去是要改变颜色的。这里我找到了一种让 SVG 方便引入的方法,而且能像字体文件一样,简单的改变颜色和大小。

SVG Sprite

如果你没听过 SVG Sprite,也许听过雪碧图(CSS Sprite),如下图所示。雪碧图是为了减少网络请求次数,将许多小图标整合到一张图片上,然后通过 CSS 定位技术显示特定位置的图标。雪碧图在使用上存在一些弊端,目前已经很少使用了。

类似的 SVG Sprite 是通过<symbol><use>实现的。<symbol>元素可以把 SVG 图标定义成一个图形模板对象,<use>元素通过 xlink:href 属性引用 symbol id 展示图形。下面代码定义了三个<symbol>图形模板,此时图形并不会展示到页面上,通过<use>元素引用 symbol id 后才可展示图形。

//定义图形
<svg width="0" height="0">
  <symbol id="shape1">
    <circle cx="40" cy="40" r="24" style="stroke:#006600; fill:#00cc00" />
  </symbol>
  <symbol id="shape2">
    <rect
      x="10"
      y="10"
      height="100"
      width="100"
      style="stroke:#006600; fill: #00cc00" />
  </symbol>
  <symbol id="shape3">
    <polygon points="10,0  60,0  35,50" style="stroke:#660000; fill:#cc3333;" />
  </symbol>
</svg>
//引用图形
<svg width="500" height="200">
  <use xlink:href="#shape1" x="0" y="25" />
  <use xlink:href="#shape1" x="60" y="25" />
  <use xlink:href="#shape2" x="150" y="0" />
  <use xlink:href="#shape3" x="280" y="10" />
</svg>

通过上面示例代码可以看出:

  1. <use>元素可以跨<svg>元素引用<symbol>
  2. <use>可以重复引用<symbol>.

如果将项目中的 SVG 图标用<symbol>元素定义成图形模板,并将其组合成一个大的<svg>加载到页面中,如下图所示。那么我们可以在页面的任何位置,只需要一行代码就可以引用这个图标了。

webpack-vue 上使用

在**src/components**下建立一个**SvgIcon**组件

<template>
  <svg :class="svgClass" aria-hidden="true">
    <use :xlink:href="iconName"/>
  </svg>
</template>

<script>
export default {
  name: 'SvgIcon',
  props: {
    iconClass: {
      type: String,
      required: true,
    },
    className: {
      type: String,
      default: '',
    },
  },
  computed: {
    iconName () {
      return `#icon-${this.iconClass}`
    },
    svgClass () {
      if (this.className) {
        return 'svg-icon ' + this.className
      } else {
        return 'svg-icon'
      }
    },
  },
}
</script>

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

在 src/assets/下建立一个 icons 文件夹,然后下面再建一个 svg 文件夹

这个 SVG 文件夹主要存放 SVG 图标。icons 文件夹下再建一个 index.js,它的功能是把组件注册到全局,方便使用:

import Vue from "vue";
import SvgIcon from "@/components/SvgIcon"; // svg组件

// 注册到全局
Vue.component("svg-icon", SvgIcon);

const requireAll = (requireContext) =>
  requireContext.keys().map(requireContext);
// 这里本来是直接找到svg文件夹下的文件了,但是为了不去处一些svg的配色需求,所以改为获取当前目录下所有的svg图片。
const req = require.context("./", true, /\.svg$/);
requireAll(req);

main.js中引入

这一步就是把文件注册到全局上。

下面是最重要的一步:

主要是修改 loader 和一个去除 SVG 内部默认的 full 属性值。

本来载入和删除 full 对于大部分的项目够用了,但是难免遇到复杂的 SVG,这个时候就不能去掉它的颜色了。所以对配置做了修改。新增了 original 文件夹存放不需要去除配色的文件了

// svg 优雅使用
import "@/assets/icons/index";

修改vue.config.js

下面主要用到 2 个插件svg-sprite-loadersvgo-loader。所以先安装他们

npm i svg-sprite-loader svgo-loader  -D
module.exports = {
  chainWebpack: (config) => {
    // 第一步
    const svgRule = config.module.rule("svg-sprite");
    svgRule.uses.clear();
    svgRule
      .test(/\.(svg)(\?.*)?$/)
      .include.add([resolve("src/assets/icons")])
      .end()
      .use("svg-sprite-loader")
      .loader("svg-sprite-loader")
      .options({
        symbolId: "icon-[name]",
      })
      .end();

    // 第二步
    const findFileFolder = (dir, filename) => {
      const files = fs.readdirSync(resolve(dir));
      const result = [];
      files.map((file) => {
        const filePath = `${dir}/${file}`;
        if (fs.statSync(filePath).isDirectory()) {
          if (file === filename) {
            result.push(filePath);
          } else {
            result.push(...findFileFolder(filePath, filename));
          }
        }
      });
      return result;
    };

    // svgo-loader 去除svg文件中的fill属性,方便前端更改颜色
    // 对于不需要更改颜色的svg,
    // 在对应文件夹(common/(项目名1)/(项目名2)/...)中创建子文件夹
    // 命名为“original”(!!必须!!)
    // 将不会更改颜色(多颜色)的svg放入original文件夹,默认不loader此文件夹文件
    const svgoRule = config.module.rule("svgo");
    const svgoExcludePaths = findFileFolder("src/assets/icons", "original");
    svgoRule
      .test(/\.(svg)(\?.*)?$/)
      .exclude.add([...svgoExcludePaths.map((path) => resolve(path))])
      .end()
      .use("svgo-loader")
      .loader("svgo-loader")
      .tap((options) => ({
        ...options,
        plugins: [{ name: "removeAttrs", params: { attrs: "fill" } }],
      }))
      .end();

    // 原有的svg图像处理loader添加exclude
    config.module.rule("svg").exclude.add(resolve("src/assets/icons")).end();
    config.resolve.alias
      .set("@", resolve("src"))
      .set("assets", resolve("src/assets"))
      .set("components", resolve("src/components"));
  },
};

使用

之后把 SVG 导入到src/assets/svg/下,例如你导入了一张main.svg的文件,然后在文件上这样使用

<svg-icon name="main" class-name="icon"> <svg-icon></svg-icon></svg-icon>

在 Vite 上使用

安装 svg-sprite-loader

npm i svg-sprite-loader --save-dev

SvgIcon component

<!-- /src/components/SvgIcon/icon.vue -->
<script setup lang="ts">
import {computed, useCssModule, useAttrs} from "vue";

export interface SvgIconProps {
  name: string
}

const props = defineProps<SvgIconProps>()

const styles = useCssModule()
const attrs = useAttrs()

const iconName = computed(() => `#icon-${props.name}`);
const svgClass = computed(() => {
  const className = [styles['svg-icon']]
  if (props.name) className.push(`icon-${props.name}`)
  return className
})
</script>

<template>
  <svg :class="svgClass" v-bind="attrs">
    <use :xlink:href="iconName"></use>
  </svg>
</template>

<style module>
.svg-icon {
  width: 1em;
  height: 1em;
  fill: currentColor;
  vertical-align: middle;
  overflow: hidden;
}
</style>
// /src/components/SvgIcon/index.ts
import SvgIcon from "./icon.vue";
import type { SvgIconProps } from "./icon.vue";

export { SvgIcon };

export type { SvgIconProps };

新建plugins文件下新建svgBuilder.ts

// /plugins/svgBuilder.ts
import { readFileSync, readdirSync } from "fs";
import { join as pathJoin } from "path";
import { Plugin } from "vite";

let idPrefix = "";
const svgTitle = /<svg([^>+].*?)>/;
const clearHeightWidth = /(width|height)="([^>+].*?)"/g;
const clearFill = /fill="[^>+].*?"/g;

const hasViewBox = /(viewBox="[^>+].*?")/g;

const clearReturn = /(\r)|(\n)/g;

const findSvgFile = (dir: string) => {
  const svgRes: string[] = [];
  const directory = readdirSync(dir, { withFileTypes: true });
  for (const dirent of directory) {
    if (dirent?.isDirectory()) {
      svgRes.push(...findSvgFile(pathJoin(dir, dirent.name, "/")));
    } else {
      const svg = readFileSync(pathJoin(dir, dirent.name))
        .toString()
        .replace(clearReturn, "")
        .replace(clearFill, "")
        .replace(svgTitle, ($1: string, $2: string) => {
          let width = "0";
          let height = "0";
          let content = $2.replace(
            clearHeightWidth,
            (s1, s2: string, s3: string) => {
              if (s2 === "width") {
                width = s3;
              } else if (s2 === "height") {
                height = s3;
              }
              return "";
            }
          );
          if (!hasViewBox.test($2)) {
            content += `viewBox="0 0 ${width} ${height}"`;
          }
          return `<symbol id="${idPrefix}-${dirent.name.replace(
            ".svg",
            ""
          )}" ${content}>`;
        })
        .replace("</svg>", "</symbol>");
      svgRes.push(svg);
    }
  }
  return svgRes;
};

const svgBuilder = (path: string, prefix = "icon"): Plugin => {
  idPrefix = prefix;
  const res = findSvgFile(path);
  return {
    name: "svg-transform",
    transformIndexHtml(html) {
      return html.replace(
        "<body>",
        `<body>
                <svg xmlns="http://www.w3.org/2000/svg" 
                    xmlns:xlink="http://www.w3.org/1999/xlink" 
                    style="position: absolute; width: 0; height: 0">
              ${res.join("")}
              </svg>`
      );
    },
  };
};

export default svgBuilder;

更新tsconfig.node.json

// tsconfig.node.json

{
  // ...

  "include": ["vite.config.ts", "./plugins/*"]
}

src 目录下新建icons文件夹内存放 svg 图标

vite.config.ts 中

import svgBuilder from "./plugins/svgBuilder";

import { resolve } from "path";

// ...

plugins: [
  // ...

  svgBuilder(resolve("./src/icons")),
];

使用

<script setup lang="ts">

import {SvgIcon} from "@/components/SvgIcon";

</script>

<template>

<!-- name 为 svg icon 的文件名 -->

<SvgIcon name="cookie"/>

</template>