Panda Noir

JavaScript の限界を究めるブログでした。最近はいろんな分野を幅広めに書いてます。

webpack.config.jsのプラクティスを考えてみた

何度もwebpack.config.jsを手書きしていて面倒くさくなってきたので、ここに書き方を残しておきます。

webpack.config.jsを書く際によく使うTipsとして、これらが挙げられます。

  • mode: env.mode || 'development'
  • path.resolve(__dirname, path_to_file)を使う
  • options.resolve.extensionsに読み込みたいファイルの拡張子を記述
  • entryを複数書くとoptions.output.filename'[name].min.js'のように指定できる
  • loaderはoptiopns.module.rulesに書き込む

ひとまずテンプレ

最初にwebpack.config.jsのテンプレを載せておきます。どうせ未来の僕がこの記事を読むときは、このテンプレを見たいときだけなので。

const path = require('path');

module.exports = (argv, env) => ({
    mode: env.mode || 'development',
    entry: path.resolve(__dirname, './src/main.js'),
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, './dist'),
    },
    module: {
        rules: [],
    },
});

記事の後半の内容が必要そうなら使うバージョン。

const path = require('path');

module.exports = (argv, env) => ({
    mode: env.mode || 'development',
    entry: path.resolve(__dirname, './src/main.js'),
    // entry: {
    //     main: path.resolve(__dirname, './src/js/main.js'),
    //     index: path.resolve(__dirname, './src/js/index.js'),
    //     gallery: path.resolve(__dirname, './src/js/gallery.js'),
    // },
    // resolve: {
    //     extensions: ['.js'],
    // },
    output: {
        // filename: '[name].min.js',
        filename: 'bundle.js',
        path: path.resolve(__dirname, './dist'),
    },
    module: {
        rules: [
            // {
            //     test: /\.css$/,
            //     use: ['to-string-loader', 'css-loader'],
            // },
        ],
    },
});

mode: env.mode || 'development'

この設定をしておくと、package.json内で開発ビルドとプロダクションビルドをカンタンに切り替えることができます。

  "scripts": {
    "build": "webpack --mode production",
    "build:dev": "webpack",
    ...
  },

応用として、options.output.filenameを書き換えることもできます。

{
    output: {
        filename: env.mode == 'production' ? 'bundle.min.js' : 'bundle.js'
    }
}

path.resolveを使う

pathモジュールのpath.resolveメソッドを使ってファイルパスを指定するようにします。例えばoptions.entryoptions.output.pathなどで使うといいです。

なぜわざわざpath.resolveを使うかという理由は webpack.config.jsで思ったpath.resolveって何のためにあるの?に書いてあります。

Windowsとかだとパス区切りが/じゃないこともあるみたい。バックスラッシュっていうやつ(\)。だから__dirname + '/src'だとパスがおかしくなってしまうことがあるからpath.resolveを使って安心安全で行こうぜ!ってことらしい。 (引用: webpack.config.jsで思ったpath.resolveって何のためにあるの?)

環境間の差異を吸収する目的のようです。

読み込みたいファイルの拡張子はoptions.resolve.extensions に

よく忘れるので書いただけです。特に解説することはないです。

entryが複数あるときのoutput.filenameの書き方

これはそこまで頻度高くないです。

{
    entry: {
        main: path.resolve(__dirname, './src/main.js'),
        sub1: path.resolve(__dirname, './src/sub1.js'),
        sub2: path.resolve(__dirname, './src/sub2.js'),
    },
    output: {
        filename: '[name].bundle.js'
    }
}

loaderは optiopns.module.rules に書き込む

めちゃくちゃ当たり前なんですが、手書きすると「あれ?どこに書くんだっけ?」となるので書いておきます。

参考

webpack.config.jsで思ったpath.resolveって何のためにあるの?

はてなブログのHTTPS化をするとJSBinの埋め込みが無効化されるので注意

JSBinの埋め込みは便利ですが、HTTPSのサイトに埋め込むにはJSBinの有料会員登録をしなければなりません。なので、JSBinを使ってコードを埋め込んでいる場合、注意してください。

JSON5ならケツカンマができる!

JSONでケツカンマがあったらなあ、コメントを入れられたらなぁと思うことありませんか?僕はpackage.json内のnpm scriptsをいじっているときに頻繁に思います。

そんな我々の思いを受けてできたプロジェクトがあります。その名もJSON5です。今回はこれを紹介したいと思います。

JSON5とは?

JSON5とは様々な仕様が追加されたJSONのスーパーセットです。たとえば以下のような仕様が追加されます。

  • 配列・オブジェクトのケツカンマ
  • シングルクォートによる文字列
  • 単行・複数行のコメント
  • 16進法による数値表記

JSON5でのパース・符号化

npmでインストールするか、scriptファイルを読み込むことで使えます。

$ npm install json5
<script src="https://unpkg.com/json5@^2.0.0/dist/index.min.js"></script>

パースも符号化もJSONとほとんど同様です。

JSON5.parse(json5string);
JSON5.stringify(object);

JSON5.stringify()では、文字列をクォートで囲むか指定できる以外はほぼJSON.stringify()と変わらないようです。

どこで使われているか

JSON5に依存しているnpmパッケージを見てみると、@babel/corevueifyparcelなどが対応しているようです。

ちなみにtsconfig.jsonでもケツカンマができますが、JSON5をサポートしているのではなく、設定ファイルを読み込むときに独自のJSONパーサーを使っているためだそうです(参考: tsconfig.json isn't strict JSON, what now? : typescript)。

参考

JavaScriptの環境構築あれこれ

JavaScriptの環境を構築するのがつらくて辛くて仕方がないのでチートシートを作ることにしました。やや長くなったので目次をつけます。

Webpack関連

Webpackはモジュールバンドラーです。分割したファイルを1つのJSファイルにまとめます。Webpack4以降では、バンドルするだけなら設定ファイルが不要です。コマンドでエントリーポイントと出力ファイルの設定をするだけでOKです。

Webpackでは、分割したファイルをモジュールとして読み込めるようにローダーを使います。例えばTypeScriptをJavaScriptに変換するローダー、CSSをJavaScriptで読み込むためのローダー、ES5に変換するローダーなどがあります。

WebpackのローダーとBabelの役割分担

ローダーはモジュールとして使える形になるようファイルを変換します。BabelではJavaScriptをコンパイルします。両者は一体どう違うのでしょうか?

僕の私見では、Babelは一部のローダーの内部処理を担当するものだと思います。実際、ローダーが内部でBabelを使うときがあります。ローダー自身をコンパクトに保ち、Babelで機能を追加する使い方です。

Webpackのインストール

$ npm i -D webpack webpack-cli

ローダーを使わずバンドルだけする場合、これだけインストールすればOKです。npm scriptの書き方はこのようになります。

  "scripts": {
    "build:dev": "webpack ./src/main.js -o ./dist/bundle.js --progress --mode development",
    "build": "webpack ./src/main.js -o ./dist/bundle.js --progress --mode production"

純粋にバンドル+圧縮(プロダクトビルド)したいだけなら以下のページを参考にwebpack.config.jsを書いてください。

webpack.config.jsのプラクティスを考えてみた - Panda Noir

ts-loader

TypeScriptを使う場合はts-loaderをインストールします。

$ npm i -D ts-loader typescript

ts-loaderはJSXも扱える

ts-loaderはJSX(TSX)も扱えます。

  1. 必要なものをインストール
  2. tsconfig.jsonにJSXを扱うと宣言
  3. webpack.config.jsにts-loaderの設定を書き込む
$ npm i -D @types/react react

JSXをReactとして扱うとtsconfig.jsonで宣言します。React-nativeに変換することもできます。

{
    "compilerOptions": {
        "jsx": "react"
    }
}

ローダーの設定をwebpack.config.jsに書き込みます。

const path = require('path');

module.exports = {
    entry: path.resolve(__dirname, './src/main.tsx'),
    output: {
        filename: 'bundle.js',
        path: path.resolve(__dirname, './dist'),
    },
    // エントリーポイントから他のtsxファイルを読み込む場合、resolveの設定も必要
    // resolve: {
    //     extensions: ['.tsx', '.ts', '.js'], 
    // },
    module: {
        rules: [
            { test: /\.tsx?$/, use: 'ts-loader', },
        ],
    }
};

これでsrc/main.tsxを変換できます。

import * as React from 'react';

const div = (<div></div>);

vue-loader

単一ファイルコンポーネントを扱いたい場合は、vue-template-compilerもインストールします。

$ npm i -D vue vue-loader vue-template-compiler

webpackではプラグインの設定も行います。

const VueLoaderPlugin = require('vue-loader/lib/plugin');

module.exports = {
    // entryとoutputの設定など
    resolve: {
        extensions: ['.vue', .js'],
    },
    module: {
        rules: [
            { test: /\.vue$/, use: 'vue-loader' },
        ],
    },
    plugins: [new VueLoaderPlugin(),],
};

これでVueをバンドルできます。TypeScriptと組み合わせたい場合は、ts-loader(上記参照)をインストールして設定を追加するだけです。webpack.config.jsを次のように変更します。

    resolve: {
        extensions: ['.vue', '.ts', '.js'],
    },
    module: {
        rules: [
            { test: /\.vue$/, use: 'vue-loader' },
            { test: /\.ts$/, use: [{loader: 'ts-loader', options: {appendTsSuffixTo: [/\.vue$/]}}] },
        ],
    },

エントリーポイントとなるファイルを.tsに変更するのも忘れないでください。

ライブラリを作りたい場合

ライブラリを作りたい場合はここのページを参考にしてください。

Webpackでライブラリを作る - Qiita

ただし、Nodeで読み込んだときに「window is not defined」というエラーが出るので、output.globalObject'this'にしておく必要があります。

ESLint編

まずインストールします。

$ npm i -D eslint
$ yarn add -D eslint

その後、eslint --initを行います。

$ npx eslint --init
# あるいは
$ ./node_modules/.bin/eslint --init

あとは対話式で答えていくだけで設定ファイルが生成されます。カンタンですね。

Prettier編

まずインストール。

$ npm i -D prettier
#あるいは
$ yarn add -D prettier

ESLintと併用する

Prettier 入門 ~ESLintとの違いを理解して併用する~ - Qiita

ESLintのプラグインとしてPrettierを組み込むやり方です。独立して2つインストールする方法と比べて、Prettierが整形した部分にESLintが文句を言わなくなる点が優れています。

おすすめ設定

{
  "singleQuote": true,
  "trailingComma": "es5",
  "tabWidth": 4,
}

jQueryでむりやりFluxをするとどうなるのか?

結論: 非常に辛い。なぜならFluxの恩恵はコンポーネント化によってもたらされるから。

jQueryのコードが汚くてつらい

こちらをご覧ください。jQueryで作ったカンタンなアプリのコードになります。

<!DOCTYPE html>
<title>jQuery</title>
<script src="https://pandanoir.net/js/jquery.slim.min.js"></script>
<script src="socket.io/socket.io.js"></script>
<script>
 jQuery($ => {
     const socket = io();
     let existsFire = false;
     let extinguishTime = 0;
     let maxRecord = 0;
     $('#explosion').html($('<img>').prop('src','http://ws.pandanoir.net/maki.png'));
     setInterval(() => {
         const rest = Math.max(0|(extinguishTime - new Date())/1000, 0);
         $('#rest-time').text(`残り${rest}秒`);
     }, 100);
     setInterval(() => {
         $('#record').text(`只今の最高記録: ${0|maxRecord}秒`);
     }, 1000);

     socket.on('info', (data) => {
         existsFire = data.existsFire;
         extinguishTime = data.extinguishTime;
         maxRecord = data.max;

         console.log(`connect [sessid = ${socket.id}]`);
     });
     socket.on('fire_extinguished', ({record}) => {
         console.log(`鎮火しました。只今の記録は${record}秒でした`);
         $('#mes').text($('#mes').text() + (`鎮火しました。只今の記録は${record}秒でした`));
         $('#explosion').html($('<img>').prop('src','http://ws.pandanoir.net/maki.png'));
     });
     socket.on('fire', (data) => {
         existsFire = true;
         extinguishTime = data.extinguishTime;
         $('#explosion').html($('<img>').prop('src','http://ws.pandanoir.net/takibi.png'));
     });
     socket.on('add_fuel', (data) => {
         existsFire = true;
         extinguishTime = data.extinguishTime;
     });
     socket.on('disconnect', () => console.log('exit !!'));
     $('#fuel').on('click', () => {
         socket.emit('add_fuel');
     });
     $('#fire').on('click', () => {
         socket.emit('fire');
     });
 });
</script>

<div id="rest-time"></div>
<div id="record"></div>
<div id="explosion"></div>
薪を足す<input type="button" id="fuel" value="fuel">
着火する<input type="button" id="fire" value="fire">
<div id="mes"></div>

…非常につらい!つらい理由はいくつかあります。

  • イベントを中心に据えているせいで、たとえば#explosion要素が変化する部分がコード中に散らばっていて把握しづらい
  • イベントハンドラーと実際の要素が離れているせいで意味を把握しづらい

ようするに、コードにコンポーネントという意味的まとまりが存在していないため読みづらくなっています。

コレを(無理やり)コンポーネント単位にまとめてみます。

<!DOCTYPE html>
<title>jQuery with component</title>
<script src="https://pandanoir.net/js/jquery.slim.min.js"></script>
<script src="socket.io/socket.io.js"></script>
<script>
 {
     // Dispatcherに相当
     const _ = jQuery('<span>');
     jQuery.extend(jQuery, {
         on: _.on.bind(_),
         off: _.off.bind(_),
         trigger: _.trigger.bind(_),
     });
 }
 const createStore = () => {
     // Dispatcherからイベントを受け取って状態を変更し、
     // storeChangedイベントを発行してViewを変更する
     const store = {
         existsFire: false,
         extinguishTime: 0,
         startTime: Infinity,
         maxRecord: 0,
     };
     $.on(`${action.addFuel} ${action.loadRemoteData}`, (_, {payload}) => {
         Object.assign(store, payload);
         store.trigger(action.storeChanged);
     });
     $.on(action.fire, (_, {payload}) => {
         Object.assign(store, payload);
         store.existsFire = true;
         store.trigger(action.storeChanged);
     });
     $.on(action.fireExtinguished, (_, {payload}) => {
         store.startTime = Infinity;
         store.maxRecord = Math.max(store.maxRecord, payload.record);
         store.trigger(action.storeChanged);
     });
     const _ = jQuery('<span>');
     jQuery.extend(store, {
         on: _.on.bind(_),
         off: _.off.bind(_),
         trigger: _.trigger.bind(_),
     });
     return store;
 };
 const action = {
     storeChanged: 'STORE.CHANGED',
     addFuel: 'ADD_FUEL',
     fire: 'FIRE',
     fireExtinguished: 'FIRE_EXTINGUISHED',
     loadRemoteData: 'LOAD_REMOTE_DATA',
 };
 const createSocket = () => {
     const socket = io.connect('http://localhost:3000');
     const trigger = (eventName) => (data) => $.trigger(eventName, {payload: data});

     socket.on('info', trigger(action.loadRemoteData));
     socket.on('fire_extinguished', trigger(action.fireExtinguished));
     socket.on('fire', trigger(action.fire));
     socket.on('add_fuel', trigger(action.addFuel));
     socket.on('disconnect', () => console.log('exit !!'));
     return socket;
 };
 jQuery($ => {
     $('<img>').prop('src','http://ws.pandanoir.net/takibi.png');
     $('<img>').prop('src','http://ws.pandanoir.net/maki.png');
     const socket = createSocket();
     const store = createStore();

     // コンポーネントたちを定義する
     // #explosion
     const explosionImageComponent = ((store) => {
         const $elm = $('<div>');
         const $img = $('<img>').appendTo($elm).prop('src','http://ws.pandanoir.net/maki.png');
         store.on(action.storeChanged, () => {
             if (store.existsFire)
                 $img.prop('src','http://ws.pandanoir.net/takibi.png');
             else
                 $img.prop('src','http://ws.pandanoir.net/maki.png');
         });
         return $elm;
     })(store);

     const restTimeComponent = ((store) => {
         const $elm = $('<div>');
         setInterval(() => {
             const rest = Math.max(Math.ceil((store.extinguishTime - new Date())/1000), 0);
             const remain = Math.max(Math.floor((+(new Date()) - store.startTime)/1000), 0);

             $elm.html(`残り${rest}秒<br>現在${remain}秒続いています`);
         }, 100);
         return $elm;
     })(store);

     const recordComponent = ((store) => {
         const $elm = $('<div>');
         store.on(action.storeChanged, () => {
             $elm.text(`只今の最高記録: ${0|store.maxRecord}秒`);
         });
         return $elm;
     })(store);

     const messageComponent = (() => {
         const $elm = $('<div>');
         const state = {
             text: []
         };
         $.on(action.fireExtinguished, (_, {payload: {record}}) => {
             state.text.push(`鎮火しました。只今の記録は${record}秒でした`);
             $elm.html(state.text.join('<br>'));
         });
         return $elm;
     })();

     const fuelButtonComponent = (() => {
         const $elm = $('<span>');
         const $button = $('<input>')
             .prop('type', 'button')
             .val('薪を足す')
             .on('click', () => socket.emit('add_fuel'));
         $elm.append($button);
         return $elm;
     })();
     const fireButtonComponent = (() => {
         const $elm = $('<span>');
         const $button = $('<input>')
             .prop('type', 'button')
             .val('着火する')
             .on('click', () => socket.emit('fire'));
         $elm.append($button);
         return $elm;
     })();

     $('body').append(
         restTimeComponent,
         recordComponent,
         explosionImageComponent,
         fuelButtonComponent,
         fireButtonComponent,
         messageComponent
     );
 });
</script>

先程と比べると、各コンポーネントの動作が明確になっています。しかし、VueやReactと比べると、どういうHTML構造になるのか分かりづらいです。やはりjQueryでFluxをするのはつらい…