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

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

npm パッケージ はどのように利用されるのか

npm パッケージは、Node.js アプリによって使用されるだけでなく、モジュールバンドラがバンドルを行う際に使用されたり、CDN 経由でブラウザから使用されたりする。
この記事ではそれぞれのケースにおいて、npm パッケージとして配布されるファイルのうち、どのファイルがどのように利用されるのか見ていく。
これを理解していないと、npm パッケージを公開する際に何をどのように配布するべきなのかも分からない。

動作確認に使った各環境のバージョンは、以下の通り。

  • Node.js14.7.0
  • npm6.14.7
  • Google Chrome84.0.4147.125

また、モジュールバンドラの例として webpack を使った検証を行っているが、そのバージョンは以下の通り。

  • webpack4.44.1
  • webpack-cli3.3.12

型定義ファイルについては以下の記事で説明したので、本記事では割愛する。

numb86-tech.hatenablog.com

検証環境の準備

Yarn のワークスペースを使って、環境を作る。

ワークスペースについても、以前書いた。

numb86-tech.hatenablog.com

今回検証したいのは「npm パッケージがどのように利用されるのか」なので、パッケージと、それを利用するアプリを用意する。
パッケージの名前はmy-package、アプリの名前はmy-appとする。

まず、ルートディレクトリに以下の内容のpackage.jsonを作る。

{
  "private": true,
  "workspaces": ["my-package"]
}

これで、my-packageがワークスペースの対象になった。
早速、以下の内容のmy-package/package.jsonmy-package/main.jsを作る。

{
  "name": "my-package",
  "version": "1.0.0",
  "main": "./main.js"
}
module.exports = {
  x: 1,
}

続いて、以下の内容のmy-app/package.jsonを作る。

{
  "name": "my-app",
  "dependencies": {
    "my-package": "1.0.0"
  }
}

そしてルートディレクトリで$ yarnを実行すると、以下のような構成になる。

├── my-app
│   ├── index.js
│   └── package.json
├── my-package
│   ├── main.js
│   └── package.json
├── node_modules
│   └── my-package -> ../my-package
├── package.json
└── yarn.lock

これで、my-appからmy-packageを利用できるようになった。

Node.js から使用する

以下のmy-app/index.jsを用意する。

const Package = require('my-package');

console.log(Package);

そして$ node my-app/index.jsを実行すると、{ x: 1 }が表示される。

npm パッケージをrequireすると、そのパッケージのpackage.jsonmainフィールドで指定されているファイルを、読み込む。
今回の例ではmy-package/package.jsonmainフィールドに./main.jsを指定したので、my-package/main.jsが読み込まれた。

また、requireではなくimportで読み込むこともできる。

まず、my-app/package.json"type": "module"を追加して、アプリ側のモジュールシステムを ES Modules(以下、ESM)にする。
その上でmy-app/index.jsを以下の内容にして、実行する。

import Package from 'my-package';

console.log(Package);

この場合も、{ x: 1 }が表示される。

この状態で今度は、my-package/package.jsonにも"type": "module"を追加し、my-package/main.jsを以下のように書き換える。

export default 9;

そして$ node my-app/index.jsを実行すると、9が表示される。
これは、ESM で書かれた書かれた npm パッケージを、同じく ESM で書かれたアプリがインポートした形になる。

Node.js におけるモジュールシステムやpackage.jsontypeフィールドについては、以下の記事に書いた。

numb86-tech.hatenablog.com

この記事では、ESM と CommonJS(以下、CJS) の互換性についても書いたが、npm パッケージにおいてもこのルールは変わらない。
例えば、CJS で書かれたモジュールを ESM でimportする場合、名前付きインポートはできない。

つまり、package.jsonmainフィールドで指定されているファイルが読み込まれる、ということ以外は、npm パッケージもそれ以外のファイルも同じように扱われる。

モジュールバンドラから使用する

フロントエンドで使用するコードの場合、モジュールバンドラで複数のファイルを結合することが多い。
その場合、npm パッケージを含む依存関係の解決も、バンドル時に行われる。

このときの挙動は、Node.js アプリから npm パッケージを使用するときのそれとは、異なる。
例えば、必ずしもmainフィールドで指定されているファイルが読み込まれるわけではない。

モジュールバンドラとして webpack を使い、確認していく。

まず、ルートディレクトリで以下を実行して、必要なライブラリをインストールする。

$ yarn add -D -W webpack webpack-cli

そして、ルートディレクトリにwebpack.config.jsを用意する。

const path = require('path');

module.exports = () => {
  return {
    entry: {
      index: './my-app/index.js',
    },
    output: {
      path: path.resolve(__dirname, 'dist'),
    },
    resolve: {
      mainFields: ['module', 'main']
    },
  }
};

この状態で$ yarn run webpack --mode=productionを実行すると、my-app/index.jsをエントリポイントとしてビルドが行われ、dist/index.jsが生成される。

my-app/index.jsは先程と同様に以下の内容にしておく。

import Package from 'my-package';

console.log(Package);

my-package側に、以下のファイルを用意する。

// my-package/main.js
module.exports = {
  x: 1,
}
// my-package/module.js
module.exports = {
  x: 'a',
}

そして、my-package/package.jsonを、以下の内容にする。

{
  "name": "my-package",
  "version": "1.0.0",
  "main": "./main.js",
  "module": "./module.js"
}

この状態でビルドしたdist/index.jsを実行してみる。

$ node dist/index.js
{ x: 'a' }

{ x: 'a' }が出力されているので、moduleフィールドで指定したファイルが読み込まれていることになる。

次に、my-package/package.jsonからmoduleフィールドを削除したうえで、ビルドして実行してみる。

$ node dist/index.js
{ x: 1 }

そうすると、mainフィールドで指定したファイルが読み込まれている。

これは、webpack.config.jsresolve.mainFieldsで指定した順番に、package.jsonのフィールドを探していくため。
['module', 'main']とすると、まずmoduleフィールドを探し、それが見つからなければmainフィールドを探す。
列挙されたフィールド名がひとつもpackage.jsonになかった場合、ビルドに失敗する。

resolve.mainFieldsにはデフォルト値が設定されており、webpack.config.jstargetの値によって決まる。
targetwebworkerweb、もしくは未指定の場合は、resolve.mainFieldsのデフォルト値は['browser', 'module', 'main']になる。
targetがそれ以外の場合は['module', 'main']になる。

ちなみに、webpack 経由で npm パッケージを利用する場合、互換性に関する挙動も変化する。

例えば、ESM から CJS を読み込む場合、Node.js では名前付きインポートができないのだが、webpack で依存関係を解決する場合は可能になる。

// my-package
// module.exports = {
//   x: 1,
// }

// 名前付きインポートがエラーにならない
import {x} from 'my-package';

console.log(x); // 1

CDN からの利用

npm パッケージは、CDN サービスを経由してブラウザで使うこともできる。

例えば、UNPKGという CDN サービスの場合、公式サイトに以下のように書かれており、全ての npm パッケージが配信の対象となっている。

unpkg is a fast, global content delivery network for everything on npm.

具体的には、https://unpkg.com/:package@:version/:fileという URL で、npm パッケージの中身が配布されている。
また、https://unpkg.com/:package@:version/にアクセスすると、配信されているファイルの一覧を見ることができる。

例えば、拙作のken-allの場合、https://unpkg.com/browse/ken-all@0.2.1/ で、v0.2.1におけるファイルの一覧を見ることができる。

f:id:numb_86:20200816234553p:plain

このうち、umd/index.jsはブラウザで使用されることを想定してビルドしたファイルなので、以下のようにブラウザから読み込んで使用することができる。

<!DOCTYPE html>
<html lang="ja">
<head>
  <meta charset="UTF-8">
  <title>Ken All Sample</title>
</head>
<body>
  <input id="post-code" maxlength="7">
  <p id="result"></p>

  <script src="https://unpkg.com/ken-all@0.2.1/umd/index.js"></script>
  <script>
    const searchBoxElem = document.querySelector('#post-code');

    searchBoxElem.addEventListener('input', e => {
      const postCode = e.currentTarget.value;

      if (postCode.length === 7) {
        const resultTextElem = document.querySelector('#result');
        KenAll.default(postCode).then(res => {
          if (res.length === 0) {
            resultTextElem.textContent = '該当する住所はありません';
          } else {
            resultTextElem.textContent = res[0].join(' ');
          }
        });
      }
    }, false);
  </script>
</body>
</html>

npm パッケージの中身がそのまま配布されているだけなので、ブラウザで使われることを想定したファイルでない場合、上手く動かない可能性がある。
例えば、https://unpkg.com/ken-all@0.2.1/dist/index.jsをブラウザで読み込むと、ReferenceError: exports is not definedというエラーが出る。

バージョンの指定については、セマンティックバージョニングの範囲指定を行うこともできる。
例えば記事執筆時点でのken-allの場合、^0.2.0を指定すると、0.2.1にリダイレクトされる。

$ curl -I https://unpkg.com/ken-all@^0.2.0/umd/index.js
HTTP/2 302
(省略)
location: /ken-all@0.2.1/umd/index.js

参考資料

ブラウザにおける ES Modules の利用とパフォーマンスについて

現代の主要なブラウザでは、ES Modules(以下、ESM)を利用することができる。
つまり、import文やexport文を使った JavaScript ファイルを、トランスパイルすることなくそのまま使えるということである。
モジュールシステムをそのまま使えるので、複数のファイルをバンドルする必要もない。

この記事ではまず、ブラウザで ESM を使う方法について説明していく。
その後、処理の流れを詳しく確認していく。これを理解していないと、パフォーマンスが非常に悪いページになってしまう恐れがある。

動作確認は Google Chrome の84.0.4147.105で行っている。

ESM 利用の基本

まずは検証用にサーバを立てる。

以下のコードを Deno(バージョンは1.2.2)で実行する。
そうすると、http://localhost:8080/にアクセスしたときにindex.htmlが表示されるので、その HTML ファイルから JavaScript ファイルを実行することにする。

import {
  listenAndServe,
} from "https://deno.land/std@0.63.0/http/mod.ts";

listenAndServe(
  { port: 8080 },
  async (req) => {
    if (req.method !== "GET") {
      req.respond({
        status: 405,
      });
      return;
    }

    switch (true) {
      case /^\/$/.test(req.url):
        req.respond({
          status: 200,
          headers: new Headers({
            "content-type": "text/html",
          }),
          body: await Deno.readFile("./index.html"),
        });
        break;

        case /\.js$/.test(req.url):
        req.respond({
          status: 200,
          headers: new Headers({
            "content-type": "text/javascript",
          }),
          body: await Deno.readFile("." + req.url),
        });
        break;

      default:
        req.respond({
          status: 404,
          headers: new Headers({
            "content-type": "text/plain",
          }),
          body: "Not found\n",
        });
        break;
    }
  },
);

console.log("Server running on localhost:8080");

まず、HTML ファイルに直接importを書く方法を試してみる。

インポートの対象となるsub.jsを用意する。

export const x = 1;
export default 9;

そしてそれをindex.htmlから読み込むのだが、そのためにはscript要素にtype="module"をつける必要がある。

<script type="module">
import {x} from '/sub.js';
console.log(x); // 1
</script>

type="module"をつけないと、以下のようにUncaught SyntaxError: Cannot use import statement outside a moduleというエラーがでる。

<script>
// Uncaught SyntaxError: Cannot use import statement outside a module
import {x} from '/sub.js';
</script>

但し、type="module"をつけなくても Dynamic Import は使えるので、以下のコードは問題ない。

<script>
import('/sub.js').then(res => {console.log(res.x)}); // 1
</script>

次に、src属性で JavaScript ファイルを読み込み、そのファイルが別の JavaScript ファイルをインポートする方法を試す。

index.htmlからindex.jsを読み込み、index.jssub.jsをインポートする。

これも問題なく動作する。

<script type="module" src="/index.js"></script>
// index.js
import {x} from '/sub.js';

console.log(x); // 1

この場合も、type="module"がないとUncaught SyntaxError: Cannot use import statement outside a moduleというエラーになる。
Dynamic Import ならエラーにならないのも同じ。

パスとして URL を指定することもできる。当然、読み込まれる側のファイルが ESM に対応している必要がある。

// index.js
import Vue from 'https://cdn.jsdelivr.net/npm/vue@2.6.11/dist/vue.esm.browser.js';

console.log(Vue.version); // 2.6.11

type="module" をつけた script タグの実行順序について

次に、scriptタグの実行順序について見ていく。

type="module"をつけないケースについては、以下の記事で説明した。

numb86-tech.hatenablog.com

結論から言うと、type="module"をつけるとdeferと同じ挙動になる。

  • HTML ファイルのパースを中断しない
  • HTML ファイルのパースが終わるまで実行を待機する
  • type="module"をつけたscriptタグが複数ある場合は、上から順番に実行する

これを検証するために、サーバの実装を以下のように書き換えた。
1.jsはリクエストから 1 秒後に返し、それ以外の JavaScript ファイルはリクエストから 3 秒後に返す。

      case /1\.js$/.test(req.url):
        setTimeout(async () => {
          req.respond({
            status: 200,
            headers: new Headers({
              "content-type": "text/javascript",
            }),
            body: await Deno.readFile("." + req.url),
          });
        }, 1000);
        break;

      case /\.js$/.test(req.url):
        setTimeout(async () => {
          req.respond({
            status: 200,
            headers: new Headers({
              "content-type": "text/javascript",
            }),
            body: await Deno.readFile("." + req.url),
          });
        }, 3000);
        break;

そして、以下の内容のindex.htmlと各 JavaScript ファイルを用意した。

<!DOCTYPE html>
<html lang="en">
<head>
  <meta charset="UTF-8">
</head>
<body>
  <script type="module" src="/1.js"></script>
  <script src="/2.js"></script>
<p>content</p>
</body>
</html>
// 1.js
console.log(1, window.document.querySelector('p'));
// 2.js
console.log(2, window.document.querySelector('p'));

こうすると、ページにアクセスした直後は、何も表示されていない。
そして約 3 秒後にcontentが表示されたあと、以下のログが流れる。

2 null
1 <p>content</p>

1.jsはページ読み込み後の 1 秒後にダウンロードが完了するが、HTML ファイルのパースが完了するまで実行しない。
そして、<script src="/2.js"></script>によってパースが中断されるので、2.jsのダウンロードが終わるまで処理が止まる。
2.jsはダウンロードが終わり次第実行されるが、この時点ではまだp要素の行までパースされていないので、p要素を取得できない。
そして HTML ファイルのパースが終わった時点で、1.jsが実行される。この時点ではパースが全て終わっているので、p要素を取得できる。

index.htmlを以下のようにすると、約 3 秒後に2.js1.jsの順番で実行される。

<body>
  <script type="module" src="/2.js"></script>
  <script type="module" src="/1.js"></script>
<p>content</p>
</body>

1.jsのダウンロードが先に終わるが、scriptタグの順番で実行されるため、まず2.jsが実行される。
また、HTML のパースを中断させる要素がないため、ページを読み込んだ時点でcontentが表示されている。

async属性をつけると、ダウンロードが完了次第、パースの完了を待たずに実行する。

そのため、以下の内容だと、ページ読み込みの約 1 秒後に1.jsが実行される。
パースが終わっていないため、p要素を取得することはできずログにはnullが表示される。

<body>
  <script async type="module" src="/1.js"></script>
  <script src="/2.js"></script>
<p>content</p>
</body>

以下の HTML ファイルにはパースを中断させる要素がないため、ページ読み込みと同時にcontentが表示される。

<body>
  <script async type="module" src="/2.js"></script>
  <script async type="module" src="/1.js"></script>
<p>content</p>
</body>

そして、scriptタグの順序とは無関係にダウンロードが終わったファイルから実行していくので、まず1.jsが実行され、その後2.jsが実行される。

全ての依存関係を解決してから実行する

ファイルの実行は、importしているファイルを全てダウンロードし終わってから行われる。

以下の 3 つの JavaScript ファイルは、main -> a -> bという依存関係を持っている。

// main.js
import '/a.js';

console.log('main');
// a.js
import '/b.js';

console.log('a');
// b.js
console.log('b');

そして各ファイルのダウンロードには、それぞれ 3 秒かかる。

このときに以下のようにmain.jsを読み込むと、このファイルが実行されるまで 9 秒もかかる。

<script type="module" src="/main.js"></script>

ページを読み込んでもしばらくはログには何も表示されず、9 秒後に以下のログが流れる。

b
a
main

main.jsをダウンロードするのに 3 秒かかるが、ブラウザはその時点で初めて、main.jsa.jsに依存していることを知る。
そのためa.jsのダウンロードを開始するが、また 3 秒かかる。そして今度はa.jsb.jsに依存していることが分かるので、b.jsのダウンロードを開始する。
3 秒後にb.jsをダウンロードし終わると、それ以上は依存関係がないことが分かり、ファイルの実行を開始する。
そのため、実行まで 9 秒かかってしまうのである。

既述した通り、asyncをつけていない場合、scriptタグの順番で実行される。
そのため以下のように書くと、main.jsが実行されるまで1.jsの実行も待機されてしまう。
ページ読み込みの 1 秒後には1.jsのダウンロードは終わっているのにもかかわらず、である。

<script type="module" src="/main.js"></script>
<script type="module" src="/1.js"></script>
<p>content</p>

この記事の冒頭に書いたように、ブラウザでもimportexportをそのまま使えるため、バンドルしなくても実行することができる。
しかし、依存関係が深い JavaScript ファイルの場合は、その解決に時間が掛かってしまうため、バンドルしてしまったほうがよいことも多い。

依存関係が重複している場合の挙動

main.jsを以下のように書き換える。

// main.js
import '/a.js';
import '/b.js';

console.log('main');

こうすると、main.jsa.jsの両方がb.jsに依存している状態になる。

このときにブラウザからmain.jsを読み込むと、ページ読み込みから 6 秒後に、以下のログが流れる。

b
a
main

これは、main.jsをダウンロードし終わったタイミングでa.jsb.jsへの依存が判明し、その時点で両者のダウンロードを開始するためである。
a.jsをダウンロードし終わるとa.jsb.jsに依存していることが分かるが、その時点で既にb.jsのダウンロードも完了しているため、この段階でファイルを実行できる。

Dynamic Import は読み込まれた時点で依存関係の解決を開始する

再びmain.jsを書き換えて、以下のようにする。

import('/a.js');

console.log('main');

main -> a -> bという依存関係だが、main.jsによるa.jsのインポートは Dynamic Import で行っている。

この状態でmain.jsをブラウザから読み込むと、3 秒後にログにmainが表示される。
そして、ページ読み込みから 9 秒後に、以下のログが流れる。

b
a

main.jsにはimport文がないため、依存関係の解決を待たず、ダウンロードが終わり次第実行される。
そのため、3 秒後にmainというログが表示された。

Dynamic Import でa.jsを読み込んでいたので、a.jsのダウンロードを行う。
累計で 6 秒後に、a.jsのダウンロードが終わる。a.jsの中身を見ると、import文でb.jsを読み込んでいたので、a.jsの実行は待機してb.jsのダウンロードを開始する。
3 秒後、つまり累計で 9 秒後にb.jsのダウンロードが完了する。b.jsの中身を見ると依存関係はここで終わりなので、このタイミングでb.jsa.jsが実行される。

参考資料