JavaScript上級 #6 SymbolとProxy

#5 メモリモデル に続いて、今回はJavaScriptのもう少し変わったツールを取り上げます。日常のコードでは頻繁に使いませんが、ライブラリの内側でよく出会う二つ — SymbolProxy

Symbol — 衝突しない唯一のキー #

SymbolはJavaScriptの7番目のプリミティブ型です (基礎 #2)。

Symbol を作る
const id1 = Symbol('id');
const id2 = Symbol('id');

console.log(id1 === id2);      // false — 同じ説明でも別のシンボル
console.log(typeof id1);        // 'symbol'
console.log(id1.toString());    // 'Symbol(id)'

Symbol(説明) で作ります。引数はデバッグ用の説明であって、アイデンティティではありません。呼び出すたびに新しく唯一の値が作られます。

オブジェクトキーとして — 衝突せずに #

同じ形の二つのライブラリがオブジェクトにメタデータを付けるとき、キーが重なって衝突しないようにするにはSymbolが有効です。

Symbol キー
const userInfo = Symbol('userInfo');

const obj = {};
obj[userInfo] = { id: 'u1' };   // 通常のキーと衝突しない
obj.userInfo = '文字列キー';       // 二つのキーは完全に別の場所

obj[userInfo];   // { id: 'u1' }
obj.userInfo;     // '文字列キー'

同じ名前でもSymbolキーと文字列キーは別の場所です。ライブラリが自分のメタデータをオブジェクトに安全に付けるときによく使います。

Symbol キーは一般的な反復には見えない #

反復と Symbol
const sym = Symbol('hidden');
const obj = {
  visible: 'A',
  [sym]: 'B',
};

Object.keys(obj);                       // ['visible']
JSON.stringify(obj);                     // '{"visible":"A"}'
for (const k in obj) console.log(k);    // visible

Object.getOwnPropertySymbols(obj);       // [Symbol(hidden)]
Reflect.ownKeys(obj);                    // ['visible', Symbol(hidden)]

for...inObject.keys、JSONはすべてSymbolキーを無視します。見たいなら Object.getOwnPropertySymbols または Reflect.ownKeys隠しメタデータの意図とよく合います。

Well-known Symbol — 言語自体が使うキー #

JavaScriptの標準が定義した特別なSymbolたちがあります。Symbol.iteratorSymbol.asyncIteratorSymbol.toPrimitive など。

中級 #3 イテレータとジェネレータ で見た Symbol.iterator が最も有名です。

Symbol.iterator 再び
const range = {
  from: 1, to: 3,
  [Symbol.iterator]() {
    let n = this.from;
    const last = this.to;
    return {
      next() {
        return n <= last
          ? { value: n++, done: false }
          : { value: undefined, done: true };
      },
    };
  },
};

[...range];   // [1, 2, 3]

ここにあえてSymbolを使った理由 — 一般のキーだったらユーザーが偶然同じキーを使うことがありえました。言語レベルの約束キーは絶対に衝突しないようにSymbolで定義されました。

Symbol.toPrimitive — プリミティブ変換のカスタム #

オブジェクトを数値/文字列として使うときどう変換されるかを決められます。

Symbol.toPrimitive
const money = {
  amount: 1000,
  currency: '円',
  [Symbol.toPrimitive](hint) {
    if (hint === 'number') return this.amount;
    if (hint === 'string') return `${this.amount}${this.currency}`;
    return `${this.amount}${this.currency}`;
  },
};

+money;             // 1000 (数値の文脈 — 'number' ヒント)
`${money}`;         // '1000円' (文字列の文脈 — 'string' ヒント)
money + 500;        // '1000円500' (default ヒント — 通常 string)

普通のアプリコードで直接使うことはほとんどありませんが、「この文脈でどう変換されるか」を正確に制御する必要があるライブラリでたまに出会います。

Symbol.for — グローバルシンボルレジストリ #

同じキーを複数のモジュールで共有したい場合は次のようにします。

Symbol.for / Symbol.keyFor
const a = Symbol.for('app/secret');
const b = Symbol.for('app/secret');

a === b;   // true — 同じキーで登録された同じシンボル

Symbol.keyFor(a);   // 'app/secret'

グローバルレジストリに登録されるシンボルです。一般的な Symbol(...) は毎回異なりますが、Symbol.for(...) は同じキーなら同じシンボルを返します。ライブラリがモジュール境界を越えて共有メタデータを置くときに活用します。

Proxy — オブジェクトの動作を傍受 #

Proxy はオブジェクトに対するすべての動作 (読み取り、書き込み、削除など) を傍受できるようにするメタプログラミングツールです。

基本 Proxy
const target = { a: 1, b: 2 };

const proxy = new Proxy(target, {
  get(obj, key) {
    console.log(`読み取り: ${key}`);
    return obj[key];
  },
  set(obj, key, value) {
    console.log(`書き込み: ${key} = ${value}`);
    obj[key] = value;
    return true;   // 成功フラグ
  },
});

proxy.a;          // 読み取り: a → 1
proxy.b = 10;     // 書き込み: b = 10

new Proxy(target, handler) — handlerオブジェクトに傍受する動作を定義します。

傍受できる動作 (trap) #

trap呼ばれるとき
getプロパティ読み取り (obj.a)
setプロパティ書き込み (obj.a = 1)
has'a' in obj
deletePropertydelete obj.a
ownKeysObject.keys, Reflect.ownKeys
apply関数呼び出し (fn())
constructnew fn()

このほかにも13種類ほどのtrapがあります。ほぼすべてのオブジェクト動作を傍受できます。

Proxy の実戦活用 #

1) デフォルト値オブジェクト #

ないキーにデフォルト値
const withDefault = (defaultValue) => new Proxy({}, {
  get(target, key) {
    return key in target ? target[key] : defaultValue;
  },
});

const counts = withDefault(0);
counts.apple = 3;
console.log(counts.apple);    // 3
console.log(counts.banana);   // 0 (なくてもデフォルト値)

get でキーの有無を検査してデフォルト値を流してあげます。

2) 検証 — 不正な値を防ぐ #

値の検証
function createValidatedUser() {
  return new Proxy({}, {
    set(target, key, value) {
      if (key === 'age' && (typeof value !== 'number' || value < 0)) {
        throw new TypeError('age は正の number でなければいけません');
      }
      target[key] = value;
      return true;
    },
  });
}

const user = createValidatedUser();
user.name = 'カーティス';  // OK
user.age = 30;            // OK
user.age = -1;            // ✗ TypeError
user.age = '三十';         // ✗ TypeError

set trapで検証後 throw するか、false を返せば代入が失敗します。

3) リアクティブ — 変更追跡 #

Vue、MobXのようなライブラリがオブジェクトの変更を追跡する核心メカニズムです。

リアクティブ — 骨組み
function reactive(target, onChange) {
  return new Proxy(target, {
    get(obj, key) {
      const value = obj[key];
      if (typeof value === 'object' && value !== null) {
        return reactive(value, onChange);   // ネストもリアクティブ
      }
      return value;
    },
    set(obj, key, value) {
      const old = obj[key];
      obj[key] = value;
      onChange({ key, old, value });
      return true;
    },
  });
}

const state = reactive({ count: 0 }, (e) => {
  console.log('変更:', e);
});

state.count++;
// 変更: { key: 'count', old: 0, value: 1 }

この骨組みがVueの ref/reactive の核心です。傍受した動作で依存関係追跡と通知を加えれば本物のリアクティブシステムになります。

4) API呼び出しの自動化 — namespace 傍受 #

プロパティアクセスで動的API
const api = new Proxy({}, {
  get(target, endpoint) {
    return (params) => {
      return fetch(`/api/${endpoint}?${new URLSearchParams(params)}`)
        .then((r) => r.json());
    };
  },
});

const users = await api.users({ active: true });
const posts = await api.posts({ tag: 'js' });
// 定義していないすべての属性が fetch 関数になる

このようなパターンはSDKライブラリでたまに見ます。短く印象的ですが — オートコンプリート/TypeScriptの体験が悪くなるので慎重に使うべきです。

Reflect — Proxy のペア #

Reflect はオブジェクトの基本動作を関数として公開したビルトインオブジェクトです。Proxyのtrapの中で元の動作をそのまま進めたいときにペアのように使われます。

Reflect.get / set
const target = {};

const proxy = new Proxy(target, {
  get(obj, key, receiver) {
    console.log(`読み取り: ${key}`);
    return Reflect.get(obj, key, receiver);   // 元の動作そのまま
  },
  set(obj, key, value, receiver) {
    console.log(`書き込み: ${key}`);
    return Reflect.set(obj, key, value, receiver);
  },
});

trapの中では直接 obj[key] よりも Reflect.get(...) のほうが正確です。getter/setterとreceiverの処理まで自動で行ってくれます。

よく使うReflectメソッドを以下に示します。

よく使う Reflect
Reflect.get(obj, key);                // obj[key]
Reflect.set(obj, key, value);         // obj[key] = value
Reflect.has(obj, key);                 // key in obj
Reflect.deleteProperty(obj, key);     // delete obj[key]
Reflect.ownKeys(obj);                  // すべての自身のキー (Symbol を含む)
Reflect.construct(Cls, args);         // new Cls(...args)
Reflect.apply(fn, thisArg, args);     // fn.call(thisArg, ...args)

普通のコードで直接使うことは少ないです。Proxyを扱うときによくペアで登場します。

Proxy の落とし穴 #

Proxyは強力ですがコストがあります。

  1. パフォーマンス — すべてのプロパティアクセスでtrap関数が呼ばれるので、コアループ内では遅いことがある
  2. オートコンプリート — 静的解析が難しく、IDEがプロパティを把握しづらい
  3. ==== 比較proxy === target は false。同じオブジェクトとして扱うコードが壊れることがある
  4. JSON / シリアライズ — どのプロパティが見えるかはtrapに依存

このようなコストのため、一般のアプリコードにProxyを直接埋め込むのは推奨されません。ライブラリの内側で使われるツールとして認識すれば十分です。

まとめ #

今回の記事で整理した内容。

  • Symbol — 唯一で衝突しない値。オブジェクトキーとして使うのに適している
  • 一般の反復/JSONにSymbolキーは見えない — 隠しメタデータに向いている
  • Well-known Symbols — Symbol.iterator など言語の約束
  • Symbol.for(key) でグローバルレジストリを共有
  • Proxy — オブジェクト動作 (読み取り/書き込み/削除など) を傍受するメタツール
  • デフォルト値オブジェクト、検証、リアクティブ、動的APIなどの応用
  • Reflect はProxyのペア — 元の動作をそのまま進める
  • Proxyのコスト — パフォーマンス、オートコンプリート、シリアライズ

次回の記事 (#7 モジュールシステムの深掘り) ではシリーズの最後として — CommonJSとES Modulesの違い、モジュールホイスティング、循環参照で何が起こるかを整理します。

X