DevOps プラットフォームは、毎週月曜日の終業後にデプロイの更新を行います。Sentry で、更新デプロイの翌日には、多くのChunkLoadError
の報告が集中的に届くことがよくあります。
この記事では、DevOps プラットフォームプロジェクト(以下、「DevOps プラットフォーム」と呼びます)でユーザーが遭遇するChunkLoadError
エラーを解決する方法について記録と共有します。
ChunkLoadError
の原因#
Sentry に記録されたChunkLoadError
が発生した際のユーザーの操作ログによると、ユーザーが他のページにリンクをクリックして移動する際に主に発生することがわかりました。これは、以下のプラクティスによってシングルページアプリケーションの構築時に発生する可能性があります。
- 初回のリソースダウンロードのサイズを減らすために、プロジェクトをコード分割することがよくあります(
React.lazy
とimport()
関数を使用)。DevOps プラットフォームの例では、すべての機能ページがパッケージに分割されています。 - アプリケーションのビルド時に、ブラウザのキャッシュをより効果的に利用するために、出力スクリプトの内容のハッシュをスクリプトファイル名の一部として設定することがあります。Webpack の場合、
filename
を[name].[contenthash:8].js
のように設定することがあります。
更新デプロイごとに、新しいビルドではコードが変更されるため、ビルドされたパッケージファイルの名前も変更されます。
例えば、前回のビルドでは、ある機能ページ A のビルドされたパッケージスクリプトの名前はA.oldhash.js
でしたが、新しいビルドではA.newhash.js
に変更されました。
ユーザーが更新デプロイ前にアプリケーションにアクセスした場合、彼のブラウザは古いバージョンのアプリケーションコードを実行し、そのコードに含まれるパッケージ情報も古いものですので、リンク A はA.oldhash.js
をリクエストします。
デプロイ後、サーバー上にはA.oldhash.js
が存在しなくなるため、ユーザーがリンク A をクリックすると、リクエストしたパッケージファイルが存在しないためエラーが発生します。
この原因に基づいて、この記事ではChunkLoadError
エラーを引き起こす具体的なケースに対して、段階的に問題を解決します。
ChunkLoadError
の解決策#
この問題の核心は、アプリケーションがデプロイされた後も、ユーザー側でまだ古いコードが実行されていることです。
したがって、問題を解決するための鍵は、デプロイ後できるだけ早くユーザー側を新しいコードに更新することです。これを実現するためには、以下の方法があります。
エントリーポイントのキャッシュを回避する#
通常、クライアントのトラフィックを節約するために、多くのリソース、特にサイズの大きいリソースをキャッシュします。これにより、デプロイ後にユーザーが再度リソースにアクセスした場合、キャッシュがまだ有効であれば、クライアントはキャッシュされたリソースを使用して古いコードを実行します。
例えば、私たちのエントリースクリプトの名前はapp.js
であり、デプロイが更新された後、ユーザーが再度app.js
ファイルをリクエストした場合、キャッシュがまだ有効であれば、ユーザーは古いapp.js
を取得し、つまり古いバージョンのアプリケーションを実行し、ChunkLoadError
が発生します。
この問題に対処するために、ビルド段階でスクリプトの内容のハッシュ値を出力ファイル名の一部として設定することができます。これにより、スクリプトの内容が変更されない限り、ブラウザはキャッシュされたスクリプトを問題なく使用できます。スクリプトの内容が変更されると、スクリプトの名前も変更されるため、キャッシュの問題は発生しません。
例えば、古いバージョンのエントリースクリプトの名前はapp.oldhash.js
であり、新しいバージョンのエントリースクリプトの名前はapp.newhash.js
となります。
ただし、エントリー HTML ファイルもキャッシュしている場合、デプロイ後にキャッシュがまだ有効であれば、クライアントが取得するのは古い HTML コンテンツであり、その中に含まれるスクリプト名も古いものになるため、エラーが発生する可能性があります(ただし、ここではChunkLoadError
ではなく、直接エントリースクリプトの 404 エラーが発生します)。したがって、エントリー HTML ファイルをキャッシュしないように設定することで、この問題を解決できます。また、エントリー HTML ファイル自体のサイズは大きくない(通常数 kB 程度)ため、毎回ダウンロードしても影響はほとんどありません。
以下は、Nginx の例です。
server {
location / {
if ($request_filename ~ .*\.(htm|html)$) {
add_header Cache-Control no-cache;
}
}
}
クライアントアプリケーションのアクティブリフレッシュ#
2 番目のケースは、ユーザーがデプロイ前にアプリケーションにアクセスし、ログアウトせずにそのまま残っているため、デプロイ更新後も古いバージョンのアプリケーションが実行されている場合です。この場合、先ほどの方法は適用できません。なぜなら、ユーザーが再度エントリー HTML ファイルをリクエストしていないからです。
この場合、ユーザーに新しいアプリケーションを取得するために、ユーザーに手動でリフレッシュを行ってもらう方法を考えることができます。例えば、アプリケーションの更新を検出した場合、ユーザーにリフレッシュをクリックするように浮動ウィンドウを表示することができます。
では、アプリケーションの更新をどのように検出するのでしょうか?私は掘金の記事で学んだ方法を紹介します。先ほどの方法で説明したように、エントリー HTML ファイルはキャッシュせず、サイズも大きくないため、エントリー HTML ファイルをポーリングし、現在の HTML ファイルと異なるかどうかを判断することで、アプリケーションの更新を検出することができます。
では、エントリー HTML ファイルが異なるかどうかをどのように判断するのでしょうか?一つの方法は、document.currentScript
を使用して現在のスクリプトが含まれる<script>
要素を取得し、そのsrc
属性を取得することです。これが現在のスクリプトのファイル名です。
ポーリングした HTML ファイルのテキストにこのファイル名が含まれていない場合、現在のスクリプトが新しい HTML ファイルで参照されなくなったことを意味し、アプリケーションが更新されたと判断できます。
const currentScriptSrc = document.currentScript.src
let hasUpdate = false
setInterval(() => {
if (!hasUpdate) {
fetch("/")
.then(response => response.text())
.then(html => {
if (!html.includes(currentScriptSrc)) {
hasUpdate = true
// ユーザーに更新があることを通知するポップアップを表示
}
})
}
}, 10000)
エッジケース#
上記の改善を行った後も、私はデプロイ更新の翌日になると、まだChunkLoadError
の報告が集中的に届くことがありました。
Sentry でこれらのレコードを確認したところ、これらのユーザーフローには共通の特徴があり、リクエストが 401 ステータスコードを返し、その後ChunkLoadError
が発生していました。
以下のシナリオが考えられます:ユーザーが前日の午後にアプリケーションにアクセスし、ログイン状態が失効したため、アプリケーションが自動的にログインページにリダイレクトします(アプリケーションのロジック)。ログインページの分割パッケージスクリプトのハッシュが変更されているため、ChunkLoadError
が発生します。
これは前のケースとは異なり、ユーザーは更新通知をまだ見て反応する前に、アプリケーションが自動的にリダイレクトされてしまいます。この場合、ログインページなどのパッシブリダイレクトページをメインパッケージに含め、分割しないようにするだけで、この問題を回避することができます。このようなページは数が少なく、
ログインページを分割しないようにデプロイした 2 週間後、Sentry を確認したところ、更新デプロイ以降、ChunkLoadError
が発生しなくなりました。
以上から、ChunkLoadError
の問題は完全に解決されたと言えます。
参考リンク#
もし私の他の記事を読んでいただけるのであれば、私のブログと xLog をフォローしてください。