一篇文章看懂前端性能优化(2023详解)

      性能优化这个词我们经常会在前端的工作或面试中遇到,这个东西说难好像也并不怎么难,毕竟谁都能说上几点。但是如果你想在工作上遇到各种场景的性能瓶颈时都有直击本质的性能方案,或者在面试时让面试官眼前一亮,那就不能只拘泥于『想到哪说到哪』或者『说个大概』,而要有一套体系化的、各个角度的、深入了解的知识图谱。这篇文章也算对我个人的前端知识的一次归纳总结,因为『性能优化』不仅仅是『优化』,什么意思呢?实施优化方案之前,首先要知道为什么要这样优化,这么做的目的是什么。这就需要你上到框架、js、css,下到浏览器、js 引擎、网络等等原理都有不错的了解。所以性能优化真的涵盖了太多前端知识,甚至是绝大部分前端知识。

      首先我们来谈一谈前端性能的本质,前端是一个网络应用,应用的性能好坏是它的运行效率决定的,前面再加上网络那就是再和网络效率有关。所以我认为前端性能的本质就是网络性能和运行性能。所以前端性能优化体系中的两个大分类就是:网络和运行时,然后我们从这两大纲领中,再细分出各个小的领域,足以织成一个巨大的前端知识图谱。

网络层面

      如果我们把网络连接比做一根水管,你现在要打开一个页面,就可以看作对面手上有一杯水,你想把水接到你杯子里。想要更快可以有 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 四次握手的时延,以及 TCP 慢启动的减速过程,给用户的感觉就是网络突然卡顿了一下,因此连接的迁移成本是很高的。如果你在高铁上可能你的 IP hUI连续变化,这会导致你的 TCP 连接不断重新连接。

而 QUIC 协议没有用四元组的方式来“绑定”连接,而是通过连接 ID 来标记通信的两个端点,客户端和服务器可以各自选择一组 ID 来标记自己,因此即使移动设备的网络变化后,导致 IP 地址变化了,只要仍保有上下文信息(比如连接 ID、TLS 密钥等),就可以“无缝”地复用原连接,消除重连的成本,没有丝毫卡顿感,达到了连接迁移的功能。

简化帧结构、QPACK 优化头部压缩

HTTP/3 同 HTTP/2 一样采用二进制帧的结构,不同的地方在于 HTTP/2 的二进制帧里需要定义 Stream,而 HTTP/3 自身不需要再定义 Stream,直接使用 QUIC 里的 Stream,于是 HTTP/3 的帧的结构也变简单了。

HTTP/3帧

  根据帧类型的不同,大体上分为数据帧和控制帧两大类,Headers 帧(HTTP 头部)和 DATA 帧(HTTP 包体)属于数据帧。

HTTP/3 在头部压缩算法这一方面也做了升级,升级成了 QPACK。与 HTTP/2 中的 HPACK 编码方式相似,HTTP/3 中的 QPACK 也采用了静态表、动态表及 Huffman 编码。

对于静态表的变化,HTTP/2 中的 HPACK 的静态表只有 61 项,而 HTTP/3 中的 QPACK 的静态表扩大到 91 项。

HTTP/2 和 HTTP/3 的 Huffman 编码并没有多大不同,但是动态表编解码方式不同。

いわゆる動的テーブル。最初の要求と応答の後、両当事者は、静的テーブルに含まれていないヘッダー項目 (一部のカスタマイズされたヘッダーなど) をそれぞれの動的テーブルに更新し、それらを表すために 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是特定版本资源的标识(比如对内容 hash 就可以生成一个 etag)。服务器当If-None-Match或者If-Modified-Since没有变化时会返回 304 状态码的响应,浏览器会认为资源没有更新从而复用本地缓存。由于Last-Modified记录的修改时间是秒为单位,如果修改频率发生在 1 秒内就不能准确判断是否更新了,所以etag的判断优先级要高于Last-Modified。

Cache-Control中如果设置no-cache会强制不使用强缓存,直接走协商缓存,即 max-age=0。如果设置no-store会不使用任何缓存。

浏览器对请求的缓存策略简单来说就是这样,我们可以看出缓存是由响应头和请求头决定的,开发过程中一般已经由网关和浏览器帮我们自动设置好了,如果你有特定需求,可以定制化使用更多Cache-Control功能。

完整的 Cache-Control 功能

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 字节,可能数据就几字节,那就非常的不划算。怎么避免这种情况呢?下面看看小数剧包如何优化。        

tcp 小数据包

对于接收方,只要不让它发送小窗口就行,接收方通常才有这种策略:接收窗口如果小于MSS、缓存空间/2的最小值,就告知对端窗口是 0,不要再发数据了,直到窗口大于那个条件。

对于发送方,使用 Nagle 算法,只有满足以下两个条件的其一才会发送:

  • 窗口大小 >= MSS 并且 总数据大小 >= MSS
  • 收到之前发送数据的 ack

如果一条都没满足,它就会一直积攒数据,然后达到某个条件一起发送。

伪代码如下

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这种数据小、交互多的场景下,Nagle 碰上延迟 ack 会很糟糕,所以需要关闭。(Nagle 算法没有系统全局配置,需要根据各自应用关闭)

说完小数据优化,现在再说回滑动窗口,其实 tcp 最终采用的窗口并不完全由滑动窗口决定,滑动窗口只是防止双端超出收发能力,还要考虑两端之间的网络情况,如果两端收发能力都很强,但此刻网络环境很差,发大量数据只会让网络更拥堵,所以还有一个拥塞窗口,tcp 会取滑动窗口和拥塞窗口的最小值。

tcp 慢启动与拥塞避免

首先要讲一下什么是 MSS,MSS 是一个 tcp segment 最大允许的数据字节长度,是由 MTU(数据链路层最大数据长度,由硬件规定的)减去 ip 头 20 字节 减去 tcp 头 20 字节算出来的,一般是 1460。也就是代表一个 tcp 包最多携带 1460 字节上层的数据。tcp 握手时会在双端协商出最小的 MSS。在实际网络环境中,请求会经过很多中间设备,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,发送端就知道这些数据接收方已经收过了,不会再重传。

 

 D-SACK在 Linux 中通过net.ipv4.tcp_dsack参数开关。

总结一下SACK和D-SACK的作用就是:让发送方知道哪些包没收到、是否重复收包,可以判断出是数据包丢了、还是 ack 丢了、还是数据包被网络延迟了、还是网络中把数据包复制了。

更『厉害』的缓存:Service Worker

上面讲到的 HTTP 缓存控制权主要还是在后端,而且如果缓存过期了,虽然有协商缓存,但多多少少还是有一点请求的,这就要求必须有网络,同时它一般只能缓存 get 请求。这些限制使前端做不了像客户端那样的本地应用。那么有没有什么办法能让前端彻底的代理缓存,无论是静态资源还是 api 接口通通都可以由前端自己来决定,甚至可以把网页像 App 一样变成一个彻底的本地应用。这就是接下来要讲到的Service Worker,让我们看看它有哪些特性。

离线缓存

Service Worker可以看作是应用与网络请求之间的代理,它可以拦截请求,基于网络是否可用或者其他自定义逻辑来采取合适的行为。举个例子,你可以在应用第一次打开后,将 html、css、js、图片等资源都缓存起来,下一次打开网页时拦截请求并直接返回缓存,这样你的应用离线也可以打开了。如果后来设备联网了,你可以在后台请求最新资源并判断是否更新了,如果更新了你可以提醒用户刷新升级。在启动上,使用 Service Worker 的前端应用完全可以不需要网络,就像客户端 App 一样。

推送通知

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 描画の基本的なコマンドです。
  • composite(合成): 到了这一步不再由 CPU 执行了,任务会交给由 GPU 处理,所以 js 如果阻塞了不会影响这个线程,CSS 硬件加速也是发生在这个线程。paint 阶段的绘制命令列表会交给合成层,合成层会将当前视口附近区域划定为图块,以 512px 为单位,优先渲染图块区域,其他不在视口附近范围的页面可以等空闲了再渲染。合成层通过光栅化线程池将绘制命令交给 GPU 绘制并输出位图,由于这些位图是各层的,这些图层还需要再由合成层合成成一张位图。一旦图层被光栅化,合成层可以对多个图层进行合成,将它们按照正确的顺序叠加在一起,形成最终的渲染结果。这个过程通常在 GPU 中进行,以减轻 CPU 的工作负担,提高渲染性能。

解释一下光栅化:光栅化是计算机图形学中的概念。在合成层中,将图层中的矢量图形、文本、图片等元素转换为位图或光栅图像。这使得这些元素可以更快地被渲染和显示,因为位图在图形硬件中处理更为高效。合成层可以将需要渲染的内容绘制到离屏(Off-screen)的内存区域中,而不是直接在屏幕上渲染。这样可以避免直接在屏幕上绘制导致的性能问题,并且允许浏览器在后台对离屏内容进行优化处理。通过将图层内容光栅化后,浏览器可以更好地利用图形硬件加速来进行渲染。现代计算机和移动设备中的图形处理单元(GPU)可以高效地处理位图图像,从而提供更流畅的动画效果和更快的渲染速度。

  • 显示: 等待显示器发出 sync 信号,代表即将显示下一帧。合成层的位图会交给浏览器进程里的biz组件,位图被放进后缓冲区,显示器显示下一帧时,前、后缓冲区会交换,从而显示最新的页面图片。js 里的requestAnimationFrame的回调也是因为 sync 信号才知道即将渲染下一帧才触发的。另外还有游戏里的垂直同步。

渲染时这些步骤就像流水线一样按序执行,如果流水线从某一步开始执行,必然会将下面的步骤都执行到底。

したがって、位置/レイアウト関連のスタイルが変更されてページが更新されると、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);
}

 如果代码变成这样,我们会发现每次循环读取的两次对象形状是不一样的,因此它们的隐藏类也是不一样的。V8 在读取到第二个对象时会发现插槽里的隐藏类和正在读的不一样,于是它会往插槽里添加一个新的隐藏类和属性值内存偏移量。这时插槽里会有两个隐藏类和偏移量。每次读取对象属性时,V8 会一一比较它们。如果正在读取的对象的隐藏类和插槽中的某个隐藏类相同,那么就使用命中的隐藏类的偏移量。如果没有相同的,同样将新的信息添加到那个插槽中。

  • 如果一个插槽中只包含 1 个隐藏类,称这种状态为单态 (monomorphic);

  • 如果一个插槽中包含了 2 ~ 4 个隐藏类,称这种状态为多态 (polymorphic);

  • 如果一个插槽中超过 4 个隐藏类,称这种状态为超态 (magamorphic)。

可以看出单态时的性能是最好的,所以我们在一个多次执行的函数中可以尝试避免修改对象或读取多个对象,来达到更好的性能。

这里可以引出一件事情,之前我看 React17 版本时官方在关于『为什么要用 _jsx 代替 createElement 』的解释时,讲到了 createElement 的一些缺点,里面就提到了 createElement 是『高度复态』的,难以从 V8 层面优化。其实看懂了这篇文章,就能明白那句话什么意思,它其实就是文章里说的超态,于是你也就明白为什么官方说 createElement 难以优化了。createElement函数在页面中会被调用非常多次,但它接受的组件 props 等参数各不相同,于是会产生非常多内联缓存,所以说它是『高度复态(超态)』的。(不过至少这一点 _jsx 好像依然没能解决,但他们能觉察到这一点还是很厉害的)


终于完了啊 ! 制作不易,转载请注明。点个赞吧!!!

おすすめ

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