28. プロキシによるメタプログラミング
目次
この本のサポートをお願いします: 購入 (PDF, EPUB, MOBI) または 寄付
(広告です。ブロックしないでください。)

28. プロキシによるメタプログラミング



28.1 概要

プロキシを使用すると、オブジェクトに対して実行される操作 (プロパティの取得など) をインターセプトしてカスタマイズできます。これらはメタプログラミング機能です。

次の例では、proxyは操作をインターセプトするオブジェクトであり、handlerはインターセプトを処理するオブジェクトです。この場合、インターセプトしているのは単一の操作であるget(プロパティの取得)のみです。

const target = {};
const handler = {
    get(target, propKey, receiver) {
        console.log('get ' + propKey);
        return 123;
    }
};
const proxy = new Proxy(target, handler);

proxy.fooプロパティを取得すると、ハンドラーがその操作をインターセプトします。

> proxy.foo
get foo
123

インターセプトできる操作の一覧については、完全なAPIのリファレンスを参照してください。

28.2 プログラミングとメタプログラミング

プロキシとは何か、そしてなぜ便利なのかを理解する前に、まずメタプログラミングとは何かを理解する必要があります。

プログラミングにはレベルがあります。

ベースレベルとメタレベルは異なる言語である可能性があります。次のメタプログラムでは、メタプログラミング言語はJavaScriptであり、ベースプログラミング言語はJavaです。

const str = 'Hello' + '!'.repeat(3);
console.log('System.out.println("'+str+'")');

メタプログラミングはさまざまな形式をとることができます。前の例では、Javaコードをコンソールに出力しました。メタプログラミング言語とベースプログラミング言語の両方にJavaScriptを使用してみましょう。この典型的な例は、eval()関数です。これは、JavaScriptコードをその場で評価/コンパイルできます。eval()にはそれほど多くの実際のユースケースはありません。以下の対話では、5 + 2式を評価するために使用します。

> eval('5 + 2')
7

他のJavaScript操作はメタプログラミングのように見えないかもしれませんが、詳しく見ると実際にはメタプログラミングです。

// Base level
const obj = {
    hello() {
        console.log('Hello!');
    }
};

// Meta level
for (const key of Object.keys(obj)) {
    console.log(key);
}

プログラムは実行中に自身の構造を調べています。JavaScriptではプログラミング構成要素とデータ構造の分離があいまいであるため、これはメタプログラミングのように見えません。すべてのObject.*メソッドは、メタプログラミング機能と見なすことができます。

28.2.1 メタプログラミングの種類

リフレクティブメタプログラミングとは、プログラムが自身を処理することを意味します。Kiczalesら [2]は、3種類のリフレクティブメタプログラミングを区別しています。

例を見てみましょう。

例: イントロスペクション。Object.keys()はイントロスペクションを実行します(前の例を参照)。

例: 自己修正。次の関数movePropertyは、ソースからターゲットにプロパティを移動します。プロパティアクセスにはブラケット演算子、代入演算子、delete演算子を使用して自己修正を実行します。(本番コードでは、おそらくこのタスクにプロパティ記述子を使用するでしょう。)

function moveProperty(source, propertyName, target) {
    target[propertyName] = source[propertyName];
    delete source[propertyName];
}

moveProperty()の使用

> const obj1 = { prop: 'abc' };
> const obj2 = {};
> moveProperty(obj1, 'prop', obj2);

> obj1
{}
> obj2
{ prop: 'abc' }

ECMAScript 5はインターセッションをサポートしていません。プロキシはそのギャップを埋めるために作成されました。

28.3 プロキシの説明

ECMAScript 6プロキシは、JavaScriptにインターセッションをもたらします。それらは次のように機能します。オブジェクトobjに対して実行できる操作はたくさんあります。例えば

プロキシは、これらの操作の一部をカスタマイズできる特別なオブジェクトです。プロキシは2つのパラメーターで作成されます。

次の例では、ハンドラーは操作gethasをインターセプトします。

const target = {};
const handler = {
    /** Intercepts: getting properties */
    get(target, propKey, receiver) {
        console.log(`GET ${propKey}`);
        return 123;
    },

    /** Intercepts: checking whether properties exist */
    has(target, propKey) {
        console.log(`HAS ${propKey}`);
        return true;
    }
};
const proxy = new Proxy(target, handler);

プロパティfooを取得すると、ハンドラーがその操作をインターセプトします。

> proxy.foo
GET foo
123

同様に、in演算子はhasをトリガーします。

> 'hello' in proxy
HAS hello
true

ハンドラーはトラップset(プロパティの設定)を実装していません。したがって、proxy.barの設定はtargetに転送され、target.barが設定されます。

> proxy.bar = 'abc';
> target.bar
'abc'

28.3.1 関数固有のトラップ

ターゲットが関数の場合、さらに2つの操作をインターセプトできます。

これらのトラップを関数ターゲットに対してのみ有効にする理由は簡単です。そうしないと、操作applyconstructを転送できなくなるからです。

28.3.2 メソッド呼び出しのインターセプト

プロキシを介してメソッド呼び出しをインターセプトする場合、1つの課題があります。それは、操作get(プロパティ値の取得)をインターセプトでき、操作apply(関数の呼び出し)をインターセプトできますが、インターセプトできるメソッド呼び出しの単一の操作はありません。これは、メソッド呼び出しが2つの異なる操作として見なされるためです。最初に、関数を取得するためのgetがあり、次に、その関数を呼び出すためのapplyがあります。

したがって、getをインターセプトし、関数呼び出しをインターセプトする関数を返す必要があります。次のコードは、その方法を示しています。

function traceMethodCalls(obj) {
    const handler = {
        get(target, propKey, receiver) {
            const origMethod = target[propKey];
            return function (...args) {
                const result = origMethod.apply(this, args);
                console.log(propKey + JSON.stringify(args)
                    + ' -> ' + JSON.stringify(result));
                return result;
            };
        }
    };
    return new Proxy(obj, handler);
}

後者のタスクにはプロキシを使用していません。元のメソッドを関数でラップしているだけです。

traceMethodCalls()を試すために、次のオブジェクトを使用してみましょう。

const obj = {
    multiply(x, y) {
        return x * y;
    },
    squared(x) {
        return this.multiply(x, x);
    },
};

tracedObjは、objのトレースされたバージョンです。各メソッド呼び出し後の最初の行はconsole.log()の出力で、2行目はメソッド呼び出しの結果です。

> const tracedObj = traceMethodCalls(obj);
> tracedObj.multiply(2,7)
multiply[2,7] -> 14
14
> tracedObj.squared(9)
multiply[9,9] -> 81
squared[9] -> 81
81

素晴らしい点は、obj.squared()内で実行されるthis.multiply()呼び出しでさえトレースされることです。これは、thisがプロキシを参照し続けるためです。

これは最も効率的な解決策ではありません。たとえば、メソッドをキャッシュすることができます。さらに、プロキシ自体がパフォーマンスに影響を与えます。

28.3.3 取り消し可能なプロキシ

ECMAScript 6を使用すると、取り消し(オフ)できるプロキシを作成できます。

const {proxy, revoke} = Proxy.revocable(target, handler);

代入演算子(=)の左側では、Proxy.revocable()によって返されたオブジェクトのプロパティproxyrevokeにアクセスするために、分割代入を使用しています。

関数revokeを最初に呼び出した後、proxyに適用する操作はすべてTypeErrorを引き起こします。後続のrevoke呼び出しは、それ以上の効果はありません。

const target = {}; // Start with an empty object
const handler = {}; // Don’t intercept anything
const {proxy, revoke} = Proxy.revocable(target, handler);

proxy.foo = 123;
console.log(proxy.foo); // 123

revoke();

console.log(proxy.foo); // TypeError: Revoked

28.3.4 プロトタイプとしてのプロキシ

プロキシprotoは、オブジェクトobjのプロトタイプになることができます。objで開始される一部の操作は、protoで継続される場合があります。そのような操作の1つはgetです。

const proto = new Proxy({}, {
    get(target, propertyKey, receiver) {
        console.log('GET '+propertyKey);
        return target[propertyKey];
    }
});

const obj = Object.create(proto);
obj.bla;

// Output:
// GET bla

プロパティblaobjに見つからないため、検索はprotoで続行され、そこでトラップgetがトリガーされます。プロトタイプに影響を与える操作は他にもあります。それらはこの章の最後にリストされています。

28.3.5 インターセプトされた操作の転送

ハンドラーが実装していないトラップの操作は、自動的にターゲットに転送されます。操作を転送することに加えて実行したいタスクがある場合があります。たとえば、すべての操作をインターセプトしてログに記録するが、ターゲットに到達するのを妨げないハンドラーなどです。

const handler = {
    deleteProperty(target, propKey) {
        console.log('DELETE ' + propKey);
        return delete target[propKey];
    },
    has(target, propKey) {
        console.log('HAS ' + propKey);
        return propKey in target;
    },
    // Other traps: similar
}

各トラップについて、最初に操作の名前をログに記録し、次に手動で実行して転送します。ECMAScript 6には、転送に役立つモジュールのようなオブジェクトReflectがあります。各トラップに対して

handler.trap(target, arg_1, ···, arg_n)

Reflectにはメソッドがあります。

Reflect.trap(target, arg_1, ···, arg_n)

Reflectを使用すると、前の例は次のようになります。

const handler = {
    deleteProperty(target, propKey) {
        console.log('DELETE ' + propKey);
        return Reflect.deleteProperty(target, propKey);
    },
    has(target, propKey) {
        console.log('HAS ' + propKey);
        return Reflect.has(target, propKey);
    },
    // Other traps: similar
}

各トラップが行うことは非常に似ているため、プロキシを介してハンドラーを実装できます。

const handler = new Proxy({}, {
    get(target, trapName, receiver) {
        // Return the handler method named trapName
        return function (...args) {
            // Don’t log args[0]
            console.log(trapName.toUpperCase()+' '+args.slice(1));
            // Forward the operation
            return Reflect[trapName](...args);
        }
    }
});

各トラップについて、プロキシはget操作を介してハンドラーメソッドを要求し、それを提供します。つまり、すべてのハンドラーメソッドは、単一のメタメソッドgetを介して実装できます。このような仮想化を簡単に行うことは、プロキシAPIの目標の1つでした。

このプロキシベースのハンドラーを使用してみましょう。

> const target = {};
> const proxy = new Proxy(target, handler);
> proxy.foo = 123;
SET foo,123,[object Object]
> proxy.foo
GET foo,[object Object]
123

次の対話では、set操作がターゲットに正しく転送されたことを確認します。

> target.foo
123

28.3.6 落とし穴: すべてのオブジェクトをプロキシで透過的にラップできるわけではない

プロキシオブジェクトは、ターゲットオブジェクトに対して実行される操作をインターセプトするものと見なすことができます。プロキシはターゲットをラップします。プロキシのハンドラーオブジェクトは、プロキシのオブザーバーまたはリスナーのようなものです。対応するメソッド(プロパティを読み取る場合はgetなど)を実装することにより、インターセプトする操作を指定します。操作のハンドラーメソッドが見つからない場合、その操作はインターセプトされません。単にターゲットに転送されます。

したがって、ハンドラーが空のオブジェクトの場合、プロキシはターゲットを透過的にラップする必要があります。残念ながら、それは必ずしも機能するとは限りません。

28.3.6.1 オブジェクトをラップするとthisに影響する

さらに深く掘り下げる前に、ターゲットをラップするとthisにどのような影響があるかを簡単に確認しましょう。

const target = {
    foo() {
        return {
            thisIsTarget: this === target,
            thisIsProxy: this === proxy,
        };
    }
};
const handler = {};
const proxy = new Proxy(target, handler);

target.foo()を直接呼び出すと、thistargetを指します。

> target.foo()
{ thisIsTarget: true, thisIsProxy: false }

プロキシ経由でそのメソッドを呼び出すと、thisproxyを指します。

> proxy.foo()
{ thisIsTarget: false, thisIsProxy: true }

これは、たとえばターゲットがthisでメソッドを呼び出す場合に、プロキシがループ内に留まるようにするためです。

28.3.6.2 透過的にラップできないオブジェクト

通常、空のハンドラーを持つプロキシはターゲットを透過的にラップします。プロキシが存在することに気づかず、ターゲットの動作を変更することはありません。

ただし、ターゲットがプロキシによって制御されないメカニズムを介してthisと情報を関連付ける場合、問題が発生します。ターゲットがラップされているかどうかによって異なる情報が関連付けられるため、機能しなくなります。

たとえば、次のPersonクラスは、WeakMap _nameにプライベート情報を保存します(この手法の詳細については、クラスの章を参照してください)。

const _name = new WeakMap();
class Person {
    constructor(name) {
        _name.set(this, name);
    }
    get name() {
        return _name.get(this);
    }
}

Personのインスタンスは透過的にラップできません。

> const jane = new Person('Jane');
> jane.name
'Jane'

> const proxy = new Proxy(jane, {});
> proxy.name
undefined

jane.nameは、ラップされたproxy.nameとは異なります。次の実装ではこの問題は発生しません。

class Person2 {
    constructor(name) {
        this._name = name;
    }
    get name() {
        return this._name;
    }
}

const jane = new Person2('Jane');
console.log(jane.name); // Jane

const proxy = new Proxy(jane, {});
console.log(proxy.name); // Jane
28.3.6.3 組み込みコンストラクターのインスタンスのラッピング

ほとんどの組み込みコンストラクターのインスタンスにも、プロキシによってインターセプトされないメカニズムがあります。したがって、それらも透過的にラップすることはできません。Dateのインスタンスで問題を説明します。

const target = new Date();
const handler = {};
const proxy = new Proxy(target, handler);

proxy.getDate();
    // TypeError: this is not a Date object.

プロキシの影響を受けないメカニズムは、内部スロットと呼ばれます。これらのスロットは、インスタンスに関連付けられたプロパティのようなストレージです。仕様では、これらのスロットを角かっこで囲まれた名前を持つプロパティのように扱います。たとえば、次のメソッドは内部メソッドであり、すべてのオブジェクトOで呼び出すことができます。

O.[[GetPrototypeOf]]()

ただし、内部スロットへのアクセスは、通常の「get」および「set」操作では発生しません。getDate()がプロキシ経由で呼び出されると、thisに必要な内部スロットを見つけることができず、TypeErrorでエラーが発生します。

Dateメソッドについては、言語仕様では次のように規定されています

特に明記されていない限り、以下に定義されている Number プロトタイプオブジェクトのメソッドはジェネリックではなく、それらに渡されるthis値は、Number 値であるか、Number 値に初期化された[[NumberData]]内部スロットを持つオブジェクトである必要があります。

28.3.6.4 配列は透過的にラップできる

他の組み込みとは対照的に、配列は透過的にラップできます。

> const p = new Proxy(new Array(), {});
> p.push('a');
> p.length
1
> p.length = 0;
> p.length
0

配列がラップ可能な理由は、プロパティアクセスがlengthが機能するようにカスタマイズされているにもかかわらず、Array メソッドが内部スロットに依存していないためです。それらはジェネリックです。

28.3.6.5 回避策

回避策として、ハンドラーがメソッド呼び出しを転送する方法を変更し、thisをプロキシではなくターゲットに選択的に設定できます。

const handler = {
    get(target, propKey, receiver) {
        if (propKey === 'getDate') {
            return target.getDate.bind(target);
        }
        return Reflect.get(target, propKey, receiver);
    },
};
const proxy = new Proxy(new Date('2020-12-24'), handler);
proxy.getDate(); // 24

このアプローチの欠点は、メソッドがthisで実行する操作のいずれもプロキシを通過しないことです。

謝辞:このセクションで説明した落とし穴を指摘してくれた Allen Wirfs-Brock に感謝します。

28.4 プロキシのユースケース

このセクションでは、プロキシが何に使用できるかを説明します。これにより、API が実際に動作するのを見る機会が得られます。

28.4.1 プロパティアクセス(getset)の追跡

objのプロパティのうち、キーが配列propKeysにあるプロパティが設定または取得されるたびにログに記録する関数tracePropAccess(obj, propKeys)があると仮定しましょう。次のコードでは、その関数をPointクラスのインスタンスに適用します。

class Point {
    constructor(x, y) {
        this.x = x;
        this.y = y;
    }
    toString() {
        return `Point(${this.x}, ${this.y})`;
    }
}
// Trace accesses to properties `x` and `y`
const p = new Point(5, 7);
p = tracePropAccess(p, ['x', 'y']);

追跡対象のオブジェクトpのプロパティを取得および設定すると、次の影響があります。

> p.x
GET x
5
> p.x = 21
SET x=21
21

興味深いことに、Pointがプロパティにアクセスするときにも追跡が機能します。これは、thisPointのインスタンスではなく、追跡対象のオブジェクトを参照するようになったためです。

> p.toString()
GET x
GET y
'Point(21, 7)'

ECMAScript 5 では、tracePropAccess()を次のように実装します。各プロパティを、アクセスを追跡するゲッターとセッターに置き換えます。セッターとゲッターは、プロパティのデータを保存するために、追加のオブジェクトpropDataを使用します。元の実装を破壊的に変更していることに注意してください。これは、メタプログラミングを行っていることを意味します。

function tracePropAccess(obj, propKeys) {
    // Store the property data here
    const propData = Object.create(null);
    // Replace each property with a getter and a setter
    propKeys.forEach(function (propKey) {
        propData[propKey] = obj[propKey];
        Object.defineProperty(obj, propKey, {
            get: function () {
                console.log('GET '+propKey);
                return propData[propKey];
            },
            set: function (value) {
                console.log('SET '+propKey+'='+value);
                propData[propKey] = value;
            },
        });
    });
    return obj;
}

ECMAScript 6 では、よりシンプルなプロキシベースのソリューションを使用できます。プロパティの取得と設定をインターセプトし、実装を変更する必要はありません。

function tracePropAccess(obj, propKeys) {
    const propKeySet = new Set(propKeys);
    return new Proxy(obj, {
        get(target, propKey, receiver) {
            if (propKeySet.has(propKey)) {
                console.log('GET '+propKey);
            }
            return Reflect.get(target, propKey, receiver);
        },
        set(target, propKey, value, receiver) {
            if (propKeySet.has(propKey)) {
                console.log('SET '+propKey+'='+value);
            }
            return Reflect.set(target, propKey, value, receiver);
        },
    });
}

28.4.2 不明なプロパティに関する警告(getset

プロパティへのアクセスに関して、JavaScript は非常に寛容です。たとえば、プロパティを読み取ろうとして名前をスペルミスした場合、例外は発生せず、結果undefinedが得られます。このような場合に例外を取得するためにプロキシを使用できます。これは次のように機能します。プロキシをオブジェクトのプロトタイプにします。

プロパティがオブジェクトで見つからない場合、プロキシのgetトラップがトリガーされます。プロキシ後のプロトタイプチェーンにもプロパティが存在しない場合、それは本当に欠落しており、例外をスローします。それ以外の場合は、継承されたプロパティの値を返します。そのためには、get操作をターゲットに転送します(ターゲットのプロトタイプもプロキシのプロトタイプです)。

const PropertyChecker = new Proxy({}, {
    get(target, propKey, receiver) {
        if (!(propKey in target)) {
            throw new ReferenceError('Unknown property: '+propKey);
        }
        return Reflect.get(target, propKey, receiver);
    }
});

作成するオブジェクトにPropertyCheckerを使用してみましょう。

> const obj = { __proto__: PropertyChecker, foo: 123 };
> obj.foo  // own
123
> obj.fo
ReferenceError: Unknown property: fo
> obj.toString()  // inherited
'[object Object]'

PropertyCheckerをコンストラクターにすると、extendsを介して ECMAScript 6 クラスに使用できます。

function PropertyChecker() { }
PropertyChecker.prototype = new Proxy(···);

class Point extends PropertyChecker {
    constructor(x, y) {
        super();
        this.x = x;
        this.y = y;
    }
}

const p = new Point(5, 7);
console.log(p.x); // 5
console.log(p.z); // ReferenceError

誤ってプロパティを作成することを心配している場合は、2つのオプションがあります。setをトラップするオブジェクトをプロキシでラップするか、Object.preventExtensions(obj)を介してオブジェクトobjを拡張不可にすることができます。これは、JavaScript がobjに新しい(独自の)プロパティを追加できないことを意味します。

28.4.3 負の配列インデックス(get

一部の Array メソッドでは、-1で最後の要素、-2で最後から2番目の要素などを参照できます。たとえば

> ['a', 'b', 'c'].slice(-1)
[ 'c' ]

残念ながら、ブラケット演算子([])を介して要素にアクセスする場合は、それは機能しません。ただし、プロキシを使用してその機能を追加できます。次の関数createArray()は、負のインデックスをサポートする配列を作成します。これを行うには、配列インスタンスをプロキシでラップします。プロキシは、ブラケット演算子によってトリガーされるget操作をインターセプトします。

function createArray(...elements) {
    const handler = {
        get(target, propKey, receiver) {
            // Sloppy way of checking for negative indices
            const index = Number(propKey);
            if (index < 0) {
                propKey = String(target.length + index);
            }
            return Reflect.get(target, propKey, receiver);
        }
    };
    // Wrap a proxy around an Array
    const target = [];
    target.push(...elements);
    return new Proxy(target, handler);
}
const arr = createArray('a', 'b', 'c');
console.log(arr[-1]); // c

謝辞:この例のアイデアは、hemanth.hm のブログ記事から来ています。

28.4.4 データバインディング(set

データバインディングとは、オブジェクト間でデータを同期することです。一般的なユースケースの1つは、MVC(モデルビューコントローラー)パターンに基づくウィジェットです。データバインディングを使用すると、モデル(ウィジェットによって視覚化されるデータ)を変更した場合でも、ビュー(ウィジェット)は最新の状態を維持します。

データバインディングを実装するには、オブジェクトに加えられた変更を監視し、それに対応する必要があります。次のコードスニペットでは、配列に対する変更の監視がどのように機能するかを概説します。

function createObservedArray(callback) {
    const array = [];
    return new Proxy(array, {
        set(target, propertyKey, value, receiver) {
            callback(propertyKey, value);
            return Reflect.set(target, propertyKey, value, receiver);
        }
    });    
}
const observedArray = createObservedArray(
    (key, value) => console.log(`${key}=${value}`));
observedArray.push('a');

出力

0=a
length=1

28.4.5 RESTful Web サービスへのアクセス(メソッド呼び出し)

プロキシを使用して、任意のメソッドを呼び出すことができるオブジェクトを作成できます。次の例では、関数createWebServiceが、そのようなオブジェクトserviceを作成します。serviceでメソッドを呼び出すと、同じ名前の Web サービスリソースの内容が取得されます。取得は、ECMAScript 6 Promise を介して処理されます。

const service = createWebService('http://example.com/data');
// Read JSON data in http://example.com/data/employees
service.employees().then(json => {
    const employees = JSON.parse(json);
    ···
});

次のコードは、ECMAScript 5 でのcreateWebServiceの簡単で乱暴な実装です。プロキシがないため、serviceでどのメソッドが呼び出されるかを事前に知っておく必要があります。パラメーターpropKeysは、その情報を提供し、メソッド名を含む配列を保持します。

function createWebService(baseUrl, propKeys) {
    const service = {};
    propKeys.forEach(function (propKey) {
        service[propKey] = function () {
            return httpGet(baseUrl+'/'+propKey);
        };
    });
    return service;
}

createWebServiceの ECMAScript 6 実装では、プロキシを使用でき、よりシンプルです。

function createWebService(baseUrl) {
    return new Proxy({}, {
        get(target, propKey, receiver) {
            // Return the method to be called
            return () => httpGet(baseUrl+'/'+propKey);
        }
    });
}

どちらの実装も、次の関数を使用して HTTP GET リクエストを行います(その仕組みについては、Promise の章で説明しています)。

function httpGet(url) {
    return new Promise(
        (resolve, reject) => {
            const request = new XMLHttpRequest();
            Object.assign(request, {
                onload() {
                    if (this.status === 200) {
                        // Success
                        resolve(this.response);
                    } else {
                        // Something went wrong (404 etc.)
                        reject(new Error(this.statusText));
                    }
                },
                onerror() {
                    reject(new Error(
                        'XMLHttpRequest Error: '+this.statusText));
                }
            });
            request.open('GET', url);
            request.send();
        });
}

28.4.6 取り消し可能な参照

取り消し可能な参照は、次のように機能します。クライアントは、重要なリソース(オブジェクト)に直接アクセスすることは許可されておらず、参照(中間オブジェクト、リソースのラッパー)を介してのみアクセスできます。通常、参照に適用されたすべての操作はリソースに転送されます。クライアントが完了すると、リソースは参照を取り消すことによって保護されます。つまり、オフに切り替えます。今後、参照に操作を適用すると例外がスローされ、何も転送されなくなります。

次の例では、リソースの取り消し可能な参照を作成します。次に、参照を介してリソースのプロパティの1つを読み取ります。参照によりアクセスが許可されるため、機能します。次に、参照を取り消します。これで、参照はプロパティを読み取ることができなくなります。

const resource = { x: 11, y: 8 };
const {reference, revoke} = createRevocableReference(resource);

// Access granted
console.log(reference.x); // 11

revoke();

// Access denied
console.log(reference.x); // TypeError: Revoked

プロキシは、操作をインターセプトして転送できるため、取り消し可能な参照の実装に最適です。これは、createRevocableReferenceのシンプルなプロキシベースの実装です。

function createRevocableReference(target) {
    let enabled = true;
    return {
        reference: new Proxy(target, {
            get(target, propKey, receiver) {
                if (!enabled) {
                    throw new TypeError('Revoked');
                }
                return Reflect.get(target, propKey, receiver);
            },
            has(target, propKey) {
                if (!enabled) {
                    throw new TypeError('Revoked');
                }
                return Reflect.has(target, propKey);
            },
            ···
        }),
        revoke() {
            enabled = false;
        },
    };
}

前のセクションのプロキシをハンドラーとして使用する手法を使用して、コードを簡略化できます。今回は、ハンドラーは基本的にReflectオブジェクトです。したがって、getトラップは通常、適切なReflectメソッドを返します。参照が取り消された場合、代わりにTypeErrorがスローされます。

function createRevocableReference(target) {
    let enabled = true;
    const handler = new Proxy({}, {
        get(dummyTarget, trapName, receiver) {
            if (!enabled) {
                throw new TypeError('Revoked');
            }
            return Reflect[trapName];
        }
    });
    return {
        reference: new Proxy(target, handler),
        revoke() {
            enabled = false;
        },
    };
}

ただし、取り消し可能な参照を自分で実装する必要はありません。ECMAScript 6 では、取り消すことができるプロキシを作成できるからです。今回は、取り消しはハンドラーではなくプロキシで行われます。ハンドラーが行う必要があるのは、すべての操作をターゲットに転送することだけです。ハンドラーがトラップを実装しない場合、それは自動的に行われることがわかりました。

function createRevocableReference(target) {
    const handler = {}; // forward everything
    const { proxy, revoke } = Proxy.revocable(target, handler);
    return { reference: proxy, revoke };
}
28.4.6.1 メンブレン

メンブレンは、取り消し可能な参照のアイデアに基づいています。信頼されていないコードを実行するように設計された環境は、そのコードを分離し、システムの残りの部分を安全に保つために、そのコードをメンブレンでラップします。オブジェクトは、2つの方向にメンブレンを通過します。

どちらの場合も、取り消し可能な参照はオブジェクトの周りにラップされます。ラップされた関数またはメソッドによって返されるオブジェクトもラップされます。さらに、ラップされたウェットオブジェクトがメンブレンに渡されると、ラップが解除されます。

信頼されていないコードが完了すると、取り消し可能な参照はすべて取り消されます。その結果、外部のコードは実行できなくなり、外部のオブジェクトも機能しなくなります。 Caja Compilerは、「サードパーティの HTML、CSS、および JavaScript を Web サイトに安全に埋め込むためのツール」です。メンブレンを使用してこのタスクを実現します。

28.4.7 JavaScriptでのDOMの実装

ブラウザのDocument Object Model (DOM) は通常、JavaScriptとC++の混合で実装されています。純粋なJavaScriptで実装することは、以下の場合に役立ちます。

残念ながら、標準のDOMはJavaScriptで複製するのが容易ではないことを実行できます。例えば、ほとんどのDOMコレクションは、DOMの現在の状態に対するライブビューであり、DOMが変更されるたびに動的に変化します。その結果、DOMの純粋なJavaScript実装はあまり効率的ではありません。JavaScriptにプロキシを追加した理由の1つは、より効率的なDOM実装を記述するのに役立てるためでした。

28.4.8 その他のユースケース

プロキシには、さらに多くのユースケースがあります。例えば

28.5 プロキシAPIの設計

このセクションでは、プロキシの仕組みと、そのように動作する理由について詳しく説明します。

28.5.1 階層化:基本レベルとメタレベルを分離する

Firefoxでは、以前からインターセプティブメタプログラミングが可能でした。__noSuchMethod__という名前のメソッドを定義すると、存在しないメソッドが呼び出されたときに通知されます。以下は、__noSuchMethod__を使用する例です。

const obj = {
    __noSuchMethod__: function (name, args) {
        console.log(name+': '+args);
    }
};
// Neither of the following two methods exist,
// but we can make it look like they do
obj.foo(1);    // Output: foo: 1
obj.bar(1, 2); // Output: bar: 1,2

このように、__noSuchMethod__はプロキシトラップと同様に機能します。プロキシとは対照的に、トラップは、操作をインターセプトしたいオブジェクトの独自のメソッドまたは継承されたメソッドです。このアプローチの問題点は、基本レベル(通常のメソッド)とメタレベル(__noSuchMethod__)が混ざり合っていることです。基本レベルのコードが誤ってメタレベルのメソッドを呼び出したり、認識したりする可能性があり、誤ってメタレベルのメソッドを定義してしまう可能性があります。

標準のECMAScript 5でさえ、基本レベルとメタレベルが混ざり合っている場合があります。たとえば、以下のメタプログラミングメカニズムは、基本レベルに存在するため、失敗する可能性があります。

これで、(基本レベルの)プロパティキーを特殊化することが問題であることが明らかになったはずです。したがって、プロキシは階層化されています。基本レベル(プロキシオブジェクト)とメタレベル(ハンドラーオブジェクト)は別々です。

28.5.2 仮想オブジェクトとラッパー

プロキシは2つの役割で使用されます。

プロキシAPIの初期の設計では、プロキシは純粋な仮想オブジェクトとして考えられていました。しかし、その役割においても、ターゲットが不変性を強制するために(後で説明します)、また、ハンドラーが実装していないトラップのフォールバックとして役立つことがわかりました。

28.5.3 透過的な仮想化とハンドラーのカプセル化

プロキシは2つの方法で保護されています。

両方の原則により、プロキシは他のオブジェクトを偽装するためのかなりの権限を得ることができます。(後で説明するように)不変性を強制する理由の1つは、その権限を抑制することです。

プロキシを非プロキシと区別する方法が必要な場合は、自分で実装する必要があります。次のコードは、2つの関数をエクスポートするモジュールlib.jsです。1つはプロキシを作成し、もう1つはオブジェクトがそれらのプロキシの1つであるかどうかを判断します。

// lib.js
const proxies = new WeakSet();

export function createProxy(obj) {
    const handler = {};
    const proxy = new Proxy(obj, handler);
    proxies.add(proxy);
    return proxy;
}

export function isProxy(obj) {
    return proxies.has(obj);
}

このモジュールは、プロキシを追跡するためにECMAScript 6のデータ構造WeakSetを使用しています。WeakSetは、その要素がガベージコレクションされるのを妨げないため、この目的に最適です。

次の例は、lib.jsの使用方法を示しています。

// main.js
import { createProxy, isProxy } from './lib.js';

const p = createProxy({});
console.log(isProxy(p)); // true
console.log(isProxy({})); // false

28.5.4 メタオブジェクトプロトコルとプロキシトラップ

このセクションでは、JavaScriptが内部的にどのように構造化されているか、およびプロキシトラップのセットがどのように選択されたかを調べます。

プログラミング言語とAPI設計の文脈では、プロトコルはインターフェイスとそれらを使用するためのルールのセットです。ECMAScriptの仕様では、JavaScriptコードの実行方法を説明しています。これには、オブジェクトを処理するためのプロトコルが含まれています。このプロトコルはメタレベルで動作し、メタオブジェクトプロトコル(MOP)と呼ばれることがあります。JavaScript MOPは、すべてのオブジェクトが持つ独自の内部メソッドで構成されています。「内部」とは、仕様にのみ存在し(JavaScriptエンジンにある場合とない場合があります)、JavaScriptからアクセスできないことを意味します。内部メソッドの名前は、二重角かっこで囲んで記述されます。

プロパティを取得するための内部メソッドは、[[Get]]と呼ばれます。角かっこ付きのプロパティ名が有効であると仮定すると、このメソッドはJavaScriptでほぼ次のように実装されます。

// Method definition
[[Get]](propKey, receiver) {
    const desc = this.[[GetOwnProperty]](propKey);
    if (desc === undefined) {
        const parent = this.[[GetPrototypeOf]]();
        if (parent === null) return undefined;
        return parent.[[Get]](propKey, receiver); // (A)
    }
    if ('value' in desc) {
        return desc.value;
    }
    const getter = desc.get;
    if (getter === undefined) return undefined;
    return getter.[[Call]](receiver, []);
}

このコードで呼び出されるMOPメソッドは次のとおりです。

行Aでは、プロトタイプチェーン内のプロキシが、「より早い」オブジェクトにプロパティが見つからない場合にgetについて見つける理由を確認できます。キーがpropKeyの独自のプロパティがない場合、検索はthisのプロトタイプparentで続行されます。

基本操作と派生操作。[[Get]]が他のMOP操作を呼び出していることがわかります。それを行う操作は派生と呼ばれます。他の操作に依存しない操作は基本と呼ばれます。

28.5.4.1 プロキシのMOP

プロキシのメタオブジェクトプロトコルは、通常のオブジェクトのメタオブジェクトプロトコルとは異なります。通常のオブジェクトの場合、派生操作は他の操作を呼び出します。プロキシの場合、各操作(基本か派生かに関係なく)は、ハンドラーメソッドによってインターセプトされるか、ターゲットに転送されます。

どの操作がプロキシを介してインターセプト可能である必要がありますか?1つの可能性は、基本操作に対してのみトラップを提供することです。もう1つの可能性は、いくつかの派生操作を含めることです。そうすることの利点は、パフォーマンスが向上し、より便利になることです。例えば、getのトラップがない場合、getOwnPropertyDescriptorを介してその機能を実装する必要があります。派生トラップの1つの問題は、プロキシの動作が矛盾する可能性があることです。例えば、getは、getOwnPropertyDescriptorによって返される記述子の値とは異なる値を返す場合があります。

28.5.4.2 選択的仲介:どの操作をインターセプト可能にする必要がありますか?

プロキシによる仲介は選択的です。すべての言語操作をインターセプトできるわけではありません。なぜいくつかの操作が除外されたのでしょうか?2つの理由を見てみましょう。

第一に、安定した操作は仲介には適していません。操作は、同じ引数に対して常に同じ結果を生成する場合、安定しています。プロキシが安定した操作をトラップできる場合、不安定になり、信頼性が低下する可能性があります。厳密等価===)は、そのような安定した操作の1つです。トラップすることはできず、その結果は、プロキシ自体を別のオブジェクトとして扱うことによって計算されます。安定性を維持する別の方法は、プロキシではなくターゲットに操作を適用することです。後で説明するように、プロキシの不変性がどのように強制されるかを見ると、ターゲットが非拡張可能なプロキシにObject.getPrototypeOf()が適用されるときにこれが起こります。

より多くの操作をインターセプト可能にしない2番目の理由は、仲介は通常は不可能な状況でカスタムコードを実行することを意味するからです。コードのこのインターリーブが多くなればなるほど、プログラムを理解してデバッグするのが難しくなります。また、パフォーマンスにも悪影響を及ぼします。

28.5.4.3 トラップ:getinvoke

ECMAScript 6プロキシを介して仮想メソッドを作成する場合は、getトラップから関数を返す必要があります。これにより、次の疑問が生じます。メソッド呼び出し用の追加のトラップ(たとえば、invoke)を導入しないのはなぜですか?これにより、次のものを区別できるようになります。

そうしない理由は2つあります。

第一に、すべての実装がgetinvokeを区別しているわけではありません。たとえば、AppleのJavaScriptCoreは区別していません

第二に、メソッドを抽出してcall()またはapply()を介して後で呼び出すと、ディスパッチを介してメソッドを呼び出すのと同じ効果があるはずです。言い換えれば、次の2つのバリアントは同等に機能する必要があります。追加のトラップinvokeがあった場合、その同等性を維持するのがより困難になります。

// Variant 1: call via dynamic dispatch
const result = obj.m();

// Variant 2: extract and call directly
const m = obj.m;
const result = m.call(obj);
28.5.4.3.1 invokeのユースケース

getinvokeを区別できる場合にのみ実行できることがあります。したがって、現在のプロキシAPIではそれらの操作は不可能です。2つの例は、自動バインディングと欠落したメソッドのインターセプトです。プロキシがinvokeをサポートしている場合に、それらをどのように実装するかを見てみましょう。

自動バインディング。プロキシをオブジェクトobjのプロトタイプにすることで、メソッドを自動的にバインドできます。

自動バインディングは、メソッドをコールバックとして使用するのに役立ちます。例えば、前の例のバリアント2はよりシンプルになります。

const boundMethod = obj.m;
const result = boundMethod();

存在しないメソッドのインターセプト。 invoke を使うと、プロキシは以前に述べた Firefox がサポートする __noSuchMethod__ メカニズムをエミュレートできます。プロキシは再びオブジェクト obj のプロトタイプになります。未知のプロパティ foo がどのようにアクセスされるかに応じて、異なる反応をします。

28.5.5 プロキシの不変性の強制

不変性とは何か、そしてプロキシでどのように強制されるかを見る前に、オブジェクトが拡張不可および設定不可によってどのように保護できるかを確認しましょう。

28.5.5.1 オブジェクトの保護

オブジェクトを保護する方法は 2 つあります。

拡張不可性。オブジェクトが拡張不可の場合、プロパティを追加できず、プロトタイプを変更することもできません。

'use strict'; // switch on strict mode to get TypeErrors

const obj = Object.preventExtensions({});
console.log(Object.isExtensible(obj)); // false
obj.foo = 123; // TypeError: object is not extensible
Object.setPrototypeOf(obj, null); // TypeError: object is not extensible

設定不可性。プロパティのすべてのデータは属性に格納されます。プロパティはレコードのようなもので、属性はそのレコードのフィールドのようなものです。属性の例:

したがって、プロパティが非書き込み可能かつ非設定可能の両方である場合、それは読み取り専用であり、その状態を維持します。

'use strict'; // switch on strict mode to get TypeErrors

const obj = {};
Object.defineProperty(obj, 'foo', {
    value: 123,
    writable: false,
    configurable: false
});
console.log(obj.foo); // 123
obj.foo = 'a'; // TypeError: Cannot assign to read only property

Object.defineProperty(obj, 'foo', {
    configurable: true
}); // TypeError: Cannot redefine property

これらのトピック(Object.defineProperty() がどのように機能するかを含む)の詳細については、「Speaking JavaScript」の以下のセクションを参照してください。

28.5.5.2 不変性の強制

従来、拡張不可性と設定不可性は、

これらの、言語操作に直面しても変更されないその他の特性は、不変性と呼ばれます。プロキシを使用すると、不変性は本質的に拡張不可性などによって制約されないため、不変性を破るのが簡単です。

プロキシ API は、ハンドラーメソッドのパラメーターと結果をチェックすることにより、プロキシが不変性を破るのを防ぎます。以下は、4 つの不変性(任意のオブジェクト obj の場合)の例と、それらがプロキシでどのように強制されるかを示しています(網羅的なリストはこの章の最後に示されています)。

最初の 2 つの不変性は、拡張不可性と設定不可性に関係します。これらは、ターゲットオブジェクトを簿記に使用することにより強制されます。ハンドラーメソッドによって返される結果は、ほとんどターゲットオブジェクトと同期している必要があります。

残りの 2 つの不変性は、戻り値をチェックすることにより強制されます。

不変性を強制することには、次の利点があります。

次の 2 つのセクションでは、不変性が強制される例を示します。

28.5.5.3 例:拡張不可のターゲットのプロトタイプは忠実に表現する必要がある

getPrototypeOf トラップに応答して、ターゲットが拡張不可である場合、プロキシはターゲットのプロトタイプを返す必要があります。

この不変性を示すために、ターゲットのプロトタイプとは異なるプロトタイプを返すハンドラーを作成しましょう。

const fakeProto = {};
const handler = {
    getPrototypeOf(t) {
        return fakeProto;
    }
};

ターゲットが拡張可能であれば、プロトタイプの偽装は機能します。

const extensibleTarget = {};
const ext = new Proxy(extensibleTarget, handler);
console.log(Object.getPrototypeOf(ext) === fakeProto); // true

ただし、拡張不可のオブジェクトに対してプロトタイプを偽装すると、エラーが発生します。

const nonExtensibleTarget = {};
Object.preventExtensions(nonExtensibleTarget);
const nonExt = new Proxy(nonExtensibleTarget, handler);
Object.getPrototypeOf(nonExt); // TypeError
28.5.5.4 例:書き込み不可で設定不可のターゲットプロパティは忠実に表現する必要がある

ターゲットに書き込み不可で設定不可のプロパティがある場合、ハンドラーは get トラップに応答してそのプロパティの値を返す必要があります。この不変性を示すために、常にプロパティに同じ値を返すハンドラーを作成しましょう。

const handler = {
    get(target, propKey) {
        return 'abc';
    }
};
const target = Object.defineProperties(
    {}, {
        foo: {
            value: 123,
            writable: true,
            configurable: true
        },
        bar: {
            value: 456,
            writable: false,
            configurable: false
        },
    });
const proxy = new Proxy(target, handler);

プロパティ target.foo は書き込み不可でも設定不可でもないため、ハンドラーは異なる値を持っていると見せかけることができます。

> proxy.foo
'abc'

ただし、プロパティ target.bar は書き込み不可で設定不可の両方です。したがって、その値を偽装することはできません。

> proxy.bar
TypeError: Invariant check failed

28.6 FAQ:プロキシ

28.6.1 enumerate トラップはどこにありますか?

ES6 にはもともと for-in ループによってトリガーされるトラップ enumerate がありました。しかし、プロキシを簡素化するために最近削除されました。Reflect.enumerate() も削除されました。(出典:TC39 ノート

28.7 リファレンス:プロキシ API

このセクションは、プロキシ API のクイックリファレンスとして機能します。グローバルオブジェクト ProxyReflect です。

28.7.1 プロキシの作成

プロキシを作成する方法は 2 つあります。

28.7.2 ハンドラーメソッド

このサブセクションでは、ハンドラーで実装できるトラップと、それらをトリガーする操作について説明します。いくつかのトラップはブール値を返します。トラップ has および isExtensible の場合、ブール値は操作の結果です。他のすべてのトラップの場合、ブール値は操作が成功したかどうかを示します。

すべてのオブジェクトのトラップ

関数(ターゲットが関数の場合に利用可能)のトラップ

28.7.2.1 基本的な操作と派生的な操作

次の操作は基本的であり、他の操作を使用してその作業を行うことはありません:applydefinePropertydeletePropertygetOwnPropertyDescriptorgetPrototypeOfisExtensibleownKeyspreventExtensionssetPrototypeOf

他のすべての操作は派生的であり、基本的な操作を介して実装できます。たとえば、データプロパティの場合、get は、getPrototypeOf を介してプロトタイプチェーンを反復処理し、独自のプロパティが見つかるか、チェーンが終了するまで各チェーンメンバーに対して getOwnPropertyDescriptor を呼び出すことによって実装できます。

28.7.3 ハンドラーメソッドの不変性

不変性は、ハンドラーの安全性の制約です。このサブセクションでは、プロキシ API によって強制される不変性と、その方法について説明します。「ハンドラーは X を行う必要がある」と書かれている場合は、そうしないと TypeError がスローされることを意味します。一部の不変性は戻り値を制限し、他の不変性はパラメーターを制限します。トラップの戻り値の正確さは、2 つの方法で保証されます。通常、不正な値は TypeError がスローされることを意味します。ただし、ブール値が期待される場合は常に、強制型変換を使用して非ブール値を有効な値に変換します。

これは、強制される不変条件の完全なリストです。

28.7.4 プロトタイプチェーンに影響を与える操作

通常のオブジェクトの次の操作は、プロトタイプチェーン内のオブジェクトに対して操作を実行します。したがって、そのチェーン内のオブジェクトの1つがプロキシである場合、そのトラップがトリガーされます。仕様では、操作は(JavaScriptコードからは見えない)内部自身のメソッドとして実装されています。ただし、このセクションでは、トラップと同じ名前を持つ通常のメソッドであると仮定します。パラメータtargetは、メソッド呼び出しのレシーバーになります。

他のすべての操作は自身のプロパティにのみ影響し、プロトタイプチェーンには影響しません。

28.7.5 Reflect

グローバルオブジェクトReflectは、JavaScriptメタオブジェクトプロトコルのすべてのインターセプト可能な操作をメソッドとして実装します。これらのメソッドの名前は、ハンドラーメソッドの名前と同じであり、既に見たように、ハンドラーからターゲットへの操作の転送に役立ちます。

いくつかのメソッドはブール値の結果を持ちます。hasisExtensibleの場合、これらは操作の結果です。残りのメソッドの場合、これらは操作が成功したかどうかを示します。

28.7.5.1 転送以外のReflectのユースケース

操作の転送以外に、Reflectが便利な理由は何ですか [4]

28.7.5.2 Object.*Reflect.*

今後、Objectは通常のアプリケーションに関心のある操作をホストし、Reflectはより低レベルの操作をホストします。

28.8 結論

これで、プロキシAPIの詳細な解説は終わりです。各アプリケーションでは、パフォーマンスを考慮し、必要であれば測定する必要があります。プロキシは常に十分な速度が出るとは限りません。一方、パフォーマンスがそれほど重要でない場合は、プロキシがもたらすメタプログラミングの力を活用できるのは素晴らしいことです。これまで見てきたように、プロキシは多くのユースケースで役立ちます。

28.9 さらに詳しく

[1] Tom Van CutsemとMark Millerによる「On the design of the ECMAScript Reflection API」。テクニカルレポート、2012年。[この章の重要な情報源です。]

[2] Gregor Kiczales、Jim des Rivieres、Daniel G. Bobrowによる「The Art of the Metaobject Protocol」。書籍、1991年。

[3] Ira R. FormanとScott H. Danforthによる「Putting Metaclasses to Work: A New Dimension in Object-Oriented Programming」。書籍、1999年。

[4] Tom Van Cutsemによる「Harmony-reflect: Why should I use this library?」。[Reflectがなぜ有用なのかを説明しています。]

次へ: 29. ECMAScript 6のコーディングスタイルのヒント