JavaScriptのモダンなコードとレガシーなコードを適切なブラウザに提供する方法
Post on:2019年8月6日
JavaScriptのモダンなコードとレガシーなコード、適切なコードを適切なブラウザに提供する方法を紹介します。特に、Edge, Safariあたりは注意が必要です。
Modern Script Loading
by Jason Miller
下記は各ポイントを意訳したものです。
※当ブログでの翻訳記事は、元サイト様にライセンスを得て翻訳しています。
- はじめに
- どうすればよいか
- オプション 1: 動的にロードする
- オプション 2: userAgentを利用する
- オプション 3: 古いブラウザにペナルティを与える
- オプション 4: 条件付きバンドルを使用する
- どれを使用すればよいか?
- 参考文献
はじめに
適切なコードを適切なブラウザに提供するのは難しい場合があります。この記事でそれを解決するいくつかの方法を紹介します。
モダンなコードをモダンブラウザに提供することはパフォーマンスが向上します。より古いレガシーブラウザを引き続きサポートしながら、JavaScriptにはよりコンパクトで最適化されたモダンなコードを含めることができます。
The tooling ecosystemは、モダンとレガシーなコードを宣言的にロードするためのmodule/nomodule パターンの使用を統合しました。ブラウザにモダンとレガシーの両方のソースを提供し、どちらを使用するか決めることができます。
1 2 |
<script type="module" src="/modern.js"></script> <script nomodule src="/legacy.js"></script> |
しかし残念ながら、それほど単純ではありません。
上記のHTMLベースのアプローチは、EdgeとSafariでスクリプトのオーバーフェッチを引き起こします。
どうすればよいか
では、どうすればよいでしょうか? ブラウザに応じて2つのコンパイルターゲットを提供したいのですが、いくつかの古いブラウザではそうするための簡潔な構文が完全にサポートされていません。
まず、問題になるのがSafariです。Safari 10.1はscript要素のnomodule属性をサポートしていないため、モダンとレガシーなコードの両方が実行されてしまいます。
ありがたいことに、SamはSafari 10と11でサポートされている非標準のbeforeloadイベントを使ってnomodule属性をポリフィルする方法を見つけました。
参考: safari-nomodule.js
オプション 1: 動的にロードする
この問題を回避するには、LoadCSSのようなスクリプトローダーを使用します。ES modulesとnomodule属性の両方を実装するためにブラウザに頼る代わりに、Moduleスクリプトを「リトマス試験」として実行し、その結果を使用してモダンコードとレガシーコードのどちらをロードするか選択できます。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
<!-- モダンブラウザを検出するためにmoduleを使用 --> <script type=module> self.modern = true </script> <!-- モダンとレガシーなコードをロードするために上記のフラグを使用 --> <script> addEventListener('load', function() { var s = document.createElement('script') if (self.modern) { s.src = '/modern.js' s.type = 'module' } else { s.src = '/legacy.js' } document.head.appendChild(s) }) </script> |
ただし、この方法では最初の「リトマス試験」が実行されてから、正しいスクリプトをインジェクトする必要があります。なぜなら、<script type=module>は常に非同期だからです。
しかし、もっと良い方法があります!
このスタンドアロン版は、ブラウザがnomoduleをサポートしているかどうかを調べることで実装できます。つまり、Safari 10.1のようなブラウザがmoduleをサポートしていてもレガシーとして扱われることを意味しますが、それは良いことかもしれません。
参考: Safari 12 bug
参考: Transform Tagged Template Literals in Safari
下記がそのためのコードです。
1 2 3 4 5 6 7 8 9 |
var s = document.createElement('script') if ('noModule' in s) { // notice the casing s.type = 'module' s.src = '/modern.js' } else s.src = '/legacy.js' } document.head.appendChild(s) |
これでモダンコード、またはレガシーコードをロードする関数に素早くロールできます。また、両方とも非同期にロードされます。
1 2 3 4 5 6 7 8 9 |
<script> $loadjs("/modern.js","/legacy.js") function $loadjs(src,fallback,s) { s = document.createElement('script') if ('noModule' in s) s.type = 'module', s.src = src else s.async = true, s.src = fallback document.head.appendChild(s) } </script> |
トレードオフは何でしょうか? プリロードです。
この方法の問題点は、完全に動的であるため、モダンスクリプトとレガシースクリプトをインジェクトするために作成した自己投資型のコードを実行するまで、ブラウザがJavaScriptのリソースを検出できないことです。通常、ブラウザはプリロード可能なリソースを探すためにストリーミングされるときにHTMLをスキャンします。
完璧ではありませんが、解決策があります。モダンブラウザにモダンバージョンのバンドルをプリロードするために<link rel=modulepreload>を使用できます。残念ながら、現時点ではChromeがmodulepreloadをサポートしているだけです。
1 2 3 |
<link rel="modulepreload" href="/modern.js"> <script type=module>self.modern=1</script> <!-- etc --> |
この方法が有効かどうかは、スクリプトを埋め込むHTML文書のサイズによって決まります。HTMLの量がスプラッシュスクリーンのように小さい場合、またはクライアントサイドのアプリを起動する場合は、プリロードスキャナを使用しなくてもパフォーマンスに影響を与える可能性は低くなります。ブラウザがストリーミングできるように、意味のあるHTMLを大量にサーバーレンダリングする場合には、プリロードスキャナは最適な方法ではないかもしれません。
下記のように記述します。
1 2 3 4 5 6 |
<link rel="modulepreload" href="/modern.js"> <script type=module>self.modern=1</script> <script> $loadjs("/modern.js","/legacy.js") function $loadjs(e,d,c){c=document.createElement("script"),self.modern?(c.src=e,c.type="module"):c.src=d,document.head.appendChild(c)} </script> |
scriptタグのmodule属性をサポートするブラウザは、<link rel=preload>をサポートするブラウザと非常に似ていることも指摘されています。 Webサイトによっては、modulepreloadに頼らずに<link rel=preload as=script crossorigin>を使用するのが理にかなっているかもしれません。従来のスクリプトのプリロードではmodulepreloadと同様に、時間の経過とともに構文解析の作業が分散されないため、パフォーマンス上の欠点があります。
参考: moduleのサポートブラウザ
参考: preloadのサポートブラウザ
オプション 2: userAgentを利用する
userAgent(ユーザーエージェント)を検出する方法は重要ではないので、ここではコードを紹介しませんが、Smashing Magazineに素晴らしい記事があります。
参考: How To Serve Legacy Code Only To Legacy Browsers
基本的にこの方法はすべてのブラウザで同じで、<script src=bundle.js>から始まります。bundle.jsが要求されると、サーバーは要求元のブラウザのユーザーエージェントの文字列を解析し、ブラウザがモダンなのかモダンではないのかに応じて、モダンJavaScriptとレガシーJavaScriptのどちらを返すか選択します。
この方法には汎用性がありますが、いくつかの懸念事項があります。
- サーバースマートが必要なため、静的な実装(静的サイトジェネレータ、Netlifyなど)では機能しません。
- JavaScript URLのキャッシュは、揮発性が高いユーザーエージェントによって異なります。
- ユーザーエージェントの検出は困難で、偽装している可能性もあります。
- ユーザーエージェントの文字列は簡単になりすましができ、新しいUAは毎日誕生します。
これらに対処する1つの方法は、最初に複数のバンドルバージョンを送信することを避けるために、module/nomoduleパターンをユーザーエージェントの違いと組み合わせることです。ページのキャッシュ機能は低下しますが、HTMLを生成するサーバーは、modulepreloadとpreloadのどちらを使うかを知っているため、効果的なプリロードが可能になります。
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
function renderPage(request, response) { let html = `<html><head>...`; const agent = request.headers.userAgent; const isModern = userAgent.isModern(agent); if (isModern) { html += ` <link rel=modulepreload href=modern.mjs> <script type=module src=modern.mjs></script> `; } else { html += ` <link rel=preload as=script href=legacy.js> <script src=legacy.js></script> `; } response.end(html); } |
リクエストに応じてサーバー上でHTMLを生成するWebサイトでは、この方法がモダンスクリプトのロードに対する効果的な解決策です。
オプション 3: 古いブラウザにペナルティを与える
module/nomoduleパターンの悪影響は、古いバージョンのChrome、Firefox、Safariで見られます。通常は自動的に最新バージョンにアップデートされるので、非常に限られたブラウザです。Edge 16-18には当てはまりませんが、Edgeの新しいバージョンでは、この問題の影響を受けないChromiumベースに変わる予定です。
一部のアプリケーションではこれをトレードオフとして受け入れることが、合理的かもしれません。つまり、多少の古いブラウザを犠牲にしても、モダンなコードでブラウザの90%に提供できることになります。特に、このオーバーフェッチの問題に悩まされているユーザーエージェントはどれもモバイル市場で大きなシェアを持っていないので、これらのバイトが高価なモバイルプランや低速プロセッサを搭載したデバイスから来る可能性は低くなります。
ユーザーが主にモバイルまたはモダンブラウザを使用しているサイトを構築している場合、module/nomoduleパターンが大多数のユーザーに機能します。古いiOSデバイスで使用している場合は、必ずSafari 10.1用のポリフィルを含めてください。
1 2 3 4 5 6 7 8 9 10 |
<!-- Safari 10.1用のポリフィル --> <script type=module> !function(e,t,n){!("noModule"in(t=e.createElement("script")))&&"onbeforeload"in t&&(n=!1,e.addEventListener("beforeload",function(e){if(e.target===t)n=!0;else if(!e.target.hasAttribute("nomodule")||!n)return;e.preventDefault()},!0),t.type="module",t.src=".",e.head.appendChild(t),t.remove())}(document) </script> <!-- ブラウザの90% --> <script src=modern.js type=module></script> <!-- IE, Edge <16, Safari <10.1, 古いデスクトップ --> <script src=legacy.js nomodule async defer></script> |
オプション 4: 条件付きバンドルを使用する
ここでの賢いアプローチの1つは、nomoduleを使ってポリフィルのようなモダンブラウザでは必要とされないコードを含むバンドルを条件付きでロードすることです。この方法では最悪の場合でも、ポリフィルがロードされるか、あるいはSafari 10.1で実行されることがありますが、効果は「過剰ポリフィル」に限られます。現在普及している方法がすべてブラウザでポリフィルをロードして実行することであることを考えると、これは実質的に改善となるかもしれません。
1 2 3 4 5 |
<!-- 新しいブラウザはロードしません --> <script nomodule src="polyfills.js"></script> <!-- すべてのブラウザはこれをロードします --> <script src="/bundle.js"></script> |
Angular CLIはMinko Gechev氏によって実証されたように、ポリフィルに対してこの方法を使用するように設定できます。この記事を読んだ後に、preact-cliで自動ポリフィルインジェクションを使用するように切り替えることができることに気付きました。このPRは、このテクニックを採用するのがいかに簡単かを示しています。
Webpackを使っている人のために、ポリフィルバンドルにnomoduleを簡単に追加できるhtml-webpack-plugin用の便利なプラグインがあります。
参考: Webpack Nomodule Plugin
どれを使用すればよいか?
その答えは、あなたのユースケースによって異なります。クライアントサイドのアプリケーションを構築していて、アプリのHTMLペイロードが<script>にすぎない場合は、オプション 1が魅力的な選択肢になるかもしれません。
サーバーでレンダリングされたWebサイトを構築していてキャッシングに影響を与える可能性がある場合は、オプション 2が役立ちます。
ユニバーサルレンダリングを使用している場合は、プリロードスキャンによって得られるパフォーマンス上の利点が非常に重要になる可能性があります。その場合は、オプション 3またはオプション 4を検討してください。使用しているアーキテクチャに適したものを選択します。
個人的には、デスクトップブラウザのダウンロードコストではなく、モバイルでの解析時間を短縮するために最適化することにしています。モバイルユーザーは実際の費用として構文解析とデータコスト(バッテリの消耗とデータ料金)を経験しますが、デスクトップユーザーはこれらの制約を持たない傾向があります。加えて、90%に最適化されており、私が取り組んでいる案件ではほとんどのユーザーはモダンブラウザ・モバイルブラウザを使用しています。
参考文献
この件について、さらに興味がありますか? 掘り下げるリソースを紹介します。
- Philのwebpack-esnext-boilerplateには、さらに素晴らしいコンテキストがあります。
- RalphはNext.jsにmodule/nomoduleを実装し、これらの問題の解決に取り組んでいます。
sponsors