ニコニコ生放送のフロントエンドを改善するためにやってきたこと

この記事は ドワンゴ Advent Calendar 2021 の12/21の記事です。

さて、早いもので私がドワンゴに再入社してから2年もの月日が流れました。この2年間で様々な仕事を担当してきましたが、このエントリではニコニコ生放送のフロントエンドを改善するためにやってきたことの内、汎用性のある内容に焦点を絞って書いていきます。
2021年にわざわざ記事にするレベルの内容ではないものも含まれるかと思いますが改善の記録としてご笑覧いただければと思います。なお、各施策の時系列は整理していないため施策の前提に矛盾があるかもしれません

Origin-Agent-Cluster ヘッダーによるページのフレームレート向上

ニコニコ生放送のトップページには埋込み型プレーヤーを設置されています。このプレーヤーは hls を処理している影響もあって非常に負荷が高く、トップページ自体のパフォーマンスにも大きく影響していました。 このパフォーマンスを改善するには、プレーヤー自体が重たい問題と、トップページとプレーヤーとの間でリソースが共有されていることによって相互に悪影響を与えている問題の2つを解消する必要があります。 トップページとプレーヤーとの間でリソースが共有されている問題の解決策として Origin-Agent-Cluster ヘッダーを採用しました。 web.dev 詳しい説明は記事を見ていただくとして、簡単に説明すると Origin-Agent-Cluster ヘッダーを埋め込み元のレスポンスヘッダーに付与することで、 same-site cross-origin なページにおいてブラウザは埋め込み元と埋め込み先のページそれぞれで独立したリソースを確保することが可能になります。この機能は現時点では Chrome/Edge 88 以降でのみサポートされていますが、firefox も好反応を示しているため対応ブラウザは増加していく可能性が高いです。

ニコニコ生放送のトップページでは導入前のフレームレートが30fps程度だったものが60fps近くまで向上しました。ただし当然ながら Origin-Agent-Cluster ヘッダーを追加したところで処理の総量が減るわけではないので、低リソースな端末に与えた影響は軽微なものでした。 Origin-Agent-Cluster ヘッダーは一つのテクニックとして有用なものではありますが、やはり地道にアプリケーションの処理の軽量化を行っていく必要があるでしょう。

ブラウザの初期レンダリング時に ReactDOM.hydrate を使う

ニコニコ生放送のアプリケーションの開発開始時点では hydrate の仕組みは提供されておらず、renderToString で SSR されたものをブラウザで ReactDOM.render する実装になっていました。初回レンダリングを ReactDOM.hydrate に置き換えることで。 hydrate に対応するにあたって障壁になったのは、SSR で生成される html とブラウザの初回レンダリングで生成される html が同一である必要がある点です。ニコニコ生放送のアプリケーションにおいては TTFB を高速化するために renderToString の結果をキャッシュして使いまわし、CSR 時にユーザーのログイン状態などを反映するアーキテクチャになっていました。このアーキテクチャのままでは hydrate をした際に DOM 構造が意図しない形になってしまうため、 SSRレンダリング結果が異なる要素においては SSR 時点ではレンダリングを行わず、ブラウザの初回レンダリング終了後に state を更新して再描画を行うアーキテクチャに変更しました。なお、SSR 時点でレンダリングされる必要のないコンテンツについては IntersectionObserver 等を利用して、データの fetch およびレンダリングを遅延させる実装も順次進めています。 現代では Next.js のような便利な存在があるため、新規開発においてこういったアーキテクチャを自分の手で開発する必要はないでしょう。

content-visibility で遅延レンダリング

content-visibilitycss のプロパティの一つで、指定されたエリアがビューポートに入るまでレンダリングを遅延させることが出来ます。 content-visibility が登場する前にレンダリングを遅延させるには、IntersectionObserver などを用いて css の display を切り替えるか、 動的にコンテンツを追加するなどの作業が必要でした。そしてこういったテクニックを使う場合、ページをスクロールするなどしてコンテンツを表示しない限り、ブラウザの検索機能などを使った場合に検索に引っ掛からないといった問題が存在しています。 content-visibility で auto を指定した場合は html 上にコンテンツは存在するがブラウザ上に描画されていない扱いであるため、ブラウザの検索機能が正しく機能します。こういった機能的な利点も備えつつ、js で処理を書く必要がなくなるためファイルサイズを減らすこともできるメリットもあるため、非常に有用な css プロパティと言えそうです。デメリットとしてはchromium 系のブラウザでしか利用できない点 *1です。非対応ブラウザでは content-visibility プロパティは単に無視されるため、遅延レンダリングが行えないことを許容できる場合はそのまま利用することが出来ます。

tsconfig の設定変更でバンドルサイズの削減

ニコニコ生放送では内製のライブラリを複数組み合わせてアプリケーションを構築しています。それぞれのライブラリは TypeScript で作成されており、以下のような tsconfig でビルドを行っていました。

{
  "module": "commonjs",
  "target": "es5",
  // 以下省略
}

module が CommonJS になっていることでバンドル時に tree shaking が上手く効かない*2問題が発生することや、最終的にブラウザに読み込ませる成果物を es5 にするかどうかはアプリケーションのレイヤーで決定する方が望ましいといった観点から、ブラウザ向けのビルドでは以下の設定でビルドするように変更しました。

{
  "module": "esnext",
  "target": "esnext",
  // 以下省略
}

これによりアプリケーション側は tree shaking の恩恵を受ける事ができるようになり、かつ es5 以降の言語レベルで成果物を生成できるようになったことで大幅なバンドルサイズの削減に成功しました。 なお、 webpack5 で CommonJS の tree shaking がサポートされたので tree shaking を目的に CommonJS から ES Modules に乗り換える必要性は無くなっているのかもしれません。しかしながら2021年において CommonJS をあえて選択し続ける必要はないでしょう。

バンドラーが CommonJS 形式のファイル経由で ES Modules 形式のファイルを読み込むことに起因する tree shaking 不備

前述の「tsconfig の設定変更でバンドルサイズの削減」の施策にてライブラリを ES Modules 形式で出力するようにしましたが、サーバーサイド向けに CommonJS のビルドも同時に出力するようにしていました。*3 ここで問題になってくるのはライブラリのディレクトリを掘って import { Hoge } from "my-library/foo/bar" のように import を行う場合です。 import { Hoge } from "my-library" のように import を行っていれば、webpack のようなバンドラーは ES Modules 形式のものが提供されていればそちらを優先して使用してくれますが、ディレクトリを掘って import するということは、CommonJS 向けのファイルか ES Modules 向けのファイルかを強制することになってしまいます*4。 ライブラリの依存が A->B->C の順である時に、A が ディレクトリを掘って CommonJS 形式の B を import してしまった場合、バンドラーは A(ES Modules) -> B(CommonJS) -> C(ES Modules) の形でバンドルを生成します。この場合 CommonJS 経由で読み込まれた C は適切に tree shaking されませんでした。原因さえ分かってしまえば解決は簡単で、ディレクトリを掘った import を廃止してパッケージ直下から import を行えば OK でした。

critical css で初期描画を高速化

ブラウザは css ファイルがダウンロード完了するまではレンダリングをブロックする仕組みとなっており、css ファイルが巨大であればある程ページの初期描画が遅れてしまいます。critical css のテクニックは、「ファーストビューに表示される要素のみに限定した css(= critical css)」 と「それ以外の css」で css を分割し、ファーストビューに入らない要素は非同期で読み込むことで初期描画を高速化させることが出来ます。 critical css は手動で作成することも可能ですが、ファーストビューに入るコンテンツを変更するたびに更新が必要であるため保守が大変です。保守コストを軽減するには www.npmjs.com のような critical css 生成ライブラリを使用して自動生成する形を取るのが望ましいでしょう。 critical に html と css を与えると自動で critical css を生成してくれるので、基本的には生成されたファイルをブラウザに配信すれば OK です。しかし、障害時のお知らせヘッダーといった稀にファーストビューに入ってくるコンテンツがある場合は個別の調整が必要となるので注意が必要です。そういった要素がある場合は、その要素が存在しない html を使って critical css を生成した上で、forceInclude オプションを使って該当要素の css を追加する形を取ると良いでしょう。
また、前述の通りcritical で critical css を生成するには html が必要となります。静的に html を作成している場合はそのまま html ファイルを渡せば良いですが、ニコニコ生放送のように SSR で動的にコンテンツを作成するアプリケーションでは critical に渡す html がありません。つまりcritical に渡す html も生成する必要が生まれます。ニコニコ生放送では各ページを Storybook でカタログ化していたため、そのカタログを生成するための story ファイルに書かれた props を流用して ReactDOM.renderToStaticMarkup を使って作成しています。story ファイルを流用することで、Storybook のカタログを保守するだけで critical css の保守もできる一石二鳥な形を実現できています。

IE11 などのレガシーブラウザとモダンブラウザでバンドルを分ける

ニコニコ生放送では IE11 といったレガシーブラウザでも動作するように babel を用いて構文の変換や polyfill の挿入を行って動作を担保しています。当然 babel によって変換されたされた js ファイルは polyfill 等が入る分サイズが大きくなってしまう訳ですが、ニコニコ生放送ではモダンブラウザ上でも IE11 向けと同一の js ファイルを利用していました。モダンブラウザからすれば、ブラウザ上にネイティブで実装されている機能も polyfill されたコードが動作してしまう為処理速度に悪影響を及ぼす上、polyfill がある分ファイルサイズが増加してしまいます。 この問題を解決するために IE11 向けのバンドルとモダンブラウザ向けのバンドルを分けることにしました。*5
生成した js は以下のように script タグの属性を使って読み分けをさせています。

<script type="module" src="app.js" />
<script noModule src="app.legacy.js" defer />

type="module" がついた script は ES Modules に対応したブラウザで読み込まれますが、レガシーブラウザでは対応していない type になるため無視されます。反対に、noModule 属性がついた script は ES Modules に対応したブラウザでは読み込まれず、レガシーブラウザでは noModule 属性の意味を解釈できないため普通に読み込みが行われます。
この手法を用いれば ES Modules に対応したブラウザとそうでないブラウザで簡単に読み込ませるスクリプトを変えられるためお手軽です。反面、 分岐点がES Modules 対応の有無であるため、ES Modules に対応したブラウザの中から更に細分化して出し分けを行うことができないことになります。
例えば MS Edge15 といった ES2017 に対応していないブラウザもサポートしたい場合は結局 ES2015 相当まで babel で変換する必要があります。<script type="module"> に対応したブラウザの中で更に細分化した polyfill や構文変換を提供したい場合には別の手法も組み合わせる必要があるでしょう。ニコニコ生放送ではまだそういった実装はありませんが、js の構文レベルを es2015 に変換*6した上で polyfill を含めないバンドルを配信し、 www.npmjs.com のようなライブラリを用いてブラウザのバージョン別に polyfill 部分を出し分けることで実現できるのではないかと考えています。*7

js のチャンク分割

js を細かい粒度で分割することでパフォーマンスを向上させる(低下させない)ことはとても大切で、現代のアプリケーションにおいては必須のテクニックと言えますが、特に何も考えずにファイルを分割してしまうとチャンク分割の恩恵を最大限受けることが難しくなってしまいます。 ニコニコ生放送のアプリケーションでチャンクを分割する際は

  • 分割後のサイズは十分に小さいか
  • dynamic import で遅延読み込み可能であるか
  • ファイルの更新頻度

辺りを意識して分割していました。特に「ファイルの更新頻度」は重要な観点で、頻度が考慮されていないチャンク分割をしてしまった場合は高頻度で更新されるコードの更新に引きづられて chunk hash が変わってしまい、結果として全体のキャッシュ効率が落ちてしまいます。 一例としてニコニコ生放送スマートフォン版ページでは以下のようなチャンク構成としました。(一部抜粋)

  • store.js(redux の store,action 系をまとめたチャンク。redux 関係のコードに変更がなければチャンクは更新されない)
  • page-*.js(特定ページで使うコード(React コンポーネントなど)をまとめたチャンク。SPA であるためページごとに分割している。変更のないページのチャンクは更新されない)
  • styles.js(css modules で生成されたクラス名が入ったチャンク。css に変更がなければチャンクは更新されない)
  • vc.js(全ページ共通で使う React コンポーネントをまとめたチャンク。変更がなければチャンクは更新されない)

最後に

いかがでしたでしょうか?
さて、ここまでの改善内容を読んだ方の中には「Next.js でできることも多いのでは?移行すれば?」と思った方もいるかと思います。それについてはおっしゃるとおりで、移行も一つの選択肢として考えています。
しかしながら Next.js に移行することになったとしても移行には時間がかかることは明白です。それであれば今のアーキテクチャでもできることを並行して進めつつ移行を検討する道を選びたいと考えました。その上、改善作業をする際には Next.js のアーキテクチャや思想に合わせた実装を行うことでいわば移行コストを先払いしている状況を作り出せているので、後に移行することになったとしても今の改善作業が完全に無駄になることはないでしょう。
また、Next.js がいかに素晴らしいフレームワークであったとしても、アプリケーション自体の品質に問題があれば真価は発揮できないという事実から目を背けることはできません。Next.js に移行するか否かに関わらず改善を続けていくことが大切だと考えています。

*1:2021年12月時点

*2:この施策の実行時点ではwebpack4を利用していました

*3:ニコニコ生放送では Node.js 上でES Modules を使う準備を進めている途中であるため、サーバーサイドでは CommonJS 形式でのビルドを利用しています

*4:この施策を実施した時点では package.json の exports フィールドは存在していない

*5:なお2021年8月をもってニコニコ生放送での IE11 のサポートが終了したため、レガシーブラウザ向けのバンドルは廃止しました。

*6:構文は polyfill で対応できないため

*7:構文のみ ES2015 に変換しているのは、変換することで発生するバンドルサイズへの影響が軽微であることと、構文ごとにバンドルを分ける事によって発生するバンドル生成コストが無視できないレベルであるためです