React でパフォーマンスに配慮した SVG アイコンの表示方法
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 の詳細は以下を参照ください。
それぞれのパターンで、アイコンを 250 個表示した HTML のファイルサイズ、DOM 数、Lighthouse の計測結果を 3 回実行した平均の値の確認をしていきます。
インライン SVG
HTML 上に直接 SVG を記述する方式です。
React で SVG ファイルを扱うときは @svgr/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
と呼ばれるものです。
以下のように複数のアイコンを <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 を使います。
以下のように 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 埋め込みが実現できます。
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 から参照します。
.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 アイコンの表示方法は
- SVG アニメーションは CSS で記述
- SVG ファイルを Base64 エンコードして CSS に埋め込む
- HTML 側は 埋め込んだ CSS のクラスを指定して表示
となりました。SVG にスタイルの記述がなかったとしてもこの方法が最速になるので、Web サイトで SVG アイコンを表示する場合はこのルールに統一するのが良さそうです。
また、Web サイトの中で SVG に動的にスタイルを当てる要件が優先される場合は
- SVG アニメーションは SMIL で記述
- SVG スプライトで表示
が選択肢になりますが、Safari で動作しなかったり、CSS 埋め込みのパフォーマンスと比べると雲泥の差があるので、スタイル変更分の SVG を事前に作成しておいて、CSS 埋め込みでクラスを切り替えて表現したほうが良さそうです。
今回の検証を行ったコードのサンプルは以下になります。
Discussion