ES2015 プロキシの概要

Addy Osmani 氏
Addy Osmani 氏

ES2015 プロキシChrome 49 以降)は、JavaScript に介入 API を提供し、ターゲット オブジェクトに対するすべてのオペレーションをトラップまたはインターセプトし、このターゲットの動作を変更できるようにします。

プロキシには、次のようにさまざまな用途があります。

  • インターセプト
  • オブジェクト仮想化
  • リソース管理
  • デバッグ用のプロファイリングまたはロギング
  • セキュリティとアクセス制御
  • オブジェクト使用に関する契約

Proxy API には、指定されたターゲット オブジェクトとハンドラ オブジェクトを受け取るプロキシ コンストラクタが含まれます。

var target = { /* some properties */ };
var handler = { /* trap functions */ };
var proxy = new Proxy(target, handler);

プロキシの動作はハンドラによって制御されます。ハンドラは、ターゲット オブジェクトの元の動作をいくつかの方法で変更できます。ハンドラには、対応するオペレーションがプロキシで実行されるときに呼び出されるオプションのトラップ メソッド(.get().set().apply() など)が含まれています。

インターセプト

まず、プレーンなオブジェクトを取得し、Proxy API を使用してインターセプト ミドルウェアを追加します。コンストラクタに渡される最初のパラメータはターゲット(プロキシされるオブジェクト)であり、2 番目はハンドラ(プロキシ自体)です。ここで、ゲッターやセッターなどの動作のフックを追加できます。

var target = {};

var superhero = new Proxy(target, {
    get: function(target, name, receiver) {
        console.log('get was called for:', name);
        return target[name];
    }
});

superhero.power = 'Flight';
console.log(superhero.power);

上記のコードを Chrome 49 で実行すると、次のようになります。

get was called for: power  
"Flight"

実際に確認できるように、プロキシ オブジェクトでプロパティ get またはプロパティ セットを実行すると、ハンドラで対応するトラップがメタレベルで呼び出されます。ハンドラのオペレーションには、プロパティの読み取り、プロパティの割り当て、関数適用が含まれ、これらはすべて対応するトラップに転送されます。

トラップ関数は、必要に応じて任意のオペレーションを実装できます(たとえば、オペレーションをターゲット オブジェクトに転送する)。トラップが指定されていない場合は、デフォルトで同じ処理が行われます。たとえば、この処理を行うための NoOps 転送プロキシは次のようになります。

var target = {};

var proxy = new Proxy(target, {});
    // operation forwarded to the target
proxy.paul = 'irish';
// 'irish'. The operation has been  forwarded
console.log(target.paul);

プレーン オブジェクトのプロキシについて説明しましたが、関数をターゲットとした関数オブジェクトを同じように簡単にプロキシできます。今回は handler.apply() トラップを使用します。

// Proxying a function object
function sum(a, b) {
    return a + b;
}

var handler = {
    apply: function(target, thisArg, argumentsList) {
        console.log(`Calculate sum: ${argumentsList}`);
        return target.apply(thisArg, argumentsList);
    }
};

var proxy = new Proxy(sum, handler);
proxy(1, 2);
// Calculate sum: 1, 2
// 3

プロキシの識別

プロキシの ID は、JavaScript の等価演算子(=====)を使用して確認できます。ご存じのとおり、2 つのオブジェクトに適用すると、これらの演算子はオブジェクト ID を比較します。次の例は、この動作を示しています。2 つの異なるプロキシを比較すると、基になるターゲットが同じであるにもかかわらず false が返されます。同様に、ターゲット オブジェクトは、そのどのプロキシとも異なります。

// Continuing previous example

var proxy2 = new Proxy (sum, handler);
(proxy==proxy2); // false
(proxy==sum); // false

プロキシを配置してもアプリの結果にそれほど影響しないように、プロキシと非プロキシ オブジェクトを区別できないようにするのが理想的です。これが、プロキシ API にオブジェクトがプロキシかどうかをチェックする手段が含まれず、オブジェクトに対するすべての操作に対してトラップも提供されない理由の一つです。

ユースケース

前述のように、プロキシにはさまざまなユースケースがあります。アクセス制御やプロファイリングなど、上記の多くは「汎用ラッパー」に分類されます。これは、他のオブジェクトを同じアドレス「スペース」でラップするプロキシです。仮想化についても取り上げました。仮想オブジェクトは、他のオブジェクトをエミュレートするプロキシです。それらのオブジェクトが同じアドレス空間に存在する必要はありません。たとえば、リモート オブジェクト(他の空間のオブジェクトをエミュレートする)や透過的な Future(まだ計算されていない結果をエミュレートする)などが該当します。

ハンドラとしてのプロキシ

プロキシ ハンドラの一般的な使用例は、ラップされたオブジェクトに対してオペレーションを実行する前に、検証やアクセス制御のチェックを実行することです。チェックに成功した場合にのみ、オペレーションが転送されます。次の検証の例は、その方法を示しています。

var validator = {
    set: function(obj, prop, value) {
    if (prop === 'yearOfBirth') {
        if (!Number.isInteger(value)) {
        throw new TypeError('The yearOfBirth is not an integer');
        }

        if (value > 3000) {
        throw new RangeError('The yearOfBirth seems invalid');
        }
    }

    // The default behavior to store the value
    obj[prop] = value;
    }
};

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

person.yearOfBirth = 1986;
console.log(person.yearOfBirth); // 1986
person.yearOfBirth = 'eighties'; // Throws an exception
person.yearOfBirth = 3030; // Throws an exception

このパターンのより複雑な例では、プロキシ ハンドラがインターセプトできるさまざまなオペレーションをすべて考慮している場合があります。ある実装では、アクセス チェックのパターンを複製して各トラップでオペレーションを転送しなければならないことを想像できます。

各オペレーションを異なる方法で転送する必要があるため、簡単に抽象化するには困難です。完璧なシナリオでは、すべてのオペレーションを 1 つのトラップで均一に処理できる場合、ハンドラでは 1 つのトラップで検証チェックを 1 回だけ実行すれば済みます。これは、プロキシ ハンドラ自体をプロキシとして実装することで実現できます。残念ながら、これはこの記事の範囲外です。

オブジェクト拡張

プロキシのもう 1 つの一般的なユースケースは、オブジェクトに対する操作のセマンティクスの拡張または再定義です。たとえば、ハンドラでオペレーションをログに記録する、オブザーバーに通知する、未定義を返す代わりに例外をスローする、ストレージのためにオペレーションを別のターゲットにリダイレクトする、などが可能です。このような場合、プロキシを使用すると、ターゲット オブジェクトを使用した場合とは大きく異なる結果になる可能性があります。

function extend(sup,base) {

    var descriptor = Object.getOwnPropertyDescriptor(base.prototype,"constructor");

    base.prototype = Object.create(sup.prototype);

    var handler = {
    construct: function(target, args) {
        var obj = Object.create(base.prototype);
        this.apply(target,obj, args);
        return obj;
    },

    apply: function(target, that, args) {
        sup.apply(that,args);
        base.apply(that,args);
    }
    };

    var proxy = new Proxy(base, handler);
    descriptor.value = proxy;
    Object.defineProperty(base.prototype, "constructor", descriptor);
    return proxy;
}

var Vehicle = function(name){
    this.name = name;
};

var Car = extend(Vehicle, function(name, year) {
    this.year = year;
});

Car.prototype.style = "Saloon";

var Tesla = new Car("Model S", 2016);

console.log(Tesla.style); // "Saloon"
console.log(Tesla.name); // "Model S"
console.log(Tesla.year);  // 2016

アクセス制御

アクセス制御も、プロキシの優れたユースケースの一つです。ターゲット オブジェクトを信頼できないコードに渡すのではなく、保護用のメンブレンでラップしたプロキシを渡すことが可能です。アプリは、信頼できないコードが特定のタスクが完了したと判断すると、その参照を取り消せます。この参照によってプロキシがターゲットから切り離されます。メンブレンは、定義された元のターゲットから到達可能なすべてのオブジェクトにこのデタッチを再帰的に拡張します。

プロキシでのリフレクションの使用

Reflect は、インターセプト可能な JavaScript 操作のためのメソッドを提供する新しい組み込みオブジェクトであり、プロキシを操作するうえで非常に有用です。実際、Reflect のメソッドはプロキシ ハンドラのメソッドと同じです。

Python や C# のような静的型言語は以前からリフレクション API を提供してきましたが、JavaScript ではそのような API が動的言語である必要はあまりありませんでした。ES5 にはすでに、他の言語ではリフレクションとみなされる Array.isArray()Object.getOwnPropertyDescriptor() など、多くのリフレクション機能が備わっていると考えられます。ES2015 では、このカテゴリの将来のメソッドを格納する Reflection API が導入されるため、推論が容易になります。これは、Object がリフレクション メソッドのバケットではなくベース プロトタイプとなることを意図しているため、理にかなっています。

Reflect を使用して、次のように、先ほどのスーパーヒーローの例で get と set のトラップで適切なフィールド インターセプトを行うように改善できます。

// Field interception with Proxy and the Reflect API

var pioneer = new Proxy({}, {
    get: function(target, name, receiver) {
        console.log(`get called for field: ${name}`);
        return Reflect.get(target, name, receiver);
    },

    set: function(target, name, value, receiver) {
        console.log(`set called for field: ${name} and value: ${value}`);
        return Reflect.set(target, name, value, receiver);
    }
});

pioneer.firstName = 'Grace';
pioneer.secondName = 'Hopper';
// Grace
pioneer.firstName

出力:

set called for field: firstName and value: Grace
set called for field: secondName and value: Hopper
get called for field: firstName

もう一つの例は、次のような場合です。

  • プロキシ定義をカスタム コンストラクタ内でラップすることで、特定のロジックを使用するたびに手動で新しいプロキシを作成する必要がなくなります。

  • 変更を「保存」する機能を追加しますが、これはデータが実際に変更された場合にのみ(仮説上、保存操作が非常に高コストになることによる)ものです。

function Customer() {

    var proxy = new Proxy({
    save: function(){
        if (!this.dirty){
        return console.log('Not saving, object still clean');
        }
        console.log('Trying an expensive saving operation: ', this.changedProperties);
    },

    }, {

    set: function(target, name, value, receiver) {
        target.dirty = true;
        target.changedProperties = target.changedProperties || [];

        if(target.changedProperties.indexOf(name) == -1){
        target.changedProperties.push(name);
        }
        return Reflect.set(target, name, value, receiver);
    }

    });

    return proxy;
}


var customer = new Customer();

customer.name = 'seth';
customer.surname = 'thompson';
// Trying an expensive saving operation:  ["name", "surname"]
customer.save();

Reflect API のその他の例については、Tagtree の ES6 プロキシをご覧ください。

Polyfilling Object.observe()

Object.observe() に別れを告げましたが、ES2015 プロキシを使用してポリフィルできるようになりました。Simon Blackwell は最近、プロキシベースの Object.observe() shim を記述しました。Erik Arvidsson は、2012 年にかなり仕様の完全なバージョンも作成しました。

ブラウザ サポート

ES2015 プロキシは、Chrome 49、Opera、Microsoft Edge、Firefox でサポートされています。Safari には、この機能に対するさまざまなパブリック シグナルが含まれていますが、私たちは楽観的です。Reflect は Chrome、Opera、Firefox に搭載されており、Microsoft Edge 向けに開発中です。

Google は、プロキシ用の制限付きポリフィルをリリースしました。プロキシの作成時に認識されているプロパティのみをプロキシできるため、汎用ラッパーにのみ使用できます。

参考資料