ウェブベージで JavaScript ファイルを読み込むためには<script>
タグを使うが、その際にasync
属性やdefer
属性を設定することができる。
そして、これらの属性を使うかどうかで、ウェブベージの表示速度や JavaScript ファイルの実行順序に違いが生まれる。
この記事の内容は、Google Chrome のバージョン83.0.4103.97
で動作確認している。
サーバは Node.js のv12.17.0
で構築している。
デフォルトでは script タグは逐次処理される
まず、async
もdefer
も設定しないケースについて見てみる。このケースのことを、便宜上default
と呼ぶことにする。
以下の HTML ファイルと JavaScript ファイルを用意した。
<html> <head lang="en"> <meta charset="utf-8"> </head> <body> <h1>Headline</h1> <script src="/1.js"></script> <p>content</p> <script src="/2.js"></script> </body> </html>
// 1.js console.log(1, window.document.querySelector('p'));
// 2.js console.log(2, window.document.querySelector('p'));
これらのファイルを、以下のサーバで配信する。
const http = require('http'); const fs = require('fs'); http.createServer((req, res) => { switch(true) { case /^\/$/.test(req.url): fs.readFile('./index.html', 'utf-8', (err, data) => { res.writeHead(200, {'Content-Type': 'text/html; charset=utf-8'}); res.write(data); res.end(); }) break; case /^\/1\.js$/.test(req.url): fs.readFile('./1.js', 'utf-8', (err, data) => { setTimeout(() => { res.writeHead(200, {'Content-Type': 'text/javascript'}); res.write(data); res.end(); }, 3000) }) break; case /^\/2\.js$/.test(req.url): fs.readFile('./2.js', 'utf-8', (err, data) => { setTimeout(() => { res.writeHead(200, {'Content-Type': 'text/javascript'}); res.write(data); res.end(); }, 3000) }) break; default: res.writeHead(404); res.end(); }; }).listen(8080);
JavaScript ファイルは、リクエストを受け取って 3 秒後に返すようにしてある。
この状態でhttp://localhost:8080/
にアクセスした結果が、以下の動画。
Headline
のみが表示され、JavaScript の実行が終わるまでcontent
は表示されない。
これは、HTML ファイルのパースは上から順番に行われ、<p>content</p>
の前に<script src="/1.js"></script>
が処理されるためである。
そのため、/1.js
のダウンロードと実行が終わるまで、content
は表示されない。
JavaScript ファイルのダウンロードは、パースや JavaScript の実行を待たずに行われる。/1.js
のダウンロードとほぼ同時に、/2.js
のダウンロードも開始される。
それにより、1
と2
がほぼ同時にログに表示される。
実行は、HTML ファイルに書かれている順番に行われる。
/1.js
ではp
要素を取得できず/2.js
では取得できているのは、そのため。
async 属性や defer 属性を使うとダウンロードとパースを並行処理できる
次に、index.html
を以下の内容に書き換えてみる。
<script>
タグにasync
属性をつけている。
<html> <head lang="en"> <meta charset="utf-8"> </head> <body> <h1>Headline</h1> <script async src="/1.js"></script> <p>content</p> </body> </html>
この状態でアクセスした結果が以下。
/1.js
のダウンロードが終わる前から、content
が表示されている。当然、querySelector
でもp
要素を取得できる。
async
の部分をdefer
に置き換えても、同じ結果になる。
async
やdefer
は、パースの途中でダウンロード中の JavaScript ファイルを見つけても、パースを中断しない。
この例で言えば、上から順番にパースしていくと、まだダウンロードが終わっていない<script async src="/1.js"></script>
に到達する。
default
の場合はここでパースを中断し、/1.js
のダウンロード、そして実行が完了してから、以降の行のパースを再開する。
だがasync
とdefer
では、/1.js
がダウンロード中だった場合、そのまま以降の行のパースを続行する。
つまり、JavaScript ファイルのダウンロードを原因としたパースの中断が、発生しない。
パースの完了を待つ defer と、待たない async
JavaScript ファイルのダウンロードが完了した後の処理は、async
とdefer
で異なる。
async
はダウンロードが終わり次第、JavaScript の実行を開始する。それに対してdefer
は、ダウンロードが完了しても、HTML ファイルのパースがまだ終わっていないのなら実行しない。パースが終わるのを待ってから、実行する。
それを確認するために、以下のindex.html
と JavaScript ファイルを用意した。
<html> <head lang="en"> <meta charset="utf-8"> </head> <body> <h1>Headline</h1> <script async src="/1.js"></script> <script src="/2.js"></script> <p>content</p> </body> </html>
// 1.js console.log('h1', window.document.querySelector('h1')); console.log('p', window.document.querySelector('p'));
// 2.js
console.log(2);
サーバのコードも変更し、/1.js
はリクエストを受け取ってから 1 秒後、/2.js
は 3 秒後に返すようにした。
case /^\/1\.js$/.test(req.url): fs.readFile('./1.js', 'utf-8', (err, data) => { setTimeout(() => { res.writeHead(200, {'Content-Type': 'text/javascript'}); res.write(data); res.end(); }, 1000) }) break; case /^\/2\.js$/.test(req.url): fs.readFile('./2.js', 'utf-8', (err, data) => { setTimeout(() => { res.writeHead(200, {'Content-Type': 'text/javascript'}); res.write(data); res.end(); }, 3000) }) break;
この場合、以下の挙動になる。
/1.js
は 1 秒後にダウンロードが完了するが、その時点では/2.js
はまだダウンロードが終わっておらず、<script src="/2.js"></script>
の部分でパースが止まっている。
そして、async
である/1.js
はダウンロードが終わった時点で実行されるため、h1
要素は取得できるがp
要素は取得できない。
次に、async
をdefer
に置き換えてアクセスしてみる。
@@ -4,7 +4,7 @@ </head> <body> <h1>Headline</h1> -<script async src="/1.js"></script> +<script defer src="/1.js"></script> <script src="/2.js"></script> <p>content</p> </body>
先ほどと同様に/1.js
は 1 秒後にダウンロードが完了するが、その時点ではまだ実行しない。
/2.js
のダウンロードと実行、そして HTML ファイルのパースが最後まで完了するまで、待機する。そしてパースが終わった段階で、実行する。そのため、h1
要素だけでなくp
要素も取得できる。
DOMContentLoaded
という、パースが完了したときに発生するイベントがある。
defer
属性を設定したスクリプトは、このイベントの直前に実行される。
そして、DOMContentLoaded
はあくまでも「パースが完了した」ことを意味しており、レンダリングが行われたことは意味しない。
以下のindex.html
とheavy.js
で確認できる。
<html> <head lang="en"> <meta charset="utf-8"> </head> <body> <script> window.document.addEventListener('DOMContentLoaded', () => { alert('DOMContentLoaded'); }); </script> <h1>Headline</h1> <script defer src="/heavy.js"></script> <script src="/2.js"></script> <p>content</p> </body> </html>
// heavy.js console.log('start'); const p = window.document.querySelector('p'); if (p) { console.log(p.textContent); p.textContent = 'overwrite'; } // 時間のかかる処理を行いたいだけであり、処理の内容に意味はない const myFunc = () => { window.document.createElement('div'); window.document.createElement('span'); window.document.createElement('ul'); window.document.createElement('li'); }; for (let i = 0; i < 1000000; i += 1) { myFunc(); } console.log('end');
/heavy.js
はリクエストを受け取ってすぐに、/2.js
はリクエストを受け取って 3 秒後に、返すようにした。
この状態でhttp://localhost:8080/
にアクセスしたのが、以下の動画。
パースが終わった時点で、既にダウンロード済みだった/heavy.js
の実行が始まり、start
がログに表示される。
既にパースは完了しているので、p
要素を取得できる。だが画面には、p
要素はまだレンダリングされていない。
その後myFunc()
を繰り返し処理する。私の環境ではこの処理には 1.6 秒ほどかかるが、これが終わるとend
がログに表示される。
そしてDOMContentLoaded
イベントが発生するのだが、この時点でもまだ、p
要素のレンダリングは行われない。アラートを閉じてようやく、レンダリングされる。
整理すると、以下の順番になる。
- HTML ファイルのパースが終わる
defer
属性のついたスクリプトを実行DOMContentLoaded
イベントが発生- レンダリング
async
はダウンロードが終わり次第実行していくので、DOMContentLoaded
イベントのタイミングとは関係がない。DOMContentLoaded
より前に実行されることもあれば、後に実行されることもある。
同じ属性を設定した script タグが複数ある場合の挙動
ダウンロードが終わり次第実行するという性質上、async
属性が設定されたスクリプトの実行順序は、保証されない。
例えば、HTML ファイルに以下のような記述があった場合、JavaScript ファイルがどの順番で実行されるかは、実際にページにアクセスしてみるまで分からない。
記述された順番とは無関係に、ダウンロードが終わったものから実行されていく。
<script async src="/1.js"></script> <script async src="/2.js"></script> <script async src="/3.js"></script>
これに対してdefer
は、必ず上から順番に実行されていくので、実行順序が保証される。
以下の場合、/1.js
より先に/2.js
や/3.js
のダウンロードが完了しても、必ず/1.js
のダウンロードと実行の完了を待つ。そのあとで/2.js
の実行を行い、最後に/3.js
を実行する。
<script defer src="/1.js"></script> <script defer src="/2.js"></script> <script defer src="/3.js"></script>