在 Vue 聰明使用 SVG-Icon
2024/4/30 更新: 雖然這篇是在 Vue3 還沒出的時候寫的,但由於看到這篇偶爾還是有些點擊率,所以決定更新一下,並免點進來看的人失望離開。 以下將包含 Vue2 & Vue3 的不同方案
一般來說我們在 Vue 的專案裡使用 SVG,會有兩種比較簡單的方式。
第一種:Using SVG as an <img>
利用 <img>
標籤來引入,此時 SVG 被視為一個圖檔載入,最大的缺點就是無法利用 CSS 來改變 SVG 的樣式。
如果你有 Icon 會有改變顏色的需求,你就需要兩張不同顏色的圖檔,兩個 <img>
標籤,然後用判斷式來控制,非常繁瑣。
<template>
<img v-if="theme === 'light'" src="/img/content/icon-light.svg" />
<img v-else-if="theme === 'dark'" src="/img/content/icon-dark.svg" />
</template>
第二種:Inline SVG
直接將 <svg>
標籤放進 Html 結構中,這種方法雖然解決了改變顏色的問題,但卻讓程式碼看起來非常雜亂。
<template>
<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 16 16">
<g
:stroke="myColor"
stroke-linecap="round">
fill-rule="evenodd"
<path d="M8 3.5v9M4.5 9.5l3.5 4 3.5-4"/>
</g>
</svg>
</template>
SVG Sprites 精靈圖
為了解決上述的困擾 SVG Sprites 是一種對於 SVG 中 <use>
及 <symbol>
標籤的應用,透過這兩個標籤,我們可以將所有的 SVG 圖示定義為 Symbol,且透過 <use>
並指定 Symbol 的 id
來引用,這樣就可以達到一次引入,多次使用的效果。例如下面的範例:
<svg xmlns="http://www.w3.org/2000/svg" style="display: none;">
<symbol id="myDot" width="10" height="10" viewBox="0 0 2 2">
<circle cx="1" cy="1" r="1" />
</symbol>
</svg>
<svg>
<use xlink:href="#myDot" />
<use xlink:href="#myDot" />
<use xlink:href="#myDot" />
</svg>
<svg>
<use xlink:href="#myDot" />
</svg>
而不管是 Vue2 或 Vue3,我們最終都是透過打包工具(Webpack、Vite)來將專案中的 SVG 檔案轉換為上述的 <symbol>
,以達到 SVG Sprites 的效果。
# Vue3 + Vite
在 Vue3 + Vite 的專案中,我們可以透過 Vite 的 Plugin 來製作 SVG Sprites。目前市面上有許多不同的 Plugin 可以使用,例如 vite-plugin-svg-icons 或 vite-svg-loader ,你可以選一個你自己喜歡的,這裡我打算自己寫一個簡單的 Plugin 來處理。
一、撰寫 Vite Plugin
在專案中新增一個 svgBuilder.js
,名稱和路徑你都可以自己調整,並且撰寫以下程式碼:
import { readFileSync, readdirSync } from "fs";
import { extname } from "path";
// 將 SVG 內容轉換為 Symbol 的函式,需要傳入目標資料夾路徑以及 SVG 檔名
function symbolFormatter(dir, svgName) {
const svgFrontTag = /<svg([^>+].*?)>/;
const viewBox = /(viewBox="[^>+].*?")/;
const widthHeight = /(width|height)="([^>+].*?)"/g;
const carriageReturn = /(\r)|(\n)/g;
return readFileSync(dir + svgName)
.toString()
.replace(carriageReturn, "") // 移除換行符號
.replace(svgFrontTag, (match, $1) => {
let width = 0;
let height = 0;
// 取得 SVG 的 width 和 height 的值後,將其從 SVG 內容中移除
let content = $1.replace(widthHeight, (match, s1, s2) => {
if (s1 === "width") width = s2;
if (s1 === "height") height = s2;
return "";
});
// 如果 SVG 沒有 viewBox 屬性,則加入 viewBox 屬性
if (!viewBox.test($1)) content += `viewBox="0 0 ${width} ${height}"`;
// 將 SVG 內容轉換為 Symbol,並使用檔名作為 Symbol 的 id
return `<symbol id="${svgName.replace(".svg", "")}" ${content}>`;
})
.replace("</svg>", "</symbol>");
}
// 遞迴尋找資料夾中的 SVG 檔案的函式,接受一個資料夾路徑作為參數
function findSvgFile(dir) {
const symbolRes = [];
const dirent = readdirSync(dir, { withFileTypes: true });
for (const content of dirent) {
// 如果是資料夾,則遞迴尋找,反之則將其轉換為 Symbol
if (content.isDirectory()) {
symbolRes.push(...findSvgFile(dir + content.name + "/"));
} else {
// 如果不是 SVG 檔,則跳過
if (extname(content.name) !== ".svg") continue;
symbolRes.push(symbolFormatter(dir, content.name));
}
}
// 返回所有被轉換為 Symbol 的 SVG 內容
return symbolRes;
}
// 最終要 export 的 Plugin 函式,接受一個資料夾路徑作為參數
export const svgBuilder = (path) => {
if (path === "") return;
const symbols = findSvgFile(path);
return {
name: "svg-builder",
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"
>
${symbols.join("")}
</svg>
`
);
}
};
};
- 由於 Vite 是在 Node 環境中執行,所以我們可以取用 Node.js 的
fs
模組來讀取檔案。 - 在
symbolFormatter
中,透過readFileSync
讀取 SVG 檔案內容,並以replace
做字串處理。 - 在
findSvgFile
中,透過readdirSync
讀取整個指定的資料夾中的檔案。 - 在
svgBuilder
中的transformIndexHtml
則是 Vite 提供的 Hook API,用來處理 Html 的內容。
二、使用 Plugin
完成 Plugin 後,我們要在 vite.config.js
中正式使用 Plugin。
import { svgBuilder } from "./src/plugins/svgBuilder";
export default () => {
return {
plugins: [
vue(),
svgBuilder("./src/assets/icon/"),
],
}
}
三、全域元件
最後,新增一個元件來包裝 SVG Sprites,並且全域註冊這個元件。
import SvgIcon from "@/components/common/SvgIcon"
const app = createApp(App);
app.component("icon", SvgIcon);
app.mount("#app");
<template>
<svg aria-hidden="true"><use :xlink:href="`#${iconName}`" /></svg>
</template>
<script setup>
defineProps({
iconName: {
type: String,
required: true
}
});
</script>
<style lang="scss" scoped>
.svg-icon {
width: 16px;
height: 16px;
vertical-align: -0.15em;
overflow: hidden;
}
</style>
你就可以在專案中像這樣使用這個元件了,若顏色並未照你預期的變換,請看文末的補充
<template>
<p style="color: red">
<icon icon-name="icon-faq" />
</p>
</template>
# Vue2 + Webpack
在 Vue2 + Webpack 專案中,我們可以透過 Webpack Loader 以及一些額外操作來製作 SVG Sprites。
一、安裝
首先建立資料夾路徑 src/assets/icon
,之後的 .svg
檔都會放在這裡。接著安裝今天的主角 svg-sprite-loader
,它就是這次要使用的 Webpack Loader。
$ npm install svg-sprite-loader -D
$ yarn add svg-sprite-loader -D
安裝好後要調整一下 Webpack 設定,在 Loader 文件 中有詳細說明如何配置。不過我們這邊用的是 Vue CLI,其中的 Webpack 版本支援 chainWebpack
,所以可以用以下方式來配置。
const path = require("path");
function resolve(dir) {
return path.join(__dirname, dir);
}
module.exports = {
chainWebpack: (config) => {
// 先刪除預設的svg配置
config.module.rules.delete("svg")
// 新增 svg-sprite-loader 設定
config.module
.rule("svg-sprite-loader") // 規則名稱
.test(/\.svg$/) // 驗證檔案格式
.include.add(resolve("src/assets/icon")) // 加入接受的檔案路徑
.end() // 結束規則的基本設定
.use("svg-sprite-loader") // 選取剛剛建立的規則
.loader("svg-sprite-loader") // 指定規則要使用的 loader
.options({ symbolId: "[name]" }) // 設定 loader 的選項
// 修改 images-loader 配置
config.module
.rule("images")
.exclude.add(resolve("src/assets/icon"));
}
}
- 使用
.include.add()
來加入你未來要存放.svg
檔的資料夾路徑。 - 使用
options()
來設定 Loader, 相關屬性可以看 Loader 的 文件。 - 使用
symbolId
屬性來決定 Symbol 的id
該以什麼方式命名,這邊用的是[name]
以檔名來命名。 - (可選) 預設的
images-loader
可以排除存放.svg
檔的資料夾,未來該資料夾下的檔案便不可用<img>
引入。
二、使用
這樣處理完之後就可以在 Vue 元件中引入 .svg
檔,並且在 template
裡使用 <use>
,你就可以獲得一個能夠改變顏色的 SVG Icon 了。
<template>
<svg><use xlink:href="#target" /></svg>
</template>
<script>
import "@/src/assets/icon/target.svg";
</script>
xlink:href
是用來指定 Symbol ID 的屬性,前面已經透過設定將 ID 設定為檔名,因此只要將 #target
改成對應的 .svg
檔名即可,例如: faq.svg
就是 #faq
。
三、全域引入與全域元件
雖然已經解決了改變 Icon 顏色以及程式碼雜亂的問題,但每當要使用 Icon 時,都必須在元件中引入對應的 .svg
檔,也是增添不少管理上的麻煩,這時可以在 main.js
中,利用 Webpack 的 require.context
一次性引入所有檔案(官方說明)。
這樣 .svg
檔就會全域性的引入,之後就不用一個個 import
了,未來如果要新增圖示,只需把檔案丟進資料夾即可,導入的部分 Webpack 會自動處理,能夠省下不少功夫。
const requireAll = requireContext => requireContext.keys().map(requireContext)
const req = require.context("@/src/assets/icon", true, /\.svg$/)
requireAll(req)
另外,我們還可以新增一個元件來包裝 SVG Sprites,並且全域註冊這個元件。
import SvgIcon from "@/components/common/SvgIcon"
Vue.component("icon", SvgIcon)
<template>
<svg aria-hidden="true"><use :xlink:href="`#${iconName}`" /></svg>
</template>
<script>
export default {
name: "SvgIcon",
props: {
iconName: {
type: String,
required: true
}
}
};
</script>
<style lang="scss" scoped>
.svg-icon {
width: 16px;
height: 16px;
vertical-align: -0.15em;
overflow: hidden;
}
</style>
這樣就可以直接用元件的方式使用 SVG 囉!
<template>
<p style="color: red">
<icon icon-name="icon-faq" />
</p>
</template>
補充
若你發現 Icon 沒有根據你的期望變換顏色,你需要將對應 Symbol 的 .svg
檔打開,並且確認是否有 fill
或 stroke
的屬性。如果有,請將它們的值由指定顏色改為 currentColor
,這樣 Icon 便會根據父元素的文字顏色來變換。