フロントエンドパフォーマンス最適化(2023年版 シンプル・分かりやすく・詳しく解説)

      フロントエンドの仕事や面接などでパフォーマンスの最適化という言葉をよく目にしますが、これは難しいことではなく、誰でも話すことができる言葉です。しかし、職場のさまざまなシナリオでパフォーマンスのボトルネックに遭遇したときに直接的なパフォーマンスの解決策を見つけたい場合、または面接中に面接官に好印象を与えたい場合は、単に「頭に浮かんだことを何でも言う」または「大まかに言う」に固執することはできません。つまり、あらゆる角度から体系的で詳細な知識マップを作成する必要があるのです。この記事は、私の個人的なフロントエンド知識の要約とも言えます。「パフォーマンスの最適化」は単なる「最適化」ではありませんが、これは何を意味しますか? 最適化計画を実装する前に、なぜこの方法で最適化する必要があるのか​​、またその目的は何なのかを知る必要があります。これには、フレームワーク、JS、CSS、ブラウザ、JS エンジン、ネットワークなどの原理をよく理解している必要があります。したがって、パフォーマンスの最適化では、フロントエンドの知識があまりにも多く、さらにはほとんどのフロントエンドの知識がカバーされます。

      まず、フロントエンドのパフォーマンスの本質について説明します。フロントエンドはネットワーク アプリケーションです。アプリケーションのパフォーマンスは、その動作効率によって決まります。前にネットワークを追加すると、それはネットワーク効率に関係します。したがって、フロントエンドのパフォーマンスの本質はネットワークパフォーマンスと運用パフォーマンスであると考えています。したがって、フロントエンド パフォーマンス最適化システムの 2 つの主要なカテゴリは、ネットワークとランタイムです。次に、これら 2 つの主要なテーマからそれぞれの小さな領域を細分化します。これは、巨大なフロントエンド ナレッジ グラフを編むのに十分です。

ネットワークレベル

      ネットワーク接続を水道管にたとえると、今ページを開こうとすると、相手が水の入ったグラスを持っているように見え、その水を自分のグラスに接続したいと考えます。より速く進みたい場合は、3つの方法があります: 1. 水道管の流れを大きくして速くする; 2. 反対側にコップの中の水を減らさせる; 3. 私のコップには水が入っているのに、水を入れていないあなたのものが必要です。水道管のトラフィックは、ネットワーク帯域幅、プロトコルの最適化、およびネットワーク速度に影響を与えるその他の要素です。コップ一杯の水が少なくなると、圧縮、コード分割、遅延読み込みなどのリクエストを減らす手段が必要になります。最後の手段はキャッシュの使用です。

      まずネットワーク速度について話しましょう。ネットワーク速度は、ユーザーのオペレーターによって決まるだけでなく、ネットワーク プロトコルの原理を理解し、効率を最適化するためにネットワーク プロトコルを調整することによっても決まります。

      コンピュータ ネットワークは理論的には OSI の 7 層モデルですが、実際には物理層、データリンク層、ネットワーク層、トランスポート層、アプリケーション層の 5 層 (または 4 層モデル) として見ることができます。各層は、独自のプロトコルのカプセル化、分解、解析を行い、独自のタスクを実行します。例えるなら、宮廷の女官が皇帝の服を一枚ずつ着たり脱いだりするようなもので、あなたはコートを担当し、私は下着を担当し、それぞれの役割を果たします。フロントエンドとしては、私たちが日々扱っているアプリケーション層のHTTPプロトコルをはじめ、アプリケーション層とトランスポート層に主に焦点を当てています。

httpプロトコルの最適化

1. HTTP/1.1 では、同じドメイン名に対するブラウザーリクエストの最大同時制限 (Chrome の場合は通常 6) に達しないようにする必要があります。

  • 大量のページ リソース リクエストがある場合は、複数のドメイン名を準備し、異なるドメイン名リクエストを使用して最大同時実行制限を回避できます。
  • 複数の小さなアイコンを 1 つの大きな画像に結合できるため、複数の画像リソースが 1 つのリクエストだけで済みます。フロントエンドは、CSS の背景位置スタイルを通じて対応するアイコン (スプライト画像とも呼ばれます) を表示します。

2. HTTPヘッダーのサイズを小さくする

  • たとえば、同じドメインからのリクエストには自動的に Cookie が送信されますが、認証が必要ない場合は無駄です。この種のリソースはサイトと同じドメインに含めるべきではありません。

3. HTTP キャッシュを最大限に活用します。キャッシュによりリクエストが直接排除され、ネットワークのパフォーマンスが大幅に向上します。

  • ブラウザーは、キャッシュ制御の no-cache や max-stale などの HTTP ヘッダー値を使用して、強力なキャッシュを使用するかどうか、キャッシュをネゴシエートするかどうか、キャッシュの有効期限がまだ利用可能かどうかなどの機能を制御できます。
  • サーバーは、cache-control の max-age、public、stale-while-revalidate などの http ヘッダー値を使用して、強力なキャッシュ時間、プロキシ サーバーによってキャッシュできるかどうか、キャッシュの有効期限が切れるまでの期間、およびキャッシュの有効期限を制御します。キャッシュを自動的に更新するのに時間がかかります。

4. HTTP/2.0 以降にアップグレードすると、ネットワーク パフォーマンスが大幅に向上します。(TLS、つまり https を使用する必要があります)

5.HTTPSを最適化する

       HTTPS パフォーマンスを大量に消費する主な側面が 2 つあります。

  • 最初のステップは、TLS プロトコルのハンドシェイク プロセスです。
  • 2 番目のステップは、ハンドシェイク後の対称的に暗号化されたメッセージの送信です。

2 番目のステップでは、現在主流の対称暗号化アルゴリズムである AES および ChaCha20 は優れたパフォーマンスを備えており、一部の CPU メーカーもそれらに対してハードウェア レベルの最適化を行っているため、このステップでの暗号化パフォーマンスの消費は非常に小さいと言えます。

最初のステップでは、TLS プロトコルのハンドシェイク プロセスによりネットワーク遅延が増加するだけでなく (ネットワークの往復時間が最大 2 RTT かかる場合があります)、ハンドシェイク プロセスの一部のステップでは、次のようなパフォーマンスの損失も発生します。

      ECDHE 鍵合意アルゴリズムが使用されている場合、クライアントとサーバーの両方がハンドシェイク プロセス中に一時的に楕円曲線の公開鍵と秘密鍵を生成する必要があります。クライアントは証明書を検証するときに、CA サーバーにアクセスして CRL または OCSP を取得します。サーバーの証明書が失効しているかどうかを確認し、その後、両者が対称暗号化キーであるプレマスターを計算します。これらの手順が TLS プロトコル ハンドシェイク全体のどの段階であるかをよりよく理解するには、次の図を参照してください。

TLSハンドシェイク

HTTPS は次の手段を使用して最適化できます。

  • ハードウェアの最適化: サーバーは、AES-NI 命令セットをサポートする CPU を使用します。
  • ソフトウェアの最適化: Linux バージョンと TLS バージョンをアップグレードします。TLS/1.3 は、ハンドシェイクの数を大幅に最適化し、必要な RTT 時間は 1 つだけであり、前方セキュリティをサポートしています (つまり、キーが現在または将来クラッキングされても、以前に傍受したメッセージのセキュリティには影響しません)。
  • 証明書の最適化: OCSP ステープリング。通常の状況では、ブラウザは証明書が失効しているかどうかを CA に確認する必要があり、サーバーは定期的に CA に証明書のステータスを問い合わせ、タイムスタンプと署名を含む応答結果を取得し、それをキャッシュすることができます。クライアントが接続リクエストを開始すると、サーバーは TLS ハンドシェイク プロセス中に「応答結果」をブラウザに直接送信するため、ブラウザは CA 自体をリクエストする必要はありません。
  • セッションの再利用 1: セッション ID。双方がセッションをメモリに保持します。次回接続が確立されるとき、Hello メッセージにはセッション ID が含まれます。サーバーはセッション ID を受信すると、メモリから検索します。見つかった場合は、 、セッションキーを直接使用してセッション状態を復元し、残りのプロセスをスキップします。セキュリティのため、メモリ内のセッション キーは定期的に期限切れになります。1. サーバーは各クライアントのセッション キーを保存する必要があるため、クライアントの数が増えるとサーバーのメモリ使用量も増加します。2. 現在、Web サイトのサービスは負荷分散によって複数のサーバーによって提供されるのが一般的ですが、クライアントが再度接続するときに、前回アクセスしたサーバーにアクセスできない可能性があります。サーバーにアクセスできなかった場合でも、完全な接続を経由する必要があります。 TLSハンドシェイクプロセス。
  • セッションの再利用 2: セッション チケット。クライアントとサーバーが初めて接続を確立するときに、サーバーは「セッション キー」を暗号化してチケットとしてクライアントに送信し、クライアントはチケットを保存します。これは、Web 開発でユーザー ID を検証するために使用されるトークン スキームに似ています。クライアントが再度サーバーに接続すると、クライアントはチケットを送信し、サーバーがそれを復号化できれば、最後のセッションキーを取得し、有効期限を確認し、問題がなければセッションを復元し、暗号化通信が直接始まります。このキーを暗号化および復号できるのはサーバーだけであるため、復号できれば不正は存在しないことになります。クラスターサーバーの場合、各サーバーの「セッションキー」の暗号化に使用されるキーが一貫していることを確認して、クライアントがチケットを持って任意のサーバーにアクセスしたときにセッションを復元できるようにします。

「セッション キー」を暗号化するキーが解読されるか、サーバーがキーを漏洩すると、以前にハイジャックされた通信暗号文が解読される可能性があるため、セッション ID とセッション チケットには前方セキュリティがありません。同時に、リプレイ攻撃に直面することも困難です。いわゆるリプレイ攻撃とは、仲介者がポストリクエストメッセージを傍受したと仮定すると、その中の情報を復号化することはできませんが、非冪等メッセージを再利用して、 https を直接再利用できるチケットサーバーがあるため、サーバーにリクエストします。リプレイ攻撃の被害を軽減するために、暗号化されたセッション キーに適切な有効期限を設定できます。


以下に http の知識ポイントを詳しく紹介します。

HTTP/0.9

初期バージョンは、迅速な利用促進を目的とした非常にシンプルな機能であり、単純なget htmlのみの機能であり、リクエストメッセージの形式は以下のとおりです。

GET /index.html

 HTTP/1.0

インターネットの発展に伴い、http はより多くの機能を満たす必要があるため、おなじみの http ヘッダー、ステータス コード、GET POST HEAD リクエスト メソッド、キャッシュなどを備えています。また、写真やビデオなどのバイナリ ファイルを送信することもできます。

このバージョンの欠点は、リクエストごとに TCP 接続が切断され、次の http リクエストでは TCP が接続を再確立する必要があることです。したがって、一部のブラウザでは非標準の Connection: keep-alive ヘッダーが追加されており、サーバーは同じヘッダーで応答します。この合意により、TCP は長時間の接続を維持できます。後続の http リクエストは、一方の当事者がアクティブに閉じるまでこの TCP を再利用できます。それ。        

HTTP/1.1

現在広く使用されているのはバージョン 1.1 です。このバージョンでは、デフォルトで tcp ロング接続が使用されます。これを閉じたい場合は、Connection: close ヘッダーを積極的に追加する必要があります。

さらに、パイプライン機構 (パイプライン処理) も備えており、クライアントは http の戻りを待たずに、同じ tcp 接続で複数の http リクエストを連続して送信できます。以前の HTTP リクエストの設計では、TCP 接続で一度に送信できる HTTP リクエストは 1 つだけであり、その戻り値を受信した後でのみ HTTP リクエストが完了し、次の HTTP リクエストを送信できるようになっていました。http/1.1 バージョンはパイプライン メカニズムに基づいて複数の https を連続的に送信できますが、1.1 は依然としてサーバー上で FIFO (先入れ先出し) 順序でのみ応答を返すことができるため、最初の http の応答が非常に遅い場合、後続のものは引き続き最初の http によってブロックされます。複数の連続した応答を受信した場合、ブラウザはそれらを Content-Length で分割します。

さらに、チャンク転送エンコーディングが追加され、バッファ形式がストリーム ストリームに置き換えられました。たとえば、ビデオの場合、ビデオを完全にメモリに読み込んでから送信する必要はなくなり、小さな部分を読み取った後でストリームを使用して小さな部分を送信することができます。Transfer-Encoding: chunked ヘッダーを使用してオンにします。各チャンクの前には、チャンクの長さを表す 16 進数が表示されます。数値が 0 の場合は、チャンクが送信されたことを意味します。大きなファイルの転送やファイル処理などのシナリオでは、この機能を使用すると効率が向上し、メモリ使用量が削減されます。

このバージョンには次のような欠点があります。

1. 行頭ブロック。完全な http が完了する前に要求と応答が必要であり、その後、次の http を送信できるようになります。前回の http が遅い場合は、次回の送信時間に影響します。同時に、ブラウザには同じドメイン名に対する同時 http リクエストの最大数があり、その制限を超えた場合は、前のリクエストが完了するまで待つ必要があります。
2. http ヘッダーの冗長性。ページ内のすべての HTTP リクエスト ヘッダーは基本的に同じである可能性がありますが、これらのテキストは毎回送信する必要があり、ネットワーク リソースの無駄になります。

実際、http1.1 の欠点は本質的に、その初期の位置付けがプレーン テキスト プロトコルであることが原因で発生します。順不同で送信したい場合は、リクエスト/レスポンスに一意の識別子を追加するなど、プロトコル自体を変更してから、相手側でテキストを解析して対応する順序を見つける必要があります。http プロトコルを再度カプセル化するか、テキストをバイナリ データに変換して、追加のカプセル化処理を実行する必要があります。開始と終了の原則によれば、新しい追加は変更よりも優れているため、明らかに後者の解決策がより合理的です。したがって、http/2.0 では、後続の操作を容易にするために元のデータをバイナリ フレームに分割します。これは、元のベースにさらにいくつかのステップを追加することに相当し、元の http コアは変更されていません。

HTTP/2.0

新しい改善点には、HTTP/1.1 で長年にわたって続いてきた多重化の最適化、行頭ブロック問題の修正、リクエストの優先順位の設定が含まれるだけでなく、ヘッダー圧縮アルゴリズム (HPACK) も含まれます。さらに、HTTP/2 はクリア テキストではなくバイナリを使用して、クライアントとサーバーの間でデータをパッケージ化して送信します。

フレーム、メッセージ、ストリーム、および TCP 接続

バージョン 2.0 は、http の下にバイナリ フレーミング層を追加すると考えることができます。メッセージ (完全なリクエストまたは応答はメッセージと呼ばれます) は多くのフレームに分割され、フレームにはタイプ、長さ、フラグ、ストリーム識別子 ストリーム、およびペイロード フレーム ペイロードが含まれます。同時にストリームという抽象的な概念も追加され、各フレームのストリーム識別子がどのストリームに属するかを表す http/2.0 は待たずにアウトオブオーダーで送信できるため、送信側・受信側はそれに応じてアウトオブオーダーで送信することになるストリーム識別子にデータがアセンブルされます。両端での重複したストリーム ID によって引き起こされる競合を防ぐために、クライアントによって開始されたストリームには奇数の ID が付けられ、サーバーによって開始されたストリームには偶数の ID が付けられます。元のプロトコルの内容には影響せず、http1.1 の最初の情報ヘッダーは Headers フレームにカプセル化され、リクエストボディは Data フレームにカプセル化されます。複数のリクエストでは 1 つの TCP チャネルのみが使用されます。この取り組みにより、新しいページの読み込みが HTTP/1.1 と比較して 11.81% ~ 47.7% 高速化できることが実際に示されました。http/2.0 では、複数のドメイン名やスプライト画像などの最適化手法は必要ありません。

HPACK アルゴリズム

HPACK アルゴリズムは、HTTP/2 で新しく導入されたアルゴリズムで、HTTP ヘッダーの圧縮に使用されます。原則は次のとおりです。

RFC 7541 の付録 A によると、クライアントとサーバーは、共通のヘッダー名と共通のヘッダー名と値の組み合わせのコードを含む共通の静的辞書 (静的テーブル) を維持し、クライアントとサーバーは最初のエントリに従います
。原理は、コンテンツを動的に追加できる共通の動的辞書 (動的テーブル) を維持し、
クライアントとサーバーは、RFC 7541 の付録 B に従って、この静的ハフマン コード テーブルに基づくハフマン コーディングをサポートします。

サーバープッシュ     

以前は、ブラウザーはサーバー データを取得するリクエストを積極的に開始する必要がありました。これには、追加の js リクエスト スクリプトを Web サイトに追加する必要があり、呼び出す前に js リソースがロードされるまで待つ必要もあります。その結果、リクエストのタイミングが遅れ、リクエストが増加します。HTTP/2 はサーバー側のアクティブ プッシュをサポートしています。これにより、ブラウザーがリクエストをアクティブに送信する必要がなく、リクエストの効率が節約され、開発エクスペリエンスが最適化されます。フロントエンドは、EventSource を通じてサーバーからのプッシュ イベントをリッスンできます。

HTTP/3.0

HTTP/2.0 は、多重化、ヘッダー圧縮など、以前のバージョンと比べて多くの最適化が行われていますが、基礎となる層が TCP に基づいているため、いくつかの問題点は解決が困難です。

行頭ブロック

HTTP は TCP 上で実行されます。バイナリ フレーミングにより、HTTP レベルでの複数のリクエストがブロックされないことがすでに保証されていますが、上記の TCP 原則から、TCP には先頭のブロックと再送信もあることを知ることができます。パッケージのackは返されず、それ以降のackは送信されません。したがって、HTTP/2.0 は HTTP レベルでの行頭ブロックのみを解決し、ネットワーク リンク全体では引き続きブロックされます。新しいプロトコルを使用して、最新のネットワーク環境でより高速に送信できれば素晴らしいと思います。

TCP、TLS ハンドシェイクの遅延

TCP には 3 回のハンドシェイクがあり、TLS (1.2) には 4 回のハンドシェイクがあり、実際の http リクエストを発行するには合計 3 回の RTT 遅延が必要です。同時に、TCP の輻輳回避メカニズムがスロースタートから開始されるため、さらに速度が低下します。

ネットワークを切り替えると再接続が発生する

TCP 接続の一意性は、両端の IP とポートに基づいて決定されることがわかっています。現在、モバイルネットワークと交通機関が非常に発達しており、オフィスに入るときや帰宅すると、携帯電話は自動的にWIFIに接続されます。携帯電話ネットワークは、地下鉄や高速鉄道の信号基地局を10年ごとに変更するのが非常に一般的です。秒。これらはすべて IP の変更を引き起こすため、以前の TCP 接続が無効になります。その結果、途中まで開いた Web ページが突然読み込めなくなり、途中でバッファリングされたビデオが最後にバッファリングできなくなります。

QUICプロトコル

上記の問題は TCP 固有の問題であり、これを解決するにはプロトコルを変更するしかありません。http/3.0 では QUIC プロトコルが使用されています。まったく新しいプロトコルにはハードウェアのサポートが必要であり、普及には必然的に長い時間がかかるため、QUIC は既存のプロトコル UDP 上に構築されています。

QUIC プロトコルには次のような多くの利点があります。

行頭ブロックなし


QUIC プロトコルには、HTTP/2 と同様のストリームと多重化の概念もあり、同じ接続上で複数のストリームを同時に送信することもできます。ストリームは HTTP リクエストとみなすことができます。

QUIC で使用されるトランスポート プロトコルは UDP であるため、UDP はパケットの順序を気にせず、パケットが失われた場合にも UDP は気にしません。

ただし、QUIC プロトコルでは、データ パケットの信頼性を確保する必要があり、各データ パケットはシーケンス番号によって一意に識別されます。フロー内のパケットが失われた場合、フロー内の他のパケットが到着しても、HTTP/3 でデータを読み取ることができず、QUIC が失われたパケットを再送信するまで、データは HTTP/3 に引き継がれません。

特定のフローのデータ パケットが完全に受信されている限り、HTTP/3 はこのフローのデータを読み取ることができます。これは、1 つのストリームでパケットが失われると他のストリームに影響を与える HTTP/2 とは異なります。

したがって、QUIC 接続上の複数のストリーム間に依存関係はなく、すべて独立しており、あるストリームでパケットが失われた場合、影響を受けるのはそのストリームのみであり、他のストリームには影響しません。

より高速な接続確立

HTTP/1 および HTTP/2 プロトコルの場合、TCP と TLS は階層化されており、それぞれカーネルによって実装されるトランスポート層と OpenSSL ライブラリによって実装されるプレゼンテーション層に属するため、統合することが難しく、変更する必要があります。最初に TCP ハンドシェイク、次に TLS ハンドシェイク。

HTTP/3 もデータを送信する前に QUIC プロトコルのハンドシェイクを必要としますが、このハンドシェイク プロセスに必要な RTT は 1 つだけです。ハンドシェイクの目的は、接続の移行など、双方の「接続 ID」を確認することです (たとえば、ネットワークが必要とするなど)。 IP スイッチングにより移行される) 接続 ID に基づいて実装されます。

HTTP/3 の QUIC プロトコルは TLS で階層化されていませんが、QUIC は内部的に TLS を含みます。独自のフレームで TLS の「レコード」を伝送します。また、QUIC は TLS 1.3 を使用するため、1 つの RTT だけで接続の確立を完了できます。 2回目の接続時でもアプリケーションデータパケットをQUICハンドシェイク情報(接続情報+TLS情報)とともに送信することで0-RTTの効果を実現します。

以下の図の右側に示すように、HTTP/3 セッションが復元されると、ペイロード データが最初のパケットと一緒に送信され、0-RTT を達成できます。

接続の移行

モバイル デバイスのネットワークが 4G から WiFi に切り替わるときは、IP アドレスが変更されたことを意味するため、接続を切断してから再確立する必要があります。接続を確立するプロセスには、TCP スリーウェイ ハンドシェイクの遅延と、 TLS の 4 ウェイ ハンドシェイクと、TCP スロー スタートの減速プロセスにより、ネットワークが突然停止したようにユーザーに感じられるため、接続の移行コストが非常に高くなります。高速列車に乗っている場合、IP hUI が継続的に変化する可能性があり、これにより TCP 接続が常に再接続されます。

QUIC プロトコルは、接続を「バインド」するために 4 タプル メソッドを使用しませんが、接続 ID を使用して通信の 2 つのエンドポイントをマークします。クライアントとサーバーはそれぞれ、自身をマークする ID のセットを選択できるため、モバイル デバイス ネットワークの場合、変更後に IP アドレスが変更されますが、コンテキスト情報 (接続 ID、TLS キーなど) が保持されている限り、元の接続を「シームレスに」再利用できるため、コストがかかりません。タイムラグなく再接続を実現する接続移行機能を提供します。

簡素化されたフレーム構造、QPACK の最適化されたヘッダー圧縮

HTTP/3 は HTTP/2 と同じバイナリ フレーム構造を使用します。違いは、HTTP/2 のバイナリ フレーム内で Stream を定義する必要があるのに対し、HTTP/3 自体は Stream を定義する必要がなく、QUIC で Stream を使用することです。直接なのでHTTP/3のフレーム構造もシンプルになりました。

HTTP/3フレーム

  フレームの種類により、一般にデータフレームとコントロールフレームの2つに分類され、データフレームにはヘッダフレーム(HTTPヘッダ)とDATAフレーム(HTTPパケットボディ)が属します。

HTTP/3 はヘッダー圧縮アルゴリズムの点でもアップグレードされ、QPACK にアップグレードされました。HTTP/2 の HPACK エンコード方式と同様に、HTTP/3 の QPACK も静的テーブル、動的テーブル、およびハフマン エンコードを使用します。

静的テーブルの変更点としては、HTTP/2 の HPACK の静的テーブルには 61 エントリしかありませんが、HTTP/3 の QPACK の静的テーブルは 91 エントリに拡張されました。

HTTP/2 と HTTP/3 のハフマン エンコーディングには大きな違いはありませんが、動的テーブルのエンコーディングとデコーディングの方法が異なります。

いわゆる動的テーブル。最初の要求と応答の後、両当事者は、静的テーブルに含まれていないヘッダー項目 (一部のカスタマイズされたヘッダーなど) をそれぞれの動的テーブルに更新し、それらを表すために 1 つの数値のみを使用します。そうすれば、相手は毎回長いデータを送信する必要がなく、この番号に基づいて動的テーブルから対応するデータを検索できるため、符号化効率が大幅に向上します。

動的テーブルがシーケンシャルであることがわかります。最初のリクエスト ヘッダーが失われ、後続のリクエストでこのヘッダーが再び検出された場合、送信者は相手がすでに動的テーブルにヘッダーを保存していると考え、ヘッダーを圧縮します。ただし、相手が動的テーブルを確立していないため、相手は HPACK ヘッダをデコードできません。したがって、通常のデコードが可能になる前に、最初のリクエストで失われたデータ パケットが再送信されるまで、後続のリクエストのデコードをブロックする必要があります。

HTTP/3 の QPACK はこの問題を解決しますが、どのように解決するのでしょうか?

QUIC には 2 つの特別な一方向ストリームがあります。いわゆる一方向ストリームの一方の端だけがメッセージを送信できます。双方向ストリームは HTTP メッセージの送信に使用されます。これら 2 つの一方向ストリームの使用法:

1 つは QPACK Encoder Stream と呼ばれるもので、辞書 (Key-Value) を相手に渡すために使用されます。たとえば、静的テーブルに属さない HTTP リクエスト ヘッダーに直面した場合、クライアントはこれを通じて辞書を送信できます。ストリーム。もう 1 つは QPACK デコーダー ストリームと呼ばれ、相手に応答して、送信したばかりの辞書がローカルの動的テーブルに更新され、後でこの辞書をエンコードに使用できることを伝えるために使用されます。この 2 つの特別な一方向ストリームは、双方の動的テーブルを同期するために使用され、エンコード側は、デコード側から更新確認通知を受信した後、動的テーブルを使用して HTTP ヘッダーをエンコードします。動的テーブルの更新メッセージが失われた場合、一部のヘッダーが圧縮されなくなるだけで、HTTP リクエストはブロックされません。

HTTPキャッシュの詳しい説明

ネットワーク リソースを要求する必要がなく、ローカル キャッシュから直接取得する場合、当然、それが最も高速になります。キャッシュ メカニズムは http プロトコルで定義されており、ローカル キャッシュ (強力なキャッシュとも呼ばれます) とリクエストを通じて検証する必要があるキャッシュ (ネゴシエーション キャッシュとも呼ばれます) に分かれています。

ローカルキャッシュ(強力なキャッシュ)

http1.0では戻り値の有効期限をexpiresレスポンスヘッダで示しており、この時間内であればブラウザは再リクエストすることなく直接キャッシュを利用することができます。http1.1 以降、より多くのキャッシュ要件を満たすことができる Cache-Control 応答ヘッダーに変更されました。内部の max-age は、リソースがリクエストから N 秒後に期限切れになることを示します。max-age はブラウザが応答を受信して​​から経過する時間ではなく、ソース サーバーで応答が生成されてから経過する時間であり、ブラウザの時間とは関係がないことに注意してください。したがって、ネットワーク上の別のキャッシュ サーバーが応答を 100 秒間保存する場合 (応答ヘッダー フィールド Age で示されます)、ブラウザのキャッシュは有効期限から 100 秒を差し引きます。キャッシュの有効期限が切れると (stale-while-revalidate、max-stale などの影響は無視します)、ブラウザはリソースが更新されたかどうかを確認する条件付きリクエスト (ネゴシエーション キャッシュとも呼ばれます) を開始します。

条件付きリクエスト (キャッシュのネゴシエート)

リクエスト ヘッダーには If-Modified-Since フィールドと If-None-Match フィールドがあり、これらはそれぞれ最後のリクエスト応答ヘッダーの Last-Modified フィールドと etag フィールドです。Last-Modified は、リソースが最後に変更された時刻を秒単位で示します。Etag は、リソースの特定のバージョンの識別子です (たとえば、Etag はコンテンツをハッシュすることによって生成できます)。If-None-Match または If-Modified-Since に変更がない場合、サーバーは 304 ステータス コード応答を返し、ブラウザはリソースが更新されていないと判断し、ローカル キャッシュを再利用します。Last-Modifiedレコードの更新時刻は秒単位であるため、1秒以内の更新頻度では更新されたかどうかを正確に判定できないため、Last-Modifiedよりもetagの判定優先順位が高くなります。

Cache-Control で no-cache が設定されている場合、強力なキャッシュは強制的に使用されず、ネゴシエートされたキャッシュが直接使用されます (つまり、max-age=0)。no-store が設定されている場合、キャッシュは使用されません。

リクエストに対するブラウザのキャッシュ戦略は、単純に次のようなものです。キャッシュは、レスポンス ヘッダーとリクエスト ヘッダーによって決定されることがわかります。開発プロセス中、通常、ゲートウェイとブラウザは自動的にそれを設定します。特定のニーズがある場合は、次のことができます。より多くのキャッシュ制御機能を使用するようにカスタマイズできます。

完全なキャッシュ制御機能

Cache-Control には、より詳細なキャッシュ制御機能もあります。レスポンス ヘッダーとリクエスト ヘッダーの完全な意味については、以下の表を参照してください。

応答ヘッダー

リクエスト ヘッダー (レスポンス ヘッダーに含まれていないもののみがリストされます) |max-stale|キャッシュは max-stale 秒以内に期限切れになった場合でも引き続き使用できます | |min-fresh|キャッシュ サービスが最新のキャッシュを返す必要がありますmin-fresh 秒以内のデータ、そうでない場合はローカル キャッシュを使用しません | |only-if-cached| ブラウザは、キャッシュ サーバーがキャッシュしている場合にのみターゲット リソースを返す必要があります。

TCPプロトコルの最適化

ノードを記述するときに必要になる場合があります。大丈夫、心配しないでください。純粋なフロントエンドのみに興味がある人は読み飛ばしてください:)

まず、さまざまな問題に対する最適化手法を直接示しますが、具体的な tcp の原理とこれらの現象が発生する理由については、後ほど詳しく紹介します。

次の TCP 最適化は通常、リクエスト側で行われます。

1. 最初のリクエストのサイズは 14kb を超えてはなりません。これにより、tcp のスロースタートを効果的に利用できます。フロントエンド ページの最初のパッケージについても同様です。

  • 初期 TCP ウィンドウが 10、MSS が 1460 であると仮定すると、最初の要求のリソース サイズは 14600 バイト、つまり約 14 kb を超えてはなりません。この方法では、相手側の TCP を一度に送信できますが、それ以外の場合は少なくとも 2 回に分けて送信することになり、追加の RTT (ネットワーク往復時間) が必要になります。

2. 小さなデータ パケット (MSS 未満) が頻繁に送信されるために TCP がブロックされた場合はどうすればよいですか?
これは、ゲーム操作 (通常、tcp プロトコルは使用されませんが) およびコマンド ライン ssh で非常に一般的です。

  • ネーゲルのアルゴリズムをオフにする
  • 遅延ACKを回避する

TCP パケット損失の再送信を最適化する方法

  • net.ipv4.tcp_sack 経由で SACK をオンにする (デフォルトで有効)
  • net.ipv4.tcp_dsack 経由で D-SACK をオンにする (デフォルトで有効)

次の TCP 最適化は通常、サーバー側で行われます。

1. サーバーが受信する同時リクエストの数が多すぎるか、SYN 攻撃に遭遇したため、SYN​​ キューがいっぱいになり、リクエストに応答できなくなります。

  • 使用syn cookie
  • 同期の再試行回数を減らす
  • 同期キューのサイズを増やす

2. TIME-WAIT が多すぎると、使用可能なポートがいっぱいになり、それ以上リクエストを送信できなくなります。

  • オペレーティング システムの tcp_max_tw_buckets 構成を使用して、同時 TIME-WAIT の数を制御します。
  • 可能であれば、クライアントまたはサーバーのポート範囲と IP アドレスを増やします。

上記の tcp 最適化方法は、tcp メカニズムの理解とオペレーティング システム パラメーターの調整に基づいており、ネットワーク パフォーマンスの最適化をある程度実現できます。以下では、tcp の実装メカニズムから始めて、次にこれらの最適化メソッドが何を行うかを説明します。

TCP 送信の前に接続を確立する必要があることは誰もが知っていますが、実際には、ネットワーク送信には接続の確立は必要ありません。ネットワークはもともとバースト性がいつでも送信できるように設計されていたため、電話網の設計は放棄されました。 。通常、いわゆる TCP 接続は実際には、相互間の通信を節約する 2 つのデバイス間の単なる状態であり、実際の接続ではありません。TCP は、5 つのタプルを通じて同じ接続であるかどうかを区別する必要があります。そのうちの 1 つはプロトコルで、残りの 4 つは src_ip、src_port、dst_ip、dst_port (両端の IP とポート番号) です。さらに、tcp メッセージ セグメントのヘッダーには 4 つの重要な点があります。シーケンス番号はパケットのシーケンス番号 (seq) であり、データ ストリーム全体におけるこのパケットのデータ部分の最初のビットの位置を示します。 、ネットワーク パケットの混乱の順序問題を解決するために使用されます。確認応答番号(ack)は、今回受信したデータの長さ+今回受信したシーケンスを表し、相手(送信者)の次のシーケンス番号でもあり、受信を確認し、パケットを失わないという問題を解決するために使用されます。 。ウィンドウは、アドバタイズド ウィンドウとも呼ばれ、フロー制御の実装に使用されるスライディング ウィンドウです。TCP フラグは、SYN、FIN、ACK などのパケットのタイプであり、主に TCP ステート マシンを制御するために使用されます。

主要な部分を以下に紹介します。

tcp 3 回の「ハンドシェイク」

スリーウェイ ハンドシェイクの本質は、双方の初期シーケンス番号、MSS、ウィンドウ、その他の情報を把握し、順序が乱れた状況でもデータを秩序ある方法で接続できるようにすることです。ネットワークとハードウェアの収容能力を確認できます。

初期シーケンス番号 (ISN) は 32 ビットで、仮想クロックにより 4 マイクロ秒の周期で 1 を加算し続けて生成され、2^32 を超えると 0 に戻り、1 サイクルは 4.55 時間かかります。各接続の確立が 0 から開始されない理由は、接続が切断されて再確立された後に遅れて到着する新しいパケットと古いパケットの間で順序が競合する問題を回避するためです。4.55 時間が最大セグメント寿命 (MSL) を超えたため、古いパッケージは存在しません。

  • クライアントは、初期シーケンスが x であるため、シーケンス = x であると仮定して、SYN (フラグ: SYN) パケットを送信します。クライアント TCP は SYN_SEND 状態になります。
  • サーバー TCP は最初 LISTEN 状態にあります。受信後、ACK パケット (フラグ: ACK、SYN) を送信します。初期シーケンスは y、seq = y、ack = x + 1 であるとします。これは、flags がSYN は 1 の長さを占有するため、次にクライアントは x + 1 から開始する必要があります。サーバーは SYN_RECEIVED 状態になります。
  • クライアントは ACK パケットを受信した後に送信します (seq = x + 1、ack = y + 1)。次に、PSH パケットの実際の内容 (データ長が 100 であると仮定)、seq = x + 1、ack = y + 1 を送信し続けます。seq と ack の実際の内容が ack パケットから変更されていないのは、Flag が ACK であり、確認のみに使用され、長さ自体を占有しないためです。クライアントは ESTABLISHED 状態に入ります。
  • サーバーは ACK パケットを受信した後、seq = y + 1、ack = x + 101 を送信します。サーバーは ESTABLISHED 状態になります。

seq と ack の計算は、このパケット キャプチャ画像と比較できます (画像はインターネットからのもので、その中のシーケンス番号は相対的なシーケンス番号です)

TCP送信プロセスの転送に失敗しました。画像ファイルを直接アップロードすることをお勧めします。

SYN タイムアウトと攻撃

3 ウェイ ハンドシェイク中、サーバーが SYN パケットを受信して​​ SYN-ACK を返した後、TCP は半接続の中間状態になります。オペレーティング システム カーネルは接続を一時的に SYN キューに入れます。ハンドシェイクが成功すると、接続は完了キューに入れられます。サーバーがクライアントから ACK を受信しない場合、タイムアウトして再試行します。デフォルトの再試行は 5 回で、1 秒、1 秒、2 秒、4 秒と 2 倍になり、5 回目のタイムアウトになるまで合計で 3 回かかります。 63 秒後、tcp が切断されます。この接続を削除します。一部の攻撃者は、この機能を利用してサーバーに大量の SYN パケットを送信し、切断します。サーバーは SYN キューから接続をクリアするまで 63 秒待つ必要があり、サーバーの TCP の SYN キューがいっぱいになります。サービスの提供を継続できなくなります。この状況は、通常の大規模な同時実行条件でも発生する可能性があります。現時点では、Linux では次のパラメータを設定できます。

  • tcp_syncookies では、4 倍の情報、64 秒ごとに増加するタイムスタンプ、SYN キューがいっぱいになった後の MSS オプション値から特別なシーケンス番号 (Cookie とも呼ばれる) を生成できます。この Cookie はシーケンスとしてクライアントに直接送信できます。建連。この賢い方法で、tcp_syncookies は情報の一部をローカルに保存せずに SYN に保存します。注意して見れば、tcp_syncookies は接続を確立するために 2 回のハンドシェイクだけを必要としているように見えることに気づくでしょう。なぜこれを tcp 標準に組み込んでいないのでしょうか? 欠点もあるので、 1. MSS のエンコーディングは 3 ビットしかないため、最大 8 つの MSS 値しか使用できません。2. サーバーには、Wscale や SACK などのオプションを保存する場所がないため、SYN​​ および SYN+ ACK でのみネゴシエートされるクライアントの SYN メッセージ内の他のオプションを拒否する必要があります。3. 暗号化操作を追加しました。したがって、通常の同時実行性が高くて SYN キューがいっぱいになっている場合は、この方法を使用しないでください。これは、単なる TCP の骨抜き化されたバージョンです。
  • tcp_synack_retries を使用すると、SYN-ACK タイムアウトの再試行回数が減り、SYN キューのクリーニング時間も短縮されます。
  • tcp_max_syn_backlog は、SYN 接続の最大数を増やします。つまり、SYN キューを増やします。
  • tcp_abort_on_overflow、SYN キューがいっぱいの場合に接続を拒否します。

tcp「ウェーブ」を 4 回実行する

クライアントが最初に切断すると仮定すると、この例のシーケンスは最後のハンドシェイクに続きます。

閉じる前、両端の TCP ステータスは ESTABLISHED です。

  1. クライアントは、FIN パケット (フラグ: FIN) を送信して、閉じられることを示します (seq = x + 101、ack = y + 1)。クライアントは FIN-WAIT-1 状態に変わります。
  2. サーバーはこの FIN を受信し、ACK (seq = y + 1、ack = x + 102) を返します。サーバーは CLOSE-WAIT 状態に変わります。この ACK を受信した後、クライアントは FIN-WAIT-2 状態に変わります。
  3. サーバーには未完了の作業がある可能性があり、完了後、FIN パケットを送信して閉じることを決定します (seq = y + 1、ack = x + 102)。サーバーは LAST-ACK 状態に変わります。
  4. クライアントは、FIN を受信した後、確認 ACK (seq = x + 102、ack = y + 2) を返します。クライアントは TIME-WAIT 状態に変わります
  5. クライアントから ACK を受信した後、サーバーは接続を直接閉じ、CLOSED 状態に変わります。2*MSL 時間を待ってもクライアントがサーバーから FIN を再度受信しない場合、クライアントは接続を閉じて CLOSED 状態に変わります。

なぜ長い TIME-WAIT が必要なのでしょうか? 1. 4 タプルを再利用する新しい接続が遅延した古いパケットを受信するのを回避できます 2. サーバーが閉じられていることを確認できます。

TIME-WAIT 時間が 2*MSL (最大セグメント生存時間、RFC793 では MSL が 2 分と定義されており、Linux では 30 秒に設定されています) であるのはなぜですか? FIN を送信した後、ACK の待機がタイムアウトすると、サーバーは再送信します。FIN の生存 MSL 時間は最も長く、再送信はこれより前に行われる必要があります。再送信された FIN の生存 MSL 時間も最も長くなります。したがって、MSL 時間を 2 倍にしても、クライアントはまだサーバーからの再送信を受信して​​いません。これは、サーバーが ACK を受信して​​閉じたことを示しているため、クライアントを閉じることができます。

切断により発生するTIME-WAITが多すぎる場合はどうすればよいですか?

Linux はデフォルトで接続を閉じる前に 1 分間待機することがわかっていますが、この時点ではポートは常に占有されています。同時に大量の短い接続がある場合、TIME-WAIT が多すぎると、ポートがいっぱいになったり、CPU が過度に占有されたりする可能性があります。

最後の 2 つの構成は使用しないことを強くお勧めします。

  • tcp_max_tw_buckets は、同時 TIME-WAIT の数を制御します。デフォルト値は 180000 です。それを超えると、システムはログを破棄して記録します。
  • ip_local_port_range、クライアントのポート範囲を増やします
  • 可能であれば、サーバーのサービス ポートを増やします (TCP 接続は IP とポートに基づいており、値が大きいほど、より多くの接続が利用可能になります)
  • 可能であれば、クライアントまたはサーバーの IP を増やします。
  • tcp_tw_reuse は、使用する前にクライアントとサーバーの両方でタイムスタンプを有効にする必要があります。これはクライアントでのみ有効です。オープン後は TIME-WAIT を待つ必要はなく、1 秒しかかかりません。新しい接続はこのソケットを直接再利用できます。タイムスタンプを有効にする必要があるのはなぜですか? 古い接続のパケットは一巡して最終的にサーバーに到達する可能性があり、ソケットを再利用する新しい接続の 5 つ組は古いパケットと同じであるため、タイムスタンプが新しいパケットよりも早い限り、それはパケットである必要があります。古い接続の無効化は回避できますが、役に立たない古いパッケージが誤って受け入れられました。
  • tcp_tw_recycle、tcp_tw_recycle 処理はより積極的で、TIME_WAIT 状態のソケットを迅速にリサイクルします。高速リサイクルは、tcp_timestamps と tcp_tw_recycle の両方が有効な場合にのみ発生します。クライアントが NAT 環境経由でサーバーにアクセスする場合、サーバーがアクティブに閉じられた後に TIME_WAIT 状態が生成されます。サーバーで tcp_timestamps オプションと tcp_tw_recycle オプションの両方がオンになっている場合、同じソース IP ホストからの TCP セグメンテーション時間は 60 秒以内になります。秒 スタンプは増分する必要があります。増分しない場合、スタンプは破棄されます。Linux では、カーネル バージョン 4.12 以降、tcp_tw_recycle 構成が削除されました。

tcp スライディング ウィンドウとフロー制御

オペレーティング システムは TCP 用のキャッシュ領域をオープンしており、これにより TCP によって送受信されるデータ パケットの最大数が制限されます。これはスライディング ウィンドウとして視覚化できます。送信者のウィンドウは送信ウィンドウ swnd と呼ばれ、受信者のウィンドウは受信ウィンドウ rwnd と呼ばれます。送信されたが ACK を受信しなかったデータの長さ + 送信されるバッファリングされたデータの長さ = 送信ウィンドウの合計長。 

送信ウィンドウ

ハンドシェイク中に、両端がウィンドウ値を交換し、最終的には最小値が採用されます。送信者のウィンドウ サイズが 20 で、最初に 10 個のパケットが送信されるとしますが、ACK はまだ受信されていないため、今後バッファに入れることができるのはさらに 10 個のパケットのみであるとします。バッファがいっぱいになると、それ以上データを送信できなくなります。受信側もデータを受信すると、そのデータをバッファに入れます。処理能力がピアの送信能力よりも低い場合、バッファが積み重なり、利用可能な受信ウィンドウが小さくなります。ACK によって運ばれるウィンドウ値により、送信側は許可されます。送信されるデータ量を減らすため。また、オペレーティング システムはバッファのサイズも調整しますが、このとき、本来利用可能な受信ウィンドウは 10 であり、ACK を通じてピアに通知されているのに、オペレーティング システムが突然バッファを縮小するという状況が発生する可能性があります。代わりに、利用可能な受信ウィンドウが 15 減少します。私には 5 の借金があります。送信者は、利用可能なウィンドウが 10 であることを以前に受信しているため、データは引き続き送信されますが、受信者がデータを処理できないため、タイムアウトになります。この状況を回避するために、TCP は、オペレーティング システムがバッファを変更したい場合は、変更された使用可能なウィンドウを事前に送信する必要があることを強制します。

上記の内容から、TCP は両端のウィンドウで送信トラフィックを制限していることが分かりますが、ウィンドウが 0 の場合は、送信を一時的に停止する必要があることを意味します。受信側のバッファがいっぱいでウィンドウ 0 の ACK が送信され、受信側が受信できるようになる時間が経過すると、ウィンドウが 0 以外の ACK が送信され、送信を続けるように送信者に通知されます。この ack が失われると非常に深刻で、送信者は受信者がそれを受信できることを決して知らず、待ち続けてデッドロック状態に陥ります。この問題を回避するために、TCP の設計では、送信者に送信を停止するように通知された後 (つまり、ウィンドウ 0 の ACK を受信した後)、タイマーを開始し、30 回ごとにウィンドウ プローブ (ウィンドウ プローブ) を送信します。 -60 秒。メッセージを受信した後、受信者は現在のウィンドウに応答する必要があります。ウィンドウ検出が 3 回連続して 0 である場合、一部の TCP 実装は RST パケットを送信して接続を中断します。

受信者のウィンドウがすでに非常に小さい場合、送信者は引き続きこのウィンドウを使用してデータを送信します。tcp ヘッダー + ip ヘッダーは 40 バイトで、データは数バイトしかない可能性があり、非常に不経済です。この状況を回避するにはどうすればよいでしょうか? 10 進ドラマ パッケージを最適化する方法を見てみましょう。        

TCP小さなパケット

受信側の場合、小さなウィンドウでの送信が許可されていない限り、受信側は通常次の戦略をとります。受信ウィンドウが MSS およびキャッシュ スペース/2 の最小値より小さい場合、ウィンドウが小さいことをピアに通知します。 0 になり、ウィンドウがその条件より大きくなるまでデータの送信を停止します。

送信側では、Nagle アルゴリズムを使用して、送信前に次の 2 つの条件のうち 1 つだけが満たされます。

  • ウィンドウ サイズ >= MSS および合計データ サイズ >= MSS
  • 以前に送信したデータの確認応答を受信する

いずれも満たされない場合はデータを蓄積し続け、特定の条件が満たされたときにデータをまとめて送信します。

疑似コードは次のとおりです

if there is new data to send then
    if the window size ≥ MSS and available data is ≥ MSS then
        send complete MSS segment now
    else
        if there is unconfirmed data still in the pipe then
            enqueue data in the buffer until an acknowledge is received
        else
            send data immediately
        end if
    end if
end if

Nagle アルゴリズムはデフォルトでオンになっていますが、データが小さく対話が多い ssh などのシナリオでは、遅延 ACK が発生すると Nagle が非常に悪くなるため、オフにする必要があります。(Nagle アルゴリズムにはグローバルなシステム構成がないため、それぞれのアプリケーションに応じてオフにする必要があります)

小規模データの最適化について話した後、スライディング ウィンドウについて話しましょう。実際、tcp が最終的に採用するウィンドウは、スライディング ウィンドウによって完全に決定されるわけではありません。スライディング ウィンドウは、両端が送受信能力を超えないようにするだけです。ネットワーク両端間の条件も考慮する必要があります。両方が送受信する場合、機能は非常に強力ですが、現時点ではネットワーク環境が非常に悪いです。大量のデータを送信すると、ネットワークがさらに混雑するだけです。 TCP はスライディング ウィンドウと輻輳ウィンドウの最小値を取得します。

tcp スロースタートと輻輳回避

まず、MSS とは何かについて説明します. MSS は、TCP セグメントの最大許容データ バイト長であり、MTU (ハードウェアによって指定されるデータ リンク層の最大データ長) から IP ヘッダーの 20 バイトを引いた値です。 TCP ヘッダー 20 バイト単位で計算すると、通常は 1460 になります。これは、TCP パケットが最大 1460 バイトの上位層データを伝送できることを意味します。最小 MSS は、TCP ハンドシェイク中に両端でネゴシエートされます。実際のネットワーク環境では、リクエストは多くの中間機器を経由し、SYNのMSSはそれらによって変更され、最終的には両端の最小値だけではなく、経路全体の最小値となります。

TCP にはネットワークの輻輳を回避する役割を担う cwnd (輻輳ウィンドウ) があり、その値は TCP セグメント サイズの整数倍で、TCP が一度に送信できるパケット数を表します (便宜上、1 から開始して表します)。その初期値は非常に小さいですが、利用可能なネットワーク送信リソースを検出するためにパケット損失と再送信が発生するまで徐々に増加します。従来のスロー スタート アルゴリズムでは、高速確認応答モードで、確認 ACK が正常に受信されるたびに cwnd + 1 が加算されるため、スロー スタートしきい値 ssthresh が設定されるまで、cwnd は 1、2、4、8、16... と指数関数的に増加します。 (スロー スタートしきい値)、ssthresh は通常 max (外部データ値/2、2*SMSS) に等しく、SMSS は送信者の最大セグメント サイズです。cwnd < ssthresh の場合はスロー スタート アルゴリズムが使用され、cwnd >= ssthresh の場合は輻輳回避アルゴリズムが使用されます。

輻輳回避アルゴリズムでは、確認 ACK が受信されるたびに、cwnd が 1/cwnd ずつ増加します。つまり、最後に送信されたすべてのパケットが確認され、cwnd + 1 になります。スロー スタート アルゴリズムとは異なり、輻輳回避アルゴリズムは 2 種類の再送信が発生するまで直線的に増加し、その後減少します。1. タイムアウト再送信が発生し、2. 高速再送信が発生します。

高速/遅延ACK、タイムアウト再送信、高速再送信

高速確認応答モードでは、受信側はパケットの受信直後に確認 ACK を送信しますが、TCP はパケットを受信するたびに確認 ACK を返すわけではないため、ネットワーク帯域幅が無駄になります。TCP は遅延確認応答モードに入る場合もあります。受信側は遅延 ACK タイマーを開始し、200 ミリ秒ごとに ACK を送信するかどうかを確認します。送信するデータがある場合は、ACK とマージすることもできます。送信者が一度に複数のパケットを送信すると仮定すると、ピアは 10 個の ack で応答することはできず、受信した最大の連続パケットの最後の ack でのみ応答します。たとえば、1、2、3、...10 が送信された場合、受信側はそれらをすべて受信するため、10 の ack で応答します。これにより、送信側は最初の 10 がすべて受信され、次の 10 が受信されたことがわかります。 1 つは 11 から開始されます。途中でパケットロスがあった場合は、パケットロス前のackを返します。

タイムアウト再送信:送信者は送信後にタイマーを開始します。タイムアウト時間 (RTO) は 1 RTT (パケット往復時間) よりわずかに大きく設定するのが適切です。受信 ACK がタイムアウトした場合、データ パケットは再送信されます。再送信データがタイムアウトした場合、タイムアウト時間は2倍となります。このとき、ssthresh は cwnd/2 となり、cwnd は初期値にリセットされ、スロースタートアルゴリズムが使用されます。cwnd が崖から落ちていることがわかり、タイムアウト再送の発生はネットワークのパフォーマンスに大きな影響を与えます。再送信する前に RTO を待つ必要がありますか?

高速再送信: TCP は高速再送信設計になっています。受信者がパケットを順番に受信しない場合、受信者は最大の連続 ack で応答します。送信者がそのような ack を 3 つ続けて受信すると、パケットが失われたと見なし、迅速に送信できます。スロー スタートに戻らずに、そのパケットを 1 回再送信します。たとえば、受信側は 1、2、4 を受信したため、ACK 2 で応答し、次に 5 と 6 を受信しました。3 が途中で中断されたため、ACK 2 で 2 回応答しました。送信者は同じ ack を 3 回連続で受信したため、3 つが失われたことがわかり、すぐに 3 つを再送信しました。受信者は 3 を受信し、データは連続しているため、6 の ack が返され、送信者は 7 から送信を続けることができます。ちょうど下の写真のようになります。

高速再送信が発生した場合:

  1. ssthresh = cwnd/2、cwnd = ssthresh + 3、失われたパケットの再送信を開始し、高速回復アルゴリズムに入ります。+3 の理由は、3 つの重複 ACK が受信されたことです。これは、現在のネットワークがこれら 3 つの追加パケットを少なくとも正常に送受信できることを示しています。
  2. 重複した ACK を受信すると、輻輳ウィンドウが 1 増加します。
  3. 新しいデータ パケットの ACK が受信されると、最初のステップで cwnd が ssthresh の値に設定されます。

高速再送信アルゴリズムは 4.3BSD の Tahoe バージョンで、高速リカバリは 4.3BSD の Reno バージョンで最初に登場し、Reno バージョンの TCP 輻輳制御アルゴリズムとも呼ばれます。Reno の高速再送アルゴリズムは 1 つのパケットの再送を目的としていることがわかりますが、実際には、再送タイムアウトにより多くのデータ パケットの再送が発生する可能性があるため、1 つのデータ ウィンドウから複数のデータ パケットが失われた場合に問題が発生します。再送信および高速回復アルゴリズムがトリガーされます。そこで登場したのが NewReno で、Reno の高速回復をベースに少し修正されており、ウィンドウ内で複数のパケット損失を回復することができます。具体的には: Reno は新しいデータの ACK を受信すると高速リカバリ状態を終了しますが、NewReno は高速リカバリ状態を終了する前にウィンドウ内のすべてのデータ パケットから確認を受信する必要があるため、スループットがさらに向上します。

TCPを「正確に」再送信する方法

部分的なパケット損失が発生した場合、送信者はどのパケットが部分的または完全に失われたのかを知りません。たとえば、受信側が 1、2、4、5、6 を受信した場合、送信側は ack を通じて 3 以降のパケットが失われたことを知り、高速再送信をトリガーできます。この時点で 2 つの決定があります。 1. 3 番目のパケットのみを再送信します。2. パケット 4、5、6... も失われたかどうかはわかりません。そのため、3 以降のすべてを単純に再送信します。どちらのオプションもあまり良い方法ではなく、3 つだけ再送信した場合、後で本当に紛失した場合、それぞれが再送信を待つ必要があります。しかし、すべてを直接再送信する場合、3 つだけが失われるのは無駄です。どのように最適化すればよいでしょうか?

高速再送信は、タイムアウト再送信がトリガーされる可能性を減らすだけであり、高速再送信もタイムアウト再送信も、1 つまたはすべてを再送信するかどうかを正確に知る問題を解決するものではありません。Selective Acknowledgment (SACK) と呼ばれるより優れた方法があり、これは両端でサポートされる必要があり、Linux は net.ipv4.tcp_sack パラメーターを通じてこれを切り替えます。SACK は tcp ヘッダーにデータを追加して、最大連続セグメントに加えてどのデータ セグメントが受信されたかを送信者に知らせるため、送信者はデータを再送信する必要がないことがわかります。百聞は一見にしかず:

Duplicate SACK (D-SACK) もあります。受信側の確認応答 ACK が失われると、送信側は受信側が受信していないと誤って認識し、タイムアウトが発生して再送が発生し、受信側は重複したデータを受信することになります。または、送信パケットがネットワークの輻輳に遭遇したため、再送信されたパケットが前のパケットより早く到着し、受信側も重複したデータを受信することになります。このとき、TCP ヘッダーに SACK データを追加できます。値は繰り返しデータ セグメントの範囲です。データ セグメントは ACK よりも小さいため、送信側は受信側がデータを受信したことを認識し、再送信しません。

 Linux では、D-SACK のオンとオフは net.ipv4.tcp_dsack パラメータによって切り替えられます。

要約すると、SACK と D-SACK の機能は、どのパケットが受信されていないのか、またパケットが繰り返し受信されたのかを送信者に知らせることであり、データ パケットが失われたかどうか、ACK が失われたかどうか、データが失われたかどうかを判断できます。パケットがネットワークによって遅延しているか、ネットワークが中断されているため、データ パケットがコピーされました。

より強力なキャッシュ: Service Worker

前述の HTTP キャッシュ制御は主にバックエンドで行われ、キャッシュの有効期限が切れた場合、ネゴシエートされたキャッシュはありますが、多かれ少なかれリクエストがまだ存在するため、ネットワークが必要となり、通常は get リクエストのみをキャッシュできます。これらの制限により、フロントエンドはクライアントなどのローカル アプリケーションを実行できなくなります。それでは、フロントエンドを完全にプロキシ キャッシュにする方法はありますか? 静的リソースであろうと API インターフェイスであろうと、すべてはフロントエンド自体によって決定できます。Web ページをアプリのような完全なローカル アプリケーションに変えることもできます。 。これが次に説明する Service Worker です。どのような機能があるかを見てみましょう。

オフラインキャッシュ

Service Worker はアプリケーションとネットワーク リクエストの間のプロキシと見なすことができ、リクエストをインターセプトし、ネットワークが利用可能かどうかやその他のカスタム ロジックに基づいて適切なアクションを実行します。たとえば、アプリケーションを初めて開いた後、HTML、CSS、JS、画像、その他のリソースをキャッシュできます。次に Web ページを開いたときに、リクエストをインターセプトしてキャッシュに直接返します。オフラインで開くことができます。後でデバイスがインターネットに接続された場合は、バックグラウンドで最新のリソースを要求し、更新されたかどうかを確認できます。更新されている場合は、ユーザーに更新してアップグレードするよう通知できます。起動に関しては、Service Worker を使用するフロントエンド アプリケーションは、クライアント アプリケーションと同様にネットワークをまったく必要としません。

プッシュ通知

Service Worker は、リクエストをプロキシするだけでなく、アプリ通知と同様に、ブラウザーにアクティブに通知を送信させることもできます。この機能を使用して、「ユーザーの呼び出し」や「ホット通知」などを行うことができます。

禁止事項

メインの js コードはレンダリング スレッドで実行され、Service Worker は別のワーカー スレッドで実行されるため、メイン スレッドがブロックされることはありませんが、dom の操作など、一部の API が使用できなくなる原因にもなります。同時に、完全に非同期になるように設計されているため、XHR や Web Storage などの同期 API は使用できませんが、フェッチ リクエストが使用できます。動的 import() も不可能で、静的インポート モジュールのみが可能です。

セキュリティ上の理由から、Service Worker は HTTPS プロトコルでのみ実行できます (http を許可するには localhost を使用します)。結局のところ、Service Worker のリクエストを引き継ぐ機能はすでに非常に強力です。仲介者によって悪意を持って改ざんされた場合でも、一般のユーザーがこれを行うことができます。 . Web ページは正しいコンテンツを表示しません。FireFox では、シークレット モードでも利用できません。

説明書

Service Worker コードは独立した js ファイルである必要があり、https リクエストを通じてアクセスできます。開発環境にいる場合は、http://localhost などのアドレスからのアクセスを許可できます。これらを準備したら、まずプロジェクト コードに登録する必要があります。

if ("serviceWorker" in navigator) {
  navigator.serviceWorker.register("/js/service-worker.js", {
    scope: "../",
  });
} else {
  console.log("浏览器不支持Service Worker");
}

Webサイトのアドレスがhttps://www.xxx.comで、Service Workerのjsがhttps://www.xxx.com/js/service-worker.jsと/js/service-workerに用意されているとします。 .js は実際には、リクエストは https://www.xxx.com/js/service-worker.js です。構成内のスコープは、Service Worker が有効になるパスを示します。スコープが設定されていない場合は、デフォルトのルート ディレクトリが有効になります。Service Worker は、Web ページ上の任意のパスで使用されます。例の記述方法では、./を設定した場合、実効パスは/js/*となり、../がルートディレクトリとなります。

Service Worker は次の 3 つのライフサイクルを通過します

  1. ダウンロード
  2. インストール
  3. 活性化

1 つ目はダウンロード段階です。Service Worker によって制御される Web ページに入ると、すぐにダウンロードが開始されます。以前にダウンロードしたことがある場合は、今回のダウンロード後に更新が決定される場合があります。更新は以下の状況で決定されます。

  1. スコープ内でページジャンプが発生しました
  2. Service Worker でイベントがトリガーされましたが、24 時間以内にダウンロードされていません。

ダウンロードしたファイルが新しいファイルであることが判明すると、Install のインストールが試行されます。新しいファイルかどうかの判断基準は、まずダウンロードし、古いファイルとバイト単位で比較することです。

Service Worker を初めて使用する場合は、インストールが試行され、インストールが成功するとアクティブ化されます。

古い Service Worker がすでに使用されている場合、Service Worker はバックグラウンドでインストールされ、インストール後も起動されず、この状態を Worker 待機中と呼びます。古い JS と新しい JS に論理的な矛盾がある可能性があることを想像してください。古い JS はしばらく実行されているため、古い JS を新しい JS に直接置き換えて Web ページを実行し続けると、Web ページが直接クラッシュする可能性があります。

新しい Service Worker はいつアクティブ化されますか? 新しい Service Worker がアクティブな Worker になる前に、古い Service Worker を使用しているすべてのページが閉じるまで待つ必要があります。ServiceWorkerGlobalScope.skipWaiting() を使用して待機を直接スキップすることもできます。 Clients.claim() を使用すると、新しい Service Worker が現在存在するページ (古い Service Worker を使用しているページ) を制御できるようになります。

イベントをリッスンすることで、インストールまたはアクティブ化がいつ行われるかを知ることができます。最も一般的に使用されるイベントは、ページがリクエストを開始したときにトリガーされる FetchEvent です。また、Cache を使用してデータをキャッシュし、FetchEvent.respondWith() を使用してリクエストを返すこともできます。必要な戻り値。以下は、キャッシュ リクエストを記述する一般的な方法です。

// 缓存版本,可以升级版本让过去的缓存失效
const VERSION = 1;

const shouldCache = (url: string, method: string) => {
  // 你可以自定义shouldCache去控制哪些请求应该缓存
  return true;
};

// 监听每个请求
self.addEventListener("fetch", async (event) => {
  const { url, method } = event.request;
  event.respondWith(
    shouldCache(url, method)
      ? caches
          // 查找缓存
          .match(event.request)
          .then(async (cacheRes) => {
            if (cacheRes) {
              return cacheRes;
            }
            const awaitFetch = fetch(event.request);
            const awaitCaches = caches.open(VERSION);
            const response = await awaitFetch;
            const cache = await awaitCaches;
            // 放进缓存
            cache.put(event.request, response.clone());
            return response;
          })
          .catch(() => {
            return fetch(event.request);
          })
      : fetch(event.request)
  );
});

上記のコード キャッシュは、確立された後は更新されません。コンテンツが変更される可能性があり、キャッシュが古くなることを心配する場合は、最初にキャッシュに戻って、ユーザーができるだけ早くコンテンツを確認できるようにすることができます。次に、Service Worker バックグラウンドで最新のものを要求します。データはキャッシュ内で更新され、最後にメインスレッドにコンテンツが更新されたことがユーザーに通知され、ユーザーはアプリケーションをアップグレードするかどうかを決定できるようになります。バックグラウンドリクエストや更新判定のコードは自分で書いてみるのも良いですが、ここでは主にService Workerがリクエスト内容が更新されたことをメインスレッドに伝える仕組みについて説明します。

Service Worker がメインスレッドと通信する方法

なぜ通信が必要なのでしょうか? まず、デバッグしたい場合、ワーカー スレッドの console.log は DevTools に表示されません。次に、Service Worker リソースが更新された場合、更新するかどうかをユーザーに通知するメッセージをページにポップアップ表示できるように、メイン スレッドに通知する必要があります。したがって、コミュニケーションはビジネス上必要なものである可能性があります。Service Worker は別のスレッドであるため、メインスレッドと直接通信することはできません。ただし、通信の問題を解決すると、多くの素晴らしい用途が可能になり、たとえば、同じサイト上の複数のページが Service Worker スレッドを使用してページ間で通信できます。では、通信の問題を解決するにはどうすればよいでしょうか? メッセージ チャネル new MessageChannel() を作成します。これには、メッセージを個別に送受信できる 2 つのポートがあります。ポートの 1 つである port2 を Service Worker に与え、port1 のポートはオンのままにしておきます。メインスレッドにアクセスすると、このチャネルを通じて通信できます。次のコードは、2 つのスレッドが相互に通信して、「ワーカー スレッド ログの印刷」、「コンテンツ更新の通知」、「アプリケーションのアップグレード」などの機能を実現する方法を示します。 

メインスレッドのコード 

const messageChannel = new MessageChannel();

// 将port2交给控制当前页面的那个Service Worker
navigator.serviceWorker.controller.postMessage(
  // "messageChannelConnection"是自定义的,用来区分消息类型
  { type: "messageChannelConnection" },
  [messageChannel.port2]
);

messageChannel.port1.onmessage = (message) => {
  // 你可以自定义消息格式来满足不同业务
  if (typeof message.data === "string") {
    // 可以打印来自worker线程的日志
    console.log("from service worker message:", message.data);
  } else if (message.data && typeof message.data === "object") {
    switch (message.data.classification) {
      case "content-update":
        // 你可以自定义不同的消息类型,来做出不同的UI表现,比如『通知用户更新』
        alert("有新内容哦,你可以刷新页面查看");
        break;
      default:
        break;
    }
  }
};

 Service Worker のコード

let messageChannelPort: MessagePort;

self.addEventListener("message", onMessage);

// 收到消息
const onMessage = (event: ExtendableMessageEvent) => {
  if (event.data && event.data.type === "messageChannelConnection") {
    // 拿到了port2保存起来
    messageChannelPort = event.ports[0];
  } else if (event.data && event.data.type === "skip-waiting") {
    // 如果主线程发出了"skip-waiting"消息,这里就会直接更新Service Worker,也就让应用升级了。
    self.skipWaiting();
  }
};

// 发送消息
const postMessage = (message: any) => {
  if (messageChannelPort) {
    messageChannelPort.postMessage(message);
  }
};

ファイル圧縮、画像パフォーマンス、デバイスのピクセル適応

js、css、画像などのリソース ファイルを圧縮すると、サイズが大幅に削減され、ネットワーク パフォーマンスが大幅に向上します。通常、バックエンド サービスは圧縮ヘッダーを自動的に構成しますが、より効率的な圧縮アルゴリズムに切り替えて圧縮率を高めることもできます。

コンテンツエンコーディング

Web サイトを開いてそのリソース ネットワークを見ると、応答ヘッダーに content-encoding ヘッダーがあることがわかります。これには、gzip、compress、deflate、identity、br およびその他の値を指定できます。圧縮なしを表す ID に加えて、ファイルを圧縮する他の値を設定して http 送信を高速化できます。最も一般的なのは gzip です。互換性サポートにより、br (Brotli) などのいくつかの新しい圧縮形式を具体的に設定して、gzip を超える圧縮率を達成できます。

フォントファイル

ページに特殊なフォントが必要で、ページ上のテキストが固定または小さい場合 (たとえば、文字と数字のみ)、必要なテキストのみが含まれるようにフォント ファイルを手動でトリミングでき、大幅な削減が可能です。ファイルサイズです。

ページ上の単語が動的である場合、それがどのような単語になるかを知る方法はありません。ユーザーがテキストを入力するときにフォント効果をプレビューできるシナリオなど、適切なシナリオ。通常、ユーザーはいくつかの単語を入力するだけなので、フォント パッケージ全体を紹介する必要はありませんが、ユーザーが何を入力するかはわかりません。したがって、バックエンド (または、nodejs に基づいて bff のレイヤーを構築) に、数単語のみを含むフォント ファイルを動的に生成させ、必要な単語に基づいてそれを返すことができます。もう 1 つのクエリ リクエストがありますが、数 MB、さらには 10 MB を超えるフォント ファイルのサイズを数 kb に縮小できます。

画像フォーマット

通常、画像は上記の方法では圧縮されません。これらの画像形式はすでに圧縮されており、再度圧縮してもあまり効果がないからです。したがって、画像形式の選択が画像サイズと画質に影響を与える鍵となります。一般に、圧縮率が小さいほど時間がかかり、画質が低下します。新しい形式は古い形式よりもすべての機能が優れている可能性がありますが、互換性は低くなります。したがって、バランスを見つける必要があります。

画像形式に関しては、一般的な PNG-8/PNG-24、JPEG、GIF に加えて、他のいくつかの新しい画像形式にも注目しています。

  • WebP
  • JPEG XL
  • AVIF

表を使用して、画像タイプ、透明度チャネル、アニメーション、エンコードとデコードのパフォーマンス、圧縮アルゴリズム、カラー サポート、メモリ使用量、互換性の観点からそれらを比較します。

 

 技術開発の観点からは、WebP、JPEG XL、AVIF などの比較的新しい画像形式の使用が優先されます。JPEG XL は従来の画像形式に代わるものとして非常に有望ですが、互換性はまだ非常に悪いです。AVIF 互換性は JPEG XL よりも優れており、圧縮後も高画質を維持し、迷惑な圧縮アーティファクトやその他の問題を回避します。ただし、デコードおよびエンコードの速度は JPEG XL ほど速くなく、プログレッシブ レンダリングはサポートされていません。WebP は基本的に IE を除くすべてのブラウザでサポートされており、複雑な画像 (写真など) の場合、WebP 可逆エンコードのパフォーマンスは良くありませんが、非可逆エンコードのパフォーマンスは非常に優れています。同様の品質の WebP の画像デコード速度は JPEG XL とそれほど変わりませんが、ファイル圧縮率は大幅に向上します。したがって、現時点では、Web サイトの画像パフォーマンスを向上させたい場合は、従来の形式ではなく WebP を使用する方が良いようです。        

絵素の使用

それでは、いくつかの最新の画像形式をサポートするブラウザーで、上記で説明した WebP、AVIF、JPEG XL に似た画像形式を使用するのに自動的に役立つものはありますが、サポートしていないブラウザーは通常の JPEG、PNG 方式にフォールバックするのでしょうか? HTML5 仕様では、新しい Picture Element が追加されています。<picture> 要素には、0 個以上の <source> 要素と <img> 要素を含めることで、さまざまなディスプレイ/デバイス シナリオに対応した画像のバージョンが提供されます。ブラウザーは、最も一致する子 <source> 要素を選択します。一致するものがない場合は、<img> 要素の src 属性内の URL を選択します。選択した画像は、<img> 要素が占めるスペースにレンダリングされます。 

<picture>
  <!-- 可能是一些对兼容性有要求的,但是性能表现更好的现代图片格式-->
  <source src="image.avif" type="image/avif" />
  <source src="image.jxl" type="image/jxl" />
  <source src="image.webp" type="image/webp" />

  <!-- 最终的兜底方案-->
  <img src="image.jpg" type="image/jpeg" />
</picture>

画像サイズの適応: 物理ピクセル、デバイスに依存しないピクセル

優れた画像パフォーマンスが必要な場合は、さまざまなサイズの要素に適切な画像サイズを使用する必要があります。500*500 の画像を 100*100 ピクセルの領域に表示すると、これは明らかに無駄です。逆に、500*500 ピクセルの 100*100 の画像は非常にぼやけてしまい、ユーザー エクスペリエンスが低下します。サイズ適応について話す前に、まずデバイス非依存ピクセルと物理ピクセルとは何なのか、そして DPR とは何なのかについて話す必要があります。

CSS で width: 100px と記述すると、実際に画面に表示されるのは、長さ 100px のデバイスに依存しないピクセル (論理ピクセルとも呼ばれます) であり、必ずしも画面上の 100 ピクセル (物理ピクセル) であるとは限りません。元のディスプレイでは、デバイスに依存しないピクセルと物理ピクセルは 1:1 でした。つまり、幅: 1px が画面上の 1 ピクセルの発光点に対応します。その後のディスプレイ技術の発展により、同じサイズの画面の画素はますます微細化し、本来の1画素の位置は4画素で構成されるようになったのかもしれません。これにより、ピクセル密度が高まり、視覚的なエクスペリエンスが向上しますが、問題も発生します。以前と同様に width: 1px がピクセル ライト ポイントを表す場合、ピクセルが小さくなったため、このデバイスでは同じページが縮小されます。この問題を解決するために、メーカーは、実際のピクセルではなく論理ピクセルであるデバイス非依存ピクセルの概念を作成しました。デバイス上の 1 ピクセルが 2 つの小さなピクセルに置き換えられた場合、デバイスのデバイス ピクセル比 (DPR) は 2 になり、幅 1 ピクセルで描画された画像は 2 ピクセルで描画されるため、サイズと一貫性を保つこと。同様に、より細かい画面を備えたデバイスでは、従来の 1 ピクセル サイズではなく 3 つの小さなピクセルで構成されていると仮定すると、その DPR は 3 になり、width: 1px は実際には 3 ピクセルで描画されます。これで、面接官が「1 ピクセルの境界線を引く方法」のような質問をした理由が理解できました。高 DPR では、1 ピクセルは実際には 1 ピクセルではないからです。

したがって、次のピクセル方程式を得ることができます: 1 CSS ピクセル = 1 デバイス非依存ピクセル = 物理ピクセル * DPR。

さまざまな DPR 画面に適切な画像を提供する

したがって、img 要素はすべて 100 ピクセルですが、表示する必要がある最適な画像サイズは実際には DPR デバイスによって異なります。DPR = 2 の場合は 200px の画像が表示され、DPR = 3 の場合は 300px の画像が表示されます。そうしないと、ぼやけた状態が発生します。

では、考えられる解決策にはどのようなものがあるでしょうか?

オプション 1: シンプルで粗雑な複数のグラフ

現在、一般的なデバイスの最高 DPR は 3 であるため、最も簡単な方法は、デフォルトで最高の 3 倍の画像表示を使用することです。しかし、これは帯域幅の大量の無駄を引き起こし、ネットワークのパフォーマンスを低下させ、ユーザーエクスペリエンスを低下させることになるため、この記事の「スタイル」とは明らかに一致しません。

オプション 2: メディアからの問い合わせ

@media メディア クエリを使用して、現在のデバイスの DPR に基づいて異なる CSS を適用できます。

#img {
  background: url([email protected]);
}
@media (device-pixel-ratio: 2) {
  #img {
    background: url([email protected]);
  }
}
@media (device-pixel-ratio: 3) {
  #img {
    background: url([email protected]);
  }
}

 このソリューションの利点は、異なる DPR の下で異なる倍率で画像を表示できることです。

このソリューションの欠点は次のとおりです。

  • 論理的な分岐が多く、市場には DPR = 2 または 3 のデバイスだけでなく、10 進数の DPR を持つデバイスも存在するため、すべてをカバーするには大量のコードを記述する必要があります。
  • 構文の互換性の問題。たとえば、一部のブラウザでは -webkit-min-device-pixel-ratio です。自動プレフィクサーを使用して解決できますが、追加コストも発生します。

 オプション 3: CSS 画像セット構文

#img {
  /* 不支持 image-set 的浏览器*/
  background-image: url("../[email protected]");

  /* 支持 image-set 的浏览器*/
  background-image: image-set(
    url("./[email protected]") 2x,
    url("./[email protected]") 3x
  );
}

 このうち、2x と 3x は異なる DPR に一致します。イメージセット ソリューションの欠点はメディア クエリの欠点と同じであるため、詳細については説明しません。利点は、メディア クエリよりもニッチであり、波のふりをできることです。

オプション 4: srcset 要素属性

<img src="[email protected]" srcset="[email protected] 2x, [email protected] 3x" />

内側の 2x と 3x は異なる DPR の一致を示し、[email protected] が最下行です。メリット、デメリットはimage-setと同じですが、メリットとしてはcssを書く必要がなく、簡潔な点かもしれません。

オプション 5: srcset 属性とsizes 属性の組み合わせ 

<img
  sizes="(min-width: 600px) 600px, 300px"
  src="[email protected]"
  srcset="[email protected] 300w, [email protected] 600w, [email protected] 900w"
/>

size="(min-width: 600px) 600px, 300px" は、画面の現在の CSS ピクセル幅が 600px 以上の場合、画像の CSS 幅は 600px であることを意味します。それ以外の場合、画像の CSS 幅は 300px です。レイアウトが柔軟であるため、画面サイズが異なると img 要素のサイズが異なる場合があります。上記の他の解決策は DPR に基づいてのみ判断でき、これを実現することはできません。サイズでは、@media が幅のしきい値に基づいて画像の幅を実際に変更することも必要です。

srcset="[email protected] 300w, [email protected] 600w, [email protected] 900w" 中の 300w、600w、900w は幅記述子と呼ばれます。DPR が 2 のデバイスを使用しており、img 要素の CSS ピクセルがサイズに基づいて 300 である場合、実際の物理ピクセルは 600 であるため、600w の画像が使用されます。

このソリューションの欠点は以前と同じで、DPR ごとに異なるピクチャを書き込む必要があることです。ただし、レスポンシブレイアウトの img 要素のサイズに応じて実際の画像解像度を柔軟に変更できるという独自の利点があります。したがって、私はオプション 5 をお勧めします。

画像の遅延読み込みと非同期デコード

画像の遅延読み込みとは、ページがターゲット領域までスクロールしていない場合、そこにある画像は要求されず、表示されず、表示領域内のコンテンツの表示が高速化されることを意味します。現在のフロントエンドの仕様は非常に充実しており、画像の遅延読み込みを実装するための js や html などのメソッドが用意されています。 

オプション 1: js で onscroll を使用する

これは単純かつ粗雑な解決策で、getBoundingClientRectAPI を通じてビューポートの上部からページ上のすべての画像の距離を取得し、onscroll イベントを通じてページのスクロールを監視し、ビューポートの高さに基づいて表示領域にどの画像が表示されるかを計算します。 、img要素のsrc属性を設定し、画像の読み込みを制御する値を設定します。

このソリューションの利点は、ロジックがシンプルで理解しやすく、新しい API が使用されず、互換性が良好であることです。

このソリューションの欠点は次のとおりです。

  1. jsを導入する必要があるためコード量と計算コストがかかる
  2. すべての画像要素の位置情報を取得する必要があるため、追加のリフローが発生する可能性があります
  3. スクロールを常に監視し、コールバックを頻繁にトリガーする必要がある
  4. スクロール リストがページ内でネストされている場合、このソリューションではネストされたスクロール リスト内の要素の表示/非表示を知ることができないため、より複雑な記述が必要になります。

オプション 2: js で IntersectionObserver を使用する

Intersection Observer (交差オブザーバー) は、HTML5 の IntersectionObserver API を介して、監視要素の isIntersecting 属性と連携して、要素が表示領域内にあるかどうかを判断し、オンスクロールの監視よりも優れたパフォーマンスで画像の遅延読み込みソリューションを実装できます。監視されている要素は、表示領域に表示または非表示になるとコールバックをトリガーし、出現率のしきい値を制御することもできます。詳細については、mdn のドキュメントを参照してください。

このソリューションの利点は次のとおりです。

  1. onscroll よりもパフォーマンスがはるかに優れています スクロールを常に監視する必要がなく、要素の位置を取得する必要もありません 可視性は描画時にレンダースレッドによって認識され、js を通じて判断する必要がありません. この書き方の方が自然です。
  2. 要素の可視性を実際に知ることができます。たとえば、要素が上位レベルの要素によってブロックされている場合、たとえそれがすでに表示領域に表示されていたとしても、その要素は非表示になります。これは、オンスクロール ソリューションでは実行できないことです。

このソリューションの欠点は次のとおりです。

  1. jsを導入する必要があるためコード量と計算コストがかかる
  2. 古いデバイスには互換性がないため、ポリフィルを使用する必要があります

オプション 3: CSS スタイルのコンテンツの可視性

content-visibility: auto スタイルを持つ要素が現在画面上にない場合、その要素はレンダリングされません。この方法では、目に見えない領域の要素の描画とレンダリングの作業を軽減できますが、HTML の解析時に画像リソースが要求されるため、この CSS ソリューションは画像の遅延読み込みを真に実装することはできません。

解決策4: HTML属性loading=lazy

<img src="xxx.png" loading="lazy" />

画像非同期デコードソリューション

ご存知のとおり、jpeg、png などの画像はエンコードされており、GPU に認識してレンダリングさせたい場合はデコードする必要があります。特定の画像形式のデコードが非常に遅い場合、他のコンテンツのレンダリングに影響します。したがって、HTML5 では、ブラウザーに画像データの解析方法を指示するための新しいデコード属性が追加されました。

そのオプションの値は次のとおりです。

  • sync: 画像を同期的にデコードして、他のコンテンツと一緒に表示されるようにします。
  • async: 画像を非同期的にデコードして、他のコンテンツの表示を高速化します。
  • auto: デフォルト モード。デコード モードが優先されないことを示します。どちらの方法がユーザーにとってより適切であるかはブラウザーによって決まります。
<img src="xxx.jpeg" decoding="async" />

これにより、ブラウザが画像を非同期にデコードできるようになり、他のコンテンツの表示が高速化されます。これは画像最適化計画のオプションの部分です。

画像パフォーマンスの最適化の概要

一般に、画像のパフォーマンスを最適化するには、次のことを行う必要があります。

  1. 圧縮率が高く、デコード速度が速く、画質が良い画像形式を選択してください。
  2. 実際の DPR と要素サイズに応じて適切な画像解像度を調整します。
  3. 画像の遅延読み込みにはパフォーマンスの高いソリューションを使用し、状況に応じて非同期デコードを使用します。

ビルドツールの最適化

現在、人気のあるフロントエンド構築/パッケージング ツールは、古くからある 、 、 、webpack近年rollup人気が出てきたものvitesnowpack新しい勢力esbuildswcなど、数多くありますturbopackそれらの一部は js で実装されており、一部は go や Rust などの高パフォーマンス言語で書かれており、一部の構築ツールはオンデマンド パッケージ化に esm 機能を使用しています。ただし、これらは開発または構築時の速度の最適化であり、クライアントのパフォーマンスとはほとんど関係がないため、ここでは説明せず、主に本番環境のパッケージ化によるネットワーク パフォーマンスの最適化について説明します。これらのツールにはさまざまな構成がありますが、一般的に使用される最適化ポイントは、コード圧縮、コード分割、パブリック コード抽出、CSS 抽出、リソース使用 CDN などですが、構成方法は異なります。これについてはドキュメントを確認してください。箱から出してすぐに機能します。この言葉を聞いてもよくわからない人もいるかもしれないので、簡単に説明します。

コード圧縮とは、変数名を置き換えたり、改行を削除したり、スペースを削除したりして、コードを小さくすることを指します。

コード分​​割の目的は、たとえば、SPA では、ページ A がローカル ルーティングを通じてホームページからリダイレクトされるため、ユーザーが必ずしもジャンプするわけではないため、ホームページ上のメイン アプリケーションとともに A ページ コンポーネントをパッケージ化する必要がないことです。同時に、ホームページのパッケージのサイズが大きくなり、最初の画面の速度に影響します。したがって、一部のビルド ツールでは、動的インポート ( ) を使用できimport('PageA.js')、ビルド ツールはホームページで参照されるページ A コードを新しいパッケージ (たとえば、 ) にパッケージ化しますa.jsユーザーがホームページをクリックしてページAにジャンプすると、a.js内部のコンポーネントコードが自動的に要求され、ルートが切り替わって表示されます。React の nextjs フレームワークなど、一部のフレームワークはすぐに使用できるため、動的なインポートを記述する必要がなく、ルートを定義するだけで自動的にコードが分割されます。これは、コード分離の使用シナリオの 1 つにすぎません。つまり、特定のモジュール コードをメイン アプリケーションと一緒にパッケージ化したくない限り、それらを分割して、js パッケージの最初のバッチのパフォーマンスを向上させることができます。

a.js共通コード抽出の目的は、SPA を作成していると仮定して、ページ A、B b.jsおよび C で ramda ライブラリを使用し、コードがこれら 3 つのページに分割され、これらが 3 つの独立したパッケージになることですc.jsしたがって、通常のロジックによれば、依存関係として ramda ライブラリもこれら 3 つのパッケージに含まれます。これは、これら 3 つのページに理由もなく重複した ramda コードがあることを意味します。最善の方法は、ramda ライブラリをメイン アプリケーションに別個のパッケージとして配置し、要求が 1 回だけで済み、ABC がこのライブラリを使用できるようにすることです。たとえば、webpack では、モジュールが共通のチャンクとして別のパッケージに抽出される前に、モジュールが繰り返し依存される回数を定義できます。

optimization: {
  // split-chunk-plugin 是webpack内置的插件 作用是自动将多个入口用到的公共文件抽离出来单独打包
  splitChunks: {
    chunks: 'all',
    // 最小30kb
    minSize: 30000,
    // 被引用至少6次
    minChunks: 6,
  },
}

 ただし、webpack4 からは、モードを通じて最適化が自動的にサポートされるため、実際にはこのことについて心配する必要はありません。不必要な最適化を避けるために使用するビルド ツールのドキュメントについて詳しく読むことができます。

CSS 抽出の目的は、たとえば、Webpack で css-loader + style-loader のみを使用する場合、CSS が js にコンパイルされ、スタイルをレンダリングするときに js がスタイルの挿入に役立つことです。その場合、js は目に見えないほど大きくなり、css スタイルのレンダリングは js が実行されるまで遅延します。js は通常、ページの最後にパッケージ化されます。つまり、最後の js リクエストと実行が完了するまで、ページはスタイルがありません。理想的な状況は、css と dom が並行して解析およびレンダリングされることです。そのため、css を抽出する必要があります。css を個別に css ファイルにパッケージ化し、html の先頭のリンク タグに配置します。 js.

ツリーシェイクの最適化

パッケージング ツールは、パッケージ化時に esm の Tree Shaking に基づいてデッド コードを削除するのに役立つことがわかっています。

たとえば、これは bar.js です。

// bar.js
export const fn1 = () => {};

export const fn2 = () => {};

 次に、index.js で fn1 関数を使用します。

// index.js
import { fn1 } from "./bar.js";

fn1();

 パッケージ化のエントリ ポイントとして Index.js を使用すると、fn2 は最終的に削除されます。

ただし、ツリーシェイキングはシナリオによっては失敗するため、コードに「副作用」がないこと、つまり、関数型プログラミングの副作用と同様に、初期化中に外部の世界に影響を与えないことが必要です。

次の例を見てください。

// bar.js
export const fn3 = () => {};
console.log(fn3);

export const fn4 = () => {};
window.fn4 = fn4;

export const fn5 = () => {};
// index.js
import { fn5 } from "./bar.js";

fn5();

fn3 と fn4 は使用されませんが、最終パッケージに含まれる予定です。なぜなら、それらを宣言すると、出力や外部変数の変更などの副作用があるからです。それらを保持しない場合、期待と矛盾するバグが発生する可能性があります。たとえば、ウィンドウが変更されたと思っていますが、実際には変更されていません。オブジェクトのプロパティにはセッターが含まれる場合があり、さらに予期せぬバグが発生する可能性があります。

さらに、次のような書き込み方法も許可されません。

// bar.js
const a = () => {};
const b = () => {};
export default { a, b };

// import o from './bar.js'
// o.a()
// bar.js
module.exports = {
  a: () => {},
  b: () => {},
};

// import o from './bar.js'
// o.a()

エクスポートされたものをオブジェクトに入れることはできません。esm の Tree Shaking は静的解析であり、実行時に何が行われるかを知ることができません。Commonjs モジュラー構文も使用されており、パッケージ化ツールはそれらの混合使用と互換性がありますが、Tree Shaking が簡単に失敗する可能性があります。

したがって、Tree Shaking 機能を最大限に活用するには、記述方法に注意する必要があります。オンラインにする前に、パッケージ分析ツールを使用して、どのパッケージに異常なサイズがあるかを確認できます。

フロントエンド技術スタックの最適化

テクノロジー スタックの選択は、ランタイム速度に影響を与えるだけでなく、ネットワーク速度にも影響を与える可能性があります。

より小さなライブラリに置き換えられました。たとえば、lodash を使用する場合、その中で 1 つの関数しか使用しない場合でも、lodash は commonjs に基づいており、ツリー シェイキングをまったく行わないため、その中のすべてのコンテンツがパッケージ化されます。 、他のものを使用することを検討してください。ライブラリの置き換え。

開発手法に起因するコードの冗長性。たとえば、sass、less、ネイティブ CSS、styled-component、emotion などのスタイル ソリューションを使用している場合。繰り返しのスタイル コードを記述するのは簡単です。たとえば、コンポーネント A とコンポーネント B の幅は両方とも 120px; です。おそらく 2 回記述することになります。きめ細かい再利用を実現するのは困難です (そうする人はほとんどいません)。 (スタイルの行を繰り返します) (再利用を考えるまでは、7 行または 8 行は同じかもしれません) プロジェクトが大きくなり、古いほど、スタイル コードの繰り返しが多くなり、リソース ファイルはますます大きくなります。 。アトミック CSS ライブラリである tailwindcss に変更することができます。 width: 120px; style が必要な場合は、react で <div className="w-[120px]"></div> と書くことができます。すべて同じです。数式は次のとおりです。すべてこのように記述されており、すべて同じクラスを再利用します。tailwind を使用すると、実行時のオーバーヘッドを発生させずに CSS リソースを十分に小さく保つことができます。同時に、コンポーネントをフォローするため、esm のツリー揺れを活用することができ、使用されなくなった一部のコンポーネントはスタイルとともにパッケージから自動的に削除されます。Sass、CSS、およびその他のソリューションの場合、CSS ファイルで使用されなくなったスタイルを自動的に削除することは困難です。さらに、styled-component や emotion などの CSS-in-JS ソリューションでもツリーシェイキングを実現できますが、コードの重複と実行時のオーバーヘッドの問題があります。また、tailwind には、nodejs の下位バージョンがサポートされていない、文法には学習コストがかかるなど、いくつかの欠点もあります。

ランタイムレベル

ランタイムとは主に、JavaScript とページ レンダリングを実行するプロセスを指します。これには、テクノロジ スタックの最適化、マルチスレッドの最適化、V8 レベルの最適化、ブラウザ レンダリングの最適化などが含まれます。 

レンダリング時間を最適化する方法

レンダリング時間は、DOM とスタイルの複雑さだけでなく、さまざまな側面からも影響を受けます。

レンダリング スレッドにはさまざまな種類のタスクがあります

このセクションについて説明する前に、まずタスクの概念について説明する必要があります。スクリプト内のコードや一部のコールバック (イベント、setTimeout、ajax など) などのマクロ タスクをすでにある程度理解している人もいるかもしれません。ただし、マクロ タスクの詳細を理解しているだけで、タスクの広範な理解が不足している可能性があります。マクロ タスクをより高いレベルから理解することによってのみ、なぜ js とレンダリングがブロックされなければならないのか、なぜ 2 つのマクロの間にギャップがないのかを真に理解することができます。タスクが隣り合って配置されているため、すぐには実装できない可能性があります。

ページを開くと、ブラウザはレンダリング スレッドを使用してレンダリング プロセスを開始します。dom、css レンダリング、js の実行など、ほとんどのフロントエンドの処理はこのレンダリング スレッドで実行されます。スレッドが 1 つしかないため、時間のかかるタスクをブロックせずに処理するために、タスク キューが設計されており、リクエストや IO などの操作が発生すると、他のスレッドに引き渡され、完了後にコールバックが実行されます。このキューの先頭タスクは常にポーリングされて実行され、js のほとんどのタスクはマクロ タスクとして理解できます。しかし、それは JS だけではありません。ページのレンダリングもレンダリング スレッドのタスクです。DevTools でパフォーマンスのレンダリングを担当するタスクを確認できます (HTML の解析、レイアウト、ペイント、いわゆるマクロ タスクの実行は、実際にはスクリプト評価タスク (コードのコンパイル、スクリプト コードのキャッシュ、およびランタイム コンパイル、コードのキャッシュなどを担当するその他のサブタスクを含む) です。 HTML 解析タスクのサブタスクになります。GC ガベージ コレクションなど、多くの組み込みタスクもあります。また、マイクロタスクと呼ばれる特別な種類のタスクもあり、これはパフォーマンス上の実行マイクロタスクであり、マクロ タスク内で生成され、マクロ タスク内のマイクロ タスク キューに配置されます。マクロタスクが実行され、すべての実行スタックが終了すると、チェックポイントが作成され、マイクロタスク キューにマイクロタスクがある場合は、すべてが実行されます。Promise.then、queueMicrotask、MutationObserver イベント、ノード内の nextTick などのマイクロタスクを作成できます。

したがって、レンダリング スレッドのタスクを理解したところで、レンダリング自体もタスクであるため、js タスクや他のタスクとキュー内で連続し、1 つずつ実行する必要があることを理解するのは難しくありません。 . このようにしてブロッキングが発生します。さまざまなリソース間のブロック関係を見てみましょう。

js ブロック レンダリングの典型的な例として、自分で html ファイルを作成して試してみることができます。

<html>
  <head>
    <title>Test</title>
  </head>
  <body>
    <script>
      const endTime = Date.now() + 3000;
      while (Date.now() <= endTime) {}
    </script>
    <div>This is page</div>
  </body>
</html>

 レンダリング スレッドは最初に HTML 解析タスクを実行し、DOM の解析プロセス中にスクリプトに遭遇したため、スクリプトの評価が実行されます。コードは終了する前に 3 秒間実行され、その後、次の < の解析とレンダリングが続行されます。 div>これはページです</div>ので、ページが表示されるまでに 3 秒かかります。スクリプトがリモート リソースの場合、リクエストは基礎となる DOM の解析とレンダリングもブロックします。

スクリプトの defer 属性を使用して最適化できます。defer は、DOM が解析された後、DOMContentLoaded イベントが発生する前まで、スクリプトの実行時間を遅らせます。

<html>
  <head>
    <title>Test</title>
  </head>
  <body>
    <script defer src="xxx.very_slow.js"></script>
    <div>This is page</div>
  </body>
</html>

 これにより、リクエストを待つ時間を無駄にする必要がなくなると同時に、要素をより安全に取得するために dom 内で js を解析する必要がなくなり、複数の遅延スクリプトによって元の実行順序が保証されます。または、ページの下部にスクリプトを直接記述することで、同様の効果を実現できます。ブラウザには通常、HTML 内のすべてのリソースに対するリクエストを事前にスキャンし、ドキュメントの解析が開始されるときにそれらを事前にリクエストする最適化メカニズムが備わっています。

スクリプトには別の属性 async もあります。js リソースがまだリクエストされている場合、js リクエストと実行もスキップされます。次のコンテンツが最初に解析され、js リクエストが完了した直後に実行されます。そのため、実行タイミングは不定でリクエストの終了時間に依存し、複数の非同期スクリプトの実行順序は保証されません。

CSSはレンダリングとJSをブロックしますか?

ここで結論を 1 つ覚えておいてください。css のリクエストと解析は、以下の dom の解析をブロックしませんが、レンダー ツリーのレンダリングと js の実行をブロックします。

なぜこのような設計になっているのかというと、

レンダーツリーがブロックされているのは、もともとdomツリーに適用されたカスケーディングスタイルシートの産物であるため、cssを待つ必要があるためです。cssを待たない仕様になっていますが、問題ありません。domツリーをレンダリングすることは可能です。ただし、2 回レンダリングするのは無駄であり、裸の DOM ツリーのユーザー エクスペリエンスは良くありません。

js が css でブロックされる理由は、js 内でスタイルを変更できるためである可能性があります。後の js を実行してスタイルを変更し、その後に前の css を適用すると、スタイルの結果が矛盾します。実際に期待される効果を得るために、js でスタイルを 2 回レンダリングするのは無駄です。また、要素のスタイルはjsで取得できますが、cssリクエストを解析する前に以下のjsを実行すると、取得されたスタイルが実態と一致しません。

要約すると、css は dom の解析を直接ブロックしませんが、レンダー ツリーのレンダリングをブロックし、js の実行をブロックすることで間接的に dom の解析をブロックします。

興味があれば、ノード サービスを自分で構築して実験することもでき、リソースの応答時間を制御することで、さまざまなリソースの相互影響をテストできます。

ブラウザのレンダリングにこれほど時間がかかるのはなぜですか? レンダリングパイプラインとは何ですか

HTML をページにレンダリングするには、通常、次の手順が必要です。

  • dom ツリーの生成: HTML を取得するとき、ページを表示するためにブラウザは正確に何をしましたか? まず、内部のすべてのリソース リクエストを事前解析し、事前リクエストを発行します。次に、html は語彙的および文法的に解析され、<body> や <div> などの要素タグや class や id などの属性に遭遇すると、解析して dom ツリーを生成します。この期間中に、<style>、<link>、<script> などの css タグや js タグが発生する可能性があります。css リソース要求は dom ツリーの解析をブロックしません。dom ツリーが css の解析を完了すると、それまでは解析されません。レンダー ツリーと後続のレイアウト ツリーなどの作成をブロックします。コードの実行であれリソース要求であれ、js に遭遇した場合、script タグに async または defer 属性がない限り、すべての実行が完了するまで待機してから dom 解析を続行します。js の前に css リソースがある場合、css がリクエスト/解析されるまで js は実行されません。これにより、css が間接的に dom の解析をブロックします。CSS 関連のコードが見つかった場合は、CSS を解析してスタイルシートにする次のステップが実行されます。
  • スタイルシートの生成: CSS も字句解析および文法解析を受け、その値の一部が標準化されます。標準化とは何ですか? たとえば、作成した font-weight: ボールドと flex-flow: 列の Nowrap は、実際には標準の CSS スタイルではなく、省略形です。エ​​ンジンが理解できる値に変換する必要があります: font-weight : 500 、フレックス方向: 列、フレックスラップ: nowrap。最終的に、シリアル化されたテキストは構造化スタイルシートになります。
  • レンダー ツリーの生成: DOM ツリーとスタイルシートを使用すると、継承、CSS セレクターの優先順位、その他のスタイル ルールを通じて、対応する DOM にスタイルを追加できます。CSS セレクターは右から左に条件を照合するため、一致の数は相対的に高くなります。最小限に抑え、最終的にはスタイルを使用してレンダー ツリーを形成します。
  • レイアウト: 一部の DOM (display: none など) は表示されないため、無効な計算を避けるために、将来表示されるノードのみを含むレンダー ツリーに基づいてレイアウト ツリーが形成されます。同時に、レイアウト段階で各要素の配置位置情報を計算することになりますが、これには時間がかかり、各要素の位置が相互に影響を及ぼします。
  • レイヤー: 位置: 絶対、変換、不透明度などの特別なスタイルに従って、さまざまなレイヤーが形成されます。ルート ノードとスクロールも 1 つのレイヤーとしてカウントされます。通常、異なるレイヤー レイアウトは相互に影響を与えないため、レイヤー化により後続の更新でのレイアウト コストが削減され、後続の複合レイヤーが個々のレイヤーで特別な変換を実行しやすくなります。
  • ペイント (描画): これは実際にディスプレイに描画するのではなく、レイヤーごとに独自の描画コマンドを生成します。これらのコマンドは、直線の描画など、GPU 描画の基本的なコマンドです。
  • コンポジット (コンポジット): このステップでは、CPU はタスクを実行しなくなり、タスクは処理のために GPU に渡されるため、js がブロックされても、このスレッドには影響しません。CSS ハードウェア アクセラレーションも発生します。このスレッド。ペイント ステージの描画コマンド リストはコンポジション レイヤーに渡されます。コンポジション レイヤーは、現在のビューポート付近の領域を 512px 単位でタイルに分割し、タイル領域を最初にレンダリングします。ビューポートは空くまで待機できます。再度レンダリングしてください。コンポジション レイヤーは、ラスタライズ スレッド プールを介して GPU に描画コマンドを渡し、ビットマップを描画および出力します。これらのビットマップは各レイヤーに属するため、コンポジション レイヤーによってこれらのレイヤーを 1 つのビットマップに合成する必要があります。レイヤーがラスター化されると、合成レイヤーは複数のレイヤーを合成し、正しい順序で積み重ねて最終的なレンダリングを形成できます。このプロセスは通常、CPU の負荷を軽減し、レンダリング パフォーマンスを向上させるために GPU で実行されます。

ラスタライゼーションの説明: ラスタライゼーションはコンピュータ グラフィックスの概念です。コンポジション レイヤーでは、レイヤー内のベクター グラフィックス、テキスト、画像、その他の要素をビットマップまたはラスター イメージに変換します。これにより、ビットマップがグラフィックス ハードウェアでより効率的に処理されるため、これらの要素のレンダリングと表示が高速化されます。コンポジション レイヤーは、レンダリングする必要があるコンテンツを画面上に直接レンダリングするのではなく、オフスクリーン メモリ領域に描画できます。これにより、画面上に直接描画することによって発生するパフォーマンスの問題が回避され、ブラウザーがバックグラウンドで画面外のコンテンツを最適化できるようになります。レイヤー コンテンツをラスタライズすることにより、ブラウザーはレンダリングにグラフィックス ハードウェア アクセラレーションをより適切に活用できるようになります。最新のコンピューターやモバイル デバイスのグラフィックス プロセッシング ユニット (GPU) はビットマップ イメージを効率的に処理し、よりスムーズなアニメーションとより高速なレンダリング速度を実現します。

  • ディスプレイ: モニターが同期信号を送信するまで待ちます。これは、次のフレームが表示されることを意味します。複合レイヤーのビットマップはブラウザ プロセスの biz コンポーネントに渡され、ビットマップはバック バッファーに置かれます。モニターが次のフレームを表示するときに、フロント バッファーとバック バッファーが交換されて最新のフレームが表示されます。ページ画像。次のフレームがレンダリングされようとしていることを同期信号が認識しているため、js の requestAnimationFrame のコールバックもトリガーされます。ゲーム内では垂直同期もあります。

レンダリング中はこれらのステップがパイプラインのように順番に実行され、パイプラインがあるステップから実行を開始すると、必ず後続のステップがすべて最後まで実行されます。

したがって、位置/レイアウト関連のスタイルが変更されてページが更新されると、2 段階レイアウトが再トリガーされてレイアウトが再計算されます。これをリフロー (リフローまたはリフロー) と呼びます。多数の要素の位置を計算する必要があり、位置が相互に影響するため、このステップには非常に時間がかかることがわかります。同時に、ペイントやコンポジットなどの後続のすべてのステップも実行されるため、リフローには再ペイントを伴う必要があります。

位置に依存しないスタイル変更 (背景色、色など) によってページが更新された場合、前のプロセスが依存するデータは変更されていないため、第 4 段階のペイントからのみ再トリガーされます。これにより、描画コマンドが再生成され、それがラスタライズされて合成レイヤー上に合成されます。プロセス全体はそれでも非常に高速であるため、再描画するだけの方が順序を変更するよりもはるかに高速です。

レンダリング原則を使用してパフォーマンスを向上させる方法

ブラウザ自体にもいくつかの最適化手法が備わっており、例えば色:赤、幅:120pxの順番の問題で繰り返しペイントが発生する心配や、複数連続によるパフォーマンスの低下を心配する必要はありません。スタイルへの変更と複数の連続した追加要素。ブラウザは、変更後すぐにレンダリングを開始するのではなく、更新を待機キューに入れ、一定の数の変更または一定の時間が経過した後にバッチで更新します。

ページを更新するコードを記述するときは、トリガーするレンダリング パイプラインをできる限り少なくすることが原則であり、ペイント段階から開始する方が、レイアウト段階よりもはるかに高速になります。一般的な考慮事項をいくつか示します。

  1. 間接的なリフローは避けてください。位置関連のスタイルを直接変更するだけでなく、状況によってはレイアウトが間接的に変更される場合もあります。たとえば、box-sizing が border-box ではなく、幅が固定されていない場合、border-width を追加または変更すると、ボックス モデルの幅とレイアウト位置に影響します。たとえば、<img /> では高さが指定されていないため、読み込み後に画像の高さが上がり、ページがリフローします。
  2. 読み書き分離の原則。js で要素の位置情報を取得すると、getBoundingClientRect、offsetTop などの強制リフローがトリガーされる場合があります。前述したように、ブラウザは一括して更新を行うため待機キューが発生するため、位置情報を取得する際に更新により待機キューがクリアされず、ページが最新でない場合があります。取得したデータが正確であることを確認するために、ブラウザは強制的にキューをクリアし、ページを強制的にリフローします。2度目に位置情報を取得し、その間に更新が発生しなかった場合、待機キューは空となり、再度リフローは発生しません。したがって、要素のバッチのサイズをバッチ変更してそのサイズ情報を取得したい場合は、次のように記述してはなりません。
const elements = document.querySelectorAll(".target");
const count = 1000;
for (let i = 0; i < count; i++) {
  // 将元素width增加20px
  elements[i].style.width = parseInt(elements[i].style.width) + 20 + "px";
  // 获取该元素最新宽度
  console.log(elements[i].getBoundingClientRect().width);
}

 ブラウザーのバッチ更新と強制リフローについての上記の説明を終えると、この方法で記述することには非常に問題があり、ページが 1000 回リフローされることがわかります。なぜなら、style.width を変更するたびに、ブラウザは更新を待機キューに入れるからです。このステップには何も問題はありません。ただし、この要素の幅を取得し始めるため、最新の幅を知るために、ブラウザーは待機キューをクリアし、バッチ更新をスキップし、ページを強制的にリフローします。その後、これを1000回繰り返します。

そして、次のように記述すると、1000 個のサイズ変更は 1 回だけリフローされます。

const elements = document.querySelectorAll(".target");
const count = 1000;
for (let i = 0; i < count; i++) {
  // 将元素width增加20px
  elements[i].style.width = parseInt(elements[i].style.width) + 20 + "px";
}
for (let i = 0; i < count; i++) {
  // 获取该元素最新宽度
  console.log(elements[i].getBoundingClientRect().width);
}

 CSSハードウェアアクセラレーション

合成層の前の段階の計算は基本的に CPU によって実行されます。CPU は GPU よりもはるかに少ない計算ユニットを備えています。複雑なタスクには強力ですが、単純で反復的なタスクには GPU よりもはるかに遅くなります。ページのレンダリングがコンポジション レイヤーから直接開始され、GPU のみによって計算される場合、速度は必然的に非常に速くなります (これがハードウェア アクセラレーションです)。どのようなメソッドをオンにできますか?

トランスフォーム 3D や不透明度などの CSS スタイルには、リフローや再描画は含まれず、レイヤーの変換のみが含まれます。そのため、前のレイアウトとペイントの段階はスキップされ、合成レイヤーに直接渡されます。GPU はレイヤー上でいくつかの単純な変換を実行します。つまり、GPU がこれらのことを処理するのは非常に簡単です。また、ハードウェア アクセラレーションに特別に使用される will-change と呼ばれる CSS 属性もあり、将来どの属性が変更されるかを GPU に事前に通知して、事前に準備することができます。

注意すべき点の 1 つは、js を使用してスタイルを変更する場合、ハードウェア アクセラレーションが可能な上記のスタイルを変更したとしても、依然として CPU を通過するということです。レンダリング パイプラインを覚えていますか? JS は DOM ツリーのコンテンツのみを変更できますが、これにより必然的に DOM 変更がトリガーされるため、レンダリング パイプラインの最初のステップから最後まで開始され、合成レイヤーから直接開始されることはありません。 。

jsで修正したスタイルはハードウェアアクセラレーションできないので、どのように修正すればよいのでしょうか?アニメーションやトランジションなどの非 JS メソッドを使用できます。js がページを完全にブロックしてもアニメーションが動作するかどうかを実験して確認できます。

        パフォーマンスを記録し、レンダリングのフリーズをトラブルシューティングする方法

ページが引っかかるかどうかを判断するには、自分で試してみて「動かない」と感じるだけでなく、主観に基づいた定量的なデータを与えることはできません。他の人を説得するには、自分の仕事のデータから始める必要があります。 。 

1. 開発者の指標

特定のページをローカルで開いたときのパフォーマンスを確認したい場合は、DevTools の Lighthouse にアクセスできます。 

 [ページ読み込みの分析] ボタンをクリックして、パフォーマンス レポートを生成します。

 これには、初回描画時間やインタラクティブ時間などのパフォーマンス指標、アクセシビリティ指標、ユーザー エクスペリエンス指標、SEO 指標、PWA (プログレッシブ Web アプリケーション) などが含まれます。

ネイティブ レンダリングの遅延の原因をトラブルシューティングする場合。DevTools でパフォーマンスに移動すると、タスクが低いものから高いものまで明確に表示されます。長いタスクを指す「長いタスク」という単語が表示されます。長いタスクの定義は、メイン スレッドを 50 ミリ秒ブロックすることです。上記のタスク。長いタスクをクリックすると、このタスクで行われた内容を詳細に表示できます (この例では、querySelectorAll に時間がかかりすぎるため、最適化する必要があります)

2. 実際のユーザーの監視

上記は開発プロセス中の一時的なトラブルシューティングにのみ適しており、対象となる機器はコンピュータとネットワーク状況のみです。プロジェクトがオンラインになった後の実際のパフォーマンスが、異なるネットワーク環境、デバイス、地理的場所のユーザーによってどのように実行されるかを知ることは不可能です。したがって、実際のパフォーマンス指標を知りたい場合は、他の方法が必要になります。

パフォーマンスが良いか悪いかを判断するには、まず明確な指標名を定義する必要があります。では、パフォーマンスを判断するにはどのような指標が必要なのでしょうか?

 

 これらの指標を取得するにはどうすればよいでしょうか? 最近のブラウザには通常、パフォーマンス API があり、多くの詳細なパフォーマンス データを確認できます。上記のデータの一部は直接利用できないものもありますが、いくつかの基本的な API を通じて計算できます。

たとえば、eventCounts - イベント数、memory - メモリ使用量、ナビゲーション - ページを開く方法、リダイレクト数、タイミング - DNS クエリ時間、TCP 接続時間、応答時間、DOM 解析およびレンダリング時間、インタラクティブな時間など。

さらに、パフォーマンスには、performance.getEntries() などの非常に便利な API もいくつかあります。

すべてのリソースと重要な瞬間に費やされた時間をリストした配列を返します。その中には、first-paint-FP インジケーターと first-contentful-paint-FCP インジケーターがあります。特定のパフォーマンス レポートのみを検索したい場合は、performance.getEntriesByName() および Performance.getEntriesByType() を使用してフィルタリングできます。

TTI (Time to Interactive) 指標の計算方法について説明します。

  1. まず、First Contentful Paint の最初のコンテンツ描画 (FCP) 時間を取得します。これは、上記のパフォーマンス.getEntries() を通じて取得できます。
  2. タイムラインの順方向に少なくとも 5 秒の持続時間を持つ静かなウィンドウを検索します。ここで、静かなウィンドウは次のように定義されます: 長いタスクがない (長いタスク、js は 50 ミリ秒を超えてタスクをブロックする)、ネットワークが 2 つ以下であるGET リクエストが処理されています。
  3. クワイエット ウィンドウの前の最後の長いタスクをタイムラインに沿って逆方向に検索し、長いタスクが見つからない場合は、FCP ステップで実行が停止します。
  4. TTI は、静かなウィンドウの前の最後の長いタスクの終了時刻です (長いタスクが見つからない場合は FCP 値と同じです)。

おそらく難しいのは、人々が長いタスクを達成する方法を知らないことです。パフォーマンス データの監視に使用できる PerformanceObserver というクラスがあります。長いタスク情報を取得するには、entryTypes に longtask を追加します。さらにタイプを追加して、他のパフォーマンス インジケーターを取得することもできます。詳細については、このクラスのドキュメントを参照してください。以下は、ロングタスクを監視する例です。

const observer = new PerformanceObserver(function (list) {
  const perfEntries = list.getEntries();
  for (let i = 0; i < perfEntries.length; i++) {
    // 这里可以处理长任务通知:
    // 比如报告分析和监控
    // ...
  }
});
// register observer for long task notifications
observer.observe({ entryTypes: ["longtask"] });
// 之后如果有长任务执行的话,会把执行数据放入性能检测队列
// 于是就会在observer中得到"longtask" entries.

さまざまなパフォーマンス指標をカウントするコードを記述した後 (または既製のライブラリを直接使用した後)、それをユーザーのページに埋め込み、ユーザーがページを開いたときにパフォーマンス統計バックエンドにレポートできます。

jsを最適化する方法

js にはさまざまな最適化の角度があり、さまざまなシナリオに分割する必要があるため、テクノロジ スタックの選択、マルチスレッド、および v8 の観点から説明する必要があります。 

テクノロジースタックの選択

1. ページレンダリングソリューションの選択

1. CSR ブラウザ側レンダリング: 現在、react や vue などの spa フロントエンド フレームワークが非常に人気があり、ステート駆動型の spa アプリケーションは高速なページ切り替えを実現できます。ただし、これに伴う欠点は、すべてのロジックがブラウザー側の js 内にあるため、最初の画面の起動プロセスが長すぎることです。

2. SSR サーバーサイド レンダリング: spa 本来のレンダリング プロセス (CSR ブラウザサイド レンダリング) はサーバー レンダリング (SSR) よりも長いため、html リクエスト -> js リクエスト -> js 実行 -> js を経由します。コンテンツのレンダリング -> dom をマウントした後、インターフェースをリクエスト -> コンテンツを更新します。サーバーサイドレンダリングでは、HTML リクエスト -> ページコンテンツのレンダリング -> js リクエスト -> イベント追加の js 実行の手順のみが必要です。ページ コンテンツのレンダリングに関しては、サーバー側のレンダリングは SPA よりもはるかに高速であり、ユーザーがコンテンツをできるだけ早く表示することが期待されるシナリオに非常に適しています。

React の nextjs フレームワークまたは vue の nuxtjs フレームワークを使用すると、フロント エンドとバック エンドで同じコード セットを使用してサーバー側の同型レンダリングを実現できます。重要な原理は、nodejs によってもたらされるサーバー側の実行環境と、仮想 dom 抽象化レイヤーによってもたらされるマルチプラットフォーム レンダリング機能です。同じコード セットを使用すると、ブラウザとサーバーの両方がレンダリングでき (サーバーは HTML テキストをレンダリングします)、ページが 2 回ジャンプする場合でも、最初の画面の速度を失うことなく spa メソッドを使用できます。スパの。同時に、2 つの主要なフレームワークの SSR パフォーマンスも常に最適化されています。たとえば、React18 の SSR では、新しい renderToPipeableStream API は HTML をストリーミングでき、時間のかかるタスクをスキップしてユーザーが見ることができる Suspense 機能を備えています。メインページの表示が速くなります。また、選択的ハイドレーションを使用して、遅延およびサスペンス (クライアント側レンダリングと同じ) で同期的にロードする必要のないコンポーネントを選択的にラップし、メイン ページのインタラクティブ時間を最適化し、SSR セグメンテーションのコードを間接的に実現することもできます。

3. SSG 静的ページ生成: たとえば、React の nextjs フレームワークは静的サイトの生成もサポートしており、パッケージ化中にコンポーネントを直接実行して最終 HTML を生成します。静的ページはランタイムなしで開くことができ、究極の開く速度を実現します。

4. アプリのクライアント側レンダリング: フロントエンド ページがアプリ内に配置されている場合、クライアントはサーバー側レンダリングと同じメカニズムを実装できます。現時点では、アプリでページを開くことはサーバー側レンダリングと同様です。 。または、より簡単なアプローチは、フロントエンド スパ パッケージをクライアント パッケージに入れることです。これもすぐに開くことができます。実際、最大の高速化ポイントは、ユーザーがアプリをインストールするときにフロントエンド リソースもダウンロードすることです。

2. フロントエンドフレームワークの選択

現代のフロントエンド開発プロセスでは、迷わずフレームワーク開発が選択されるのが一般的です。ただし、プロジェクトが現在も将来も複雑でなく、パフォーマンスを徹底的に追求している場合は、実際には、react や vue のようなステート駆動フレームワークを使用する必要はありません。これらを利用してステータスページのみを修正して更新するという開発上の利便性を享受できますが、DX(開発者エクスペリエンス)を向上させる場合にはパフォーマンスコストも発生します。まず、追加ランタイムの導入によりjsの数が増えました。第 2 に、それらは少なくともコンポーネント レベルのレンダリングであるため、つまり、状態が変化した後、対応するコンポーネントが完全に再実行されるため、ブラウザーのレンダリング パフォーマンスを向上させるために、取得された仮想 DOM は diff を通過する必要があります。これらの追加リンクは、js または jquery を直接使用して dom を正確に変更するほど高速ではないことを意味します。したがって、プロジェクトが現在も将来も複雑ではなく、十分に高速で軽量であることが必要な場合は、js または jquery を使用して直接実装できます。

3. フレームワークの最適化

React フレームワークを選択した場合は、通常、開発プロセス中に追加の最適化を行う必要があります。たとえば、依存関係が変更されていない場合は useMemo を使用してデータをキャッシュし、依存関係が変更されていない場合は useCallback を使用して関数をキャッシュし、クラス コンポーネントの shouldComponentUpdate を使用してコンポーネントを更新する必要があるかどうかを判断します。React は内部的に変数の参照アドレスが変化するかどうかに基づいて更新するかどうかを決定するため、2 つのオブジェクトまたは配列リテラルがまったく同じであっても、それらは 2 つの異なる値であることに注意してください。

また、可能であれば、最新バージョンを使用し続けるようにしてください。一般に、新しいバージョンではパフォーマンスが最適化されます。

たとえば、React18 では、長いタスクがページ インタラクションをブロックすることを防ぐために、タスク優先順位メカニズムが追加されています。優先度の低い更新は優先度の高い更新 (ユーザーのクリックや入力など) によって中断され、優先度の低い更新は優先度の高い更新が完了するまで継続されます。こうすることで、ユーザーは対話時に応答がタイムリーであると感じるようになります。useTransition と useDeferredValue を使用して、優先度の低い更新を生成できます。

さらに、React18 ではバッチ更新も最適化されます。以前は、バッチ更新は実際には次のようなロック メカニズムを通じて実装されていました。

lock();
// 锁住了,更新只是放进队列并不会真的更新

update();
update();

unlock();
// 解锁,批量更新

 これにより、react の外部にはロックが存在しないため、ライフサイクル、フック、react イベントなどの固定された場所でのみバッチ更新を使用するように制限されます。また、setTimeout や ajax など、現在のマクロタスクから独立した API を使用している場合、内部の更新はバッチで更新されません。

lock();

fetch("xxx").then(() => {
  update();
  update();
});

unlock();
// updates已经脱离了当前宏任务,一定在unlock之后才执行,这时已经没有锁了,两次update就会让react渲染两次。

 React18の一括更新は優先度に基づいて設計されているため、一括更新するためにreactで指定した場所にある必要はありません。

4. フレームワークエコロジーの選択

フレームワーク自体に加えて、環境に配慮した選択もパフォーマンスに影響します。vue の生態は一般的に比較的固定的ですが、react の生態は非常に豊富であり、パフォーマンスを追求するには、さまざまなライブラリの特性と原理を理解する必要があります。ここでは主に、グローバルな状態管理とスタイル ソリューションの選択について説明します。

状態管理ライブラリを選択する場合、react-redux は極端な条件下でパフォーマンスの問題が発生する可能性があります。redux ではなく、react-redux について話していることに注意してください。Redux は単なる一般的なライブラリです。非常にシンプルで、さまざまな場所で使用できます。パフォーマンスについて直接話すことは不可能です。react-redux は、react が redux を使用できるようにするために使用されるライブラリです。redux の状態は毎回新しい参照であるため、react-redux は状態に依存するどのコンポーネントを更新する必要があるかを知ることができません。比較するにはセレクターを使用する必要があります。前後の値が変化するかどうか。グローバル状態に依存するコンポーネントの各セレクターは 1 回実行する必要があるため、セレクター内のロジックが重い場合やコンポーネントの数が多い場合、パフォーマンスの問題が発生します。mbox を試すことができます。基本原理は vue と同じです。インターセプトしたオブジェクトのゲッターとセッターに基づいて更新をトリガーするため、どのコンポーネントを更新する必要があるかが自然にわかります。また、zustand はエクスペリエンスが高く、非常にお勧めします。これも redux ベースですが、非常に使いやすく、テンプレート コードをあまり必要とせずにコンポーネントの外で使用できます。

スタイル ソリューションのうち、ランタイムで使用できるのは、styled-component や情動などの css-in-js ソリューションのみです。ただし、絶対ではありません。一部の css-in-js ライブラリでは、props に基づいてスタイルを動的に計算しない場合、ランタイムが削除されます。スタイルがコンポーネントの props に基づいて計算される場合、ランタイムは不可欠であり、コンポーネント js の実行時に CSS を計算し、スタイル タグを追加します。これにより、2 つの問題が発生します。1 つはパフォーマンスのコスト、もう 1 つはスタイルのレンダリング時間が js の実行段階まで遅れることです。これは、css、sass、less、stylus、tailwind などの css-in-js 以外のソリューションを使用して最適化できます。ここで最も推奨されるのは、ネットワーク レベルの最適化について話したときに言及した tailwind です。これはランタイムがゼロであるだけでなく、原子化によりスタイルを完全に再利用でき、CSS リソースが非常に少なくなります。

jsマルチスレッド

js タスクがページのレンダリングをブロックすることは以前からわかっていましたが、ビジネスに長いタスクが必要な場合はどうなるでしょうか? 大きなファイルのハッシュなど。この時点で、別のスレッドを開始して、この長いタスクを実行させ、メインスレッドに最終結果を伝えることができます。 

ウェブワーカー 

const myWorker = new Worker("worker.js");

myWorker.postMessage(value);

myWorker.onmessage = (e) => {
  const computeResult = e.data;
};
// worker.js
onmessage = (e) => {
  const receivedData = e.data;
  const result = compute(receivedData);
  postMessage(result);
};

Web ワーカーには、それを作成したスレッド (Web ワーカーを作成したページ ウィンドウ) からのみアクセスできます。

共有ワーカー

Shared Worker には、複数の異なるウィンドウ、iframe、ワーカーからアクセスできます。

const myWorker = new SharedWorker("worker.js");

myWorker.port.postMessage(value);

myWorker.port.onmessage = (e) => {
  const computeValue = e.data;
};
// worker.js
onconnect = (e) => {
  const port = e.ports[0];

  port.onmessage = (e) => {
    const receivedData = e.data;
    const result = compute(receivedData);
    port.postMessage(result);
  };
};

スレッドセーフについて

Web ワーカーは他のスレッドとの通信ポイントを注意深く制御しているため、同時実行性の問題が発生することは実際には困難です。非スレッドセーフなコンポーネントや DOM にはアクセスできません。シリアル化されたオブジェクトを介して特定のデータをスレッドに出入りする必要があります。したがって、コード内に問題を作成するには、非常に熱心に取り組む必要があります。

コンテンツセキュリティポリシー

ワーカーには独自の実行コンテキストがあり、ワーカーを作成したドキュメントのコンテキストとは異なります。したがって、作業者はドキュメントのコンテンツ セキュリティ ポリシーによって管理されません。たとえば、ドキュメントはこの http ヘッダーによって制御されます

Content-Security-Policy: script-src 'self'

 これにより、ページ内のすべてのスクリプトが eval() を使用できなくなります。ただし、ワーカーがスクリプトで作成された場合でも、eval() はワーカー スレッドで使用できます。Worker で Content-Security-Policy を制御するには、Worker スクリプトの http 応答ヘッダーにそれを設定する必要があります。1 つの例外は、ワーカーのオリジンがグローバルに一意の識別子 (blob://xxx など) である場合、ドキュメントの Content-Security-Policy を継承することです。

データ転送

メインスレッドとワーカースレッドの間で渡されるデータは、共有メモリアドレスではなくコピーされます。オブジェクトは渡される前にシリアル化され、受信時に逆シリアル化されます。ほとんどのブラウザは、構造化クローン作成アルゴリズムを使用してコピーを実装しています。

V8エンジンの内部最適化に適応

V8 コンパイル パイプライン

  1. 環境の準備: V8 は最初にコードのランタイム環境を準備します。この環境には、ヒープ スペースとスタック スペース、グローバル実行コンテキスト、グローバル スコープ、組み込み関数、ホスト環境によって提供される拡張関数とオブジェクトが含まれます。メッセージループシステム。グローバル実行コンテキストとグローバル スコープを初期化します。実行コンテキストには主に変数環境、字句環境、this、スコープチェーンが含まれます。var および function によって宣言された変数は変数環境に置かれます。このステップはコードを実行する前に実行されるため、変数をプロモートできます。const と let で宣言された変数は、スタック構造である字句環境に置かれます。{} コード ブロックに出入りするたびに、変数はスタックからプッシュおよびポップされ、スタックからポップアウトされます。アクセスできないため、const と let には字句効果があります。
  2. イベントループシステムの構築: メインスレッドはタスクキューからタスクを継続的に読み出して実行する必要があるため、ループイベント機構を構築する必要があります。
  3. バイトコードの生成: V8 は実行環境を準備した後、まずコードに対して字句解析および構文解析 (パーサー) を実行し、AST とスコープ情報を生成します。その後、AST とスコープ情報は Ignition と呼ばれるインタープリターに入力され、変換されます。それをバイトコードに変換します。バイトコードはプラットフォームに依存しない中間コードです。ここでバイトコードを使用する利点は、バイトコードを最適化されたマシン コードにコンパイルできることと、バイトコードをキャッシュすることで、マシン コードをキャッシュするよりも多くのメモリを節約できることです。バイトコードの生成時に解析が遅れます。V8 はすべてのコードを一度にコンパイルしません。関数宣言が見つかった場合でも、関数内のコードをすぐに解析しません。最上位関数から AST とバイトコードのみを生成します。
  4. バイトコードの実行: V8 のインタプリタはバイトコードを直接実行できます。バイトコードでは、ソース コードは Ldar、Add、およびその他のアセンブリのような命令にコンパイルされ、フェッチ、命令の解析、命令の実行、データの保存などの命令を実装できます。 。一般に、インタープリタにはスタックベースとレジスタベースの 2 種類があります。スタックベースのインタープリタはスタックを使用して関数パラメータ、中間計算結果、変数などを保存します。レジスタベースの仮想マシンはレジスタを使用してパラメータと中間計算結果を保存します。Java 仮想マシン、.Net 仮想マシン、初期の V8 仮想マシンなど、ほとんどのインタープリタはスタックベースですが、現在の V8 仮想マシンはレジスタベースの設計を採用しています。
  5. JIT ジャストインタイム コンパイル: バイトコードを直接実行できますが、時間がかかります。コードの実行速度を向上させるために、V8 ではインタプリタにモニターが追加されています。バイトコードの実行中に、特定のコードが存在しない場合、繰り返しが検出されました 複数回実行された場合、監視によりこのコードはホット コードとしてマークされます。

         特定のコードがホット コードとしてマークされると、V8 はそのバイトコードを最適化コンパイラー TurboFan に渡し、最適化コンパイラーはバイトコードをバイナリ コードにコンパイルし、コンパイルされたバイナリ コードに対してコンパイルを実行します。最適化されたバイナリ マシン コードの実行効率が大幅に向上します。このコードを後から実行すると、V8 では最適化されたバイナリ コードが優先されます (JIT (Just-In-Time Compilation))。

          ただし、静的言語とは異なり、JavaScript は柔軟な動的言語です。変数の型とオブジェクトのプロパティは実行時に変更できます。ただし、最適化コンパイラによって最適化されたコードは、固定型のみをターゲットにできます。実行プロセス中に、変数が動的に変更されると、最適化されたマシンコードは無効なコードになります。このとき、最適化コンパイラは非最適化操作を実行する必要があり、次回実行時に解釈と実行のためにインタプリタにフォールバックします。追加の非最適化プロセスは、従来のバイトコードの直接実行よりも遅くなります。

上記のコンパイルパイプラインから、js は同じコードを複数回繰り返し実行していることがわかりますが、JIT の存在により、速度が非常に速い(Java や Java などの静的に強く型付けされた言語と同レベル) c#)。ただし、型とオブジェクトの構造は自由に変更できないことが前提となります。たとえば、次のコードです。

const count = 10000;
let value = "";
for (let i = 0; i < count; i++) {
  value = i % 2 ? `${i}` : i;
  // do something...
}

V8 エンジンのストレージ オブジェクトの最適化

JS オブジェクトはヒープに保存されます。これは文字列をキー名として持つ辞書に似ています。任意のオブジェクトをキー値として使用でき、キー名を通じてキー値の読み取りと書き込みが可能です。ただし、V8 がオブジェクト ストレージを実装したとき、主にパフォーマンスを考慮して、辞書ストレージを完全には使用しませんでした。辞書は非線形データ構造であるため、ハッシュ計算とハッシュの競合により、順次格納されるデータ構造よりもクエリ効率が低下します。ストレージと検索の効率を向上させるために、V8 では複雑なストレージ戦略が採用されています。シーケンシャル ストレージ構造は線形リストや配列などの連続したメモリ部分であり、非線形構造は一般にリンク リストやツリーなどの不連続なメモリを占有します。

オブジェクトは、通常のプロパティと並べ替えプロパティに分かれています。数値プロパティは自動的に昇順に並べ替えられ (並べ替えプロパティと呼ばれ)、オブジェクトのすべてのプロパティの先頭に配置されます。文字列プロパティは、作成された順序で通常のプロパティ内に配置されます。

V8 内では、これら 2 つのプロパティの保存とアクセスのパフォーマンスを効果的に向上させるために、並べ替えプロパティと通常のプロパティ、つまり要素とプロパティの 2 つの非表示プロパティをそれぞれ保存するために 2 つの線形データ構造が使用されています。

これら 2 つの条件が満たされる場合、オブジェクトの作成後に新しい属性が追加されない、オブジェクトの作成後に属性が削除されない場合、V8 はオブジェクトごとに隠しクラスを作成し、オブジェクト内に次を指すマップ属性値が存在します。それ。オブジェクトの隠しクラスは、オブジェクトに含まれるすべての属性と、オブジェクトの開始メモリに対する各属性値のオフセットの 2 点を含む、オブジェクトの基本的なレイアウト情報を記録します。これにより、属性を読み込む際の一連の処理が不要となり、直接オフセットを取得してメモリアドレスを計算することができます。

ただし、js は動的言語であり、オブジェクトのプロパティは変更できます。オブジェクトに新しい属性を追加したり、属性を削除したり、属性のデータ型を変更したりすると、オブジェクトの形状が変更され、V8 によって新しい非表示クラスが再構築され、パフォーマンスが低下します。

したがって、必要な場合を除き、delete キーワードを使用してオブジェクトの属性を削除したり、属性を追加/変更したりすることはお勧めできません。オブジェクトがいつ宣言されたかを判断することをお勧めします。同じオブジェクト リテラルを同時に宣言する場合は、正確に同じであることを確認することが最善です。

// 不好,x、y顺序不同
const object1 = { a: 1, b: 2 };
const object2 = { b: 1, a: 2 };

// 好
const object1 = { a: 1, b: 2 };
const object2 = { a: 1, b: 2 };

 最初の書き方では 2 つのオブジェクトの形状が異なるため、異なる隠しクラスが生成され、再利用できません。

同じオブジェクトのプロパティが複数回読み取られると、V8 はそのオブジェクトのインライン キャッシュを作成します。たとえばこのコード:

const object = { a: 1, b: 2 };

const read = (object) => object.a;

for (let i = 0; i < 1000; i++) {
  read(object);
}

オブジェクト属性を読み取る通常のプロセスは、隠しクラスの検索 -> メモリ オフセットの検索 -> 属性値の取得です。V8 は、読み取り操作が複数回実行される場合にこのプロセスを最適化します。

インラインキャッシュはICと呼ばれます。V8 が関数を実行するとき、関数内の呼び出しサイト (CallSite) でいくつかの主要な中間データを監視し、これらのデータをキャッシュします。次回関数が再度実行されるとき、V8 はこれらの中間データを直接使用できます。これにより、これらのデータを再度取得するプロセスが必要なため、V8 は IC を使用して一部の繰り返しコードの実行効率を効果的に向上させることができます。

インライン キャッシュは、各関数のフィードバック ベクトル (FeedBack Vector) を維持します。フィードバック ベクトルは多数の項目で構成され、各項目はスロットと呼ばれます。上記のコードでは、V8 はリード関数で実行された中間データをフィードバック ベクターのスロットに順次書き込みます。

コード内の戻り object.a は呼び出しポイントです。オブジェクトのプロパティを読み取るため、V8 は読み取り関数のフィードバック ベクトルでこの呼び出しポイントにスロットを割り当てます。各スロットにはスロットのインデックス (スロット インデックス) が含まれます。 )、スロットの種類 (type)、スロットの状態 (state)、隠しクラスのアドレス (map)、属性のオフセット V8 が再度 read 関数を呼び出して return object.a を実行すると、オフセットを検索します。対応するスロットの a 属性の値を取得すると、V8 はメモリ内の object.a の属性値を直接取得できるため、隠しクラスで検索するよりも実行効率が高くなります。

const object1 = { a: 1, b: 2 };
const object2 = { a: 3, b: 4 };

const read = (object) => object.a;

for (let i = 0; i < 1000; i++) {
  read(object1);
  read(object2);
}

 このようにコードを書くと、ループごとに読み込まれる2つのオブジェクトの形状が異なるため、隠しクラスも異なることがわかります。V8 が 2 番目のオブジェクトを読み取ると、スロット内の非表示クラスが読み取られているクラスと異なることがわかり、新しい非表示クラスと属性値のメモリ オフセットがスロットに追加されます。この時点で、スロットには 2 つの非表示クラスとオフセットが存在します。オブジェクトのプロパティが読み取られるたびに、V8 はそれらを 1 つずつ比較します。読み取られているオブジェクトの隠しクラスがスロット内の隠しクラスの 1 つと同じである場合、ヒットした隠しクラスのオフセットが使用されます。同等の情報がない場合は、新しい情報もそのスロットに追加されます。

  • スロットに非表示クラスが 1 つだけ含まれている場合、この状態は単相性 ( monomorphic) と呼ばれます。

  • スロットに 2 ~ 4 個の隠しクラスが含まれている場合、この状態はポリモーフィズム ( polymorphic) と呼ばれます。

  • スロットに 4 つを超える隠しクラスがある場合、この状態はスーパー ステート ( magamorphic) と呼ばれます。

単相性のパフォーマンスが最高であることがわかります。そのため、より良いパフォーマンスを達成するために、複数回実行される関数内でオブジェクトの変更や複数のオブジェクトの読み取りを避けるように努めることができます。

以前 React17 バージョンを見たとき、「createElement の代わりに _jsx を使用する理由」に関する公式の説明では、createElement のいくつかの欠点について説明されていました。 V8レベル。実はこの記事を理解していればこの文の意味が分かりますし、実際に記事内で言及されているスーパーステートなので公式がcreateElementの最適化が難しいと言っていた理由が理解できると思います。createElement 関数はページ内で何度も呼び出されますが、受け入れるコンポーネントの props やその他のパラメーターが異なるため、大量のインライン キャッシュが生成されるため、「高度にポリモーフィック (ハイパーステート)」と言われます。(ただし、少なくともこの point_jsx はまだ解決されていないようですが、これを実現できることは依然として非常に強力です)


ついに終わりました!作るのは簡単ではありませんので、転載の際はご指示ください。親指を立ててください!

おすすめ

転載: blog.csdn.net/YN2000609/article/details/132408002