關於怎麼用 SVG Icon 的研究紀錄

# Why

公司專案使用的 Icon 是從 figma 下載 SVG Icon 放到專案中
若是不需要更改顏色的 Icon 可以直接用 img 來呈現

<img src=".../xxx.svg">

但總是會有需要改顏色的情境 (ex: disabled 要用灰色之類)
以 img tag 的方式使用無法更改顏色
用 Inline SVG 複製貼上醜又難維護

<template>
<div>
  <svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24">
      <g fill="none" fill-rule="evenodd">
          ...可能很長很長...
      </g>
  </svg>
  ...其他 html DOM
</div>
</template>

因此使用第三方套件 vite-plugin-vue2-svg(專案為 vite + vue2)
將指定資料夾中的 SVG 檔案打包成 Vue Component
使用時會 render Inline SVG,便可透過 fill: currentColor + text color 更改顏色

<template>
  <component :is="svgComponent" :width="width" :height="height" />
</template>

<script lang="ts">
import { defineComponent, defineAsyncComponent } from '@vue/composition-api'

export default defineComponent({
  name: 'SvgIcon',
  props: {
    name: { type: String, required: true },
    width: { type: String },
    height: { type: String, default: '100%' },
  },
  setup(props) {
    const svgComponent = defineAsyncComponent(() => import(`/icons/${props.name}.svg`))

    return {
      svgComponent,
    }
  },
})
</script>

<style lang="scss" scoped>
::v-deep {
  svg:not([fill^='url']),
  path:not([fill^='url']),
  g:not([fill^='url']) {
    fill: currentColor;
  }
}
</style>

p.s. vite-plugin-vue2-svg 預設會將 viewBox 拿掉
這會使得 icon 沒辦法用 width, height 更改大小
因此要將 removeViewBox 設為 false

// vite.config.ts
import { createSvgPlugin } from 'vite-plugin-vue2-svg' // only for vue2.x

export default defineConfig({
  plugins: [
    createSvgPlugin({
      svgoConfig: {
        plugins: [
          {
            // ref. https://github.com/svg/svgo#default-preset
            name: 'preset-default',
            params: {
              overrides: {
                removeViewBox: false,
              },
            },
          },
        ],
      },
    }),
  ]
})

但 vite-plugin-vue2-svg 只有 vue 2 & vite 2 能夠使用
考慮到未來或許會升級 vite3 (vue 3),需要捨棄 vite-plugin-vue2-svg
因此尋找能更優雅使用 SVG Icon 的方式
尤其是想要能夠像 Font Icon 一樣透過 css class 簡單好讀

# Approach

# 1. Font Icon

可藉由 IcoMoon (opens new window) 提供的服務把 SVG 打包成 Font Icon

  • 優點:
    • 不需要安裝額外的第三方套件
    • 能使用 font-size, color 調整大小、顏色
  • 缺點:
    • 由於是 Font,會受到所有跟 Font 有關的 css 影響 ( line-height, letter-space 之類 )
    • 佔用偽類別
    • 每次有新增 Icon 都要重新生成 Font Icon
    • Font Icon 無法透過 VS Code 的插件「SVG Preview」預覽

# 2. SVG Sprite

可藉由 SVG Sprites (opens new window) 提供的服務把所有 SVG 打包成一個 SVG Sprite 檔案

  • 優點:
    • 不需要安裝額外的第三方套件
    • 用 Inline SVG 但寫起來簡短
<svg>
  <use xlink:href="#{name}"></use>
</svg>
  • 缺點:
    • 每次新增 Icon 時都要重新生成 SVG Sprite
    • SVG 數量越多 Sprite 檔案越大
    • SVG Sprite 無法透過 VS Code 的插件「SVG Preview」預覽

# 3. Pure CSS Icon

Vue, Vite 的核心成員,大神 Anthony Fu 的這篇文 聊聊纯CSS 图标 (opens new window)
使用 css 屬性 mask-image 將 SVG 作為遮罩圖層使用
將 background 設為 currentColor,就能夠用 css 屬性 color 去更改顏色
寬高設為 1em 就能夠讓 SVG Icon 依據 font-size 改變大小
如此便擁有了 Font Icon 的優點
能夠像 Font Icon 一樣以 css class 指定 icon
且又不會受到其他 Font css 的影響

簡直太神啦 🙏


# Mask-Image 效果展示

+ =

但為了使用 Pure CSS Icon 額外引入 UnoCSS 與相關套件反而違反原本想盡量減少第三方套件的原意 🤔
何況專案已經有使用 WindiCSS

恩?那何不乾脆用 WindiCSS 提供的 plugins 功能
將專案中的 SVG 檔案參考 UnoCSS Pure CSS Icon 的方法生成個別的 class

// windi.config.ts
import { defineConfig } from 'windicss/helpers'
import plugin from 'windicss/plugin'

export default defineConfig({
  plugins: [
    plugin(({ addComponents }) => {
      // TODO: 
    }),
  ],
})

首先是從放置 SVG 的 /icons 資料夾中讀取全部 SVG File 的檔名

const svgIcons = fs
  .readdirSync(path.resolve(__dirname, '/icons'))
  .filter(fileName => fileName.includes('.svg'))
  .map(fileName => ({ name: fileName.replace('.svg', ''), url: `/icons/${fileName}` }))


分成

  • i-{name}?mask 用 mask-image,可以更換顏色的 Icon
    如果遇到不支援 mask-image 的則用 background
  • i-{name} 用 background-image,不能更換顏色的 Icon
svgIcons.forEach(({ className, url }) => {
  const className = `.i-${name}`

  addComponents({
    [`${className}\\?mask`]: {
      'display': 'inline-block',
      'background-image': `url("${url}")`,
      'background-repeat': 'no-repeat',
      'background-size': '100% 100%',
      'background-color': 'transparent',
      'width': '1em',
      'height': '1em',
      '@supports (-webkit-mask-image: url(#mask)) or (mask-image: url(#mask))': {
        'mask-size': '100% 100%',
        'mask-repeat': 'no-repeat',
        'mask-image': `url("${url}")`,
        'background': 'currentColor',
      },
    },
    [className]: {
      'display': 'inline-block',
      'background-image': `url("${url}")`,
      'background-repeat': 'no-repeat',
      'background-size': '100% 100%',
      'background-color': 'transparent',
      'height': '1em',
      'width': '1em',
    },
  })
})

p.s. url 的部分也可以轉成 base64 字串,將 SVG 內容本身一起打包進 css
並且可以在轉換時判斷該 SVG 是單色還是多色 SVG(可依據 fill 是不是 currentColor),單色的就可以使用 mask-image
這樣就不需要產生 i-{name}?maski-{name} 兩種 class


完成了以 SVG filename 作為命名的 class

  • 優點:

    • 不需要安裝額外的第三方套件 ( 除了 WindiCSS
    • 能使用 font-size, text color 調整大小、顏色
    • 不會受到其他 font css 屬性的影響
    • 不需要佔用偽類別
    • 可以個別新增/更換 svg 檔案
    • 因為是 class,使用 vuetify 這樣的 ui framework 時
      可利用 xxx-icon 這類的 props 去替換成自己的 icon,就不需要用 slot 另外寫了
  • 缺點:

    • mask-image 支援度問題,雖然目前主流瀏覽器的最新版都有支援

# But

本來覺得一切都很順利,可以直接用在專案上
卻發現一個 mask-image + firefox 的已知 render bug
用了 mask-image 的 DOM 有 rotate 時,會跑出預期外的框線 😭

2 年前就有人回報,但還沒修好
https://bugzilla.mozilla.org/show_bug.cgi?id=1671784 (opens new window)
https://bugzilla.mozilla.org/show_bug.cgi?id=1764056 (opens new window)

雖然 Firefox 的使用人數相較之下算少
但已知會有 Bug,就只能放棄直接用在專案上了

🫠

反正都研究了,留個紀錄

Last Updated: 2023/07/04 18:27:40