Sass製SVG爆速表示ライブラリのご紹介

こんにちは。ニコニコ生放送生放送フロントエンドシステムセクションのmisuken(GitHub/Twitter)です。

今回は10月に公開したsmart-svgreact-sass-inlinesvgという2つのSVG表示ライブラリに関して、作成に至った経緯、ライブラリの特徴、工夫したポイント、パフォーマンス等の話をしていきたいと思います。

ライブラリを作成した経緯

これまでのSVGの表示方法

ニコニコ生放送ではこれまで、SVGを表示する際にはreact-inlinesvgというライブラリを使用していました。

react-inlinesvg<img>のようにsrcにURLを渡して表示するタイプのライブラリです。 SVG要素がDOMに展開されるため、CSSからスタイルを適用できます。

react-inlinesvgのREADMEに書いてある使用例。

import React from 'react';
import SVG from 'react-inlinesvg';

export default function App() {
  return (
    <main>
      <SVG
        src="https://cdn.svgporn.com/logos/react.svg"
        width={128}
        height="auto"
        title="React"
      />
    </main>
  );
}

実際の実装ではSVG名で簡単に使えるように、react-inlinesvgをラップしたコンポーネントを生成できるようにしていました。

import { createSvgComponent } from "@nicolive/react-svg";

const CheckIcon = createSvgComponent("CheckIcon");
// <CheckIcon />

しかし、ニコニコ生放送では開発が進むに連れて複雑な表示要件が多くなり、既存の作り方ではSVG周りで面倒に感じる場面が多くなってきました。

  • 状態によって数種類のアイコンが切り替わる場合
  • 深い位置のアイコンの部分だけ違うコンポーネントをいくつも用意したい場合
  • ホバーなど、CSSのセレクタの条件でアイコンを変更したい場合
  • SVGやCSSの読み込み完了時のレイアウトシフトを防ぎたい場合

また、SVGは視覚的な表現を実現するものということもあり、視覚のためにReactのコードやロジックを増やすのも避けたいという思いもありました。

そこで、CSSからSVGのスタイルだけでなく、表示するSVG自体を指定できれば、視覚のためにReactのコードやロジックを書く必要も無くなり、実装やレビューのコストを抑えれられると考えるようになりました。

装飾や視覚的な責務をCSSに閉じることができれば、SVGに関連した変更によるReact側のデグレも防げることを意味します。(ただし、CSSで書いた分はVRTによる担保が重要です)

CSSでSVGを表示する方法

CSSでSVGを表示する方法で最も知られているのはbackground-image: url()ですが、この方法ではSVGの色を指定できない問題があります。

この問題を回避するアプローチは2つ。

  1. animationstartを使う(animationstartイベントを使ってCSSからHTML要素のsvgを切り替えられるようにした話)
  2. mask-image: url()を使う(ただしIE11は非対応)

最初、1の方法で実現したところ、他チームの開発者から2の方法を教えてもらったため、パフォーマンス等の検証を行うことにしました。

その結果、それぞれに優位性があることがわかったため、2つをライブラリとして公開することにしました。

ライブラリの特徴

10月に公開したsmart-svg(mask-image方式)、react-sass-inlinesvg(animationstart方式)と以前から使用していたreact-inlinesvgの特徴は以下のようになります。

Function smart-svg react-sass-inlinesvg react-inlinesvg
SassからのSVG指定
JSXからのSVG指定
SVGの子要素のスタイル制御
SVGの色指定
丸や四角の形状のサポート
疑似要素でのSVG表示
IE11のサポート
React以外での利用
パフォーマンス A+ A C

サービスの要求にもよりますが、ニコニコ生放送の場合はSVGの子要素のスタイルを制御したい場面がそこまで多くないので、サイトの9割はsmart-svgで十分カバーできます。

smart-svgは疑似要素でもSVGを表示できるため、条件が合えば既存のReactコードには触れずに作業が終わる点も快適です。

一部では、アニメーション等でSVGの子要素のスタイルを制御したい場面があるので、そういった場所だけreact-sass-inlinesvgを使用して全体を網羅できます。

smart-svgの使用感

smart-svgはSassで完結しており、mixinを呼び出すだけでスマートにSVGを表示できます。 Sassを入れればReactに限らずあらゆる場面で使用でき、パフォーマンスが非常に良く、スムーズに表示されます。

@use "smart-svg";

// 元の色のSVG
.icon {
    @include smart-svg.show("https://cdn.svgporn.com/logos/react.svg", 1em);
}

// 任意の色のSVG
.color-icon {
    @include smart-svg.show("https://cdn.svgporn.com/logos/react.svg", 1em, red);
}

// 疑似要素へのSVG
.button {
    &::before {
        @include smart-svg.show-with-pseudo("https://cdn.svgporn.com/logos/react.svg", 1em, blue);
    }
}

丸や四角の形状をサポートしているので、デザイナーが手薄なチームや個人開発ではより役立ちます。

@use "smart-svg";

// 丸い形状のSVG
.circle-icon {
    @include smart-svg.show-circle("https://cdn.svgporn.com/logos/react.svg", 1em, silver);
}

// 四角い形状のSVG
.square-icon {
    @include smart-svg.show-square("https://cdn.svgporn.com/logos/react.svg", 1em, silver);
}

// 形状の追加は疑似要素に対応できないので、<span>や<button>要素に使用すると良いでしょう

smart-svgはCSSが適用前のSVGが表示されるタイミングは存在しないため、レイアウトシフトの心配もありません。

react-sass-inlinesvgの使用感

react-sass-inlinesvgはReactとSassを連携して使用するため、最初に簡単なセットアップを済ませる必要がありますが、 セットアップでSVGのパスと名前をマッピングすることにより以下のメリットを得られます。(マッピング情報を自動生成するスクリプトを書くと、より便利になります)

  • 利用時にコードヒントを得られる
  • typo等の間違いはビルド時に気付ける
  • Storybookで利用可能なSVGの一覧を見られる

smart-svgと同様にパフォーマンスも良いのでサクサク表示されます。

使い方は簡単で、SassからもJSXからも使用したいSVGを指定できます。

import React from "react";
import { SVG } from "path/to/atoms/svg/Svg";
import classNames from "./example.module.scss";

export const Component = (
  <div>
    <SVG className={classNames.svg} />
    <SVG className={classNames.svg} defaultName="Sass" />
    <SVG className={classNames.svg} />
  </div>
);

Sass側のインタフェースはsmart-svgとほぼ同じですが、URLではなくマッピングしたSVG名で指定します。

@use "path/to/atoms/svg";

.svg {
    // 最後の要素はReactのアイコン
    &:first-child {
        @include svg.show(svg.$React, 1em) {
            // 必要であれば、ここにローディング中のスタイルを書けます
            @include svg.placeholder;
        }
        // ホバーのときにアイコンの色を変更
        &:hover {
            > * {
              fill: lime;
            }
        }
    }
    // "Sass"のアイコンを表示中の要素にサイズだけ指定
    &[data-svg-name="Sass"] {
        @include svg.show(null, 2em);
    }
    // 最後の要素はSVGのアイコン
    &:last-child {
        @include svg.show(svg.$Svg, 3em);
        // ホバーのときにアイコンを変更
        &:hover {
            @include svg.show(svg.$Sass);
        }
    }
}

react-sass-inlinesvgSassからSVGを不可視化する機能も充実しており、条件によってSVGを使用しない場合に不可視化したり、要素自体を消すことも可能です。

工夫したポイント

react-sass-inlinesvgを完成させるためには様々な問題を乗り越える必要がありました。

問題を乗り越えるために工夫したポイントを紹介します。

タスクの分散

以前zennで書いたanimationstartイベントを使ってCSSからHTML要素のsvgを切り替えられるようにした話の通り、CSS側からJSに処理を連携させるにはanimationstartを使うアプローチがあります。

以前の記事の段階で、一応目的自体は達成できていたのですが、Storybookでニコニコ生放送で使用しているSVGを一覧で表示したところ、react-inlinesvgに対して大きなパフォーマンスの悪化が見られました。

例えばページに100個のSVGを表示する場合、一般的なReactの作法に従って実装すると100個のanimationstartイベントが1タスクで発生するため、長時間タスクとなってしまいます。

この問題は、幾つかのプロセスを分割することで改善しました。

  1. 初期描画時はanimationstartが発火しない状態にしておく
  2. コンポーネントを一定数ごとにanimationstart受け入れ可能状態に切り替える
  3. 1タスク内で同時に発生するanimationstartイベントを一旦キューに入れる
  4. 後からまとめて処理する際に重複する処理は1回だけ行うようにする

後述する改善も施された後の結果を見ると、animationstartをusレベルの短時間で大量に捌くブロックが複数に分散されていることがわかります。

非同期のタイミングで1タスク内で捌ける程度の分量をまとめて処理し、それを何度か実行するようスケジューリングすることで、長時間タスクが発生することを防いでいます。

DOMイベントの利用

Reactのイベントハンドラを使用すると、Reactが内部でラップしている関係上それだけでオーバーヘッドが掛かってしまいます。

react-sass-inlinesvgでは、animationstartが同時に発生するイベント数が多くなる仕組み上、このオーバーヘッドを無視することはできません。

react-sass-inlinesvgでは、Reactが制御するような厳格なタイミングの管理も必要ないため、DOMのイベントを直接監視することで処理コストを削減しました。

DOMの反映

react-inlinesvgでは動的に取得したSVG要素を、ReactElementに変換する処理を行い、SVGをReactとして描画するようになっていました。 しかし、パフォーマンスを分析する中でSVGのように大量に表示される要素の場合、Reactの処理のオーバーヘッドがscriptingの部分で嵩んでいることがわかりました。

たしかにReactとして処理したほうが正攻法ですし、Propsで渡された属性値と、動的に取得したSVG要素の属性値をマージする必要もあるので適切ではあるのですが、ここはパフォーマンスを大きく改善できるポイントだと感じました。

react-sass-inlinesvgでは、動的なSVGの内部はinnerHTMLで更新し、SVG要素自体の属性値はDOM処理でマージします。 これにより、Reactのライフサイクルのオーバーヘッドを回避し、scriptingの処理コストを削減しています。

通信とキャッシュの最適化

react-inlinesvgでは1コンポーネントごとに個別にfetchを呼んでいたのですが、react-sass-inlinesvgのチューニングを行う中で、1つずつfetchを呼ぶよりもp-settleでまとめて実行したほうがパフォーマンスが良くなる傾向があることに気付きました。

さらに、キャッシュの最適化においては、react-inlinesvgに対して大きな改善ポイントが見つかりました。 react-inlinesvgにも同じSVGを何度も通信して取得しないよう、デフォルトでキャッシュ機構が有効になっているのですが、それが十分機能していない可能性があるということです。

WebサイトでSVGを使用する際、SVGのユニーク率は低く、同じSVGを複数回使用する傾向があります。 例えば、10種類のSVGを10個ずつ、合計100個表示する場合、理想的にはfetchは10回で、残りの90要素はキャッシュを使って表示するべきです。

しかし、Reactのライフサイクルでは、初期描画後の副作用処理(componentDidMountやuseEffect)で100個のコンポーネントがfetchしようとした際、まだキャッシュは存在しないので全てfetchの処理に回ってしまいます。

同じURLのfetchが重なった場合、ブラウザ側の実装によっては多少の効率化が行われるかもしれませんが、fetch自体の処理は100回走ってしまうので、明らかなコスト増になります。

もし、10種類のSVGを先に描画し、後から残りの90個のSVGを表示するのであれば、現状のreact-inlinesvgでもキャッシュが適用されそうですが、現実には初期描画でまとめて表示されるシーンのほうが多いでしょう。

react-sass-inlinesvgはこの問題を改善するため、個々のコンポーネント間で同一のSVGが使用される場合、それらが同時に描画されたとしても最低限の回数だけ通信し、結果を全てのコンポーネントでシェアするようにしました。 これにより大幅なパフォーマンスの改善が見られました。

animationstart

animationstartイベントはブラウザによってはアニメーション時間が短いとイベントが発生しないことに気付きました。 しかし、長くするとそれだけ無駄なanimationのコストが発生してしまいます。

react-sass-inlinesvgの使用方法の場合、SVGごとにanimationが発生するので以下のようになってしまいます。

また、CSSのanimationはアニメーションが終了するときにanimationendイベントも発生するため、アニメーション終了のイベントコールバック分のscriptingも細かく大量に発生しています。

この問題はanimation-play-statepausedによって解決しました。 これを指定すると、animationstartは発生するものの、瞬時にアニメーションが停止するため、無駄なanimationのコストとanimationendの発生を防ぐことができます。

疑似要素に対するanimationの制約

react-sass-inlinesvgでは、任意に指定されるanimationとの競合を避けるため、疑似要素に対してanimationを設定し、animationstartイベントを発生させています。しかし、Firefoxではanimation-nameだけでなく、contentも一緒に変更しないとイベントが発生しない罠もありました。

結果、animationstartを発生させる部分の実装は以下のようになっています。

  &[data-svg-status]::before {
    // NOTE: contentも変更しないとFirefoxではanimationstartが発生しません
    content: "#{$svg-name}";
    // アニメーションはpaused指定されているので、長い時間に指定しても問題ありません。
    // 逆に短すぎると処理に負荷が掛かっているときにアニメーションがスキップされるので注意。
    animation: svg_#{$svg-name} 1s paused !important;
  }

丸や四角など形状に対応

これはsmart-svg側で導入されたものをreact-sass-inlinesvgでも使えるように工夫したポイントです。

当初、smart-svgの実態はただのCSSなので、ライブラリ化は不必要と考えていました。

SVGに色を付ける場合。

.svg {
    background-color: #888;
    mask-image: url('');
    mask-repeat: no-repeat;
    mask-position: center;
}

SVG自体の色を使う場合。

.svg {
    background-image: url('');
    background-repeat: no-repeat;
    background-position: center;
}

しかし、以下に挙げるように様々な利用シーンを想定するとSassのmixin1つで書けたほうが便利なため、インタフェースを洗練したライブラリに仕上げました。

  • 表示パターンによって複数のプロパティを設定するのが面倒
  • background-colorfill相当になる点がわかりにくい
  • 大抵はSVGのサイズを一緒に設定する且つwidthとheightは同じでボイラープレートが多い
  • 疑似要素に適用する場合はさらに一緒に使用するプロパティが増える
  • 丸や四角など形状も合わせて表示したいシーンがある

smart-svgreact-sass-inlinesvgは極力同じ機能が使えたほうが便利でわかりやすいので、一部制限はあるもののreact-sass-inlinesvgでも同じインタフェースで丸や四角の形状を利用できるよう対応させました。

パフォーマンス

ここからはパフォーマンス計測した結果を紹介します。

比較

表の対象の列は以下の意味になります。

対象 説明
RI react-inlinesvg
RSI react-sass-inlinesvgでSVGをSassから指定
RSI(def) react-sass-inlinesvgでSVGをJSXから指定
smart smart-svg

計測に使用するページは、同じSVGを複数表示した場合や、CPU性能の違いによる影響を見るため、以下の4つの条件を使用しました。

  • ニコニコ生放送で使用しているSVG114種を1セット表示
  • 上記をCPU slowdown x4で表示
  • ニコニコ生放送で使用しているSVG114種を5セット表示
  • 上記をCPU slowdown x4で表示

その他の条件は以下の通りです。

  • Storybookのwebpack.configをmode: productionでビルド
  • Storybookのフレーム内のページを使用
  • SVG表示以外のオーバーヘッドは除外
  • ネットワークのキャッシュは無効化
  • 計測マシンスペック
    • OS MacBook Pro (13-inch, 2019, Two Thunderbolt 3 ports)
    • CPU 1.4 GHz Intel Core i5
    • メモリ 16 GB 2133 MHz LPDDR3

114種1セットを表示

react-inlinesvgに対して、それぞれ50%程度良いパフォーマンスであることがわかります。

114種1セットを表示(CPU slowdown x4)

CPU性能が低いときはreact-sass-inlinesvgの効率が良く、特にJSX側でSVG名を指定する使い方で、その傾向が顕著になっています。

114種5セットを表示

重複したSVGが多くなるとsmart-svgの優位性が際立ちます。

114種5セットを表示(CPU slowdown x4)

CPU性能が低く、重複したSVGが多い場合、react-inlinesvgに対してsmart-svgはわずか14%のコストで完了しています。

詳細な分析その1

次に、114種類のSVGを5セット表示した際のパフォーマンス計測結果を見てみましょう。

CPU slowdown x4 の条件下で実行した結果です。

react-inlinesvg

1コンポーネントずつ愚直に処理を行っているため、コストが嵩み、長時間タスクも大量に発生していることがわかります。 計測しきれずに8.5秒で止まっていますが、実際はまだ処理が終わっているわけではありません。

また、最も簡単な使い方で表示しており、読み込み中に表示するコンポーネントを設定していないため、レイアウトシフトもたくさん発生しています。

読み込み中に表示するコンポーネントを追加するなど、レイアウトシフトを防ぐ場合はさらに処理コストが増加します。

react-sass-inlinesvg

初期描画が完了したあと、タスク分散されたanimationstartが順次行われていることがわかります。 CPU slowdown x4 にも関わらず、onload後の処理では長時間タスクも発生しておらず、効率的に処理できていることがわかります。

smart-svg

Sassで完結しているため、オーバーヘッドを除くとほぼscriptingが発生しないことがわかります。

詳細な分析その2

最後に、1種類のSVGを570個表示した際のパフォーマンス計測結果を見てみましょう。

CPU slowdown x4 の条件下で実行した結果です。

react-inlinesvg

ほとんどのSVGを1タスク内で処理しようとするためか、5秒ほどの長時間タスクになってしまっています。

react-sass-inlinesvg

1回通信したあと、全てパース済みのキャッシュからDOMに反映するだけなので、ほとんどコストが発生していません。

smart-svg

ブラウザ側の最適化が効くためか、オーバーヘッド以外のコストはほとんど発生しません。

まとめ

今回はreact-sass-inlinesvgsmart-svgという2つのSVG関連のライブラリを作成に至った経緯、工夫したポイント、パフォーマンスの詳細などについて話してきました。

ニコニコ生放送では新規に作成するコンポーネントや、リファクタするタイミングで徐々に乗り換えを進めています。

SVGは様々な表示方法がありながら、アプローチの仕方によって使い勝手やパフォーマンスが大きく変わります。

SVGを多く使用するサイトや、表示要件の複雑なサイトでは、より良いSVG表示方法を模索して実装効率やパフォーマンスを改善してみてはいかがでしょうか。


株式会社ドワンゴでは、教育事業、ニコニコ事業、モバイル事業など様々なサービス、コンテンツを一緒につくるメンバーを募集しています。 ドワンゴに興味がある。または応募しようか迷っている方がいれば、気軽に応募してみてください。