前端项目优雅使用svg

前言

在前端开发中,大家都接触到设计图。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 后才可展示图形。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
//定义图形
<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**组件

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
<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,它的功能是把组件注册到全局,方便使用:

1
2
3
4
5
6
7
8
9
10
11
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 文件夹存放不需要去除配色的文件了

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

修改vue.config.js

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

1
npm i svg-sprite-loader svgo-loader  -D
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
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的文件,然后在文件上这样使用

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

在 Vite 上使用

安装 svg-sprite-loader

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

SvgIcon component

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
<!-- /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>
1
2
3
4
5
6
7
// /src/components/SvgIcon/index.ts
import SvgIcon from "./icon.vue";
import type { SvgIconProps } from "./icon.vue";

export { SvgIcon };

export type { SvgIconProps };

新建plugins文件下新建svgBuilder.ts

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
// /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

1
2
3
4
5
6
7
// tsconfig.node.json

{
// ...

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

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

vite.config.ts 中

1
2
3
4
5
6
7
8
9
10
11
import svgBuilder from "./plugins/svgBuilder";

import { resolve } from "path";

// ...

plugins: [
// ...

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

使用

1
2
3
4
5
6
7
8
9
10
11
12
13
<script setup lang="ts">

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

</script>

<template>

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

<SvgIcon name="cookie"/>

</template>