Panda Noir

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

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をするのはつらい…

WebSocketでカンタンなゲームを作った

火をつけて消えないように薪を足し続けるだけのゲームを作りました。サイトに接続している人なら誰でも薪を足したり、火をおこせます。

http://ws.pandanoir.net/

WebSocket通信をどう使っているか

WebSocketは一度確立したコネクションを切断しないでそのまま繋ぎ続ける技術で、クライアント―サーバー間の双方向通信が可能になります。

たとえばクライアントAが火を起こすと、次のような処理をします。

  1. クライアントAが「着火した」とサーバーに送る
  2. サーバーが全クライアントに「着火した」と送る
  3. 全クライアントの情報が更新される

薪が足されたときも同様です。

火が消えたときはサーバーが全クライアントに「火が消えた」と送るだけです。各クライアントが消えたかは関係ありません。

使用した技術

WebSocketとVue、Vuexを組み合わせて作りました。

今回は「残り時間」を表示しなければならないので、Reactive Time with Vue.js - Cushionを参考にして現在時刻を更新しています。

// store/index.js
import Vue from 'vue';
import Vuex from 'vuex';
import fire from './modules/fire.js';

Vue.use(Vuex);

const store = new Vuex.Store({
    modules: { fire },
});

store.dispatch('fire/start'); // 時刻の更新を開始する

export default store;
// store/modules/fire.js
import socket from '../socket';
const state = {
    existsFire: false,
    extinguishTime: 0,
    startTime: Infinity,
    maxRecord: 0,
    now: +new Date(), // 現在時刻を数値で格納
};

const getters = {
    restTime({extinguishTime, now}) {
        return Math.max( Math.ceil((extinguishTime - now) / 1000), 0 );
    },
    remainTime({now, startTime}) {
        return Math.max( Math.floor((now - startTime) / 1000), 0 );
    },
    /* ... */
};

const actions = {
    start({commit}) {
        // これがdispatchされると時刻の更新が始まる
        const fps = 32;
        setInterval(() => commit('updateTime'), 1000 / fps); // 1秒間に時刻を32回更新するように設定
    },
    /* ... */
};

// mutations
const mutations = {
    updateTime(state) {
        // 上のsetIntervalに設定されているように、この関数が1秒に32回呼び出される
        state.now = +new Date();
    },
    /* ... */
};

export default {
    namespaced: true,
    state,
    getters,
    actions,
    mutations
};

また、Socket.ioの部分は、イベントを受け取るとstoreにアクションを発行するよう設定しています。

// store/socket.js
import io from 'socket.io-client';
import store from './';

const socket = io();

socket.on('info', (data) => store.commit('fire/getServerState', {payload: data}));
socket.on('fire_extinguished', (data) => store.commit('fire/fireExtinguished', {payload: data}));
socket.on('fire', (data) => store.commit('fire/litFire', {payload: data}));
socket.on('add_fuel', (data) => store.commit('fire/addFuel', {payload: data}));

export default socket;

ほかはただ表示するためのコンポーネントを作っただけで技術的に面白い部分はないので、割愛します。

なぜrequire('lodash/zipWith')で関数ひとつだけ読み込めるのか

lodashというユーティリティライブラリがあります。lodashは便利な関数をたくさん提供しています。たとえば配列をシャッフルするshuffle()や、配列の差をとるdifference()などが挙げられます。

lodashは300個以上の関数を提供しています。しかし、実際に使うのはそのうちせいぜい10個です。コードサイズが大きくなってしまうのはJavaScript的に大罪です。そこで、必要な関数だけピックアップする方法が提供されています。それがタイトルにもあるrequire('lodash/zipWith')といった書き方です。

今回はどうしてそれで300を超える関数が読み込めるようになっているのか解説します。

lodashモジュールの様々な読み込み方

lodashは以下のように複数の読み出し方に対応しています。

const {zipWith} = require('lodash'); // lodashのすべてが読み込まれてしまっている
const {zipWith} = require('lodash/array'); // lodashのうち配列操作系だけ読み込む
const zipWith = require('lodash/zipWith'); // 関数をピンポイントで読み込む

ES Modulesを使う書き方もできます(node_modules/配下を参照するので、Node.jsでのみ動作します)。

import * as _ from 'lodash';
import * as lodashArray from 'lodash/array';
import zipWith from 'lodash/zipWith';

では、どうしてこのような読み込み方ができるのでしょうか?実際にインストールしたパッケージを見てみます。

f:id:panda_noir:20190419212255p:plain

なんと631個もJavaScriptファイルが置かれています!そして、それぞれ関数に対応したファイル、arrayに対応したファイルがあります。

つまり、require('lodash/array')ではlodashパッケージ内にあるarray.jsが読み込まれます。同様にrequire('lodash/zipWith')ではzipWith.jsが読み込まれます。

仕様ではどうなっているのか?

Node.jsのドキュメントのModulesページを見てみます。

どうやら、require('example-module/path/to/file')./node_moduels/example-module/path/to/fileと同様のようです(現在のディレクトリのnode_modulesになければ見つかるまで親ディレクトリを辿っていくようです)。

つまり、上のrequire('lodash/array')はドキュメントで保証されている動作らしいです。

これって名前空間なのでは?

このrequire('lodash/array')という書き方、lodashという名前空間のarrayを読み込むと見えませんか?実際の動作も名前空間にみえます。非常に面白いですね。

ただ、実際にコレをやっているパッケージをあまり見ない気がします。そもそもやる必要があるパッケージが少ないというのがありますが。

気がついたらimmerが独自クラスのインスタンスもサポートしていた

immerという、ミュータブルな操作を書く感覚でイミュータブルな操作が行えるライブラリがあります。

import {produce} from 'immer';

// 破壊的に配列をシャッフルする関数
const shuffle=(a,i=a.length,j) => {for(;[a[i],a[j]]=[a[j=0|Math.random()*i],a[--i]],i;);};

const arr = [ 0, 1, 2, 3, 4, 5, 6, 7, 8, 9 ]
const shuffledArray = produce(arr, (draftArray) => {
    shuffle(draftArray);
}); // シャッフルされた配列が得られる

以前のimmerはネイティブの配列とオブジェクトしか扱えませんでした。しかし、久しぶりに見てみたらインスタンスにも適用できるようになっていました(ほとんどのインスタンスは対応していますが、すべてに対応しているわけではないです)。

1. インスタンスに対応

immerのインスタンス対応はMyClass.prototype[immerable] = trueか、myInstance[immerable] = trueとするだけで対応できます。

import {produce, immerable} from 'immer';
class Vector{
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    setZ(z) {
        this.z = z;
    }
}

const vector = new Vector(1, 2);
vector[immerable] = true; // これだけでimmerで使えるようになります

const after = produce(vector, draft => {
    draft.x += 4;
    draft.y += 4;
    draft.setZ(7);
});

console.log(vector, after);

immerableはprototypeに直接設定しても問題ありません。(というかこっちのほうが恐らく主流です)

Vector.prototype[immerable] = true;

クラスがgetterやsetterを使っている場合には使えないようです。

2. draftを外部に出せるcreateDraft()

draftを外に出すと、ネストが浅くなるだけでなく既存のコードをほんの少し変えるだけでimmutable化できるようになります。たとえばshuffle()という破壊的変更を加える関数を考えます。

const shuffle = (arr) => {
    // 破壊的にarrをシャッフルする
    for (let i = arr.length - 1; i > 0; i = 0 | i - 1) {
        const j = 0 | Math.random() * (i+1);
        [arr[i], arr[j]] = [arr[j], arr[i]];
    }
};
const arr = [1, 2, 3, 4, 5];
shuffle(arr);

このコードにcreateDraftを加えてちょっと書き直せば、arrが破壊されなくなります。

import {createDraft, finishDraft} from 'immer';
const shuffle = (arr) => {
    // 破壊的にarrをシャッフルする
    for (let i = arr.length - 1; i > 0; i = 0 | i - 1) {
        const j = 0 | Math.random() * (i+1);
        [arr[i], arr[j]] = [arr[j], arr[i]];
    }
};
const arr = [1, 2, 3, 4, 5];
const draft = createDraft(arr); // 追加
shuffle(draft); // draftを渡すように変更する
const shuffledArr = finishDraft(draft); // 追加

ただ、個人的にはネストされたほうが「このエリアはimmerのエリアだな」と分かりやすくなるのでcreateDraftよりproduceが好きです。