JavaScript
promise
proxy
AsyncAwait

よくある闇のJavaScriptパターンとそれに対する防衛法

この記事は闇の魔術に対する防衛術 Advent Calendar 2018の5日目の記事です。

概要

VueやReact,Expressなどの各フレームワーク特有のアンチパターンではなく、JavaScriptプログラミングで全般的に見かけるであろうアンチパターンたちです。

以下の3つについて書いています

  • 副作用の処理
  • プロパティーチェック
  • コールバック地獄

副作用の処理

副作用という言葉を、ここでは「ある処理に注目したときに、それに付随して行われるべき処理」という意味合いで使用します。
どの言語でやってもあるものなのですが、副作用というのはめんどくさいものです。
例えば、「aという値を変更したらbを変更してcという関数を実行する」という処理をjsで書くと以下のようになります。

const a = 3;
const b = f(a); // fは何らかの関数
c(b);

この場合はいわゆる副作用が下の2行で済んでいますが、これが長くなると大変です。以下のようなコードを想像してみてください。

const a = 3; // メインの処理
do_something_a(a);
do_something_b(a);
// ...
do_something_z(a);

関数をまとめてしまうのも手ですが、無理やりまとめてしまうと今度はコードの見通しが悪くなってしまうかもしれません。

解決策1. getter/setter

こういう状況に対する典型的な答えの一つは、getterとsetterを導入することです。

a.set(3); // 値を設定する用の関数を呼び出す
const b = 4 + a.get(); // 値を使用するときもそのための関数を使う

こうしてしまえば、以下のように上の問題を回避できます。

  • 他の値に合わせて自動で変わってほしい値 → getterでその都度計算
  • 値をセットするときに何か出力するとか他の処理を書く → setter内にその処理をまとめる

非常に有名なフロントエンドフレームワークであるVueやReactでは一部のプロパティーを内部的にこのような形式に変換することでプログラミングをしやすくしています。
とはいえ、全ての値についてgetterやsetterを書くのは普通は非常に冗長なので、この解決策は(少なくともJavaScriptでは)好まれていません。

解決策2. Proxy

あまり使用されているケースをまだまだ見ませんが、ES6からはProxyという仕様が加わり、より自然にできるようになりました。

いろいろ解説する前に、まずはどんなコードになるか見てみましょう。(注:以下のサンプルコードはMDNのページからそのまま引っ張ってきています。)

例えば、getterは以下のように書くことができます。

var handler = {
    get: function(target, name){
        return name in target?
            target[name] :
            37;
    }
};

var p = new Proxy({}, handler);
p.a = 1;
p.b = undefined;

console.log(p.a, p.b); // 1, undefined
console.log('c' in p, p.c); // false, 37

今までgetterとsetterを記述していたオブジェクトについて、Proxyでは「もととなるオブジェクト」と「ハンドラ」というものを指定してオブジェクトを作成します。

上の例では、空のオブジェクト {} をもとに、ハンドラ handler を用いて処理をしています。その中での get 関数は元のオブジェクト target とプロパティー名 name を受けとりget処理を書いています。上の例では単純に値がセットされていなかったら37を返しています。

setterのほうを見てみましょう。

let validator = {
  set: function(obj, prop, value) {
    if (prop === 'age') {
      if (!Number.isInteger(value)) {
        throw new TypeError('年齢が整数ではありません');
      }
      if (value > 200) {
        throw new RangeError('年齢が不正なようです');
      }
    }

    // 値を保存する既定の挙動
    obj[prop] = value;

    // 値の保存が成功したことを返します。
    return true;
  }
};

let person = new Proxy({}, validator);

person.age = 100;
console.log(person.age); // 100
person.age = 'young'; // 例外が投げられる
person.age = 300; // 例外が投げられる

先程の handler の代わりに、validatorをハンドラとして使用しています。その中のsetでは、もととなるオブジェクト、代入する対象のプロパティーだけでなく、代入する値を受け取ります。あとは、値が不正なら例外を投げているだけです。

上の例は非常に簡単でしたが、setやget関数内にいろいろな処理を書き込むことでいろいろできることが想像できると思います。

プロパティーチェック

最近は見かけなくなった気がしますが、JavaScriptに慣れていない神経質な人が書いたコードだと、以下のような部分に遭遇することがあります。

function a(obj) {
  if (!obj.a) return
  if (!obj.a.b) return
  if (!obj.c) return
  // ...
}

JavaScriptでは、undefinedのプロパティーを読もうとすると大抵Cannot read property 'xxx' of undefinedというエラーが出ることが知られています。
そのため、オブジェクトがある一定のプロパティーを持っていないと処理をしたくないという状況が往々にして発生します。

解決策1. TypeScriptを使う

反則気味ですが、TypeScriptを使えば型を通じてオブジェクトのプロパティーについて制約を書けられるので、上のようなコードを撲滅しつつ堅牢性を担保できます。

解決策2.(非推奨) Proxyを使う

さきほど紹介したProxyを使えば、undefinedなプロパティーを読んだときでも適当な値を返すことができるでしょう。とはいえ、関数内で全てProxyに変換し上のような挙動をさせると、冗長になる上に、「値が不正なまま処理が進んでしまう」おそれがあります。

解決策3. がんばる

全然解決策では無いですが、TypeScriptもProxyも使わない場合は、「頑張る」しかありません。
つまり、上のようなコードを書かずして自動的にプロパティーチェックをするような機構はJavaScriptには存在しませんので、人力でエラーを防ぐ必要があります。

有効と思われるのは、
- jsdocなどのdocsをちゃんと書く
- 関数が呼ばれる文脈をしっかり限定する
- 死んだ時のためにエラーハンドリングをちゃんと考える

くらいでしょうか。ここはJavaScriptの限界という気がします。

解決策4. BabelでOptional Chainingを入れる

(12/9追記)
TC39ではまだStage 1(まだ提案段階)ですが、Babelを使うと以下のようなコードが使用可能になります。(以下TC39のページからの引用)

a?.b                          // undefined if `a` is null/undefined, `a.b` otherwise.
a == null ? undefined : a.b

a?.[x]                        // undefined if `a` is null/undefined, `a[x]` otherwise.
a == null ? undefined : a[x]

a?.b()                        // undefined if `a` is null/undefined
a == null ? undefined : a.b() // throws a TypeError if `a.b` is not a function
                              // otherwise, evaluates to `a.b()`

a?.()                        // undefined if `a` is null/undefined
a == null ? undefined : a()  // throws a TypeError if `a` is neither null/undefined, nor a function
                             // invokes the function `a` otherwise

上の行が下の行と等価なコードです。ちょっと面食らうかもしれませんが、一番上のような書き方をすれば、「無いときはとにかくundefinedを取得したい」というときはコード量が幾分か減ると思われます。ただし、先にも述べましたがStage 1のため、これから仕様が変わることが充分に予想されます。使用にあたってはそこを注意して使いましょう。

コールバック地獄

非同期処理などの、「コールバック処理」を複数つなげようとすると、愚直に書くと以下のようになってしまいます。

getDocA(v => {
  getDocB(v2 => {
    getDocC(v3 => {
      // ...
    });
  });
});

複数の非同期処理を順番に実行する場合は、上のように「コールバックの中にコールバック、さらに中にコールバック、、、」という状況になります。

単純に見づらいですし、内側のコールバックになればなるほど変数のスコープもわかりにくくなってしまいます。

解決策.(一部の非同期処理) Promiseとasync/await

Promiseとasync/awaitを使うことで、「非同期処理を一回やったらコールバックを実行して終わり」というタイプの処理はこれで書けます。

Promiseを使った場合、以下のような感じです。

new Promise((resolve, reject) => {
  getDocA(v => {
    resolve(v);
  })
}).then(v => new Promise((resolve, reject) => {
  getDocB(v2 => {
    resolve(v2);
  });
})).then(v2 => new Promise((resolve, reject) => {
  getDocC(v3 => {
    resolve(v3);
  });
});

詳しい解説はMDNの「Promise」を使うJavaScript Promiseの本
他の記事を参照してほしいのですが、以上のように、非同期処理を以上のように「処理する順番に」書けていることがわかるかと思います。

また、async/awaitを使うと以下のように書けます。

function b(fn) {
  return new Promise((resolve, reject) => {
    fn(u => resolve(u));
  });
}
async a() => {
  const v = await b(getDocA);
  const v1 = await b(getDocB);
  const v2 = await b(getDocC);
}

関数を宣言するときに先頭にasyncキーワードをつけることで、中ではawaitを使用することでPromiseが解決されるまで次の行は実行されません。以上のPromiseによる例よりもさらに直感的に書けていることがわかります。

Promiseやasync/awaitを使う際の注意点

しかし、先程断ったように、上の解決策を以下のような「複数回関数が呼ばれうるもの」の対して適用しようとすると途端にうまく行かなくなります。

new Promise((resolve, reject) => {
  document.getElementById('#click').addEventListener('click', () => {
    resolve(3);
  });
}).then(v => {
  console.log(v);
});

上のコードを実行し、何回クリックをしても、最初だけしか 3 は出力されません。
これは、Promiseには「一回resolveされるともうresolveを実行してもthenは実行されない」という性質があるためです。

以上のような性質を持つ場合、残念ながらPromiseで書くことはできないので、既存のコールバックのような書き方をすることが未だに多いです。(VueやReactではかなり楽に書けますが)

終わりに

ということで、以下の事項について、よくある状況と対処を書いてみました。

  • 副作用の処理
  • プロパティーチェック
  • コールバック地獄

これらの状況に遭遇したときに、少しでもこの記事が助けになれば幸いです。よきJavaScriptライフを!

次の記事は@kangaさんのインフラエンジニアと謎のサーバです!