💨

React でパフォーマンスに配慮した SVG アイコンの表示方法

2022/05/23に公開

SVG アイコンをアニメーションさせたい

Web サイトのアイコンといえば、一昔前は PNG を CSS Sprite で表示する手法がメジャーでしたが、昨今の Web サイトでは高解像度環境やレスポンシブウェブ対応などもあり、ピクセルデータの PNG よりベクターデータの SVG が主流になっているかと思います。
ちょっと前まではカスタムフォントアイコンなどもありましたが、作成手順の面倒さや、多色表現ができなかったり、アクセシビリティの観点からも SVG に及ばず今は下火な印象です。

Web サイトには様々なアイコンがありますが、ローディング状態を示すアイコンはアニメーション表現することが前提になります。古くはアニメーション GIF、最近では APNG で実装していましたが、せっかくアイコンを SVG で統一しているのだから、アニメーションアイコンも SVG で実装したくなります。

ちょうど良いことに SVG はスタイルを埋め込むことができます。以下のように SVG の中に直接 CSS を書くことでアニメーション SVG を実装することができます。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="16" height="16">
  <style>
    @keyframes spinner {
      0% {
        opacity: 1;
      }
      100% {
        opacity: 0;
      }
    }
    .st0 {
      width: 1.3px;
      height: 4px;
      fill: #333;
      animation: spinner 1.1s linear infinite;
    }
    .st0:nth-child(1) {
      animation-delay: -1.1s;
    }
    .st0:nth-child(2) {
      animation-delay: -1s;
    }
    .st0:nth-child(3) {
      animation-delay: -.9s;
    }
    .st0:nth-child(4) {
      animation-delay: -.8s;
    }
    .st0:nth-child(5) {
      animation-delay: -.7s;
    }
    .st0:nth-child(6) {
      animation-delay: -.6s;
    }
    .st0:nth-child(7) {
      animation-delay: -.5s;
    }
    .st0:nth-child(8) {
      animation-delay: -.4s;
    }
    .st0:nth-child(9) {
      animation-delay: -.3s;
    }
    .st0:nth-child(10) {
      animation-delay: -.2s;
    }
    .st0:nth-child(11) {
      animation-delay: -.1s;
    }
  </style>
  <rect x="7.35" y="0" transform="rotate(0 8 8)" class="st0" />
  <rect x="7.35" y="0" transform="rotate(30 8 8)" class="st0" />
  <rect x="7.35" y="0" transform="rotate(60 8 8)" class="st0" />
  <rect x="7.35" y="0" transform="rotate(90 8 8)" class="st0" />
  <rect x="7.35" y="0" transform="rotate(120 8 8)" class="st0" />
  <rect x="7.35" y="0" transform="rotate(150 8 8)" class="st0" />
  <rect x="7.35" y="0" transform="rotate(180 8 8)" class="st0" />
  <rect x="7.35" y="0" transform="rotate(210 8 8)" class="st0" />
  <rect x="7.35" y="0" transform="rotate(240 8 8)" class="st0" />
  <rect x="7.35" y="0" transform="rotate(270 8 8)" class="st0" />
  <rect x="7.35" y="0" transform="rotate(300 8 8)" class="st0" />
  <rect x="7.35" y="0" transform="rotate(330 8 8)" class="st0" />
</svg>

この SVGで こんな感じのアニメーションアイコンをお手軽に表示できます。

通常のアイコンもアニメーションアイコンも、同じ単体の SVG ファイルとして扱えるので管理もしやすくなりました。

いざ適用

早速このローディングアイコンを Next.js を使っているサービスの画像の遅延読み込み箇所に適用したところ、明らかに体感できるレベルでページのパフォーマンスが劣化しました。

画像の数が多いページほど顕著になり、先程の SVG に埋め込んだアニメーション CSS がどうやらパフォーマンスに影響を与えてる様子。試しに SVG のアニメーション CSS を削除してみるとパフォーマンスが戻ってきたので、間違いなさそうです。

とはいえ、SVG を諦めて APNG にするのも悔しいので、どうにかして SVG でパフォーマンスを落とさずにアニメーションさせることができないか、いろいろと検証をしてみました。

SVG 表示手法の検証

SVG をページに表示する方法としてはいくつか方法があります。
今回は以下の 5 パターンで検証してみました。

各パターンの詳細は後述します。
また、比較として以下のタイプをそれぞれのパターンで検証します。

  • アニメーションなしの通常アイコン
  • CSS アニメーションアイコン
  • SMIL アニメーションアイコン

SMIL とは Synchronized Multimedia Integration Language の略で、マルチメディア記述用の言語です。SVG では <animate> 要素を記述することでアニメーションさせることができます。

CSS アニメーションにパフォーマンス劣化の原因があるなら、SMIL なら改善する可能性があるかもしれない、と考えたためです。

前述の SVG の CSS アニメーションを SMIL で書き直すと以下のようになります。

<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="64" height="64">
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(0 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" begin="-1.1s" />
  </rect>
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(30 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" begin="-1s" />
  </rect>
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(60 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" begin="-0.9s" />
  </rect>
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(90 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" begin="-0.8s" />
  </rect>
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(120 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" begin="-0.7s" />
  </rect>
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(150 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" begin="-0.6s" />
  </rect>
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(180 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" begin="-0.5s" />
  </rect>
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(210 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" begin="-0.4s" />
  </rect>
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(240 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" begin="-0.3s" />
  </rect>
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(270 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" begin="-0.2s" />
  </rect>
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(300 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" begin="-0.1s" />
  </rect>
  <rect x="7.35" y="0" width="1.3" height="4" fill="#333" transform="rotate(330 8 8)">
    <animate attributeName="opacity" from="0" to="1" dur="1.1s" repeatCount="indefinite" />
  </rect>
</svg>

SMIL の詳細は以下を参照ください。

https://developer.mozilla.org/ja/docs/Web/SVG/SVG_animation_with_SMIL

それぞれのパターンで、アイコンを 250 個表示した HTML のファイルサイズ、DOM 数、Lighthouse の計測結果を 3 回実行した平均の値の確認をしていきます。

インライン SVG

HTML 上に直接 SVG を記述する方式です。
React で SVG ファイルを扱うときは @svgr/webpack を使うことが多いかと思われます。

https://react-svgr.com/docs/webpack/

以下のように、SVG ファイルをインポートしてそのまま React コンポーネントとして扱えるのが便利です。

import Icon from './icon.svg'

const Component: FC = () => (
  <button>
    <Icon />
    ボタンラベル
   </button>
)

検証結果

Static CSS SMIL
HTML size 297 kB 457 kB 620 kB
DOM elements 3,256 3,506 6,256
First Contentful Paint 2.1 s 2.9 s 3.6 s
Speed Index 2.3 s 9.1 s 3.7 s
Time to Interactive 4.6 s 36.4 s 13.1 s
Total Blocking Time 247 ms 28,393 ms 6,017 ms
main thread work 0.9 s 47.8 s 10.2 s
Script Parsing & Compilation 14 ms 15 ms 12 ms
Script Evaluation 272 ms 2,753 ms 336 ms
Parse HTML & CSS 117 ms 114 ms 189 ms
Style & Layout 152 ms 13,388 ms 3,280 ms
Rendering 73 ms 4,928 ms 788 ms
Garbage Collection 14 ms 103 ms 13 ms
Other 205 ms 26,446 ms 5,549 ms

CSS アニメーションの SVG が段違いに悪い数値になっています。(ちなみに自分が最初に Next.js に適用したのはこの方式でした)
というのも、SVGR で React コンポーネントとして扱った場合、SVG の中に書かれたクラス名はハッシュ化されずにそのままグローバルに登録されるため、アイコンの数(つまり 250 回)同じクラスでスタイルを上書きしていることになるためです。
SMIL ではこのスタイルの重複が発生していないため、CSS アニメーションに比べて幾分かマシな値になっています。

メリット

  • スタイルなしの場合、パフォーマンスは実用に十分
  • インライン SVG は CSS でスタイルの上書きが可能

デメリット

  • HTML の DOM 数が増加し、ファイルサイズが肥大化する。DOM 数が増えると React のパフォーマンスも落ちる
  • スタイルのある SVG を使うとグローバルにスタイルが登録されてしまうため、クラス名衝突のリスクがある
  • しかも表示した SVG の回数分、スタイルがグローバルに定義される
  • スタイルあり SVG の登場回数が多いとレンダリングパフォーマンスが極端に悪くなる

SVG スプライト

いわゆる SVG fragment identifiers と呼ばれるものです。

https://caniuse.com/svg-fragment

以下のように複数のアイコンを <symbol> 要素で定義しておき、HTML の中に置いておきます。

<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" style="position: absolute; width: 0; height: 0" aria-hidden="true">
  <symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" id="icon1">
    <!-- 中略 -->
  </symbol>
  <symbol xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" id="icon2">
    <!-- 中略 -->
  </symbol>
</svg>

そして表示箇所で以下のように <use> を使ってID を指定して定義済みの SVG を表示します。

<svg viewBox="0 0 16 16" width="16" height="16">
  <use xlink:href="#icon1"></use>
</svg>

インライン SVG のように同じ SVG を何度も記述する必要がなく、SVG の定義を集約しつつ、呼び出し時にも最小限の参照でスッキリ書くことができます。

SVG スプライトの実装に当たっては svg-sprite-loader を使います。

https://github.com/JetBrains/svg-sprite-loader

以下のように SVG をインポートしつつ、集約定義や個別呼び出しが可能になります。

import sprite from 'svg-sprite-loader/runtime/sprite.build'
import icon1 from './icon1.svg'
import icon2 from './icon2.svg'

sprite.add(icon1)
sprite.add(icon2)

const App: Fc () => (
  <main>
    <div dangerouslySetInnerHTML={{ __html: sprite.stringify() }} />
  </main>
)

const Component: FC = () => (
  <button>
    <svg viewBox={icon1.viewBox} width="16" height="16">
      <use xlinkHref={`#${icon1.id}`} />
    </svg>
    ボタンラベル
   </button>
)

検証結果

Static CSS SMIL
HTML size 29 kB 32 kB 32 kB
DOM elements 521 533 560
First Contentful Paint 0.8 s 0.9 s 0.8 s
Speed Index 0.9 s 5 s 1.4 s
Time to Interactive 3.1 s 161.8 s 9.1 s
Total Blocking Time 100 ms 129,960 ms 3,833 ms
main thread work 0.8 s 162.4 s 9.7 s
Script Parsing & Compilation 15 ms 14 ms 14 ms
Script Evaluation 217 ms 638 ms 195 ms
Parse HTML & CSS 31 ms 38 ms 24 ms
Style & Layout 230 ms 90,283 ms 5,158 ms
Rendering 85 ms 22,454 ms 1,352 ms
Garbage Collection 15 ms 59 ms 11 ms
Other 208 ms 48,920 ms 2,973 ms

インライン SVG よりも重複記述がなくスマートに記述できるものの、CSS アニメーションをさせようとすると Lighthouse の計測もタイムアウトしてしまうくらい、圧倒的にパフォーマンスが悪い結果となりました。
SMIL アニメーションであれば、インライン SVG よりもパフォーマンス良いものの、特別良い数値というわけではないので、積極的に採用する理由にはならなさそうです。
全体的に Static や SMIL が前提なら、インラインSVG よりマシといった具合です。
あと検証してて気づきましたが、Safari で CSS も SMIL も動作しませんね…

メリット

  • インライン SVG と比べて HTML 上の重複情報が少なく、 DOM 数が少なく簡潔に書ける
  • スタイルなしの場合、パフォーマンスはインライン SVG 相当
  • クラス名がちゃんとスコープドされてグローバルを汚染しない
  • SVG 自体に CSS でスタイルの上書きが可能

デメリット

  • svg-sprite-loader の導入にヒトクセある
  • CSS アニメーションのレンダリングパフォーマンスが今回の手法の中でも最も悪い
  • Safari で CSS, SMIL ともに動作しない

img 要素

難しいことをせずに、ド直球で img 要素を使って SVG ファイルを呼び出します。

<img src="/icon.svg" width="16" height="16" alt="" decoding="async" />

decoding="async" をつけるかつけないかでパフォーマンスにだいぶ影響が出るので忘れずにつけるようにしましょう。

検証結果

Static CSS SMIL
HTML size 20.9 kB
(IMG 3 kB)
22.1 kB
(IMG 3.6 kB)
22.4 kB
(IMG 4.4 kB)
DOM elements 256 256 256
First Contentful Paint 0.8 s 0.8 s 0.8 s
Speed Index 0.8 s 0.8 s 0.8 s
Time to Interactive 2.5 s 2.2 s 2.2 s
Total Blocking Time 27 ms 27 ms 20 ms
main thread work 0.4 s 1.2 s 2.7 s
Script Parsing & Compilation 12 ms 12 ms 11 ms
Script Evaluation 159 ms 174 ms 168 ms
Parse HTML & CSS 77 ms 82 ms 82 ms
Style & Layout 27 ms 91 ms 73 ms
Rendering 35 ms 681 ms 2,089 ms
Garbage Collection - - -
Other 143 ms 220 ms 319 ms

インライン SVG や SVG スプライトと比較すると、Static, CSS, SMIL どれでも圧倒的にパフォーマンスが良い結果になりました。
この時点で、パフォーマンスの観点からは SVG はページに直接 DOM を記述しない形が望ましいことが分かります。
また、img 要素経由で呼び出すと SMIL よりも CSSアニメーションの方がレンダリングコストが低いという、インライン SVG や SVG スプライトとは逆の結果になっているのが興味深いところです。

メリット

  • アニメーションの有無に係わらず高いパフォーマンス
  • DOM 数が少なく、HTML サイズの肥大化も最小限に抑えられる
  • SVG のリクエストをキャッシュさせれば次回以降のパフォーマンス向上も期待できる

デメリット

  • SVG 自体に CSS でスタイルの上書きができない
  • アイコンの種類が多いほどリクエスト数が増える

Base64 エンコード埋め込み img 要素

内容としては img 要素の呼び出しと同じですが、外部の SVG ファイルを参照するのではなく、SVG を Base64 エンコードしたものを Data URI スキーム で読み込む形を取ります。

<img src="data:image/svg+xml;base64,…中略…" width="16" height="16" alt="" decoding="async" />

Data URI スキームはファイルのリクエスト数を減らし、表示速度を改善する手法としてよく取り上げられますが、こちらを適用してみて値がどう変化するかを検証してみることにしました。

Webpack の Resource assets を使うと以下のように簡単に Base64 埋め込みが実現できます。

webpack.config.js
module.exports = {
  // …中略…
  module: {
    rules: [
      {
         test: /\.svg/,
         type: 'asset/inline'
      }
    ]
  }
}
import icon from './icon.svg'

const Component: FC = () => (
  <button>
    <img src={icon} width="16" height="16" alt="" decoding="async" />
    ボタンラベル
   </button>
)

検証結果

Static CSS SMIL
HTML size 410 kB 624 kB 861 kB
DOM elements 256 256 256
First Contentful Paint 2.6 s 3.7 s 4.9 s
Speed Index 2.6 s 3.7 s 4.9 s
Time to Interactive 3.9 s 5 s 6.2 s
Total Blocking Time 13 ms 30 ms 20 ms
main thread work 0.4 s 1.6 s 4.1 s
Script Parsing & Compilation 14 ms 13 ms 13 ms
Script Evaluation 186 ms 204 ms 206 ms
Parse HTML & CSS 51 ms 62 ms 66 ms
Style & Layout 29 ms 102 ms 73 ms
Rendering 39 ms 993 ms 3,490 ms
Garbage Collection - - -
Other 106 ms 206 ms 311 ms

数値の傾向はほとんど img 要素 と変わりませんが、パフォーマンスとしてはより悪くなっています。

  • Base64 エンコードをすると元のコードよりもデータ量が増える
  • アイコンの登場回数が多ければ多いほど HTML のデータ量が肥大化する
  • Base64 デコードのオーバーヘッド

あたりが原因と思われます。
img 要素よりもファイルのリクエスト数を抑えられるのがメリットですが、HTTP2 の時代ではあまりメリットになり得ないようです。

メリット

  • DOM 数が少なく、HTML サイズの肥大化も最小限に抑えられる
  • 外部ファイル依存がなく、リクエスト数を抑えられる

デメリット

  • Base64 エンコードにより SVG のファイルサイズが増加する
  • 埋め込みにより HTML のファイルサイズが肥大化する
  • SVG 自体に CSS でスタイルの上書きができない

Base64 エンコード埋め込み CSS

Base64 エンコードを HTML に埋め込むと肥大化を招く結果となったいたことを考慮して、
Base64 の埋め込み先を CSS に変更してみました。

以下のように SVG ファイルを CSS から参照します。

index.module.css
.icon {
  content: url("./icon.svg");
  display: inline-block;
  width: 32px;
  height: 32px;
}

Base64 エンコード埋め込み img 要素 にあるWebpack の Resource assets 設定が有効であれば、CSS の SVG ファイル参照も Base64 エンコードの対象になります。

そしてこの CSS ファイルをコンポーネント側で参照します。

import style from './index.module.css'

const Component: FC = () => (
  <button>
    <span role="img" aria-label="" className={style.icon} />
    ボタンラベル
   </button>
)

ここでは画像の振る舞いを期待するため、role="img" を付与し、alt 属性相当に aria-label 属性を付与しています。

検証結果

Static CSS SMIL
HTML size 21.3 kB
(CSS 2 kB)
21.3 kB
(CSS 2.9 kB)
21.3 kB
(CSS 3.8 kB)
DOM elements 256 256 256
First Contentful Paint 0.9 s 0.9 s 0.9 s
Speed Index 0.9 s 0.9 s 0.9 s
Time to Interactive 2.1 s 2.8 s 2.2 s
Total Blocking Time 10 ms 30 ms 10 ms
main thread work 0.3 s 1.2 s 2.7 s
Script Parsing & Compilation 12 ms 15 ms 14 ms
Script Evaluation 120 ms 142 ms 133 ms
Parse HTML & CSS 10 ms 13 ms 12 ms
Style & Layout 41 ms 109 ms 105 ms
Rendering 28 ms 732 ms 2,136 ms
Garbage Collection - - -
Other 90 ms 175 ms 288 ms

SVG の定義を CSS の一箇所に集約して HTML の DOM 数やファイルサイズの肥大化を抑制しつつ img 要素と同等のパフォーマンスを維持する、という双方のいいとこ取りをした形になりました。
Static, CSS, SMIL どのタイプでも img 要素と同等か、それよりも早い値が確認できています。

メリット

  • img 要素と同等かより高いパフォーマンス
  • DOM 数が少なく、HTML サイズの肥大化も最小限に抑えられる
  • Base64 の定義を CSS の一度に集約できるため、HTML の肥大化を回避できる
  • Parse HTML & CSS の速度がダントツ
  • CSS をキャッシュさせれば次回以降のパフォーマンス向上も期待できる

デメリット

  • SVG 自体に CSS でスタイルの上書きができない
  • CSS の画像要素はアクセシビリティツリーには登録されないため、role="img"aria-label などでカバーする必要がある

まとめ

当初は何も考えずにデファクトスタンダードに近い SVGR を使って SVG を表示していましたが、SVG の内容によってパフォーマンスに大きな影響が出ることが分かりました。

検証の結果、パフォーマンスに配慮した SVG アイコンの表示方法は

  1. SVG アニメーションは CSS で記述
  2. SVG ファイルを Base64 エンコードして CSS に埋め込む
  3. HTML 側は 埋め込んだ CSS のクラスを指定して表示

となりました。SVG にスタイルの記述がなかったとしてもこの方法が最速になるので、Web サイトで SVG アイコンを表示する場合はこのルールに統一するのが良さそうです。

また、Web サイトの中で SVG に動的にスタイルを当てる要件が優先される場合は

  1. SVG アニメーションは SMIL で記述
  2. SVG スプライトで表示

が選択肢になりますが、Safari で動作しなかったり、CSS 埋め込みのパフォーマンスと比べると雲泥の差があるので、スタイル変更分の SVG を事前に作成しておいて、CSS 埋め込みでクラスを切り替えて表現したほうが良さそうです。

今回の検証を行ったコードのサンプルは以下になります。

https://github.com/okamoai/react-svg-benchmarks
https://okamoai.github.io/react-svg-benchmarks/

Discussion