30歳からのプログラミング

30歳無職から独学でプログラミングを開始した人間の記録。

script タグの書き方でウェブベージの表示速度やスクリプトの実行順序はどう変化するのか

ウェブベージで JavaScript ファイルを読み込むためには<script>タグを使うが、その際にasync属性やdefer属性を設定することができる。
そして、これらの属性を使うかどうかで、ウェブベージの表示速度や JavaScript ファイルの実行順序に違いが生まれる。

この記事の内容は、Google Chrome のバージョン83.0.4103.97で動作確認している。
サーバは Node.js のv12.17.0で構築している。

デフォルトでは script タグは逐次処理される

まず、asyncdeferも設定しないケースについて見てみる。このケースのことを、便宜上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/にアクセスした結果が、以下の動画。

f:id:numb_86:20200609175531g:plain

Headlineのみが表示され、JavaScript の実行が終わるまでcontentは表示されない。

これは、HTML ファイルのパースは上から順番に行われ、<p>content</p>の前に<script src="/1.js"></script>が処理されるためである。
そのため、/1.jsのダウンロードと実行が終わるまで、contentは表示されない。

JavaScript ファイルのダウンロードは、パースや JavaScript の実行を待たずに行われる。/1.jsのダウンロードとほぼ同時に、/2.jsのダウンロードも開始される。
それにより、12がほぼ同時にログに表示される。

実行は、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>

この状態でアクセスした結果が以下。

f:id:numb_86:20200609175556g:plain

/1.jsのダウンロードが終わる前から、contentが表示されている。当然、querySelectorでもp要素を取得できる。

asyncの部分をdeferに置き換えても、同じ結果になる。

asyncdeferは、パースの途中でダウンロード中の JavaScript ファイルを見つけても、パースを中断しない。

この例で言えば、上から順番にパースしていくと、まだダウンロードが終わっていない<script async src="/1.js"></script>に到達する。
defaultの場合はここでパースを中断し、/1.jsのダウンロード、そして実行が完了してから、以降の行のパースを再開する。
だがasyncdeferでは、/1.jsがダウンロード中だった場合、そのまま以降の行のパースを続行する。
つまり、JavaScript ファイルのダウンロードを原因としたパースの中断が、発生しない。

パースの完了を待つ defer と、待たない async

JavaScript ファイルのダウンロードが完了した後の処理は、asyncdeferで異なる。
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;

この場合、以下の挙動になる。

f:id:numb_86:20200609175619g:plain

/1.jsは 1 秒後にダウンロードが完了するが、その時点では/2.jsはまだダウンロードが終わっておらず、<script src="/2.js"></script>の部分でパースが止まっている。
そして、asyncである/1.jsはダウンロードが終わった時点で実行されるため、h1要素は取得できるがp要素は取得できない。

次に、asyncdeferに置き換えてアクセスしてみる。

@@ -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>

f:id:numb_86:20200609175644g:plain

先ほどと同様に/1.jsは 1 秒後にダウンロードが完了するが、その時点ではまだ実行しない。
/2.jsのダウンロードと実行、そして HTML ファイルのパースが最後まで完了するまで、待機する。そしてパースが終わった段階で、実行する。そのため、h1要素だけでなくp要素も取得できる。

DOMContentLoadedという、パースが完了したときに発生するイベントがある。
defer属性を設定したスクリプトは、このイベントの直前に実行される。
そして、DOMContentLoadedはあくまでも「パースが完了した」ことを意味しており、レンダリングが行われたことは意味しない。

以下のindex.htmlheavy.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/にアクセスしたのが、以下の動画。

f:id:numb_86:20200609175707g:plain

パースが終わった時点で、既にダウンロード済みだった/heavy.jsの実行が始まり、startがログに表示される。
既にパースは完了しているので、p要素を取得できる。だが画面には、p要素はまだレンダリングされていない。
その後myFunc()を繰り返し処理する。私の環境ではこの処理には 1.6 秒ほどかかるが、これが終わるとendがログに表示される。
そしてDOMContentLoadedイベントが発生するのだが、この時点でもまだ、p要素のレンダリングは行われない。アラートを閉じてようやく、レンダリングされる。

整理すると、以下の順番になる。

  1. HTML ファイルのパースが終わる
  2. defer属性のついたスクリプトを実行
  3. DOMContentLoadedイベントが発生
  4. レンダリング

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>

参考資料

npm link で OSS 活動の効率を上げる

npm にはlinkというコマンドが用意されており、これを使うことで npm パッケージの開発効率が上がる。
既存のパッケージに手を加えた際の動作確認にも使えるので、OSS 活動の効率も上がる。
この記事では、npm linkの仕組みと、それをどのように利用できるのかについて説明する。

動作確認に使った npm のバージョンは6.14.5
Node.js のバージョンは12.17.0。これ以前のバージョンだと以下の動作確認でエラーが出るので注意。

サンプル用のパッケージとアプリを作る

まずはパッケージを作成する。
my-package-dirというディレクトリを作り、そこに以下の内容のpackage.jsonを作成する。

{
  "name": "my-package",
  "type": "module",
  "main": "main.js"
}

そして、以下の内容のmain.jsを作る。

export default (arg1, arg2) => arg1 + arg2;

これで、my-packageをパッケージとして公開すると、main.jsexportしている関数を使えるようになる。
だが、公開する前にローカル環境で確認したい。こういうケースでnpm linkが使える。

取り敢えず、確認用のアプリを作る。
my-package-dirとは別にmy-app-dirを作り、そこに移動。
以下のpackage.jsonapp.jsを作る。

{
  "name": "my-app",
  "type": "module"
}
import myPackage from 'my-package';

console.log(myPackage(2, 3));

この状態で$ node app.jsを実行すると、my-packageをインストールしていないので当然エラーになる。

$ node app.js
(node:19472) ExperimentalWarning: The ESM module loader is experimental.
internal/modules/run_main.js:54
    internalBinding('errors').triggerUncaughtException(
                              ^

Error [ERR_MODULE_NOT_FOUND]: Cannot find package 'my-package' imported from /Users/numb/my-app-dir/app.js

npm link でシンボリックリンクを作成する

ここから、npm linkを使う。

まずはmy-package-dirに移動。そこでnpm linkを実行する。
そうすると、シンボリックリンクが作成されたことが分かる。

$ npm link

/Users/numb/.ndenv/versions/v12.17.0/lib/node_modules/my-package -> /Users/numb/my-package-dir

/Users/numb/.ndenv/versions/v12.17.0の部分は、環境によって異なる。これ以降は{prefix}と表記する。

{prefix}/lib/node_modules/を見てみると、確かにmy-packageが作られている。

$ ls -l {prefix}/lib/node_modules
total 0
lrwxr-xr-x   1 numb  staff   26  6  5 00:48 my-package -> /Users/numb/my-package-dir
drwxr-xr-x  24 numb  staff  768  6  2 14:46 npm

{prefix}/lib/node_modules/は、グローバルインストールしたパッケージがインストールされる場所。
つまり、npm パッケージをグローバルインストールすると、ここに格納される。

$ npm i -g cowsay

$ ls -l {prefix}/lib/node_modules
total 0
drwxr-xr-x  11 numb  staff  352  6  5 00:54 cowsay
lrwxr-xr-x   1 numb  staff   26  6  5 00:48 my-package -> /Users/numb/my-package-dir
drwxr-xr-x  24 numb  staff  768  6  2 14:46 npm

$ npm un -g cowsay

$ ls -l {prefix}/lib/node_modules
total 0
lrwxr-xr-x   1 numb  staff   26  6  5 00:48 my-package -> /Users/numb/my-package-dir
drwxr-xr-x  24 numb  staff  768  6  2 14:46 npm

そして、{prefix}/lib/node_modules/my-packagemy-package-dirのシンボリックリンクなので、同じものを指す。

$ ls /Users/numb/.ndenv/versions/v12.17.0/lib/node_modules/my-package
main.js         package-lock.json   package.json

$ ls /Users/numb/my-package-dir
main.js         package-lock.json   package.json

シンボリックリンクの名前はディレクトリ名ではなくpackage.jsonnameの値なので、注意する。

npm link {パッケージ名} でパッケージをインストールする

これで、my-packageをインストールする準備が整った。
my-app-dirに移動し、そこで$ npm link my-packageを実行する。

$ npm link my-package
/Users/numb/my-app-dir/node_modules/my-package -> /Users/numb/.ndenv/versions/v12.17.0/lib/node_modules/my-package -> /Users/numb/my-package-dir

そうすると、node_modulesmy-packageというファイルが作られる。
これは先程作成された{prefix}/lib/node_modules/my-packageにリンクされている。そして先程確認したように、このファイルはmy-package-dirにリンクされている。
これにより、my-app-dir/node_modulesmy-package-dirをインストールしたのと同じ効果を得られるのである。

確認のため、app.jsを実行してみる。

$ node app.js
5

無事に動いていることを確認できた。

そして、node_modules/my-packageはシンボリックリンクなので、my-package-dirと常に同期している。my-package-dirの変更はリアルタイムに反映される。

例えば、my-package-dir/main.jsexportしている関数を変更して、引数を 3 つ取るようにする。

export default (arg1, arg2, arg3) => arg1 + arg2 + arg3;

そしてmy-app-dir/main.jsを以下のように書き換える。

import myPackage from 'my-package';

console.log(myPackage(2, 3, 4));

そうすると、my-package-dir/main.jsの変更が反映されていることが分かる。

$ node app.js
9

このように、パッケージに手を加えてもその都度インストールし直す必要はない。そのため、クローンしてきた既存のパッケージに手を加えて試行錯誤する作業も、やりやすくなる。

CLI ツールの動作確認を行う

npm linkは、CLI 機能の動作確認にも役に立つ。
npm linkによって、CLI 機能をローカルで試せるようになる。

npm パッケージの CLI 機能の基本については、以下の記事に書いた。

numb86-tech.hatenablog.com

my-packageに CLI 機能を追加して、それをローカルで試す。

まず、my-package-dir/hello.jsを作成する。

#!/usr/bin/env node

console.log('Hello!!!');

そして、my-package-dir/package.jsonを、以下の内容に書き換える。

{
  "name": "my-package",
  "type": "module",
  "main": "main.js",
  "bin": {
    "hello": "hello.js"
  }
}

この状態で、my-package-dir$ npm linkを実行する。

$ npm link

{prefix}/bin/hello -> {prefix}/lib/node_modules/my-package/hello.js
{prefix}/lib/node_modules/my-package -> /Users/numb/my-package-dir

シンボリックリンクが 2 つ作られた。後者は、最初に$ npm linkしたときと同じもの。
それに加えて、{prefix}/bin/helloが作られた。

まず、{prefix}/bin/について説明しておく。
グローバルインストールした npm パッケージに CLI 機能がついていた場合、そのコマンドのシンボリックリンクがここに作成されていく。

また、最初からnpmnpxというシンボリックリンクが作成されており、これにより、$ npm$ npxというコマンドを利用できるようになっている。

$ ls -l /Users/numb/.ndenv/versions/v12.17.0/bin/
total 92424
lrwxr-xr-x  1 numb  staff        39  6  5 01:26 hello -> ../lib/node_modules/my-package/hello.js
-rwxr-xr-x  1 numb  staff  47320288  5 30 04:04 node
lrwxr-xr-x  1 numb  staff        38  6  2 14:46 npm -> ../lib/node_modules/npm/bin/npm-cli.js
lrwxr-xr-x  1 numb  staff        38  6  2 14:46 npx -> ../lib/node_modules/npm/bin/npx-cli.js

同じ要領で、helloコマンドも利用できるようになった。
ただ、筆者のようにndenvを使っている場合は、$ ndenv rehashを行う必要がある。

$ hello
-bash: hello: コマンドが見つかりません

$ ndenv rehash

$ hello
Hello!!!

無事に実行できた。

グローバルインストールされたのと同じ状態なので、どのディレクトリからも利用できる。
そして、先程と同じようにシンボリックリンクなので、my-package-dir/hello.jsの変更は同期されている。

#!/usr/bin/env node

console.log('Cowabunga!!!');
$ hello
Cowabunga!!!

npm unlink でシンボリックリンクを削除する

動作確認が終わったら、npm unlinkでシンボリックリンクを削除できる。

まず、my-app-dir/node_modulesにシンボリックリンクがあるので、これを削除する。

$ ls node_modules/
my-package

my-app-dir$ npm unlink my-packageを実行すれば削除される。

次に、{prefix}/lib/node_modules{prefix}/binに作成されているシンボリックリンクを削除する。

$ ls {prefix}/lib/node_modules/
my-package  npm
$ ls {prefix}/bin
hello   node    npm npx

my-package-dir$ npm unlinkを実行すると、これが削除される。

$ ls {prefix}/lib/node_modules/
npm
$ ls {prefix}/bin
node    npm npx

先に{prefix}/lib/node_modules/my-package{prefix}/bin/helloを削除してしまうと、my-app-dir/node_modules/my-packageを削除できない。
このファイルのリンク先である{prefix}/lib/node_modules/my-packageを先に削除してしまったために、上手く動作しないのだと思われる。

参考資料