30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

フロントエンドのパフォーマンスチューニングを俯瞰する

去年からフロントエンドのパフォーマンスについて断続的に学んでいるが、自分の頭のなかにある知識はどれも断片的で、まとまりを欠いているような感覚があった。
知識と知識がつながっておらず、各施策が何のために行われるのかも、必ずしも自明ではなかった。何となく「パフォーマンスに効果がある」と言ってしまうが、それが何を指しているのかは実は曖昧だった。
このような状態では新しい知識を得ていくのが難しいというか、効率的に行えないように思えた。議論の背景が分からないし、文脈や問題意識を上手く掴めないから。何の話をしているのかよく分からない、という状態になりがち。書かれてあることの意味は分かっても論旨を掴めているわけではないから、自分のなかに定着しない。
そこで、現時点で自分が知っていることを整理して、自分なりに分類しておくことにした。

当たり前だが、どのテクニックがどの程度有効なのかは、状況によって違う。
だからこそ計測しながらの試行錯誤が重要になるのだが、効率的に試行錯誤を行うためには仕組みの理解が重要になる。
表面的に暗記した「テクニック」を闇雲に試しても、上手くいく可能性は低い。
それぞれの「テクニック」は、何のために、どんな効果を期待して、どのようなアプローチで解決を試みているのか。それを理解していなければ、的外れな対策を繰り返すことになりかねない。

一口に「パフォーマンス」といっても、何を解決したいのかはアプリケーションによって異なる。
操作性が問題になっているときにスタイルシートの読み込みについて工夫しても、上手くいく可能性は低い。それよりもまず先に調査や検証すべきことが、他にある。
パフォーマンス改善にどの程度のコストを割けるのか、割くべきなのかも、アプリケーションや事業によって異なる。
状況や目標によって何をやるべきかは変わってくるわけだが、表面的な知識しかないと、それを考えるのが難しくなる。

だから細かいテクニックやツールの話ではなく、どういう考え方やアプローチがあるのかを列挙するようにした。抽象的な話に終始するのを防ぐために、ある程度は具体的にも書いていくが。

リソースを速く取得する

とにかくリソースを速く取得する。速ければ速いほどよい。
リソースを使って描画するにせよ、スクリプトを実行するにせよ、リソースがブラウザに到着しないことにはどうにもならない。

リソースのサイズを小さくする

サイズが小さければ小さいほど、短い時間でダウンロードが終わる。なので、リソースを小さくする工夫をする。

  • テキストリソースの圧縮
    • gzipbrotliなどで圧縮した状態でサーバから配信する。対応している圧縮形式ならブラウザが自動的に展開してくれるので、開発者が明示的に展開をする必要はない。そして、HTTP ヘッダを使ったネゴシエーションを行うので、対応しているブラウザに対してのみ最新の圧縮形式を利用する、ということを行いやすい。
  • より適したファイル形式を使う
    • ファイル形式毎に一長一短あるので、それを把握したうえで適したファイル形式を使う。そうすることでファイルサイズが不要に肥大化してしまうことを防げる。写真の場合は PNG より JPEG が適している、など。
  • 適切なサイズの画像を使う
    • 不必要に大きな画像を使っていないか確認する。
    • HTTP ヘッダの情報を参考にして、クライアント毎に適した画像を返す。
      • acceptフィールドを見て、ブラウザが対応している場合はより圧縮効率が優れているファイル形式を使うようにする。
      • Client Hints を使う。
        • HTTP リクエストのDPRフィールドやWidthフィールドなどを活用する。これらの情報を使うことでサーバは、クライアントのデバイスピクセル比や端末のサイズを知ることができる。それにより、クライアント毎に適切なサイズの画像を返せるようになる。
  • JavaScript ファイルやスタイルシートの中身を精査し、不要なコードが含まれていないか、読み込んでしまっていないか、確認する
    • 複数の JavaScript ファイルやスタイルシートをひとつのファイルにまとめているが、そのページでは不要なコードが多数含まれてしまっている、というケースがあり得る。ファイルを小さい単位で分割し、そのページで本当に必要なコードのみをダウンロードするようにする。
    • モジュールバンドラによっては、不要なコードをバンドルから取り除く Tree Shaking という機能があるので、それも活用する。
    • 開発時にのみ必要なソースマップやデバッグ用のコードなどが含まれていないかにも、注意する。
  • 使用しているライブラリのサイズを削減する
    • ライブラリの選定基準にファイルサイズも含めて、より小さいライブラリを採用するようにする。
    • ライブラリ全体を読み込むのではなく、必要な機能のみを読み込むようにする。
    • 依存関係の問題で、異なるバージョンの同じライブラリが読み込まれていることがある。依存関係やバージョンの指定によってはこの重複を排除できる。
    • モジュールバンドラの設定を適切に行い、最適なファイルがバンドルされるようにする。
      • ライブラリによっては複数のファイルを配布していることがあり、モジュールバンドラは設定に応じて読み込むファイルを変える。モジュールバンドラを使いこなすことも重要だし、最適化が可能な形式でファイルを配布しているライブラリを採用することも重要。
    • 本当にそのライブラリが必要なのか吟味する。
      • 機能によっては、ライブラリを使わずスクラッチで書いたほうがメリットが大きいかもしれない。
  • JavaScript ファイルの過剰なトランスパイルを行わない
    • Babel などのトランスパイラの設定を適切に行い、過剰なトランスパイルが行われないようにする。例えば全てのコードを ES5 に置き換える場合、元のコードよりも非常に大きなコードが出力される可能性が高い。本当にそのようなトランスパイルが必要なのか、考える。

通信の効率化

同じネットワーク環境を利用し、取得するリソースの総量が同じだったとしても、通信の仕方によって、リソースを取得し終わるまでの時間は異なる。
そのため、RTT を減らしたり、通信を並列化したり、オーバーヘッドを削減したりすることなどが、重要になる。

  • HTTP/2 を利用する
    • HTTP/2 は HTTP/1.1 と異なり、ひとつの TCP 接続のなかで、HTTP メッセージを並列的に処理する。
      • そのため、TCP や TLS のハンドシェイクはオリジン毎に一度で済む。また、 HTTP レベルでの HoL ブロッキングは解決できる。
    • さらに、HTTP ヘッダを圧縮して送信するため、HTTP メッセージのサイズを削減できる。
  • HTTP/3 を利用する
    • HTTP/3 では TCP ではなく UDP を使っており、その上で QUIC というプロトコルを使っている。これによりハンドシェイクを削減し少ない RTT で通信を開始できる他、トランスポート層での HoL ブロッキングの削減も期待できる。
    • HTTP/2 と同様に HTTP/3 も、HTTP ヘッダを圧縮して送信する(圧縮形式は異なる)。
  • HTTP ヘッダ を小さくする
    • HTTP が高機能になるにつれ、HTTP ヘッダも肥大化する傾向にある。リソースを小さくするだけでなく、HTTP ヘッダも小さいほうが望ましい。
      • HTTP/2 や HTTP/3 を利用すれば HTTP ヘッダは圧縮されるが、不要なデータ(使っていない Cookie など)が HTTP ヘッダに含まれていないかも留意する。
  • リソースをひとつにまとめて通信回数を減らす
    • リソースを小さく分割すればするほど、通信回数も増える。何度も HTTP メッセージのやり取りが発生することになり、その度に HTTP ヘッダは送信され、状況によってはハンドシェイクも発生する。
    • しかし HTTP/2 や HTTP/3 では HTTP メッセージの並列的な処理が可能になり、HTTP ヘッダも圧縮されるため、リソースをまとめる必要性は薄い。
    • むしろ、個々のリソースを個別にキャッシュできなくなる、優先度の高いリソースだけ先にダウンロードしたり実行したりするということが行えなくなる、各ページ毎に必要最小限のリソースだけを読み込ませることが難しくなる、などのデメリットがある。HTTP/1.1 以外ではデメリットのほうが大きくなる可能性がある。
  • ES Modules を適切に利用する
    • 現代の主要なブラウザは ES Modules に対応しておりimportexportをそのまま使えるため、JavaScript ファイルをバンドルしなくても実行できる。しかし、全ての依存関係を解決してから、つまり依存関係にある全てのファイルをダウンロードしてから実行を開始するため、依存関係が深い場合はバンドルしてしまったほうがよい。なお、Dynamic Import の場合は対象の JavaScript ファイルが読み込まれた段階で依存関係の解決を開始する。そのため、実行を後回しにしても問題ないようなスクリプトの場合は Dynamic Import を活用する、という選択肢もある。

CDN サービスを利用する

クライアントから地理的に近いエッジサーバがリソースを返すことで、レイテンシが小さくなる。また、エッジサーバは配信に最適化されているため、その点でも高速化が見込める。
CDN 事業者によっては、Edge Worker やエッジコンピューティングと呼ばれる、エッジサーバでプログラムを動かすサービスを提供している。このサービスを上手く利用すると、動的なコンテンツをキャッシュできるようになるなど、より高度なキャッシュ戦略が可能になる。

予め接続を開始しておく

  • Resource Hints を使って事前に接続を開始する
    • link要素のrel属性に特定の値を指定することで利用できる。dns-prefetchpreconnectprefetchprerenderの 4 つ。あるページにいるときに、次に遷移するページについて予想できる場合、そのページに関するリソースを事前に取得しておいたり、TCP と TLS の接続だけ済ませておいたりする。そうすることで、実際にそのリソースが必要になったときに速く取得できるようになる。
    • これらの処理はブラウザがアイドル状態のときに行われるため、現在のページの表示を妨げることもない。

リソースをブラウザに保存する

リソースを取得する「最速」の手法は、キャッシュやストレージを使い、ネットワークへのアクセスを発生させずに取得することである。

  • HTTP ヘッダ を使いキャッシュの扱いをブラウザに指示する
  • Service Worker と Cache Storage API を使い、リソースのキャッシュとその利用を制御する
  • リソースを適切に管理し、キャッシュ制御を行いやすくする
    • 例えば、更新頻度が高い JavaScript ファイルと更新頻度が低い JavaScript ファイルに分割しておくことで、後者については長くキャッシュできるようにしておく。
    • src.1dfc5a.jsのようにリソース名にハッシュ値を含め、長期のキャッシュとリソースの確実な更新を両立させようとするのも、キャッシュ戦略のひとつ。
  • Web Storage や Indexed DB の利用
    • リソースの種類によっては、ブラウザのストレージ機能を使って保存するのも有効かもしれない。

ページを速く構築する

取得したリソースを使って画面の描画やスクリプトの実行を行うのは、ブラウザの仕事。文法などに誤りがなければ、ブラウザが自動的に行ってくれる。
しかし、どのリソースをどのタイミングで、どういう順番でダウンロードし実行するのかといったことについては、アプリケーションの開発者がブラウザに指示を出すことができる。
そしてその指示の内容によって、ページの表示速度や使い勝手は大きく左右される。

クリティカルレンダリングパスを最適化する

ブラウザがページをロードしレンダリングするために行う一連の処理のことを、クリティカルレンダリングパスと呼ぶ。
これは所定のプロセスで行われるが、そのプロセスを理解し、ブラウザがスムーズにページを表示できるように工夫することが、クリティカルレンダリングパスの最適化である。
クリティカルレンダリングパスを意識せずに開発を行ってしまうと、ページロードにかかる時間が大幅に増加してしまう恐れがある。

  • 可能なら JavaScript の実行を後回しにする
    • デフォルトでは、HTML の Parse 中にscript要素を見つけると、当該 JavaScript ファイルのダウンロードと実行が行われるまで Parse がブロックされてしまう。そうするとレンダリングが中断されてしまう。
    • script要素にasync属性やdefer属性をつけることで、HTML の Parse のブロックを防いだり、スクリプトを実行するタイミングを遅らせたりすることができる。
  • link要素のmedia属性を正しく使う
    • JavaScript ファイルと同様に、スタイルシートのダウンロードやパースも、レンダリングをブロックする。だが、画面の描画に影響を与えないことが分かっているスタイルシートは、ブロックしない。そのため例えば、media="print"と指定されたスタイルシートは印刷時にのみ適用されるため、このスタイルシートの処理にどれだけ時間がかかったとしてもレンダリングをブロックすることはない。
  • スタイルシートを非同期で読み込む
    • link要素を JavaScript によって動的に HTML に挿入することで、スタイルシートによるレンダリングのブロックを回避できる。しかし当然ながら、link要素が挿入されスタイルシートのダウンロードや処理が終わるまでは、そのスタイルシートの内容はページに反映されない。そのためこの手法を使うためには、読み込みが後回しになっても問題ないようなスタイルを別ファイルとして切り出しておく必要がある。
    • <link rel="preload" href="/foo.css" as="style" onload="this.onload=null; this.rel='stylesheet'">のようにpreloadを使っても同様の処理ができる。
  • クリティカルレンダリングパスに関与するリソースのサイズを削減する
    • サイズが大きければ大きいほど、ダウンロードや実行に時間がかかる。そのため、リソースの中身を精査して、ページ読み込み時に不要な処理が書かれているようなら、別ファイルとして切り出してサイズを削減する。

優先度の高いリソースのダウンロードを早期に実行する

クリティカルレンダリングパスには関与しないのでレンダリングをブロックすることはないが、早期に取得したいリソースも存在する。
ウェブフォントや、ファーストビューで使われている画像などが、それにあたる。これらを早期に取得するための工夫を施すことで、不完全なコンテンツが表示されてしまう時間を減らすことができる。

  • preloadで重要なリソースを優先的に取得する
    • link要素のrel属性にpreloadを、href属性にリソースのパスを、指定する。
    • ブラウザは、HTML ファイルを読むことで必要なサブリソースを発見し、リクエストを発行していく。だが、スタイルシートのなかで指定されている画像やウェブフォントなど、HTML には書かれていないサブリソースもあり、それについては発見が遅れる。ページの構築において優先度の低いサブリソースなら問題ないが、そうでない場合、HTML のなかでpreloadとして指定しておくことで、早期に取得を開始できる。

Flash of Unstyled Content (FOUC) を防ぐ

FOUC とは、意図したスタイルが適用されていないコンテンツが表示されてしまうこと。ページ読み込みを速めるためのテクニックとして、優先度の低いスタイルシートのダウンロードや処理を後回しにする手法を紹介した。
その際、後回しにすべきでないスタイルを後回しにしていたり、スタイルを取得する前に対象のコンテンツが表示されてしまったりした場合に、FOUC が発生する。
もし発生している場合は、スタイルシートの内容やブラウザに読み込ませるタイミングを見直す。

Flash of Unstyled Text (FOUT) や Flash of Invisible Text (FOIT) を防ぐ

ウェブフォントはファイルサイズが大きく、ダウンロードに時間がかかりやすい。また、レンダリングツリー(構築された DOM ツリー CSSOM ツリーから作られる)が完成してからリクエストが行われるため、ダウンロードの開始そのものが遅くなる。それでいて、コンテンツの表示内容に対する影響が大きい。

FOUT は、ウェブフォントが適用される前のテキストが表示され、ウェブフォントが読み込まれると適用後のテキストに切り替わる現象。FOIT は、ウェブフォントが読み込まれるまでテキストが表示されない現象。
これらはユーザー体験を大きく悪化させるため、対策が必要になる。

  • 必要なフォントのみを読み込む
    • 提供されている全てのフォント読み込むのではなく、ウェブサイトで使っているフォントだけを読み込むようにする。そうすれば、読み込まれるフォントのサイズが小さくなり、より速くダウンロードできるようになる。
  • 積極的にキャッシュする
    • ウェブフォントはサイズが大きく、それでいて更新頻度が低い可能性が高いので、キャッシュを積極的に利用する。
  • preloadを利用する
    • 前述の通り。
  • font-displayディスクリプタを使う
    • スタイルシートの@font-faceのなかで使用可能で、ウェブフォントを取得できるまでの処理を指定できる。これにより、ウェブサイトの特性に合わせた設定が可能になる。
  • CSS Font Loading API を利用する
    • JavaScript からウェブフォントを読み込むことが可能で、レンダリングツリーの完成を待たずにリクエストを実行できる。JavaScript によって、preloadよりも細かい制御が可能。

優先度の低いリソースのダウンロードや描画、実行を後回しにする

ページの表示時に使用するが、処理を後回しにしてしまってもよいリソースがある。初期表示時には必要がなかったり、ページ表示後にバックグラウンドで処理すれば十分なものであったり。
そういったリソースの処理を後回しにすることで、重要度の高いリソースの処理を優先的に行うことができる。
どのリソースを後回しにするべきなのかブラウザが判断するのは難しいので、アプリケーション開発者が明示的に指示を行う。

  • 画像の遅延ロード
    • スクロールしなければ表示されない画像については、取得を後回しにしても問題ない可能性がある。その際はimg要素のloading属性を使ったり、Intersection Observer API を使ったりするなどして、必要になったタイミングで読み込めばよい。
  • Dynamic Import を使う
    • 使っていないスクリプトは除去すべきだが、使っているがすぐには使わないスクリプト、特定の条件でのみ使うことになるスクリプト、などがある。そういったスクリプトは Dynamic Import で読み込むとよい。
    • モジュールバンドラによっては、Dynamic Import で読み込んでいるファイルはバンドルせずにコードを分割してくれるものも多い。
  • preloadによるスクリプトの遅延実行
    • 既述したように、preloadによってリソースを取得できるが、JavaScript ファイルの場合は取得しただけで実行はされない。実行するためにはscript要素を動的に HTML に挿入する必要がある。この手法を使うと、ダウンロードだけは先に行うが実行は必要になったタイミングで行う、といったことが可能になる。
  • loadDOMContentLoadedのタイミングでスクリプトを実行する
    • Service Worker の登録などはページ表示時には必要ないはずなので、そういった処理はloadイベントやDOMContentLoadedイベントの発生時に実行するようにしておく。そうすることで、レンダリング処理を妨げずに済む。

不要なリソースの取得を止める

そもそも使っていない JavaScript ファイルやスタイルシートをダウンロードしていることは、意外と多い。認識の誤りであったり、単純に見落としていたり、本当に不要なのか確認するのが難しくて放置されていたり。
クリティカルレンダリングパスに関与するようなリソースであればレンダリングのブロック要因になってしまうし、そうでなかったとしても、ネットワーク帯域や、マシンの CPU やメモリを無駄に浪費してしまうことになる。

JavaScript による UI の構築をサーバで行う

ウェブアプリケーションにおける JavaScript の重要性は年々高まっており、UI の構築のほぼ全てを JavaScript で行うことも珍しくない。リッチな UI を提供できるし、Single Page Application (SPA) にすればページ遷移も高速になる。
しかしこの場合、ブラウザはまず HTML を読み込み、その後script要素から JavaScript を読み込む。そして読み込まれた JavaScript を実行することで、UI を構築することになる。そのため、ページの初期表示が遅くなりやすい。

JavaScript による UI の構築をサーバで行ってしまうことで、この問題を解決できる。
サーバで JavaScript を実行して HTML を構築し、それを配信する。そうすればブラウザが受け取るのは静的な HTML ファイルなので、高速に描画できる。そして JavaScript を非同期で読み込ませれば、「初期表示の高速化」と「JavaScript を活用したリッチな UI」を両立できる。

Server Side Rendering (SSR) は、ブラウザからリクエストがある度に HTML の構築を行う手法。Static Site Generation (SSG) は、ビルド時に HTML の構築を行っておく手法。
それぞれに一長一短あるが、リクエストがあった際にビルド済みの HTML ファイルを返すだけでよく、CDN との相性も良い SSG のほうが、パフォーマンスという観点から見ればメリットが多い。

Next.js のようなフレームワークを使うと、SSR と SSG を組み合わせて使うなど、柔軟な設定が可能になる。

快適な UI を実現する

ページの表示を高速化することだけでなく、ユーザーの操作に素早く反応し滑らかに動く UI を作ることも、「パフォーマンス」の重要な要素である。

ブラウザによる描画内容の更新を、フレームという単位で表現する。描画内容を更新することを、「フレームを更新する」と呼ぶ。
そして、1 秒間にフレームの更新が何回行われたかを示す単位が fps。
60 fps を実現できれば、つまり 1 秒間にフレームの更新を 60 回行えれば、滑らかな UI を実現できるとされている。
逆に fps が低い場合、例えば 1 秒間に数回しか表示が更新されないような状態だと、画面をスクロールした際などに強いカクつきを感じることになる。

60 fps を実現するためには、1 回のフレームの更新を 16.7 ミリ秒で終わらせる必要がある。
しかしフレームを更新するためにブラウザが行わなければならない仕事は多く、16.7 ミリ秒以内で収めることは難しい。
そのため、いかにしてブラウザの仕事を減らすかが重要になる。

JavaScript によるメインスレッドの占有を防ぐ

ブラウザは基本的にシングルスレッド(メインスレッド)で動いている。レンダリングに関する処理の全てがメインスレッドで行われるわけではないのだが、時間のかかるスクリプト処理によってメインスレッドが占有されてしまうと、レンダリングが遅れる可能性が高い。
スクリプトの処理に 100 ミリ秒を費やしたとしたら、その時点で 16.7 ミリ秒という「予算」を大幅に超過しており、60 fps どころではなくなってしまう。

  • ロジックを工夫したり適切な API を使ったりするなどして、効率的なコードを書く
    • 動けば良い、機能要件を満たしていれば良い、ではなく、より効率的に機能を実現するコードを書く。
      • React アプリで、メモ化などを使ってコンポーネントの再レンダリングを抑制したり、ウィンドウイング処理(画面に映っていない部分はレンダリングしないようにして、レンダリングするコンポーネントの量を減らすテクニック。巨大なリストや表などで使われる)を利用したりするのも、そのため。コンポーネントの再レンダリングが行われる度に React による差分検出処理が発生するため、これが多発すればその分だけメインスレッドを占有してしまう。
  • 無駄な処理を無くす
    • 行う必要のない処理が行われていれば、それはブラウザに余計な仕事を与えているだけなので、取り除く。バグとして表面化することはないので、その存在に気付かず見落としている可能性がある。
      • スクロールやマウスオーバーのような高頻度で発生するイベントで何か処理を行っている場合、本当にそのタイミングで実行すべきことなのか、不必要に処理を頻発させていないか、よく考える。
  • requestIdleCallback を使う
    • 優先度の高くない処理の場合、requestIdleCallback を使って処理を後回しにすることができる。この API にコールバック関数を渡すと、ブラウザがアイドル状態になったときに実行される。そのため、フレームの更新を阻害せずに済む。しかし、requestIdleCallback に渡した処理が時間のかかるものであった場合、それが終わるまで次のフレーム更新を行えない。フレーム更新 -> 時間のかかる処理 -> フレーム更新、となる。そのため requestIdleCallback に渡す処理は、小さく分割されたタスクであることが望ましい。また、DOM の操作などは後述する requestAnimationFrame を使うようにする。
  • requestAnimationFrame を使う
    • requestAnimationFrame にコールバック関数を渡すと、次のフレームの更新の直前に、その関数が実行される。この API を使うことで、アニメーションのための DOM の更新などが適切なタイミングで行われる可能性が高まる。
  • Web Worker を使う
    • Web Worker を使うことで、メインスレッドとは別にワーカスレッドを作成し、マルチスレッドによる並列処理が可能になる。計算量が多い処理をワーカスレッドに行わせれば、その分だけメインスレッドの手が空く。
  • メモリの使用量に気をつける
    • メモリ管理は JavaScript エンジンが自動的に行なってくれる。そのため、メモリ管理を意識しなくてもコードは書ける。だが、JavaScript エンジンによるメモリ管理のための処理もメインスレッドで行われるため、レンダリングを阻害する要因になり得る。例えばメモリが逼迫するとガベージコレクションが頻発するようになり、その分だけメインスレッドは占有される。メモリが逼迫する要因は複数あるが、例えば使っていないタイマーやイベントリスナが解放されずにいると、いつまでもメモリに残り続けてしまう。

レンダリングに関する処理内容が少なくて済むようにする

レンダリングに必要な処理の内容は、一定ではない。
例えば、ある HTML 要素の大きさを変更した場合、その HTML 要素を画面上のどこに表示するのかという、レイアウトに関する計算を行わなければならない。
そして、当該 HTML 要素だけでなく、その変更に影響を受ける他の HTML 要素についても、計算を行う必要がある。「影響を受ける他の HTML 要素」が多ければ多いほど、処理に時間がかかることになる。
その一方で、HTML 要素の色だけを変えた場合は、レイアウトに関する処理は行わずに済む。

このように、HTML の操作内容によって、処理の多さは変わる。
より少ない処理で済むような方法を採用することで、フレームの更新にかかる時間を短くできる。
どの CSS プロパティを操作するとどんな処理が発生するのかは、以下のページを見ることで確認できる。
CSS Triggers

Layout Thrashing を防ぐ

JavaScript がレイアウトに関する CSS プロパティを更新したとする。
通常は JavaScript の処理が終わったあとに、レイアウトの計算が行われる。
だが JavaScript のなかでレイアウトに関する情報を参照した場合、その時点でレイアウトの計算が行われる。そうしないと、CSS プロパティ更新後の、最新の値を取得できないから。
そして、レイアウトの計算が終わるまで、JavaScript の処理は中断される。それは当然、そのあとに行われるレンダリング処理の実行が遅れることを意味する。
この現象を、Forced Synchronous Layout という。

Forced Synchronous Layout は、CSS プロパティの更新だけでなく、HTML 要素の挿入などでも発生する可能性がある。
そして、Forced Synchronous Layout が頻発している状態を、Layout Thrashing という。

Layout Thrashing が発生してしまうと、フレームの更新が遅れることになる。
JavaScript コードのロジックを見直し、レイアウト情報の更新と参照が繰り返されないように修正する必要がある。

Deno Deploy で WebAssembly を動かす

Deno Deploy や Rust の練習として、Rust から出力した WebAssembly を Deno Deploy を動かしてみた。
その手順をまとめておく。

ローカルでの動作確認は以下の環境で行った。

  • rustc 1.50.0
  • cargo 1.50.0
  • Deno 1.9.2
  • deployctl 0.3.0

使用しているクレートのバージョンは以下。

  • brotli@3.3.0
  • js-sys@0.3.50
  • wasm-bindgen@0.2.73

Deno Deploy

Deno Deploy は、Edge Server で JavaScript や TypeScript、WebAssembly を動かせるサービス。
公式ドキュメントによればExtremely fastとのこと。

以下のHello World!スクリプトを見れば分かるように、fetchイベントを使ってリクエストを制御するという、Service Worker と同様の書き方ができる。

addEventListener("fetch", (event) => {
  const response = new Response("Hello World!", {
    headers: { "content-type": "text/plain" },
  });
  event.respondWith(response);
});

ローカルでの開発にはdeployctlという開発ツールを使う。
インストール方法や使い方は公式ドキュメントに分かりやすくまとまっている。

以下の内容のmod.jsを書き、$ deployctl run --watch mod.jsで動かしてみる。

const html = `
<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
  <title>Deno Deploy</title>
</head>
<body>
  <a href="/json">Show JSON</a>
</body>
</html>
`;

addEventListener("fetch", (event) => {
  const { pathname } = new URL(event.request.url);
  if (pathname === "/json") {
    return event.respondWith(
      new Response(JSON.stringify({ a: 1, b: 2 }), {
        headers: { "content-type": "application/json; charset=UTF-8" },
      })
    );
  }

  event.respondWith(
    new Response(html, {
      headers: { "content-type": "text/html" },
    })
  );
});

この状態でhttp://localhost:8080/にアクセスするとShow JSONが表示され、それをクリックすると JSON が表示される。

このようにdeployctlを使うことでローカルで開発できるのだが、後述するようにdeployctlで動いたからといって本番環境でも動くとは限らないので、注意する。

Rust から WebAssembly を出力する

次に、Rust でコードを書いてそれを WebAssembly で出力する。
Rust で書くことや WebAssembly を動かすことそれ自体が目的なので中身は何でもよいのだが、brotliによる圧縮を行うことにする。

?src=http://example.comのようにsrcクエリで URL を指定して、そのリソースをbrotliで圧縮してクライアントに返す。
実用性は一切考えていない。

以下の内容のsrc/lib.rsを書いた。

use brotli::CompressorReader;
use js_sys::Uint8Array;
use std::io::Read;
use wasm_bindgen::prelude::*;

#[wasm_bindgen]
pub extern "C" fn compress_by_brotli(text: &str) -> Uint8Array {
    let bytes = text.as_bytes();

    let mut compressor = CompressorReader::new(bytes, 4096, 6, 20);
    let mut compressed = Vec::new();
    compressor.read_to_end(&mut compressed).unwrap();

    js_sys::Uint8Array::from(&compressed[..])
}

これを WebAssembly に変換すると、JavaScript からcompress_by_brotliを呼び出せるようになる。
この関数に文字列を渡すと、brotliで圧縮されUint8Array形式で返ってくる。

変換にはwasm-packを使うので、インストールする。

そして$ wasm-pack build --target webを実行すると、/pkgディレクトリに WebAssembly が出力される。

WebAssembly を読み込む

続いて、出力した WebAssembly を Deno で読み込む。

以下のコードで動く。
ファイル名のcompressの部分はCargo.tomlで指定した[package]nameによって決まるので、適宜置き換える。

import init, { compress_by_brotli } from "./pkg/compress.js";

await init(Deno.readFile("./pkg/compress_bg.wasm"));

const text = "abc";
console.log(compress_by_brotli(text));
$ deno run --allow-read mod.js
Uint8Array(7) [
   7,  1, 128, 97,
  98, 99,   3
]

Uint8Arrayが返ってきている。
new Responseの第一引数にはUint8Arrayをそのまま渡せるので、HTTP レスポンスとして返すのも難しくない。

WebAssembly の読み込みと利用が成功したのであとは JavaScript を書いていくだけなのだが、ひとつだけ注意点がある。
実はDeno.readFileは Deno Deploy には存在しないので、使おうとするとエラーになる。
Edge Server なのだからreadFileがないのは当然のような気がするが、deployctlでは動いていしまうので見落としていた。

本番環境では GitHub に置いたファイルを読み込むようにして、解決した。

if (Deno.env.get("ENVIRONMENT") === "production") {
  const res = await fetch(
    "https://raw.githubusercontent.com/numb86/brotli-compression/main/pkg/compress_bg.wasm"
  );
  await init(await res.arrayBuffer());
} else {
  await init(Deno.readFile("./pkg/compress_bg.wasm"));
}

Deno Deploy では環境変数を設定できるので、それで処理を分けている。

コードの全文は以下に置いてある。

github.com

デプロイ

案内に従ってデプロイする。上述の環境変数の設定も行っておく。

最後に動作確認。
はてなブックマークの新着記事で試してみる。

まずオリジナルのリソース。

https://b.hatena.ne.jp/site/numb86-tech.hatenablog.com/?mode=rss

f:id:numb_86:20210428211540p:plain

f:id:numb_86:20210428211612p:plain

gzipでエンコーディングされており、ファイルサイズは21.8kB

次に、Deno Deploy 経由でリソースを取得する。

https://brotli-compression.deno.dev/?src=https://b.hatena.ne.jp/site/numb86-tech.hatenablog.com/?mode=rss

f:id:numb_86:20210428211638p:plain

f:id:numb_86:20210428211624p:plain

brotliでエンコーディングされており、ファイルサイズが14.1kBになっている。