RexHung's Blog

塵世中一個前端迷途小書僮!

0%

[Next Note] — 在 Next 優雅的引入 SVG

Introduction & 前言

Banner

還在找怎麼在 Next 最優雅引入 SVG 的使用方式嗎?看到這邊就對了。

如果你需要參考怎麼在 Next.js 透過 Component 方式引入 SVG ,並且自定義一些參數可以讓開發者快速調整樣式,請看這篇

使用純 React.js 也適用

如果你是使用 Vue 也可以參考筆者早期的文章 [Vue Note] — 在 Vue 優雅的引入 SVG 幾種方式

SVG(可縮放向量圖形) - Scalable Vector Graphics

- SVG 是一種圖型格式,SVG是一種基於可延伸標記式語言 XML(電腦所能理解的資訊符號,類似 HTML),用於描述二維向量圖形的圖形格式。SVG 由 W3C 制定,是一個開放標準。

有興趣可以參考這篇文章 設計師對 SVG 應該有的觀念 裡面介紹到了 SVG 的優點,包括 放大不會失真、可以做成動畫同時保有小檔案容量的優點 及 圖示集…等等。


Summary & 摘要

不囉唆先上連結

筆者近期因為一些專案上的實作,接觸到了要把 SVG 元件化的設計,因為之前有曾經在 Vue 上元件畫過 SVG (可參考 [Vue Note] — 在 Vue 優雅的引入 SVG 幾種方式),固這次也把過程筆記下來

因為這次實作的專案是 Next.js ,如果你是使用純 React.js 也不用擔心,基本上只有設定部分會有些許差異,但實作方面是大同小異的

順帶一提上次實作 Vue.js 的 SVG 元件已經是疫情時候的事情了,時間過真快

本篇文章預設學習前的基本條件需求:

  • 知道且會基本使用 Next.js
  • 會使用終端機安裝套件
  • 一顆熱忱的心 -> 超級重要,碰到坑千萬不要氣餒

本篇文章將有以下幾個步驟:

  1. 前前前言
  2. SVG組成介紹
  3. 環境安裝
  4. 實際使用
  5. 進階使用
  6. 建立元件
  7. 額外問題

如果只想知道怎麼使用,也可以跳過 前前前言 以及 SVG組成介紹 ,直接進入重點
元件化的使用會在 進階使用

看完文章你可以做到:

  • 開發者可以更容易的透過 CSS 或者參數客製化 SVG
  • 統一管理 SVG 的設定以及規範

前前前言

基本上我們在專案使用 SVG 引入有幾種方式

首先介紹最基本的 <Img> 引入方式,這個方式最簡單只需要在專案內引入圖片檔案,接著再放到圖片標籤裡即可。

1
2
3
4
5
6
7
8
9
10
11
import React from 'react'
import LogoSvg from '@/assets/images/logo.svg'

export default function HomePage() {
return (
<div>
{/* 直接把 Svg 帶入 Img 標籤使用 */}
<img src={LogoSvg} alt="logo" />
</div>
)
}
  • 如同之前文章提到的,第一種方式的優點是快速,缺點是好幾個 SVG 要引好幾次,另外無法直接對 SVG 改變顏色。

Inline 引入方式

這種方式之前文章也有提到,就直接貼過來

第二種方式更簡單,如果你仔細打開 SVG 的檔案會發現長得像下面這樣。

SVG Code

直接把這幾段程式碼貼入到你的檔案中即可出現。

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
import React from 'react'
import LogoSvg from '@/assets/images/logo.svg'

export default function HomePage() {
return (
<div>
{/* 直接把 Svg 帶入使用 */}
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 512 512">
<path fill="var(--ci-primary-color, currentColor)" d="M256,16C123.452,16,16,123.452,16,256S123.452,496,256,496,496,388.548,496,256,388.548,16,256,16ZM403.078,403.078a207.253,207.253,0,1,1,44.589-66.125A207.332,207.332,0,0,1,403.078,403.078Z" class="ci-primary"/>
<rect width="40" height="40" x="152" y="200" fill="var(--ci-primary-color, currentColor)" class="ci-primary"/>
<rect width="40" height="40" x="320" y="200" fill="var(--ci-primary-color, currentColor)" class="ci-primary"/>
<path fill="var(--ci-primary-color, currentColor)" d="M338.289,307.2A83.6,83.6,0,0,1,260.3,360H251.7a83.6,83.6,0,0,1-77.992-52.8l-1.279-3.2H137.968L144,319.081A116,116,0,0,0,251.7,392H260.3A116,116,0,0,0,368,319.081L374.032,304H339.568Z" class="ci-primary"/>
</svg>
</div>
)
}
  • 第二種方式雖然可以自由改變大小及顏色,但與第一種方式同樣的,需要引入好幾個 SVG 要貼入好幾次,而且這種方式會讓版面嚴重的變得雜亂,一點也不清爽。

Component 引入方式

最後也是之前文章的出發點,這次一模一樣的動機,也貼內容過來

是筆者最推薦的一種方式,這種方式日後擴充性高使用方便且可以任意放大放小不失真加上想改變顏色就可以直接改變

在了解這種方式之前要先來介紹一下 SVG 的標籤,基本上你必須認識最基本的三個標籤,分別為 <defs><use><symbol>

基本上 <svg> 你們已經認識了,還有包在 <svg> 裡面的 <path><rect>,這些就是定義圖形的長相,但為什麼還有另外上面三個呢?

這三個標籤 <defs><symbol><use> 可以使我們輕鬆達到把所有的 SVG 放在同一個 .svg 檔案裡,然後透過分別給予他們的 ID Name 去呼叫並使用它。


SVG組成介紹

<defs> 標籤

用於預定義一个元素使其能夠在 SVG 圖像中重複使用,舉的例子:

1
2
3
4
5
6
7
8
9
10
<svg>
<defs>
<symbol id="3d-svg" viewBox="0 0 512 512">
<path fill='var(--ci-primary-color, currentColor)' d='M68.983,382.642l171.35,98.928a32.082,32.082,0,0,0,32,0l171.352-98.929a32.093,32.093,0,0,0,16-27.713V157.071a32.092,32.092,0,0,0-16-27.713L272.334,30.429a32.086,32.086,0,0,0-32,0L68.983,129.358a32.09,32.09,0,0,0-16,27.713V354.929A32.09,32.09,0,0,0,68.983,382.642ZM272.333,67.38l155.351,89.691V334.449L272.333,246.642ZM256.282,274.327l157.155,88.828-157.1,90.7L99.179,363.125ZM84.983,157.071,240.333,67.38v179.2L84.983,334.39Z' class='ci-primary'/>
</symbol>
</defs>

<use xlink:href="#3d-svg" x="50" y="50" />
<use xlink:href="#3d-svg" x="200" y="550" />
</svg>

<symbol> 標籤

這個標籤通常會包在 <defs> 標籤裡面,用於定義可重複使用的符號,這邊要記得,不管使用 <defs> 或是 <symbol> 都不會直接顯示,必須使用最後一個要介紹的標籤。

<use> 標籤

透過 <use> 標籤可以使用上面定義那兩個標籤的 SVG,使用方法如上面所示:

1
2
<use xlink:href="#3d-svg" x="50" y="50" />
<use xlink:href="#3d-svg" x="200" y="550" />

切記 ID 名稱一定要對到才有用。


環境安裝

在要元件化之前要提一下這邊跟之前比較不同的地方在哪裡

之前我們是把 SVG 都引入到程式碼裡面

這樣做的優點是一開始就會載入全部 SVG ,只需要載入一次後續圖案載入會很快,幾乎不需要 Loading

缺點也顯而易見,很多沒用到的 SVG 可能也會被載入

這次我們需要修改一下做法,我們只會讓有使用到的 SVG 在元件載入到畫面的時候,才去引入使用

安裝重點套件 @svgr/webpack

讓我們先安裝重點套件 @svgr/webpack

1
$ npm i @svgr/webpack -D

這個套件其實就是貫穿整個文章最重要的東西,它會把 SVG 轉化為 React.js 元件


實際使用

確定套件有安裝好之後,就可以實際使用了

直接到要使用的地方輸入下面的程式碼

1
2
3
4
5
6
7
8
9
import GithubIcon from "./github.svg"; // 實際引入需要看你檔案放置的位置 

const Page = () => {
return (
<GithubIcon />
);
};

export default Page;

如果你是使用 Next.js turbopack 開發的話,這時候可能會噴錯,如果依照 @svgr/webpack 的介紹直接去修改 next.config.ts 補上下面設定會噴錯

1
2
3
4
5
6
7
8
9
10
11
12
13
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */

// Add @svgr/webpack to the webpack config
webpack: (config) => {
config.module.rules.push({
test: /\.svg$/,
use: ["@svgr/webpack"],
});
},
}

這時候請在 next.config.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
import type { NextConfig } from "next";

const nextConfig: NextConfig = {
/* config options here */

// Add @svgr/webpack to the webpack config
experimental: {
turbo: {
rules: {
'*.svg': {
loaders: ['@svgr/webpack'],
as: '*.js',
},
},
},
}

// Add @svgr/webpack to the webpack config
webpack: (config) => {
config.module.rules.push({
test: /\.svg$/,
use: ['@svgr/webpack'],
});
return config;
},
};

export default nextConfig;

詳細內容可以參考 Demo 分支 feat/svgr-normal-use


進階使用

所以我說那個元件呢?

如果依照上面使用方式未免太不清爽了,我們每一個元件都必須要一個一個自己引入去使用,雖然不是不行,但我們有提到要做成元件

請先在 src 底下開一個 components 資料夾,然後再建立一個 SvgIcon 的元件資料夾

首先我們要先定義型別(非使用 TS 可以跳過)

SvgIcon 底下建立 types 資料夾,再分別建立兩個檔案

  • svg.d.ts
1
2
3
4
5
declare module '*.svg' {
import { FC, SVGProps } from 'react'
const content: FC<SVGProps<SVGElement>>
export default content
}
  • webpack.d.ts
1
2
3
4
5
6
7
8
9
10
11
12
13
14
interface WebpackRequireContext {
keys(): string[]
(id: string): { default: React.ComponentType<React.SVGProps<SVGElement>> }
}

declare global {
interface NodeRequire {
context(directory: string, useSubdirectories: boolean, regExp: RegExp): WebpackRequireContext
}

const require: NodeRequire
}

export { WebpackRequireContext }

安裝配套插件

這邊我們需要另外安裝兩個插件,一個是可以把 className 組起來的插件 clsx

另一個是排版優化插件 prettier

1
2
$ pnpm i clsx
$ pnpm i prettier

建立指令產出對應型別

再來我們需要建立 generate 這個資料夾在 SvgIcon 資料夾下,裡面會建立一支檔案

這支檔案是幫我們生成型別用的,也會順便生成 Demohtml

  • generate-icon-types.mjs
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
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
import fs from 'fs'
import path from 'path'
import { fileURLToPath } from 'node:url'
const __filename = fileURLToPath(import.meta.url)
const __dirname = path.dirname(__filename)
import prettier from 'prettier'

/**
* @description 格式化 HTML 內容,讓縮排美觀
* @param {string} html - HTML 原始字串
* @returns {string} - 格式化後的 HTML
*/
function formatHtml(html, parser = 'html') {
try {
return prettier.format(html, {
parser,
printWidth: 120,
tabWidth: 2,
useTabs: false,
singleQuote: true,
semi: false,
trailingComma: 'es5',
bracketSpacing: true,
arrowParens: 'always',
endOfLine: 'lf',
})
} catch {
return html
}
}

/**
* @description 檢查 SVG 是否需要修改(包含 width、height 或 fill 屬性)
* @param {string} svgContent - SVG 檔案的內容
* @returns {boolean} - 是否需要修改
*/
function needsModification(svgContent) {
// 檢查 svg 標籤是否有 width 或 height 屬性
const svgTag = svgContent.match(/<svg[^>]*>/)[0]
if (svgTag.includes('width=') || svgTag.includes('height=')) {
return true
}

// 檢查是否有 fill 屬性,且值不是 "currentColor" 或 "none"
const fillMatches = svgContent.match(/fill=["']([^"']*)["']/g)
if (fillMatches) {
for (const match of fillMatches) {
const value = match.match(/["']([^"']*)["']/)[1]
if (value !== 'currentColor' && value !== 'none') {
return true
}
}
}

// 檢查是否有 stroke 屬性,且值不是 "currentColor" 或 "none"
const strokeMatches = svgContent.match(/stroke=["']([^"']*)["']/g)
if (strokeMatches) {
for (const match of strokeMatches) {
const value = match.match(/["']([^"']*)["']/)[1]
if (value !== 'currentColor' && value !== 'none') {
return true
}
}
}

return false
}

/**
* @description 移除 SVG 標籤中的 width 和 height 屬性
* @param {string} svgContent - SVG 檔案的內容
* @returns {string} - 處理後的 SVG 內容
*/
function removeSvgDimensions(svgContent) {
return svgContent.replace(/<svg[^>]*\s(width|height)=["'][^"']*["'][^>]*>/g, (match) => {
return match.replace(/\s(width|height)=["'][^"']*["']/g, '')
})
}

/**
* @description 將 SVG 中的 fill 和 stroke 屬性替換為 currentColor
* @param {string} svgContent - SVG 檔案的內容
* @returns {string} - 處理後的 SVG 內容
*/
function replaceFillWithCurrentColor(svgContent) {
return svgContent.replace(/<(path|circle|rect)[^>]*>/g, (match) => {
return match
.replace(/\sfill=["'][^"']*["']/g, ' fill="currentColor"')
.replace(/\sstroke=["'][^"']*["']/g, ' stroke="currentColor"')
})
}

/**
* @description 處理 SVG 內容,移除尺寸並更新 fill 屬性
* @param {string} svgContent - SVG 檔案的內容
* @returns {string} - 處理後的 SVG 內容
*/
function processSvgContent(svgContent) {
return replaceFillWithCurrentColor(removeSvgDimensions(svgContent))
}

/**
* @description 提取 SVG 檔案中的 name 屬性
* TODO: 之後可以改成多語系
* @param {string} svgContent - SVG 檔案的內容
* @returns {string | null} - 提取的 name 屬性值或 null
*/
function extractSvgName(svgContent) {
const nameMatch = svgContent.match(/name=["']([^"']+)["']/)
return nameMatch ? nameMatch[1] : null
}

/**
* @description 生成圖示名稱的型別定義檔案
*/
async function generateIconTypes() {
const svgDir = path.join(__dirname, '..', 'svg')
const withColorDir = path.join(svgDir, 'withColor')
const typesDir = path.join(__dirname, '..', 'types')
const demoDir = path.join(__dirname, '..', 'demo')
const outputFile = path.join(typesDir, 'iconNames.ts')
const demoFile = path.join(demoDir, 'index.html')

if (!fs.existsSync(typesDir)) {
fs.mkdirSync(typesDir, { recursive: true })
}
if (!fs.existsSync(demoDir)) {
fs.mkdirSync(demoDir, { recursive: true })
}

try {
// 取得主 svg 資料夾下的 svg
const files = fs.readdirSync(svgDir).filter((f) => f.endsWith('.svg'))
// 取得 withColor 資料夾下的 svg
let withColorFiles = []
if (fs.existsSync(withColorDir)) {
withColorFiles = fs.readdirSync(withColorDir).filter((f) => f.endsWith('.svg'))
}

const iconNames = [
...files.map((file) => file.replace('.svg', '')),
...withColorFiles.map((file) => 'withColor/' + file.replace('.svg', '')),
].sort()

if (iconNames.length === 0) {
console.warn('在 svg 及 withColor 資料夾中找不到任何 SVG 檔案')
return
}

// 更新需要修改的 SVG 檔案
const updatedFiles = []
// 主 svg
files.forEach((name) => {
const svgPath = path.join(svgDir, `${name}`)
const svgContent = fs.readFileSync(svgPath, 'utf8')
if (needsModification(svgContent)) {
const processedContent = processSvgContent(svgContent)
if (svgContent !== processedContent) {
fs.writeFileSync(svgPath, processedContent, 'utf8')
updatedFiles.push(name)
}
}
})

// withColor 只移除寬高
withColorFiles.forEach((file) => {
const svgPath = path.join(withColorDir, file)
const svgContent = fs.readFileSync(svgPath, 'utf8')
// 只檢查 width/height
const svgTag = svgContent.match(/<svg[^>]*>/)?.[0] || ''
if (svgTag.includes('width=') || svgTag.includes('height=')) {
const processedContent = removeSvgDimensions(svgContent)
if (svgContent !== processedContent) {
fs.writeFileSync(svgPath, processedContent, 'utf8')
updatedFiles.push('withColor/' + file.replace('.svg', ''))
}
}
})

const typeDefinition = `export type IconName =
${iconNames.map((name) => ` | '${name}'`).join('\n')}

export const availableIconNames: IconName[] = [
${iconNames.map((name) => ` '${name}',`).join('\n')}
]
`

fs.writeFileSync(outputFile, typeDefinition, 'utf8')

const getSvgContentByName = (name) => {
if (name.startsWith('withColor/')) {
const file = name.replace('withColor/', '')
return fs.readFileSync(path.join(withColorDir, file + '.svg'), 'utf8')
}
return fs.readFileSync(path.join(svgDir, name + '.svg'), 'utf8')
}

const htmlContent = (() => {
const svgElements = iconNames
.map((name) => {
const svgContent = getSvgContentByName(name)
const displayName = extractSvgName(svgContent)
return `
<div class="bg-white rounded-xl p-6 shadow-lg hover:shadow-xl transition-all duration-300 cursor-pointer hover:-translate-y-2 group relative icon-item"
data-name="${name}"
data-display-name="${displayName || ''}"
onclick="copyIconName('${name}')">
<div class="w-16 h-16 mx-auto mb-4 flex items-center justify-center bg-slate-50 rounded-lg p-3">
${svgContent}
</div>
${displayName ? `<div class="text-sm text-slate-500 text-center mb-1">${displayName}</div>` : ''}
<div class="text-sm font-semibold text-slate-700 text-center break-all">${name}</div>
<div class="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity duration-200">
<div class="bg-blue-500 text-white text-xs px-2 py-1 rounded shadow-lg">
點擊複製
</div> </div>
</div>`
})
.join('')

return `<!DOCTYPE html>
<html lang="zh-TW">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>SVG Icon Demo - ${iconNames.length} 個圖示</title>
<script src="https://cdn.tailwindcss.com"></script>
<script>
tailwind.config = {
theme: {
extend: {
fontFamily: {
sans: ['-apple-system', 'BlinkMacSystemFont', 'Segoe UI', 'Roboto', 'Microsoft JhengHei', 'sans-serif']
}
}
}
}
</script>
<style>
.gradient-bg {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
}

.toast {
position: fixed;
top: 2rem;
right: 2rem;
z-index: 1000;
transform: translateX(100%);
transition: transform 0.3s ease;
}

.toast.show {
transform: translateX(0);
}

.icon-item {
transition: all 0.3s ease;
}

.icon-item.hidden {
display: none;
}
</style>
</head>

<body class="gradient-bg min-h-screen p-8 font-sans">
<div class="max-w-6xl mx-auto">
<div class="text-center mb-4 text-white">
<h1 class="text-4xl md:text-5xl font-bold mb-2 drop-shadow-lg">🎨 SVG 圖示庫</h1>
<p class="text-xl opacity-90">點擊複製名稱</p>
</div>

<div class="bg-white/10 backdrop-blur-lg rounded-xl p-6 text-white text-center mb-8 border border-white/20">
<strong>總共 ${iconNames.length} 個圖示</strong> | 最後更新:${new Date().toLocaleString('zh-TW')}
</div>
<div class="mb-8">
<div class="relative">
<input type="text" id="searchInput"
class="w-full px-4 py-3 rounded-lg bg-white/10 border border-white/20 text-white placeholder-white/50 focus:outline-none focus:ring-2 focus:ring-white/30"
placeholder="搜尋圖示名稱或中文名稱..." oninput="searchIcons(this.value)">
<div class="absolute right-3 top-1/2 -translate-y-1/2 text-white/50">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"></path>
</svg>
</div>
</div>
</div>

<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-6">
${svgElements}
</div>
</div>

<!-- Toast 通知 -->
<div id="toast" class="toast bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg right-0">
<div class="flex items-center">
<svg class="w-5 h-5 mr-2" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7"></path>
</svg>
<span id="toast-message">已複製到剪貼簿</span>
</div>
</div>

<script>
function copyIconName(iconName) {
navigator.clipboard.writeText(iconName).then(() => {
showToast('已複製 "' + iconName + '" 到剪貼簿');
}).catch(err => {
console.error('複製失敗:', err);
showToast('複製失敗,請手動複製', 'error');
});
}

function showToast(message, type = 'success') {
const toast = document.getElementById('toast');
const toastMessage = document.getElementById('toast-message');

toastMessage.textContent = message;

if (type === 'error') {
toast.className = 'toast bg-red-500 text-white px-6 py-3 rounded-lg shadow-lg';
} else {
toast.className = 'toast bg-green-500 text-white px-6 py-3 rounded-lg shadow-lg';
}

toast.classList.add('show');

setTimeout(() => {
toast.classList.remove('show');
}, 3000);
}

function searchIcons(query) {
const searchText = query.toLowerCase();
const items = document.querySelectorAll('.icon-item');

items.forEach(item => {
const name = item.dataset.name.toLowerCase();
const displayName = item.dataset.displayName.toLowerCase();

if (name.includes(searchText) || displayName.includes(searchText)) {
item.classList.remove('hidden');
} else {
item.classList.add('hidden');
}
});
}
</script>
</body>

</html>`
})()

const html = await formatHtml(htmlContent)

fs.writeFileSync(demoFile, html, 'utf8')

console.log(`\n✅ 成功生成檔案!`)
console.log(`📁 型別檔案: ${outputFile}`)
console.log(`🎨 Demo 檔案: ${demoFile}`)
console.log(`🎯 包含 ${iconNames.length} 個圖示`)

if (updatedFiles.length > 0) {
console.log(`\n🔄 已更新以下 ${updatedFiles.length} 個 SVG 檔案:`)
updatedFiles.forEach((name) => {
console.log(` - ${name}.svg`)
})
} else {
console.log('\n✨ 所有 SVG 檔案都已經符合規範,無需更新')
}
} catch (error) {
console.error('❌ 生成檔案時發生錯誤:', error.message)
process.exit(1)
}
}

generateIconTypes()

如果沒有使用 TS 可以將 generateIconDemo() 那段改成下面這樣

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
/**
* @description 生成圖示預覽頁面
*/
async function generateIconDemo() {
const svgDir = path.join(__dirname, '..', 'svg')
const withColorDir = path.join(svgDir, 'withColor')
const demoDir = path.join(__dirname, '..', 'demo')
const demoFile = path.join(demoDir, 'index.html')

if (!fs.existsSync(demoDir)) {
fs.mkdirSync(demoDir, { recursive: true })
}
//...略

// 這些都可以註解或移除
// const typeDefinition = `export type IconName =
// ${iconNames.map((name) => ` | '${name}'`).join('\n')}

// export const availableIconNames: IconName[] = [
// ${iconNames.map((name) => ` '${name}',`).join('\n')}
// ]
// `

// fs.writeFileSync(outputFile, typeDefinition, 'utf8')

//...下略
}

接著我們到 package.jsonscript 新增一行

1
2
3
4
5
6
7
8
9
10
11
12
13
{
"name": "next-svg-component",
"version": "0.1.0",
"private": true,
"scripts": {
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint",
"svg:generate": "node src/components/SvgIcon/generate/generate-icon-types.mjs" // 新增這行,實際檔案位置看你的路徑
},
//...略
}

存放 SVG

在跑指令之前,我們需要在 SvgIcon 資料夾底下再建立一個 svg 資料夾,在這個資料夾底下也順手再開一個 withColor 的資料夾

這個就是你的 SVG 原始檔存放位置

建立好丟檔案進去接著就可以跑指令

Folder

目前為止你的目錄長上面這樣

好了之後可以使用下面兩種其中一種方式跑指令

1
2
$ pnpm run svg:generate
$ node generate-icon-types.mjs

Run Generate

終端機顯示有更新之後

  1. 你的 types 應該會多出一個 iconNames.ts
  2. 你的 SvgIcon 底下會多出一個 demo/index.html

第二個指令需要看你實際位置在哪裡去修改路徑,可以先用 pwd 查看目前處在哪一個地方

這時候可以打開 /SvgIcon/demo/index.html

HTML Demo

指令做了些什麼?

這邊說一下指令都做了些什麼

  • 產出 types 定義
  • Svg 內寫死的 width, heightcolor 都移除,顏色改為 currentColor
  • 產生 demo/index.html ,方便透過網頁直接看所有可使用的 SVG 圖案

如果不移除寬高跟顏色,會導致之後透過 CSS 無法修改 Svg 的樣式


建立元件

前面搞這麼多,終於可以來建立我們的 SVG 元件了

SvgIcon 資料夾底下建立 index.tsx

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
import { SVGProps } from 'react'
import { WebpackRequireContext } from './types/webpack'
import { IconName } from './types/iconNames'
import clsx from 'clsx'

function importAll(requireContext: WebpackRequireContext, prefix = '') {
const icons: Record<string, React.ComponentType<SVGProps<SVGElement>>> = {}
requireContext.keys().forEach((item: string) => {
const iconName = prefix + item.replace('./', '').replace('.svg', '')
icons[iconName] = requireContext(item).default
})
return icons
}

const svgIcons = importAll(
(
require as unknown as { context: (dir: string, useSubdirs: boolean, pattern: RegExp) => WebpackRequireContext }
).context('./svg', false, /\.svg$/)
)
const svgWithColorIcons = importAll(
(
require as unknown as { context: (dir: string, useSubdirs: boolean, pattern: RegExp) => WebpackRequireContext }
).context('./svg/withColor', false, /\.svg$/),
'withColor/'
)
const Icons = { ...svgIcons, ...svgWithColorIcons }

/**
* @description SvgIcon 元件的屬性
* @param {string} name - 圖標名稱
* @param {number} size - 圖標大小
* @param {string} className - 圖標類名
* @param {SVGProps<SVGElement>[]} childrenSVGProps - 子圖標屬性
* @param {SVGProps<SVGElement>} props - 其他屬性
*/
export interface IconProps extends SVGProps<SVGElement> {
name: IconName
size?: number
className?: string
childrenSVGProps?: SVGProps<SVGElement>[]
}

const SvgIcon: React.FC<IconProps> = ({ name, size = 16, className = '', childrenSVGProps, ...props }) => {
const IconComponent = Icons[name]
if (!IconComponent) {
console.warn(`Icon "${name}" not found`)
return null
}
return (
<IconComponent
width={size}
height={size}
className={clsx('cursor-pointer', className)}
{...(childrenSVGProps ? ({ childrenSVGProps } as unknown as Record<string, unknown>) : {})}
{...props}
/>
)
}

export default SvgIcon

對於元件可以傳遞的參數後續都可以依照使用情境去進行修改

實際使用

建立結束後我們就可以到想使用的地方去進行使用

1
2
3
4
5
6
7
8
9
import SvgIcon from "@/components/SvgIcon"; // 實際引入需要看你檔案放置的位置 

const Page = () => {
return (
<SvgIcon name="github" className="text-red-500" />
);
};

export default Page;

詳細內容可以參考 Demo 分支 feat/svgr-component-use


額外問題

插件導致直接引入 SVG 掛掉

如果你使用 @svgr/webpack 插件後,影響到了你原本的 SVG 載入,導致原本的 SVG 都壞掉不顯示了,可以嘗試在 next.config.ts 修改 @svgr/webpackconfig.module.rules 設定

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
import type { NextConfig } from "next";

const nextConfig: NextConfig = {

webpack: (config) => {
/* config options here */
const iconPath = path.resolve(__dirname, 'src/components/SvgIcon/svg')

config.module.rules.push({
test: /\.svg$/,
include: iconPath,
use: [
{
loader: '@svgr/webpack',
options: {
svgo: false,
template: (variables, { tpl }) => {
// 這邊可以對你的 SVGIcon Component 做一些設定
},
},
},
],
})
}

透過 include 可以只針對你的 SvgIcon 底下的 SVG 去進行轉換而已,不會影響到其他單純直接引入的 SVG 圖案

相關文章也可以參考這篇 今天聊聊@svgr/webpack遇到的坑

使用 svgr 插件,但是在 turbopack 模式下發生錯誤

可以參考這篇文章 How to integrate @svgr/webpack to make turbopack work? #50337


Conclusion & 結論


參考網站