モダンなJavaScript開発の流れと必ず必要になるモジュールについて

2021年3月16日JavaScript

モダンなJavaScript開発がどのように行われているのかについてまとめました。

モダンなJavaScript開発の流れ

近年では、一つのjsファイルに記述するのではなく、モジュールごとにファイルを分けて開発を進めます。

メリットの一つとして、サードパーティのモジュールも自作のモジュールとともに使うことができる点です。
何千ものオープンソースのパッケージがあり、それらは、NPM(Node Package Manager)経由で利用することができます。
NPMは、リポジトリ(repository, ソフトウェアパッケージの保存場所のこと)であり、ソフトウェアそのものです。

NPMについては、こちらの記事を参照してみてください。

複数に分けられたモジュールをすべて開発し終えたら、その後ビルドプロセス(build process)に入ります。
ビルドプロセスが完了すると、最終的に製品となるJavaScritpのファイル(JavaScript Bundle)が生成されます。
それをサーバー上に設置してウェブサイトやアプリとして公開します。これをデプロイ(deplay)といいます。

ビルドプロセスとは

ビルドプロセスとはシンプルにすると2ステップに分かれます。

バンドリング

最初のプロセスは、すべてのモジュールを一つの大きなファイルにします。これをバンドリング(bundling)といいます。ここでは、とても複雑なことがなされています。不要なコードを削除したり、圧縮したりします。このプロセスが重要な理由として、古いブラウザではモジュールなどには対応していません。モジュールは古いブラウザでは実行されないということです。また圧縮することでよりよいパフォーマンスになります。

トランスパイルとポリフィル

2つ目のステップとして、トランスパイル(transpiling)とポリフィル(polyfilling)が行われます。これらは、モダンな機能をもったJavaScriptを古いブラウザでも対応できるES5の形にコンバートします。
このことにより、古いブラウザでもモダンなJavaScriptを理解できるようになります。これが、BABEL(バベル)というツールで行われます。
Babelについてはこちらの記事を参照してみてください。

JavaScirptバンドラー

このビルドプロセスを行うのに使われるのが、webpackもしくはparcelというJavaScirptバンドラーです。
webpackが最も有名です。parcelはとてもシンプルに利用することができます。
そして、これらのツールは、NPM経由で利用することができます。
parcelについては、以下の記事を参考にしてみてください。

モジュールとは

モジュールとは、ソースコードを機能ごと二分割して、メンテナンスしやすくする仕組みのことです。
モジュールは次のような特徴があります。

  • カプセル化されたコードのため再利用性が高い
  • 独立したファイル(大抵の場合)
  • インポートしたりエクスポートしたりして使う

モジュールのメリット

モジュールのメリットは、大規模になればなるほど利いてきます。

複雑なアプリケーションをこのモジュールをたくさん組み合わせることで作ることができます。たとえば、パソコンを例にすると、パソコンには数百の部品で構成されています。それらを組み合わせることで、パソコンになります。(Compose software)

その部品をつくる人や、他の部品がどのように作られているかを把握しなくてもパソコンとして機能することができます。そして、全体のコードを考えなくても機能させることができます。(Isolate components)

モジュールにすることで、とても抽象的なコードにできます。(Abstract code)

モジュールは自然に組織化されたよりわかりやすいコードになります。(Organized code)

モジュールは再利用可能で、他のプロジェクトでも容易に利用することができます。

ES6のモジュールとスクリプトの違い

ES6のモジュール スクリプト
トップレベルの変数環境(スコープ) モジュールスコープ スクリプトスコープ
デフォルトのモード strict Mode(手動で宣言する必要がない) sloppy Mode
トップレベルのthis undefined window
importsとexportsの機能 あり なし
HTMLとのリンク
<script type="module"></script>
<script></script>
ファイルのダウンロード 非同期 同期(非同期操作を利用しない限り)

モジュールコンテキスト

モジュールの場合、グローバルコンテキストが、モジュールコンテキストになります。
1点大きく異なる点があります。
グローバルコンテキストのthisはwindowオブジェクトでしたが、
モジュールコンテキストでは、windowオブジェクト自体は使えるものの、トップレベルでのthisは、undefinedになります。

console.log(this);//undefined
function fn() {
  console.log(this);
}
//undefined
// グローバルコンテキストの場合、windowオブジェクト

ただし、グローバルオブジェクトであるブラウザはwindowオブジェクトが使用できるため、モジュールコンテキストでもthisが使えます。
なお、関数コンテキストは変わりません。

モジュールスコープ

モジュールの場合、スクリプトスコープが、モジュールスコープになります。
モジュールスコープは、ファイルを区切ると、その中でしか変数や関数を使えません。外部で使用したい場合は、import/exportを使う必要があります。
なお、グローバルスコープ、関数スコープ、ブロックスコープは変わりません。

Strictモード

モジュールでは、自動的にStrictモードになります。
Strictモードは、通常のJavaScriptで許されている一部の書き方を制限するモードのことです。

  • バグの混入の防止(エラーを出す)
  • コードの安全性を確保
  • 予約語の確保

といった理由があります。

予約語については、MDNのページを参照ください。

モジュールの基本的な使い方

モジュールのimportの流れ

importsはトップレベルで行われる必要があります。また、importsはホイスティングがあります。

モジュールのimportの流れは次のようになります。

  1. index.jsがパースされる(読み込まれる)
  2. 同期的にモジュールがインポートされる
  3. トップレベルにimportsがあるので、実行前にインポートされる
  4. 各jsファイルのダウンロードは非同期的に行われる
  5. それぞれのexportsが行われる(コピーではなく参照している)
  6. それぞれのモジュールが実行される
  7. index.jsが実行される

同期的にモジュールがインポートされることで、不要なコードの削除やバンドリングを可能にしています。

HTMLのtypeをmoduleにする

まず、HTMLでscriptタグの読み込みを次のように変更する必要があります。


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

typeをmoduleと指定する必要があります。
なお、typeをmoduleと指定した時点でdefer属性が自動で付与される状態となります。
そのため、非同期的に読み込みされます。
deferについては、次の記事を参考にしてみてください。

また、type="module"の場合、通常のscriptファイルと異なり、何回呼ばれても1回しか読み込まれません。
これは、importのときでも同様に1回しか読み込まれないので、注意しましょう。

Named import/export

JavaScriptファイルのimport/exportには次の方法があります。

exportするファイル

if文で囲ったりできません。
常にトップレベルコード(どのスコープにも入っていない状態)で書かれている必要があります。


export const addToCart = function (product, quantity) {
  cart.push({ product, quantity });
  console.log(`${quantity}個の ${product}をカートにいれました。`);
};

複数のnamed exportsを作ることができます。
そのとき、{}の中にいれます。


const totalPrice = 4443;
const totalQuantity = 643;
export { totalPrice, totalQuantity as tq};

また、asでの名前の変更ができます。そのファイルで使用するときの名前になります。
名前の変更はexportのタイミングでもimportのタイミングでも可能です。

importするファイル

import { addToCart, totalPrice as price, qt } from './shoppingCart.js';
console.log('Importing');
addToCart('peanuts', '5pcs ');
console.log(price, qt);

webpackを使うときは、「.js」は省略できます。
また、import * as 大文字で始めることで、クラスのようにインポートすることもできます。


import * as ShoppingCart from './shoppingCart.js';
ShoppingCart.addToCart('ご飯', 3);
console.log(ShoppingCart.totalPrice);

default import/export

exportするファイル

//default export
//名前がないのでimportのときにつける
export default function(product, quantity) {
  cart.push({ product, quantity});
  console.log(`${quantity} ${product}`);
}

defaultのexportは名前をつけません。
そのため、importのときに好きな名前をつけます。

importするファイル

// default import
// 下のようにシンプルな方を使う
import Cart from './shoppingCart.js';
Cart('pizza', 4);

デフォルトのエクスポートでは名前をつけていないので、インポートのときに自由に名前(上の例ではCart)をつけて使います。

モジュールパターン

モジュール・パターンとは、処理を切り分けて、各々が明確な役割を持った部品にすることです。
複数ファイルで使用できるパブリックな値やメソッドと単一ファイルでしか使えないプライベートな値やメソッドを明確に切り分けることができます。
この考え方はIIFE(即時実行関数式、即時関数)ととても良く似ています。
実際モジュールを利用するときはよくIIFEを使います。
即座に呼び出される特性と、一回しか呼び出されないという特性を活かすことができます。
まとめて呼び出すことができること、一回だけ呼び出せばいいことがこのモジュールのシステムととても相性がいいためです。

//IIFE (即時実行関数式)の基本形
(function() {

})();

IIFE (即時実行関数式、即時関数)の使い方と具体例について、こちらを参考にしてみてください。

たとえば、次のようなShoppingCartという名前のIIFEを作成します。



const ShoppingCart = (function () {
  const cart = [];
  const shippingCost = 500;
  const totalPrice = 3000;
  const totalQuantity = 3;

  const addToCart = function (product, quantity) {
    cart.push({ product, quantity });
    console.log(`${quantity}つの${product}がカートに追加されました。`);
  };

  const orderStock = function (product, quantity) {
    console.log(`${quantity}つの${product}を仕入先に発注しました。送料:${shippingCost}円がかかります。`);
  };

  //returnすることで、ShoppingCart関数スコープ外でも値を取得できる。
  return {
    addToCart,
    orderStock,
    cart,
    totalPrice,
    totalQuantity,
  }
})();

そして、次のように呼び出します。


ShoppingCart.addToCart('マヨネーズ', 8);//8つのマヨネーズがカートに追加されました。
ShoppingCart.addToCart('パセリ', 2);//2つのパセリがカートに追加されました。

//スコープとクロージャー
console.log(ShoppingCart.totalPrice);// return されているので3000と値を取得できる
console.log(ShoppingCart.shippingCost);//return されていないのでundefined

//クロージャーがあるためreturnされていないshippingCost「送料」が取得できている
ShoppingCart.orderStock('マヨネーズ', 8)//8つのマヨネーズを仕入先に発注しました。送料:500円がかかります。

IIFEは一度実行されたら消えるはずですが、普通に呼び出すことができます。それはクロージャーがあるためです。

クロージャーについては、こちらを参考にしてみてください。

クロージャーによってaddToCartもそのほかの変数もすべて生まれた場所の記憶は残っているため、このように利用することができます。

基本的にreturnされないとスコープの関係で上のようにundefinedが返されます。
しかし、上のshippingCostの例のように、たとえreturnされていない場合でも、クロージャーがあるため、orderStockという関数を経由して呼び出すことが可能です。

スコープについては、こちらを参考にしてみてください。

IIFE (即時関数)をモジュールに書き換える

まずは、次のようなIIFE (即時関数)をみてみましょう。

IIFE (即時関数)での書き方

//moduleA部分
const moduleA = (function () {
  console.log('IIFEが呼ばれました。');

//パブリックとプライベートな値
  let privateVal = 1;
  let publicVal = 1;
//パブリックとプライベートな関数
  function privateFn() {}
  function publicFn() {
    console.log(`No. ${publicVal++}`);
  }


  return {
    publicFn,
    publicVal,
  };
})();

//moduleB部分
const moduleB = function (moduleA) {
  console.log(moduleA); //{publicVal: 10, publicFn: ƒ}
  moduleA.publicFn(); //No. 1
  moduleA.publicFn(); //No. 2
  moduleA.publicFn(); //No. 3
  console.log(moduleA.publicVal); //1
  console.log(moduleA.publicVal); //1
  console.log(moduleA.publicVal); //1
};
moduleB(moduleA);

moduleA.publicValと呼び出すと、常に1が取得されます。
publicValはmoduleAに格納されるとき、プリミティブ型のため、参照先ではなく値がコピーされてます。
そのため、moduleAの中のpublicValと外のpublicValでは値の参照先が異なるため、外のpublicValでは常に1が返されます。
privateValもprivateFnも即時関数の中でしか実行することはできません。
publicValとpublicFn()はmoduleAに返却されているため外部から実行することができます。

//moduleB部分
const moduleB = function ({ publicFn: fn, publicVal: val }) {
  fn(); //No. 1
  fn(); //No. 2
  fn(); //No. 3
  console.log(val); //1
  console.log(val); //1
  console.log(++val); //2
  console.log(++val); //3
};

moduleB(moduleA);

分割代入を使うと、次のように変更することができます。

moduleAはオブジェクトなので、{}で分割代入します。
そして、分割代入の構文ではそれぞれ名前をつけることができます。

このIIFEをモジュールに変更します。

ESモジュールでの書き方

moduleA部分
//moduleA部分

console.log('moduleが呼ばれました。');

//パブリックとプライベートな値
let privateVal = 1;
export let publicVal = 1;

//パブリックとプライベートな関数
function privateFn() {}
export function publicFn() {
  console.log(`No. ${publicVal++}`);
}

まず、関数部分が不要になります。
そして、パブリックな値とメソッドをreturnしていましたが、不要になります。
その代わりに、exportで外部に渡せるようにします。

moduleB部分
import { publicFn as fn, publicVal as val } from './moduleA.js';

//moduleB部分
  fn(); //No. 1
  fn(); //No. 2
  fn(); //No. 3
  console.log(val); //1
  console.log(val); //1
  console.log(val); //1
  console.log(++val); ////エラーになる
  console.log(++val); ////エラーになる

まず、関数部分が不要になります。
そして、moduleBを実行していましたが、不要になります。
その代わりに、importで外部ファイルから必要なデータを受け取れるようにします。

ここで、エラーが出ているのがわかります。
letで宣言していますが、constant variableを変更しようとしているとエラーがでます。
これは、IIFEと似たような理由ですが、valというのは、プリミティブ型なのでmoduleAのpublicVal = 1の「1」という値がコピーされているだけです。
moduleAのpublicValとは全く別のものとなります。そのため、valを変更してもmoduleAのpublicValは変更されません。また、値に不整合が生じるため、エラーがでます。

そのため、プリミティブ型ではなく、参照をコピーできるオブジェクトにすることで値を変更することができます

プリミティブ型をオブジェクトに変更

moduleA部分
//moduleA部分

console.log('moduleが呼ばれました。');

//パブリックとプライベートな値
let privateVal = 1;
// export let publicVal = 1;
export let publicVal = {property: 1};

//パブリックとプライベートな関数
function privateFn() {}
export function publicFn() {
  console.log(`No. ${publicVal.property++}`);
}
moduleB部分
import { publicFn as fn, publicVal as val } from './moduleA.js';

//moduleB部分
  fn(); //No. 1
  fn(); //No. 2
  fn(); //No. 3
  console.log(val.property); //4
  console.log(val.property); //4
  console.log(val.property); //4
  console.log(++val.property); //5
  console.log(++val.property); //6

オブジェクトの参照先がコピーされているため、moduleAとBのvalは同じ値を参照しています。
そのため、オブジェクトの参照先の値が変更されても、同じように変更されることになります。

ダイナミックインポート

ダイナミックインポートimport()は、非同期的に実行させることができます。

import('./moduleA.js’)の 戻り値は、Promiseになります。
そのため、Promiseのチェーンを作ることが可能です。
ダイナミックインポートを使うことで、必要なときに呼び出してあげることができるので、待ち時間の削減が期待できます。
async/awaitでも書くことができます。

// Named Importでの書き方
// import { publicFn as fn, publicVal as val } from './moduleA.js';

// Promiseチェーンでの書き方
import('./moduleA.js').then(function(modules) {
  modules.publicFn(); //No. 1
  modules.publicFn(); //No. 2

})

// async/awaitでの書き方
async function fn() {
  const modules = await import('./moduleA.js');
  modules.publicFn();
}
fn(); //No. 1

CommonJSのモジュールシステム

モジュールの代表的なものに、ESM(ESモジュール)とCJS(CommonJSモジュール)があります。

CommonJSモジュール(CJS)では、Node.jsを使って、require/exportsを使ってモジュールを管理システムです。
ESモジュール(ESM)は、上で説明したECMAScriptに基づくモジュール管理システムです。
(import/export)ブラウザ側で使用します。ブラウザ側のみでモジュールを使う場合は、基本的にはESモジュールを使います。

ESM CJS
キーワード import/export require/exports
環境 ブラウザ Node.js
ファイルの拡張子 .mjs .cjs

基本的にjsファイルでいいのですが、Universal JavaScriptとしてJavaScriptを利用する場合は、明示的にわかるようにファイルの拡張子をわける場合があります。

CommonJSのモジュールは既存に存在するほとんどのNode.jsで使われているため、理解しておく必要があります。ES6のモジュールが実装されるまではずっとCommonJSのモジュールを利用していたためです。
NPMのリポジトリも、ほとんどがCommonJSのモジュールのシステムを未だに利用しています。その理由としては、NPMはもともとはNode.jsでのみ使われることが想定されていたためです。
CommonJSのモジュールでは、次のような記述をするので、参考までに知っておきましょう。


// Export
exports.addToCart = function (product, quantity) {
  cart.push({ product, quantity})
  console.log(`${quantity}つの${product}をカートに追加します。送料は${shippingCost}です。`);
}

// Import
const { addToCart} = require('./shoppingCart');