ページの表示が妙に遅い、あるいは JavaScript から要素を取得しようとしたら null が返ってきた——こうした問題の原因が、<script> を書いた場所と属性にあることは少なくありません。HTML には、スクリプトの読み込みと実行のタイミングを制御するための async と defer という2つの属性が用意されています。この記事では、属性を付けない素の <script> が何をしているのかというところから始めて、defer と async の違い、どちらを選ぶべきかの判断基準、そしてインラインスクリプトや type="module" での扱いまでを解説します。
目次
属性を付けない script は HTML の解析を止めている
ブラウザーは HTML を上から順に読み進めながら、DOM(画面を構成する要素のツリー)を組み立てていきます。この作業を解析(パース)と呼びます。ところが解析の途中で <script src="..."> に出会うと、ブラウザーはそこで解析をいったん停止し、ファイルのダウンロードが終わるのを待ち、さらにその中身を実行し終えてから、ようやく続きの HTML を読み始めます。
<head>
<!-- ここで解析が止まる。ダウンロード+実行が終わるまで先に進まない -->
<script src="/js/main.js"></script>
</head>
<body>
<!-- この h1 は main.js の実行が終わるまで表示されない -->
<h1>Webool ブログ</h1>
</body>
この仕様には理由があります。スクリプトは document.write() で HTML そのものを書き足せますし、まだ解析されていない部分の DOM を先に触ってしまうと結果が予測できません。そのため、ブラウザーは安全側に倒して「スクリプトの実行が終わるまで待つ」という動きをします。
問題は、これがそのまま表示の遅れになることです。<head> の中に重いスクリプトを置くと、本文がまったく描画されない時間が生まれます。しかも、このとき困ったことがもうひとつ起きます。解析がまだ <body> に到達していないため、スクリプトから document.querySelector() で要素を探しても見つからないのです。
// head で読み込むと、この時点ではまだ h1 が DOM に存在しない
const title = document.querySelector('h1');
console.log(title); // null
title.textContent = 'こんにちは'; // TypeError になる
この2つの問題——表示が遅れることと、DOM がまだ無いこと——を解決するのが defer と async です。どちらも「ファイルのダウンロードは解析と並行して進める」という点は共通で、違うのはいつ実行するかと順序を守るかどうかです。
defer は解析が終わってから、書いた順に実行される
defer を付けると、ブラウザーは HTML の解析を止めずにファイルのダウンロードだけをバックグラウンドで進めます。そしてHTML の解析がすべて終わったあと、DOMContentLoaded イベントが発生する直前に実行されます。
<head>
<!-- 解析を止めずに読み込み、解析完了後に実行される -->
<script defer src="/js/main.js"></script>
</head>
<body>
<h1>Webool ブログ</h1>
</body>
実行される時点では DOM がすべて出来上がっているので、先ほどの null 問題は起きません。<head> に書いてあるのに <body> の要素を普通に操作できる、というのが defer の使いやすいところです。
そして defer のもうひとつの重要な性質が、記述した順序が保証されることです。複数の defer スクリプトがある場合、たとえ小さいファイルのダウンロードが先に終わったとしても、HTML に書いた順番どおりに実行されます。
<!-- ダウンロードは3つ同時に進むが、実行は必ず上から順 -->
<script defer src="/js/library.js"></script>
<script defer src="/js/plugin.js"></script>
<script defer src="/js/app.js"></script>
この例のように、ライブラリ本体を読み込んでからプラグイン、最後に自分のコード、という依存関係があるときでも、defer なら安心して並べられます。順序が守られるおかげで、素の <script> をそのまま置き換えても動作が壊れにくいのです。
async は読み込めたものから、すぐに実行される
async も、ダウンロードを解析と並行して行う点は defer と同じです。違うのは実行のタイミングで、ダウンロードが完了した瞬間に実行されるという点です。そのとき HTML の解析がまだ途中であれば、解析はそこで中断され、スクリプトの実行が終わってから再開されます。
<head>
<!-- 読み込みが終わり次第すぐ実行。他のスクリプトを待たない -->
<script async src="https://example.com/analytics.js"></script>
</head>
「すぐ実行される」ということは裏を返すと、実行順序がまったく保証されないということでもあります。複数の async スクリプトを並べた場合、実行される順番はダウンロードが終わった順、つまりファイルサイズやネットワークの状況次第で毎回変わります。
<!-- 危険な例:plugin.js が library.js より先に実行されることがある -->
<script async src="/js/library.js"></script>
<script async src="/js/plugin.js"></script>
もし plugin.js が library.js の存在を前提にしていたら、この書き方では「関数が未定義です」というエラーが出たり出なかったりする、非常にやっかいな不具合になります。しかも開発環境ではキャッシュが効いていて再現せず、本番環境でだけ起きる、ということも珍しくありません。
実行が早い分、DOM が完成しているとも限りません。async スクリプトは DOMContentLoaded より前に実行されることも後に実行されることもあり、その中で要素を取得しても存在するかどうかは運次第です。async は「他の何にも依存せず、自分だけで完結して動くスクリプト」のための属性だと考えてください。
3つの挙動を比較する
ここまでの内容を並べて比べると、違いがはっきりします。いずれも src で外部ファイルを読み込む場合の挙動です。
| 項目 | 属性なし | defer | async |
|---|---|---|---|
| ダウンロード中に解析を止めるか | 止める | 止めない(並行) | 止めない(並行) |
| 実行時に解析を止めるか | 止める | 解析は完了済み | 止める(解析中なら中断) |
| 実行のタイミング | タグに到達した時点 | HTML の解析がすべて終わったあと | ダウンロードが完了した瞬間 |
| 実行順序 | 記述順 | 記述順(保証される) | 保証されない(読み込めた順) |
| 実行時に DOM が完成しているか | していない | している | 不定 |
DOMContentLoaded との前後 | 前 | 直前(発生を待たせる) | 不定 |
| 主な用途 | 互換性が必要な特殊なケース | DOM を操作する自作スクリプト、依存関係のあるライブラリ | アクセス解析、広告など独立したスクリプト |
表の「DOMContentLoaded との前後」の行を補足しておきます。defer のスクリプトは DOMContentLoaded の発生よりも前に実行され、逆に言えば DOMContentLoaded は defer スクリプトの実行が終わるまで発生しません。つまり defer スクリプトの中で DOMContentLoaded を待つリスナーを登録しても、そのリスナーはちゃんと呼ばれます。一方 async のスクリプトは既に DOMContentLoaded が終わったあとに実行される可能性があるため、その中でリスナーを登録しても一生呼ばれないことがあります。
defer と async のどちらを選ぶか
判断の軸はシンプルで、そのスクリプトが他の何かに依存しているかどうかです。ページの DOM を操作するもの、他のライブラリの関数を呼ぶもの、逆に他のスクリプトから呼ばれる前提のものは、すべて defer を選びます。実行の順番と DOM の完成が保証されている defer なら、想定どおりに動きます。
async がふさわしいのは、他と一切関わらず単独で動き、いつ実行されても構わないスクリプトです。アクセス解析のタグや、エラー計測ツールのような「早く動き始めたいが、ページの中身には触らない」ものが代表例です。こうしたスクリプトは1つでも早く実行されたほうが計測漏れが減るので、順序を捨ててでも async にする価値があります。
迷ったときは defer にしておくのが安全です。async にして得られるのは「数十ミリ秒早く実行が始まるかもしれない」程度の差である一方、順序が崩れたときの不具合は再現も原因特定も難しくなります。実際のページでは、次のように自作のコードは defer、計測系は async と使い分けるのが典型的な構成です。
<head>
<meta charset="UTF-8">
<title>Webool ブログ</title>
<!-- 独立して動く計測タグ:順序を気にしないので async -->
<script async src="https://example.com/analytics.js"></script>
<!-- ページの動作を担うコード:順序と DOM が必要なので defer -->
<script defer src="/js/library.js"></script>
<script defer src="/js/app.js"></script>
</head>
なお、1つの <script> に async と defer を両方書くこともできます。この場合、両方に対応しているブラウザーでは async が優先されます。defer は async に対応していない古いブラウザー向けのフォールバックという位置づけで、現在のブラウザーはどちらも問題なく対応しているため、あえて併記する必要はほとんどありません。
インラインスクリプトでは無視される
見落としやすいのが、async と defer は外部ファイルを読み込むときにしか効かないという点です。src 属性がなくタグの中に直接コードを書いたスクリプト(インラインスクリプト)に付けても、ブラウザーは属性を無視して、その場ですぐに実行します。
<head>
<!-- defer は効かない。ここで即座に実行され、h1 はまだ存在しない -->
<script defer>
console.log(document.querySelector('h1')); // null
</script>
</head>
<body>
<h1>Webool ブログ</h1>
</body>
エラーにも警告にもならないので、書いた本人は「遅らせたつもり」でいるのに実際は遅れていない、という状態になります。仕様上も src がないときにこれらの属性を指定してはいけないとされています。
インラインのコードを DOM の完成後に動かしたい場合は、DOMContentLoaded を待つか、そもそも外部ファイルに切り出して defer を付けるのが正攻法です。
<script>
// インラインで遅らせたいときはイベントで待つ
document.addEventListener('DOMContentLoaded', () => {
console.log(document.querySelector('h1')); // 要素が取得できる
});
</script>
type=”module” は最初から defer と同じ動き
type="module" を付けて JavaScript モジュールとして読み込む場合、話が少し変わります。モジュールスクリプトは既定で defer と同じ挙動になり、HTML の解析を止めず、解析が終わったあとに記述順で実行されます。そのため、モジュールに defer を書いても何の効果もありません。
<!-- defer を書かなくても defer 相当。書いても意味は変わらない -->
<script type="module" src="/js/app.mjs"></script>
<!-- module に async を付けると、依存の解決が済み次第すぐ実行される -->
<script type="module" async src="/js/tracker.mjs"></script>
一方、モジュールに async を付けた場合は意味があります。そのスクリプトと import した依存モジュールの読み込みが完了した時点で、解析の完了を待たずに実行されます。クラシックなスクリプトと同じく、順序は保証されません。
さらにモジュールでは、async はインラインスクリプトにも適用できます。クラシックスクリプトでは無視されると説明しましたが、モジュールに限っては src がなくても async が働くという違いがあるので、混同しないよう気を付けてください。
「head に defer」と「body の末尾に置く」の関係
async や defer が普及する前からある定番のテクニックが、</body> の直前に <script> を置くという書き方です。解析すべき HTML がもう残っていないので解析を止める害がなく、DOM も出来上がっている、という理屈です。
<body>
<h1>Webool ブログ</h1>
<!-- 昔ながらの書き方。ここまで解析が進んでから読み込みが始まる -->
<script src="/js/main.js"></script>
</body>
結果として得られるものは <head> の defer とよく似ていますが、決定的な差が1つあります。それはダウンロードが始まる時刻です。body 末尾に置いた場合、ブラウザーがそのタグに到達するまでリクエストは発行されません。HTML が長ければ長いほど、ダウンロードの開始が遅れます。
<head> に defer で書けば、HTML を読み始めた直後にダウンロードが始まり、本文の解析と並行して転送が進みます。解析が終わる頃にはファイルが手元にある、という状態を作れるわけです。今から書くコードであれば、<head> に defer を選ぶほうが有利です。
ただし、body 末尾の書き方が間違いというわけではありません。既存のページを触っていて、インラインスクリプトが混ざっていて順序を崩したくないような場合は、無理に移動させる必要はないでしょう。
document.write は使えなくなる
async や defer を付けたスクリプトの中では、document.write() を使ってはいけないとされています。冒頭で触れたとおり、素の <script> が解析を止めるのは、まさに document.write() で HTML を差し込めるようにするためでした。解析と切り離して実行される async や defer では、その前提が成り立ちません。
実際の挙動は状況によって変わり、解析の途中であれば呼び出しが単に無視されて何も起きず、解析がすでに終わったあとに呼ぶとページ全体が書き換えられて中身が消えるということも起こります。どちらにしても意図どおりには動きません。
// defer / async のスクリプトでは使わない
document.write('<p>お知らせ</p>');
// DOM の API で追加する
const p = document.createElement('p');
p.textContent = 'お知らせ';
document.body.appendChild(p);
古い広告タグやウィジェットの埋め込みコードには、いまだに document.write() を使っているものがあります。そうした外部スクリプトに async を付けると表示が消えることがあるので、提供元の指示どおりの書き方を守ってください。自分で書くコードであれば、createElement() と appendChild()、あるいは insertAdjacentHTML() に置き換えるのが正解です。
JavaScript で追加した script は既定で async になる
ここまでは HTML に直接書く場合の話でしたが、JavaScript から <script> 要素を生成して挿入することもあります。この動的に作られたスクリプトは、属性を何も書かなくても既定で async と同じ扱いになります。つまり、続けて2つ挿入しても、実行される順番は保証されません。
// 順序が保証されない書き方
['/js/library.js', '/js/plugin.js'].forEach((src) => {
const script = document.createElement('script');
script.src = src;
document.head.appendChild(script);
});
依存関係があって順番を守りたいときは、script.async = false を明示的に代入します。これによって、挿入した順に実行されるようになります。HTML に書いた defer と似た挙動を、動的な読み込みで再現する方法だと考えてください。
// async = false を付けると挿入順に実行される
['/js/library.js', '/js/plugin.js'].forEach((src) => {
const script = document.createElement('script');
script.src = src;
script.async = false; // これが無いと順序は不定
document.head.appendChild(script);
});
注意したいのは、script.async = false は「同期的に読み込む」という意味ではないことです。ダウンロード自体は並行して進み、実行の順番だけが揃えられます。読み込みの完了を待って処理を続けたい場合は、load イベントか、それを Promise で包んだ関数を使います。
実際の読み込み順を確認する方法
ログを仕込んで実行順を見る
いちばん手軽なのは、各スクリプトの先頭に console.log() を置き、DOMContentLoaded と load のリスナーも並べて、コンソールに出る順番を見る方法です。defer なら記述順に並び、async なら実行のたびに順番が変わることが目で確認できます。
<head>
<script defer src="/js/a.js"></script>
<script defer src="/js/b.js"></script>
<script>
// DOM の構築が終わった時点(defer の実行はこれより前)
document.addEventListener('DOMContentLoaded', () => {
console.log('DOMContentLoaded');
});
// 画像なども含めてすべて読み終わった時点
window.addEventListener('load', () => {
console.log('load');
});
</script>
</head>
a.js と b.js の中でそれぞれファイル名をログに出しておけば、a.js → b.js → DOMContentLoaded → load の順に並ぶはずです。属性を async に変えて読み込み直すと、この並びが崩れることが分かります。
開発者ツールのネットワークタブで待ち時間を見る
ダウンロードのタイミングを確かめるには、ブラウザーの開発者ツールにあるネットワークタブが役に立ちます。リクエストが時系列の帯(ウォーターフォール)で表示されるので、スクリプトのリクエストがいつ発行され、どれだけ並行しているかが分かります。
属性を付けない <script> が並んでいると、リクエストが階段状にずれて発行され、その間ページの描画が止まっているのが見えます。defer に変えると帯が横に揃い、並行して転送されている様子が確認できます。キャッシュの影響を受けないよう「キャッシュを無効化」にチェックを入れ、回線速度を絞る機能を使うと違いが分かりやすくなります。
あわせて、パフォーマンス計測タブや Lighthouse のようなツールで「レンダリングを妨げるリソース」として指摘されていないかも見ておくとよいでしょう。指摘されたスクリプトは、そのまま defer の付け忘れである場合がほとんどです。
まとめ
属性を付けない <script src="..."> は、ダウンロードと実行のあいだ HTML の解析を止めてしまうため、表示の遅れと「要素がまだ無い」という2つの問題を引き起こします。defer はダウンロードを解析と並行して行い、解析がすべて終わったあと、DOMContentLoaded の直前に記述した順で実行します。async も並行してダウンロードしますが、完了した瞬間に実行するため解析を中断することがあり、実行順序は保証されません。DOM を操作するコードや依存関係のあるライブラリは defer、アクセス解析のように独立して動くものは async、と使い分けるのが基本で、迷ったら defer を選べば大きく外しません。どちらの属性も src のある外部ファイルにしか効かず、インラインスクリプトでは無視される点、type="module" は既定で defer 相当に動く点、そして JavaScript から生成した <script> は既定で async 扱いになり script.async = false で順序を揃えられる点も、あわせて覚えておいてください。