Introduction & 前言
還在找怎麼在 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
會使用終端機安裝套件
一顆熱忱的心 -> 超級重要,碰到坑千萬不要氣餒
本篇文章將有以下幾個步驟:
前前前言
SVG組成介紹
環境安裝
實際使用
進階使用
建立元件
額外問題
如果只想知道怎麼使用,也可以跳過 前前前言 以及 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 的檔案會發現長得像下面這樣。
直接把這幾段程式碼貼入到你的檔案中即可出現。
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 = { 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 = { experimental : { turbo : { rules : { '*.svg' : { loaders : ['@svgr/webpack' ], as : '*.js' , }, }, }, } 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 資料夾,再分別建立兩個檔案
1 2 3 4 5 declare module '*.svg' { import { FC , SVGProps } from 'react' const content : FC <SVGProps <SVGElement >> export default content }
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 資料夾下,裡面會建立一支檔案
這支檔案是幫我們生成型別用的,也會順便生成 Demo 的 html
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' 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 } } function needsModification (svgContent ) { const svgTag = svgContent.match (/<svg[^>]*>/ )[0 ] if (svgTag.includes ('width=' ) || svgTag.includes ('height=' )) { return true } 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 } } } 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 } function removeSvgDimensions (svgContent ) { return svgContent.replace (/<svg[^>]*\s(width|height)=["'][^"']*["'][^>]*>/g , (match ) => { return match.replace (/\s(width|height)=["'][^"']*["']/g , '' ) }) } function replaceFillWithCurrentColor (svgContent ) { return svgContent.replace (/<(path|circle|rect)[^>]*>/g , (match ) => { return match .replace (/\sfill=["'][^"']*["']/g , ' fill="currentColor"' ) .replace (/\sstroke=["'][^"']*["']/g , ' stroke="currentColor"' ) }) } function processSvgContent (svgContent ) { return replaceFillWithCurrentColor (removeSvgDimensions (svgContent)) } function extractSvgName (svgContent ) { const nameMatch = svgContent.match (/name=["']([^"']+)["']/ ) return nameMatch ? nameMatch[1 ] : null } 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 { const files = fs.readdirSync (svgDir).filter ((f ) => f.endsWith ('.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 } const updatedFiles = [] 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) } } }) withColorFiles.forEach ((file ) => { const svgPath = path.join (withColorDir, file) const svgContent = fs.readFileSync (svgPath, 'utf8' ) 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 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 }) } }
接著我們到 package.json 的 script 新增一行
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 原始檔存放位置
建立好丟檔案進去接著就可以跑指令
目前為止你的目錄長上面這樣
好了之後可以使用下面兩種其中一種方式跑指令
1 2 $ pnpm run svg:generate $ node generate-icon-types.mjs
終端機顯示有更新之後
你的 types 應該會多出一個 iconNames.ts
你的 SvgIcon 底下會多出一個 demo/index.html
第二個指令需要看你實際位置在哪裡去修改路徑,可以先用 pwd 查看目前處在哪一個地方
這時候可以打開 /SvgIcon/demo/index.html
指令做了些什麼? 這邊說一下指令都做了些什麼
產出 types 定義
將 Svg 內寫死的 width, height 及 color 都移除,顏色改為 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 }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/webpack 的 config.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 ) => { 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 } ) => { }, }, }, ], }) }
透過 include 可以只針對你的 SvgIcon 底下的 SVG 去進行轉換而已,不會影響到其他單純直接引入的 SVG 圖案
相關文章也可以參考這篇 今天聊聊@svgr/webpack遇到的坑
使用 svgr 插件,但是在 turbopack 模式下發生錯誤 可以參考這篇文章 How to integrate @svgr/webpack to make turbopack work? #50337
Conclusion & 結論
參考網站