[Перевод] ES6 в деталях: прокси

Ряд публикаций Джейсона Орендорфа ES6 In Depth посвящен ES6, который добавили в язык программирования JavaScript в 6 итерации ECMAScript.

Сегодня будем делать такие штуки:

var obj = new Proxy({}, {
 get: function (target, key, receiver) {
   console.log(`getting ${key}!`);
   return Reflect.get(target, key, receiver);
 },
 set: function (target, key, value, receiver) {
   console.log(`setting ${key}!`);
   return Reflect.set(target, key, value, receiver);
 }
});


Немного сложно для первого примера. Подробнее объясню позже, пока что посмотрим, что за объект мы создали.

> obj.count = 1;
   setting count!
> ++obj.count;
   getting count!
   setting count!
   2

Что здесь происходит?

Мы перехватываем доступ к свойствам этого объекта.
Мы перегружаем оператор “.”

Как это происходит?

Самая крутая штука в вычислительной технике называется виртуализацией. Эта техника довольно общего назначения используется для того, чтобы делать удивительные вещи. Вот как это работает.

1. Берем любое изображение.

81bec11eda3a45faab2a59e5d8b3cb83.jpg
Фото: Martin Nikolaj Bech

2. Обводим что-нибудь на этом изображении линией.

ff942cfc96fd464695feff5a49b1c784.png

3. Теперь заменяем всё, что находится внутри этой области или снаружи, чем-нибудь совершенно неожиданным. Есть только одно правило: правило обратной совместимости. Подмена должна быть такой, чтобы никто не заметил изменений.

20c27dd647234399bebabbeb87342319.png
Фото: Beverley Goodwin

Вы, вероятно, уже знакомы с подобными трюками по классическим фильмам о компьютерных науках вроде «Шоу Трумана» или «Матрицы», в которых персонаж находится внутри некоторой области, а окружающий мир заменен сложной иллюзией нормальности.

Возможно, чтобы соответствовать требованию правила обратной совместимости, ваша подмена должна быть тщательно продумана. Но основной трюк заключается в том, чтобы начертить правильную линию.

Под линией подразумевается граница API – интерфейс. Интерфейсы описывают взаимодействие двух частей кода. Так что, если в системе уже есть описанный интерфейс, можно считать, что линия проведена. Вы знаете, что можно заменить любую часть, при этом не повлияв на другую.

Когда не существует интерфейса, его необходимо придумать. Самые крутые хаки в программировании были связаны с тем, чтобы провести границу API там, где ее нет, и встроить этот интерфейс в существующий, прилагая огромные усилия.

Виртуализация, виртуальная память, Docker, Vagrind, rr – все эти проекты в разной степени занимаются запуском и управлением новых и довольно часто неожиданных интерфейсов в существующей системе. В некоторых случаях понадобились годы, новые возможности операционных систем и даже «железо», чтобы всё это могло работать как следует.

Лучшие трюки с виртуализацией привносят новое понимание того, что именно должно быть виртуализировано. Чтобы написать к чему-либо API, должно быть понимание. Как только вы поймете, вы сможете делать удивительные вещи.

ES6 предоставляет поддержку виртуализации для своей основополагающей концепции – объекта.

Что есть объект?

22ad757a0e034e919390ba2cc24d78d3.jpg
Фото:Joe deSousa

Нет, действительно. Подождите минутку. Поразмыслите как следует. Листайте дальше, как только будете знать ответ.
Для меня это очень сложный вопрос, и я никогда не слышал ответа, который мог бы удовлетворить меня в достаточной степени.

Удивительно? Определение фундаментальных вещей – это всегда сложно. Взгляните хотя бы на несколько первых определений в Euclid’s Elements. Спецификация языка ECMAScript попала в хорошую компанию, так что может позволить себе определить объект как «член типа Object».

Далее в этой спецификации сказано, что «объект является коллекцией свойств». Не так уж плохо, если вам необходимо определение прямо сейчас. Позже мы вернемся к этому вопросу.

Как я уже говорил, чтобы написать к чему-либо API, должно быть понимание. Я обещаю: когда мы разберемся со всем этим, то будем лучше понимать объекты и сможем делать с ними невероятные вещи.

Давайте проследуем по стопам комитета по стандартам ECMAScript и посмотрим, чего нам будет стоить определить API – интерфейс для объектов JavaScript. Методы какого вида нам необходимы? Что могут делать объекты?

Это в некотором роде зависит от объекта. Объекты DOM-элементов могут делать одно, объекты AudioNode – другое. Но некоторые фундаментальные способности являются общими.

• У объектов есть свойства. Значения свойств можно получать, устанавливать, удалять и т. д.
• У объектов есть прототипы. Это то, как работает наследование в JavaScript.
• Некоторые объекты являются функциями или конструкторами и их можно вызывать.

Почти всё, что делают программы на JavaScript с объектами, происходит при помощи использования свойств, прототипов и функций. Даже особенное поведение объекта Element или AudioNode достигается простым вызовом методов, которые являются всего лишь унаследованными свойствами функции.

Так что, когда комитет по стандартам ECMAScript определил набор из 14 внутренних методов, которые представляют из себя общий для всех объектов интерфейс, никого не удивило, что они в итоге решили сфокусироваться на этих трех основополагающих вещах.

Полный список можно найти в таблицах 5 и 6 стандарта ES6. Здесь же я объясню некоторые из них. Странные двойные квадратные скобки [[ ]] дают понять, что речь идет о внутреннем методе, скрытом от обычного JavaScript-кода. Нельзя вызвать, удалить или перезаписать их так же, как обычный метод.

obj.[[Get]](key, receiver) – получить значение свойства.

Вызывается, когда JS-код выполняет: obj.prop или obj[key].

Obj – это объект, в котором производится поиск; receiver – это объект, с которого был начат поиск значения этого свойства. Иногда приходится искать в нескольких объектах. Obj может быть объектом или цепочкой прототипов recevier’а.

obj.[[Set]](key, value, receiver) – установить свойство объекта.

Вызывается, когда JS-код выполняет: obj.prop = value или obj[key] = value.
В присвоении вида obj.prop += 2, метод [[Get]] выполняется первым, за ним следует вызов [[Set]. То же для ++ и --.

obj.[[HasProperty]](key) – проверка на существование свойства.

Вызывается, когда JS-код выполняет: key in obj.

obj.[[Enumerate]]() – список перечислимых свойств obj.

Вызывается, когда JS-код выполняет: for (key in obj)…
Возвращает объект итератора. Так цикл for-in получает имена свойств объектов.

obj.[[GetPrototypeOf]]() – возвращает прототип obj.

Вызывается, когда JS-код выполняет: obj.__proto__ или Object.getPrototypeOf(obj).

functionObj.[[Call]](thisValue, arguments) – вызов функции.

Вызывается, когда JS-код выполняет: functionObj() или x.method().
Опционален. Не каждый объект является функцией.

constructorObj.[[Construct]](arguments, newTarget) – вызов конструктора.

Вызывается, когда JS-код выполняет: new Date(2890, 6, 2), например.
Опционален. Не каждый объект является конструктором.

Аргумент участвует в процессе создания подклассов. Обсудим этот вопрос в следующих статьях.
Возможно, вы можете догадаться, что за остальные 7.
Во всем стандарте ES6 каждая строчка синтаксиса или встроенная функция, делающая что-либо с объектами, описана в терминах 14 внутренних методов. ES6 провел четкую границу вокруг «мозгов» объекта. Что позволяют сделать прокси, так это заменить стандартные «мозги» произвольным кодом.

Вспомните, что когда мы говорим о переопределении этих внутренних методов, то речь идет о переопределении внутреннего синтаксиса obj.prop, встроенных функций вроде Object.keys() и тому подобного.

Proxy

ES6 определяет новый глобальный конструктор – Proxy, который принимает два аргумента: объект target и объект handler. Простой пример выглядит как-то так:

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

Опустим пока объект handler и сосредоточимся на том, как соотносятся объекты proxy и target.
Я могу объяснить поведение proxy одним предложением. Все внутренние методы proxy отправляются в target. То есть, если что-то вызывает proxy.[[Enumerate]](), вызов вернет target.[[Enumerate]]().

Давайте попробуем. Сделаем что-нибудь, что приведет к вызову proxy.[[Set]]()

proxy.color = "pink";

Окей, что произошло? proxy.[[Set]]() должен был вызвать target.[[Set]](), и этот вызов должен был создать новое свойство в target. Так ли?

> target.color
   "pink"

То же происходит и для всех внутренних методов. Этот proxy будет вести себя почти так же, как и его target.
Для правильности созданной иллюзии существуют все-таки ограничения. Можно обнаружить, что proxy !== target. И proxy иногда не будут проходить некоторые проверки типа, которые будет проходить target. Даже если target данного proxy, например, DOM-элемент, proxy не является экземпляром Element, так что некоторые вещи вроде document.body.appendChild(proxy) будут вызывать ошибку TypeError.

Обработчики Proxy

Вернемся к объекту handler. Это то, что делает proxy полезными. Методы объекта handler могут переопределять любые внутренние методы объекта proxy.
Например, если вы хотите перехватить все попытки установить свойства объекта, это можно реализовать, определив метод handler.set().

var target = {};
var handler = {
 set: function (target, key, value, receiver) {
   throw new Error("Please don't set properties on this object.");
 }
};
var proxy = new Proxy(target, handler);

> proxy.name = "angelina";
   Error: Please don't set properties on this object.

Полный список методов handler’a можно найти на страничке Proxy в MDN. Там 14 методов, которые повторяют 14 внутренних методов, определенных в стандарте ES6.
Все методы handler’a опциональны. Если внутренний метод не перехватывается handler’ом, он отправляется в target, как можно было видеть выше.

Пример: «невозможные» автозаполняемые объекты

Теперь мы знаем достаточно о proxy, чтобы попытаться использовать их для чего-то действительно странного, чего-то, что было бы невозможным без его использования.

Упражнение первое. Опишем функцию Tree():

> var tree = Tree();
> tree
   { }
> tree.branch1.branch2.twig = "green";
> tree
   { branch1: { branch2: { twig: "green" } } }
> tree.branch1.branch3.twig = "yellow";
   { branch1: { branch2: { twig: "green" },
                branch3: { twig: "yellow" }}}

Обратите внимание, как промежуточные объекты branch1, branch2 и branch3 создаются волшебным образом сами по себе, когда это необходимо. Удобно, правда? Как это теоретически могло бы работать?

До сегодняшнего дня не было способа, которым этого можно было бы достичь. Но с proxy это всего лишь несколько строк кода. Написать их нужно в tree.[[Get]](). Если хотите, можете попробовать реализовать это сами, прежде чем продолжить чтение.

5186a450b116478aacd31bcf085cb87a.jpg
Фото: Chiot’s Run

Вот мое решение:

function Tree() {
 return new Proxy({}, handler);
}

var handler = {
 get: function (target, key, receiver) {
   if (!(key in target)) {
     target[key] = Tree();  // auto-create a sub-Tree
   }
   return Reflect.get(target, key, receiver);
 }
};


Обратите внимание на вызов Reflect.get() в конце. Оказывается, есть очень важная и общая для всех потребность сказать «теперь требуется поведение по умолчанию, а именно – делегирование target’y» в методах handler’a proxy. Для этого в ES6 определяется новый объект Reflect с 14 собственными методами, которые можно использовать как раз для этих целей.

Пример: Read-only view

Кажется, у меня могло развиться ложное впечатление, будто proxy легко использовать. Нужно выполнить еще одно упражнение, чтобы проверить это утверждение.

В этот раз задание будет посложнее: нужно реализовать функцию readOnlyView(object), которая принимает объект, а возвращает proxy этого объекта, который ведет себя точно так же, за исключением возможности модифицировать его. Так что, например, он должен вести себя как-то так:

> var newMath = readOnlyView(Math);
> newMath.min(54, 40);
   40
> newMath.max = Math.min;
   Error: can't modify read-only view
> delete newMath.sin;
   Error: can't modify read-only view

Как можно было бы это имплементировать?
Во-первых, необходимо перехватывать вызов всех внутренних методов, которые бы видоизменили объект target, будучи вызванными. Их 5, вот они:

function NOPE() {
 throw new Error("can't modify read-only view");
}

var handler = {
 // Override all five mutating methods.
 set: NOPE,
 defineProperty: NOPE,
 deleteProperty: NOPE,
 preventExtensions: NOPE,
 setPrototypeOf: NOPE
};

function readOnlyView(target) {
 return new Proxy(target, handler);
} 

Это работает. Мы предупреждаем установку, изменение и так далее в read-only view.
Какие в этой схеме подводные камни?
Самая большая проблема заключается в том, что метод [[Get]], как и другие, может все-таки возвращать объекты, которые потом можно будет видоизменить. Так что даже если какой-нибудь объект x доступен только для чтения, его свойство x.prop может быть доступно для записи. Это большая проблема!

Чтобы решить ее, необходимо описать метод handler.get()

var handler = {
 ...

 // Wrap other results in read-only views.
 get: function (target, key, receiver) {
   // Start by just doing the default behavior.
   var result = Reflect.get(target, key, receiver);

   // Make sure not to return a mutable object!
   if (Object(result) === result) {
     // result is an object.
     return readOnlyView(result);
   }
   // result is a primitive, so already immutable.
   return result;
 },

 ...
};

Но этого недостаточно. Такой же код необходим и для других методов, включая
getPrototypeOf и getOwnPropertyDescriptor.

Это порождает дополнительные проблемы. Когда геттер или метод вызываются при помощи такого proxy, значение this, передающееся этому методу или геттеру, будет собственно самим proxy. Но, как мы видели ранее, множество различных методов производят проверку типа, которую proxy не проходит. Было бы лучше подменить объект target для proxy в этом месте. Знаете, как это сделать?
Из этого упражнения мы можем вынести, что создать proxy легко, но создать proxy с интуитивным поведением – довольно сложно.

Всякая всячина

• Для чего действительно хороши proxy?

Они безусловно полезны, когда вы хотите логировать обращения к объектам. Они очень удобны для дебага. Фреймворки для тестирования смогут использовать их для создания моков.
Они также будут полезны, если вам необходимо поведение, немного отличающееся от того, что делает обычный объект. Например, ленивое заполнение объектов.

Мне очень не нравится то, что я сейчас скажу, но один из лучших способов увидеть, что происходит в коде, использующем proxy, это… обернуть объект handler proxy в другой объект proxy, выводящий в консоль каждое обращение к объекту handler.
Proxy также можно использовать, если необходимо ограничить доступ к объекту, как это было сделано в примере с readOnlyView. Такой вид использования является редким в коде приложений, но Firefox использует proxy у себя с целью реализации границ безопасности между разными доменами. Они – ключевая часть нашей модели безопасности.

• Proxies WeakMaps. В нашем примере readOnlyView мы создавали новый объект proxy каждый раз, когда выполнялся доступ к объекту. Можно сохранить больше памяти, если кэшировать каждый создаваемый proxy в WeakMap, так что всё равно, сколько раз readOnlyView будет передан объект – для него создастся только один proxy.

Это один из случаев, которые могут побудить использовать WeakMap.

• Отзываемые proxy. ES6 также определяет еще одну функцию: Proxy.revocable(target, handler), которая создает proxy, так же как вызов new Proxy(target, handler), за исключением того, что proxy может быть отозван позже. (Proxy.revocable возвращает объект со свойством .proxy и методом .revoke.) Как только proxy отозван, он просто перестает работать, все его внутренние методы сбрасываются.

• Инварианты объектов. В некоторых ситуациях ES6 требует, чтобы методы handler’a proxy сообщали результаты, соотносящиеся с состоянием объекта target. Это делается, чтобы обеспечить выполнение правил неизменяемости во всех объектах, в том числе и proxy. Например, proxy не может претендовать на то, чтобы быть нерасширяемым, если его target действительно является таковым.

Непосредственные правила слишком сложны, чтобы быть описанными в этой статье, но, если вам вдруг попадется сообщение об ошибке вида proxy can't report a non-existent property as non-configurable, это является причиной. Лучшее средство здесь – изменить то, что proxy сообщает о себе. Также есть возможность видоизменять target, чтобы отразить то, что сообщает proxy.

Так что же такое объект?

Думаю, когда мы оставили этот вопрос, ответ был такой: «Объект – это коллекция свойств».

Мне не очень нравится это определение, даже если опустить возможность вызова и прототипы. Я считаю слово «коллекция» слишком щедрым, учитывая то, как плохо могут быть определены proxy. Методы его handler’a могут делать всё что угодно (могут возвращать случайные результаты).
Комитет по стандартам ECMAScript расширил сферу возможностей, определяя, что может делать объект, стандартизируя его методы и добавляя виртуализацию как возможность верхнего уровня, которую все могут использовать.
Сейчас объекты могут быть чем угодно.
Возможно, самый честный ответ на вопрос «Что есть объект?» это 12 требуемых внутренних методов в качестве определения. Объект в JavaScript это нечто, имеющее возможность выполнять [[Get]], [[Set]] и так далее…
________________________________________

Углубили ли мы свое понимание того, что есть объект? Я не уверен! Делали ли мы невероятные штуки? Определенно! Мы делали то, что раньше нельзя было делать в JavaScript.

Могу ли я использовать proxy уже сегодня?

Нет! По крайней мере не в Web! Только Firefox и Microsoft Edge поддерживают proxy, и полифила, конечно же, нет.
Использование proxy в node.js или io.js требует включения отключенной по умолчанию опции (--harmony_proxies) и harmony-reflect полифила, так как V8 имплементирует более старую версию спецификации Proxy. (Предыдущая версия этой статьи содержала ошибки на этот счет. Спасибо Mörre и Aaron Powell, что поправили меня в комментариях.)

Так что не стесняйтесь экспериментировать с proxy! Создавайте зеркальные залы, где будут тысячи копий каждого объекта, все похожие друг на друга до такой степени, что невозможно будет производить отладку! Сейчас самое время. Пока что есть небольшая вероятность, что некоторое количество вашего опрометчивого proxy-кода попадет в production.

Впервые proxy были имплементированы в 2010 году Андреасом Галем с code review Блейка Каплан. Позже они были полностью переделаны комитетом по стандартам. Эдди Бруэл имплементировал новую спецификацию в 2012 году.
Я имплементировал Reflect с code review Джеффа Волдена. Эти изменения будут в Firefox Nightly с этих выходных – все за исключением Reflect.enumerate(), который еще не имплементирован.

В следующий раз поговорим о самой спорной функции в ES6 – классах, и кто бы мог представить ее лучше, чем человек, занимающийся ее имплементацией в Firefox? Так что, пожалуйста, не пропустите следующую статью от инженера Mozilla Эрика Фауста, в которой он будет подробно рассказывать о классах в ES6.

© Habrahabr.ru