Panda Noir

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

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が好きです。

Nginxのincludeディレクティブはいちいちフルパスを書かなくてもいい

ずっとNginxのincludeディレクティブは絶対パスをちゃんと書かないといけないと思っていましたが、どうやら--prefixで指定したディレクトリからの相対パスでも書けるようです。

$ nginx -V
nginx version: nginx/1.15.10
built by gcc 4.8.5 20150623 (Red Hat 4.8.5-36) (GCC)
built with OpenSSL 1.0.2k-fips  26 Jan 2017
TLS SNI support enabled
configure arguments: --prefix=/etc/nginx --sbin-path=/usr/sbin/nginx

configure arguments内の--prefix=/etc/nginxから見た相対パスを書くことが出来ます。たとえば以下の2つは同じです。

include /etc/nginx/conf.d/default.conf;
include conf.d/default.conf;

というか、すでにPHPを使っている場合はこの記法を使っているはずです。

include fastcgi_params;
include /etc/nginx/fastcgi_params; # こう書いてもいい