初心者向けにモダンJavaScriptの開発で必要になる
CommonJSとECMAScriptでのモジュールの違い、babelとwebpackの知識に関してまとめておきます。
CommonJSとECMAScript
JavaScriptの歴史的経緯にもなるのですが、
実はJavaScriptには
サーバサイドのNodeJS(CommonJS)とブラウザのJavaScript(ECMAScript)
の大まかに2つの言語仕様があります。1
元々のJavaScriptの欠点としてJavaScript内でモジュール化されたJavaScriptファイルを外部参照するという言語機能がなかったことが起因しています。
(かつてはHTMLのscriptタグでグローバル参照するというやり方が従来だった)
モジュール機能が先に言語仕様として策定されたのがCommonJSでECMAScriptのモジュール機能は後発となります。
したがって、大まかな文法はほぼ一緒なもののモジュール機能の記述に関して、CommonJSとECMAScript間で大きな違いがあります。
なお、ECMAScriptには仕様策定を決めているTC39という委員会があります。
CommonJSとECMAScriptのModulesの違い
細かい違いは以下の記事のほうが参考になります
参考:CommonJS と ES6の import/export で迷うなら
CommonJSのモジュール機能
CommonJSでモジュールを外部参照できるようにするためには、主にmodule.exportsを使います。
ここだとabc.jsというファイル名のモジュールを作成します。
module.exports = 変数
CommonJSでモジュールを参照するためには、requireを使います。
requireには参照するモジュールのファイル名を指定します。
abcにはmodule.exportsした変数そのものが参照できます。
const abc = require('abc')
ECMAScriptのモジュール機能
ECMAScriptでモジュールを外部参照できるようにするためには、主にexportもしくはexport defaultを使います。(exportsでないのに注意)
ここだとdef.jsというファイル名のモジュールを作成します。
export 変数
export default 変数
ECMAScriptでモジュールを参照するためには、importを使います。
importには参照するモジュールのファイル名を指定します。
import { 変数 } from 'def' // exports 変数を参照する場合
import def from 'def' // exports default 変数を参照する場合
// import { default as def } from 'def' // こうもかける
ちなみにimport文はトップレベルでないと使えないという制限があります。
// トップレベル
import def from 'def' // OK
if (条件分岐) {
// ブロック{}で囲まれている箇所はトップレベルでない
import def from 'def' // NG
}
dynamic import
Promise形式で外部モジュールを非同期に参照読み込みすることもできます。(dynamic import)
これにより、うまく使いこなせれば読み込みのオーバヘッドをなくすことができます。(並列読み込みによる高速化)
また、こちらに関してはトップレベルでなくても使えます。
import('def').then(module => { /* 処理 */ })
参考:Chrome、Safari、Firefoxで使えるJavaScriptのdynamic import(動的読み込み)
NodeJSからECMAScript Module(ESM)のimport、exportができるようになる(将来的に)
現在、NodeJS側からESMを参照できるようにするという流れでNodeJSチームが頑張って対応しています。2
Plan for New Modules Implementation
NodeJS 13.2.0から--experimental-modulesフラグ無しでESMのimport、exportが使える
ついにNode 13.2.0(2019/11/21リリース)から--experimental-modules無しでESMのimport、exportが使えるようになりました。(Phase4)
拡張子も.jsのままで.mjsにする必要はなく、package.jsonに"type":"module"
の設定を追加するだけでESMのimport、exportが使えます。
公式:ECMAScript Modules
{
"type": "module",
"name": "node13.2",
"version": "1.0.0",
"main": "index.js",
"license": "MIT",
"dependencies": {
"body-parser": "^1.19.0",
"express": "^4.17.1"
}
}
ESMのimportを使ってNodeJSのサーバを立ててみます。
import { default as express } from 'express'
import { default as bodyParser } from 'body-parser'
const app = express()
const wrap = (fn) => (req, res, next) => fn(req, res, next).catch(err => {
console.error(err)
if (!res.headersSent) {
res.status(500).json({message: 'Internal Server Error'})
}
})
process.on('uncaughtException', (err) => console.error(err))
process.on('unhandledRejection', (err) => console.error(err))
process.on('SIGINT', () => process.exit(1))
app.use(express.static('dist'))
app.use(bodyParser.urlencoded({extended: true}))
app.use(bodyParser.json())
app.get('/api', wrap(async (req, res) => {
res.json('hello world!!')
}))
app.listen(3000, () => {
console.log('Access to http://localhost:3000')
})
警告は出ますが、babel無しでESMのモジュールのimportができるようになりました。
$ node app.js
(node:62642) ExperimentalWarning: The ESM module loader is experimental.
Access to http://localhost:3000
babel
フロントエンドの実装にはブラウザ間でHTML、CSS、JSの実装レベルが異なるという大きな問題があります。
ブラウザの各機能の実装状況はCan I useなどで確認できます。
この問題に関して新しいJS文法を古いJS文法に変換(トランスパイル)して古いブラウザでも使えるようにするためのツールがbabelです。
babelの役割は非常に多く、多数のプラグインとpresetが存在しています。
例えば以下のようなことが実現できます。
- 新しいJS文法(ES6以降)から古い文法(ES5)へのpolyfill(core-js、regenerator-runtime)
- NodeJSでECMAScriptのimport/exportを使えるようにする(@babel/node)
- TC39 proposal文法(ブラウザに実装されていない未来のECMAScript文法)を使う(@babel/plugin-proposal-optional-chainingなど)
- ReactなどのJSX文法の変換(@babel/preset-react)
- TypeScriptをJavaScriptに変換(@babel/preset-typescript)
Polyfill参考:Babel 7.4.0で非推奨になった@babel/polyfillを使わず、core-js@3で環境構築する
TypeScript参考:Babel 7でTypeScriptをトランスパイルしつつ型チェックをする 〜webpack 4 + Babel 7 + TypeScript + TypeScript EsLint + Prettierの開発環境を構築する〜
babelの設定ファイル
.babelrc, babel.config.js, babel.config.cjs, babel.config.jsonのいずれかのファイルに設定を書くか
webpackの場合、後述のbabel-loaderプラグインのoptionsに指定する方法があります。
(babel実行時に自動に設定ファイルを探しに行く)
主にpluginと複数のpluginをまとめたpresetを指定することがほとんどです。
例えば.babelrc、babel.config.jsonの場合は次のように書きます。
{
"plugins": ["@babel/plugin-proposal-optional-chaining"],
"presets": [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": 3,
"modules": false
}]
]
}
babel.config.js、babel.config.cjsの場合は次のように書きます。
module.exports = function (api) {
api.cache(true) // この変換設定関数をキャッシュする
const presets = [
["@babel/preset-env", {
"useBuiltIns": "usage",
"corejs": 3,
"modules": false
}]
]
const plugins = ["@babel/plugin-proposal-optional-chaining"]
return {
presets,
plugins
}
}
babelの実行
babelのトランスパイルには@babel/core、@babel/cliが必要です。
# package.json作成
$ npm init -y
# babelのコマンドラインツールのインストール
$ npm install @babel/core @babel/cli
# babelのプラグインのインストール
$ npm install @babel/plugin-proposal-optional-chaining
# polyfill系
$ npm install @babel/preset-env core-js regenerator-runtime
例えば、次のようなindex.jsを作成します。
const message = {hello: 'Hello', world: 'world!'}
console.log(message?.hello + '' + message?.world)
.babelrcもしくはbabel.config.jsを作成し、次のコマンドを実行します。
$ babel index.js --out-file out.js
変換後のout.jsは次のようになります。
(ES5文法への変換、optional-chaining文法が変換される)
var message = {
hello: 'Hello',
world: 'world!'
};
console.log((message === null || message === void 0 ? void 0 : message.hello) + '' + (message === null || message === void 0 ? void 0 : message.world));
webpack
webpackは、モダンJavaScriptアプリケーション用の静的モジュールバンドルです。
webpackがアプリケーションを処理するとき、プロジェクトが必要とするすべてのモジュールをマップし、1つ以上のバンドルを生成する依存関係グラフを内部で構築します。
要はwebpackでビルドすることでnode_modules以下の依存関係も含め、1つのJSファイルにまとめることができます。
コアなコンセプトとして以下の機能があります。
- Entry:ビルド対象のアプリケーションエントリーポイントとなるファイル
- Output:出力先のフォルダ、ファイル名
- Loaders:ビルド用ローダー(メインの変換処理を行うための設定)
- Plugins:ビルドの補助プラグイン
- Mode:development/productionビルドかの指定
- Browser Compatibility:ブラウザ最適化
公式:Concept
バージョン4.0.0以降、webpackはプロジェクトをバンドルするための設定ファイルを必ずしも必要としません。
ただし、デフォルトの設定を上書きする際はwebpack.config.jsを作成して設定を記述します。
Entry
エントリポイントは、webpackが内部依存関係グラフの構築を開始するために使用するモジュールを示します。
webpackは、エントリポイントが依存する他のモジュールとライブラリを(直接的および間接的に)把握します。
デフォルトでは、その値は./src/index.jsですが、webpack構成でentryプロパティを設定することにより、異なる(または複数のエントリポイント)を指定できます。
module.exports = {
entry: './path/to/my/entry/file.js'
};
Output
outputプロパティは、作成したバンドルを出力する場所とこれらのファイルに名前を付ける方法をwebpackに指示します。
デフォルトでは、メイン出力ファイルの場合は./dist/main.js、その他の生成されたファイルの場合は./distフォルダになります。
設定ファイルにて出力先を変更することも可能です。
const path = require('path');
module.exports = {
entry: './path/to/my/entry/file.js',
output: {
path: path.resolve(__dirname, 'dist'), // 出力するフォルダ名
filename: 'my-first-webpack.bundle.js' // 出力するメインファイル名
}
};
Loader
すぐに使えるwebpackは、JavaScriptファイルとJSONファイルのみを理解します。Loaderを使用すると、webpackは他の種類のファイルを処理し、アプリケーションで使用して依存関係グラフに追加できる有効なモジュールに変換できます。
大まかに言うと、LoaderにはWebpack構成に2つのプロパティがあります。
- testプロパティは、変換する対象のファイルを識別します。
- useプロパティは、変換を行うために使用するLoaderを指定します。
const path = require('path');
module.exports = {
output: {
filename: 'my-first-webpack.bundle.js'
},
module: {
rules: [
{ test: /\.txt$/, use: 'raw-loader' }
]
}
};
Plugins
Loaderは特定のタイプのモジュールを変換するために使用されますが、
プラグインを活用して、バンドルの最適化、リソース管理、環境変数の注入などの幅広いタスクを実行できます。
プラグインを使用するには、require()
してplugins配列に追加する必要があります。
ほとんどのプラグインはオプションでカスタマイズできます。
プラグインはさまざまな目的のためにconfigで複数回使用できるため、new演算子で呼び出してプラグインのインスタンスを作成する必要があります。
const HtmlWebpackPlugin = require('html-webpack-plugin'); //installed via npm
const webpack = require('webpack'); //to access built-in plugins
module.exports = {
module: {
rules: [
{ test: /\.txt$/, use: 'raw-loader' }
]
},
plugins: [
new HtmlWebpackPlugin({template: './src/index.html'})
]
};
Mode
modeパラメーターをdevelopment、production、またはnoneに設定することにより、
各環境に対応するwebpackの組み込みの最適化を有効にできます。
デフォルト値はproductionです。
module.exports = {
mode: 'production'
};
Browser Compatibility
webpackは、ES5準拠のすべてのブラウザーをサポートしています(IE8以下はサポートされていません)。
webpackでは、import()
およびrequire.ensure()
にPromiseが必要です。
古いブラウザをサポートする場合は、
これらの文法を使用する前にpolyfillをロードする必要があります。
webpackの実行
次のような簡単なReactのプロジェクト構成でビルドしてみます。
├── dist
├── index.html
├── package.json
├── src
│ └── index.tsx
├── tsconfig.json
└── webpack.config.js
webpackのビルドにはwebpack
、webpack-cli
、babel-loader
のパッケージが追加で必要です。
今回はReactのプロジェクトのため、react
、react-dom
、@babel/preset-react
を追加します。
さらにtypescriptを使うため、typescript
、@babel/preset-typescript
、@types/react
、@types/react-dom
のパッケージが必要です。
@babel/preset-typescript
は型チェックをしてくれないため、tscで並列で型チェックを行います。
npm-run-all
を使うとrun-p
コマンドで並列でコマンドを実行することができます。
webpack
コマンドでwebpack.config.jsの設定でプロジェクトをビルドすることができます。
package.jsonは次のようになります。
{
"name": "test",
"version": "1.0.0",
"license": "MIT",
"scripts": {
"start": "run-p check-types webpack",
"check-types": "tsc -w",
"webpack": "webpack --watch"
},
"devDependencies": {
"@babel/cli": "^7.7.4",
"@babel/core": "^7.7.4",
"@babel/plugin-proposal-optional-chaining": "^7.7.4",
"@babel/preset-env": "^7.7.4",
"@babel/preset-react": "^7.7.4",
"@babel/preset-typescript": "^7.7.4",
"@types/react": "^16.9.13",
"@types/react-dom": "^16.9.4",
"babel-loader": "^8.0.6",
"core-js": "^3.4.2",
"html-webpack-plugin": "^3.2.0",
"npm-run-all": "^4.1.5",
"react": "^16.12.0",
"react-dom": "^16.12.0",
"regenerator-runtime": "^0.13.3",
"typescript": "^3.7.2",
"webpack": "^4.41.2",
"webpack-cli": "^3.3.10"
}
}
index.tsxにReactの最小のプログラムを記載します。
Reactの型に関しては次のサイトが参考になります。
import React from 'react'
import ReactDOM from 'react-dom'
const message = {hello: 'Hello', world: 'world!'}
const App: React.SFC<{message: string}> = (props) => {
return <h1>{props.message}</h1>
}
ReactDOM.render(
<App message={`${message?.hello} ${message?.world}`} />,
document.getElementById('root')
)
vscodeの場合、Optinal Chainingのエラー表記が出る場合は以下の設定を行うと解消されます
TypeScriptのOptional Chainingは用法用量を守って正しく使え
typescriptのビルド設定をtsconfig.jsonに記載します。
今回はtypescriptのビルドには@babel/preset-typescript
を使うため、型のチェックのみを行います。
("noEmit": true
が重要)
{
"compilerOptions": {
/* トランスパイル後のECMAScriptのバージョン */
"target": "ES2019",
/* 相対パスではないモジュールは node_modules 配下を検索する */
"moduleResolution": "node",
/* 今回、トランスパイルは Babelが行うので、`tsc`コマンドでJavaScriptファイルを出力しないようにする */
"noEmit": true,
/* 厳格な型チェックオプション(noImplicitAny、noImplicitThis、alwaysStrict、
strictBindCallApply、strictNullChecks、strictFunctionTypes、
strictPropertyInitialization)を有効化する */
"strict": true,
/* 各ファイルを個々のモジュールとしてトランスパイルする。
Babel では技術的制約で、ネームスペースなどのファイルを跨いだ構文を解釈してトランスパイルできない。
このオプションを有効にすれば、Babel でトランスパイルできない TypeScriptの構文を検出して警告を出す */
"isolatedModules": true,
/* ES modules 形式以外の、CommonJS 形式などのモジュールを default import 形式で読み込める
例)const module = require('module') -> import module from 'module' */
"esModuleInterop": true,
/* Reactの場合、JSX文法のチェックを有効にする */
"jsx": "react"
},
"include": ["src/**/*"]
}
webpack.config.jsにwebpackのビルド設定を記述します。
const HtmlWebpackPlugin = require('html-webpack-plugin')
const path = require('path')
module.exports = {
mode: 'development', // 開発モードビルド
entry: './src/index.tsx', // ビルド対象のアプリケーションのエントリーファイル
devtool: "source-map", // ソースマップを出力するための設定、ソースマップファイル(.map)が存在する場合、ビルド前のソースファイルでデバッグができる
output: {
path: path.resolve(__dirname, 'dist'), // 出力するフォルダ名(dist)
filename: 'bundle.js' // 出力するメインファイル名
},
module: {
rules: [
{
test: /\.ts(x?)$/, // .ts .tsxがbabelのビルド対象
exclude: /node_modules/, // 関係のないnode_modulesはビルドに含めない
use: {
loader: 'babel-loader', // babel
options: {
presets: [
// polyfillのpreset
['@babel/preset-env', {
useBuiltIns: 'usage',
corejs: 3,
modules: false // ECMAScript向けビルド
}],
// reactのpreset
'@babel/preset-react',
// typescript→javascript変換のpreset
'@babel/preset-typescript'
],
// optional-chaining文法を使うためのプラグイン
plugins: ['@babel/plugin-proposal-optional-chaining']
},
}
}
]
},
plugins: [
new HtmlWebpackPlugin({template: './index.html'}) // ビルドしたbundle.jsをindex.htmlに埋め込む
]
}
index.htmlは以下のようになります。
<!DOCTYPE html>
<html lang="ja">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>タイトル</title>
</head>
<body>
<div id='root'></div>
<!-- webpackビルドが完了するとHtmlWebpackPluginによりビルド済みのbundle.jsが埋め込まれる -->
<!-- <script type="text/javascript" src="bundle.js"></script></body> -->
</body>
</html>
ビルドには次のコマンドを実行します。
成功するとdistフォルダにbundle.jsが埋め込まれたindex.htmlが生成されます。
$ npm start
webpack is watching the files…
Hash: e532e63e7e9060154c4e
Version: webpack 4.41.2
Time: 1350ms
Built at: 2019-11-25 17:56:00
Asset Size Chunks Chunk Names
bundle.js 1.1 MiB main [emitted] main
bundle.js.map 1.26 MiB main [emitted] [dev] main
index.html 326 bytes [emitted]
Entrypoint main = bundle.js bundle.js.map
[./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 472 bytes {main} [built]
[./src/index.tsx] 495 bytes {main} [built]
+ 65 hidden modules
Child html-webpack-plugin for "index.html":
1 asset
Entrypoint undefined = index.html
[./node_modules/html-webpack-plugin/lib/loader.js!./index.html] 484 bytes {0} [built]
[./node_modules/webpack/buildin/global.js] (webpack)/buildin/global.js 472 bytes {0} [built]
[./node_modules/webpack/buildin/module.js] (webpack)/buildin/module.js 497 bytes {0} [built]
+ 1 hidden module
[17:56:01] Found 0 errors. Watching for file changes.
webpackのミドルウェア
webpackのミドルウェアを導入することができれば、さらに開発の効率があがります。
導入方法だけでそれぞれ記事ができてしまうため、ここでは紹介までにしておきます。
- webpack-dev-derver:開発用のサーバを起動して、その上でwebpackを実行するためのミドルウェア。HMR(Hot Module Replacement)なども設定でき、プログラム修正時にブラウザをリロードすることなくモジュールの再読み込みを行ってくれる。
- webpack-dev-middleware:バックエンド(NodeJS)でwebpackビルドができるようになるミドルウェア
- webpack-hot-middleware:バックエンドのwebpackビルド時にHMRしてくれるミドルウェア
Parcel
webpackに関してはビルドの設定の自由度が高い反面、ビルドの設定が難しいという問題があります。
簡易的なプロジェクトであれば、静的バンドラーの一種であるParcelを使うという選択肢もあります。
Parcelはビルド設定ファイルがなく裏側で色々よしなにやってくれる反面、細かいビルド設定ができないという欠点もあります。
本格的なプロジェクトの場合はwebpackをマスターしたほうが後々良いでしょう。