Proxy и Reflect¶
Объект — это набор свойств — эй! Только не закатывай глаза! Да, я знаю, что эта фраза уже приближается к уровню «митохондрии — электростанции клетки», но обещаю: сейчас мы подойдём к теме с новой стороны.
Кхм. Объект — это набор свойств, внутренних слотов и внутренних методов, которые позволяют взаимодействовать с этими свойствами. Когда вы вводите ({}).theProperty в консоли разработчика, вы ожидаете, что запустится следующая операция в духе «выбери своё приключение»:
- Находится ли этот ключ где-нибудь в цепочке прототипов объекта?
- Да.
- Это свойство-данные?
- Результатом будет значение из дескриптора свойства.
- Это свойство-аксессор?
- Вызывается геттер, а результатом становится значение, возвращённое этим методом.
- Это свойство-данные?
- Нет.
- Результатом будет
undefined.
- Результатом будет
- Да.
Само использование синтаксиса доступа к свойству не представляет все эти шаги — скорее, точечная нотация является API, запускающим внутреннюю операцию [[Get]], определённую спецификацией, а результат определяется шагами этой операции [[Get]].
Мы не можем вмешаться и изменить конкретные шаги внутренних методов объекта — да и вряд ли захотели бы: это территория движка JavaScript. Зато мы можем перехватить эти операции с помощью объекта-прокси и изменить, расширить или полностью переопределить работу объекта на самом фундаментальном уровне.
Конструктор Proxy позволяет создать объект, выступающий прокси для целевого объекта. С его помощью можно перехватывать и переопределять операции над целевым объектом, используя прокси как посредника.
При вызове с new конструктор Proxy возвращает объект — ничего неожиданного. Он принимает два аргумента: целевой объект и объект-обработчик:
В консоли здесь нет ничего слишком неожиданного. Только что созданный прокси содержит ссылку на целевой объект и набор внутренних слотов, обозначаемых <> или [[]] — в зависимости от браузера. Это показывает, что мы не должны взаимодействовать с этими слотами напрямую, как со строковым ключом свойства. [[Target]] — внутренний слот, представляющий объект, с которым мы хотим работать, включая определённое нами свойство. [[Handler]] представляет объект-обработчик, который служит посредником при взаимодействии с целевым объектом.
Уверен, вы сразу заметили [[isRevoked]] в Chrome. Не волнуйтесь, мы не столкнулись с серьёзным расхождением в поведении браузеров из эпохи 2010 года — это всего лишь различие в отображении внутреннего слота. Скоро мы до него доберёмся.
Если изменить значение свойства, определённого в целевом объекте, это изменение будет видно через ссылку на него из объекта-прокси:
На первый взгляд это похоже на классическую работу «по ссылке»: «значения объектов хранятся по ссылке», «объекты — это набор свойств», «объекты — электростанции скрипта» и так далее. Однако помните: речь идёт не о переменных или свойствах — в данном случае сам объект-прокси является ссылкой на целевой объект. Этот прокси можно полностью использовать вместо целевого объекта:
Доступ к свойству объекта-прокси фактически означает доступ к этому свойству целевого объекта через прокси. Нельзя и обычным способом определить собственное свойство прокси — вместо этого свойство будет определено в целевом объекте:
В том виде, в котором мы использовали прокси до сих пор, мы фактически создали необычное ссылочное значение с лишними шагами. Это лишь потому, что мы не просили объект-обработчик делать что-либо через прокси: здесь обработчик просто переводит с одного языка на тот же самый язык. Сценарий применения становится понятнее, когда мы начинаем создавать в обработчике функции-обработчики, иногда называемые ловушками:
Для каждой операции, которую можно выполнить над объектом, существует соответствующая ловушка, причём имена у них достаточно предсказуемы. Метод get(), определённый в обработчике, — ловушка для внутреннего метода [[Get]], который срабатывает при попытке получить значение свойства объекта. Возвращая таким способом явное значение, мы, по сути, нарушили нормальную операцию [[Get]]: перехватили её и изменили результат. Теперь независимо от того, что происходит с targetObject, попытка получить значение любого свойства вернёт именно то, что мы указали:
Как и любая функция, эта ловушка может выполнять любые нужные нам действия:
Разумеется, это позволяет управлять результатами операций тоньше, чем с помощью console.log и строки. Метод get() объекта-обработчика принимает три аргумента: целевой объект, ключ свойства и «получатель» (receiver). Последний понятие более высокого уровня: аргумент receiver представляет значение this внутри геттера — ссылку на объект, связанный с методом get в момент вызова этого метода. Это может звучать неоднозначно, как обычно бывает с this, но в большинстве случаев получателем будет целевой объект.
Используя эти аргументы, мы можем с помощью метода get получать и изменять значения свойств целевого объекта:
FYI
Вернёмся к [[isRevoked]], теперь, когда вы знаете, что именно можно отозвать. Существует второй способ создать объект-прокси — фабричная функция Proxy.revocable().
Объект, возвращаемый вызовом Proxy.revocable (с теми же аргументами target и handler, что и new Proxy), содержит два свойства. Первое свойство, proxy, вполне предсказуемо содержит объект-прокси и имеет то же значение, что и прокси, созданный конструктором Proxy с такими же аргументами.
Второе свойство — метод revoke, который можно использовать, чтобы отсоединить прокси от целевого объекта:
Вызов revoke() отменяет проксирование объекта: после вызова прокси, возвращённый Proxy.revocable(), больше не будет хранить ссылки на целевой объект и обработчик:
Помните: «отозванный» означает именно отозванный — после вызова revoke пути назад нет. Если других ссылок на прокси не существует, он становится доступен для сборки мусора; то же относится к целевому объекту и обработчику, если на них больше нигде нет ссылок.
Насколько я понимаю, движок JavaScript в Chrome показывает внутренний слот [[isRevoked]] в консоли разработчика для быстрой отладки. В самом языке (пока) нет способа напрямую получить значение этого внутреннего слота. Отзыв прокси одинаково хорошо работает в обоих браузерах.
У каждого внутреннего метода объекта есть соответствующая ловушка. Это означает, что можно изменить базовое поведение любого объекта, на любом уровне и во всём языке:
Так что просто убедитесь, что делаете это правильно.
Никакого давления.
Кстати, раз уж заговорили:
Reflect¶
В приведённых до сих пор примерах кода вы, возможно, заметили несколько вещей. Во-первых, мы веселимся так, будто на дворе 2009 год, — обращаемся к свойствам с помощью старомодной скобочной нотации.
Во-вторых, немного странно выполнять [[Get]] для получения значения свойства в контексте изменения самой работы [[Get]], используя как раз тот синтаксис, который мы меняем. Всё работает, но с точки зрения строгой технической эстетики что-то не так.
В-третьих, и это безусловно самое важное: нарушение таких основополагающих предположений, как «если я создаю свойство объекта, всё произойдёт ожидаемым образом», обязательно рано или поздно приведёт к проблемам в коде. Возможность изменять саму сущность объектов проводит тончайшую границу между «захватывающе» и «ужасающе», особенно с точки зрения будущей поддержки. Одна мысль о кодовой базе, заполненной объектами, которые могут вести себя как объекты, а могут и не вести, способна породить кошмары.
Поэтому спецификация ES-262 предусмотрительно описывает следующие инварианты для [[Set]] — то есть правила, которым должна следовать операция [[Set]]:
- Результатом
[[Set]]является логическое значение. - Нельзя изменить значение свойства на отличное от значения соответствующего свойства целевого объекта, если это собственное свойство-данные целевого объекта является недоступным для записи и настройки.
- Нельзя установить значение свойства, если соответствующее свойство целевого объекта является ненастраиваемым собственным свойством-аксессором, у которого атрибут
[[Set]]равенundefined.
Как только вы начинаете менять работу [[Set]], соблюдение этих правил становится вашей ответственностью. Если написать что-то вроде следующего:
Моя операция [[Set]] не вернула логическое значение, которого требует JavaScript согласно священным инвариантам [[Set]]. Правда, этот фрагмент всё ещё работает: мы находимся не в строгом режиме, возвращённое значение приводится к Boolean и в данном случае оказывается истинным. Но в любом контексте это работать не будет:
Разумеется, можно переписать код и явно вернуть ожидаемое логическое значение. Но даже при работе с таким простым методом обработчика мы оказываемся в ситуации «нужно всегда внимательно делать это именно так», чтобы не добавить в кодовую базу принципиально неисправные объекты. Никому это не нужно.
Это подводит нас к объекту Reflect — набору статических методов, каждый из которых имеет то же имя и параметры, что и методы обработчика прокси. Reflect предоставляет ограничители, помогающие решить все перечисленные проблемы (включая не самые приятные ощущения), и набор методов для взаимодействия с объектами, гарантирующих, что мы не слишком далеко отклонимся от того, как объекты должны работать.
Reflect — это объект пространства имён: обычный объект со статическими свойствами и методами, подобный объектам Math или Temporal:
Каждый из этих методов соответствует имени метода обработчика прокси и принимает те же параметры в том же порядке. Их синтаксис гораздо лучше подходит для текущего контекста, чем скобочная нотация. Уже одно это, на мой взгляд, заметно улучшает ситуацию:
Никаких сложностей и, что самое важное, не нужно перечитывать спецификацию ES-262, чтобы убедиться, что мы случайно не нарушаем правила игры для объектов: Reflect.set() выполняет нужную нам операцию [[Set]] и возвращает ожидаемое спецификацией логическое значение. С помощью Proxy мы меняем работу объектов, а с помощью Reflect убеждаемся, что изменённые объекты всё ещё работают так, как должны.
Собираем всё вместе¶
Собрав всё вместе, мы можем использовать объекты-прокси и Reflect для таких задач, как проверка данных:
…или установки и поддержки внутреннего состояния объекта, например количества обращений к определённому свойству объекта:
При работе с одним объектом это, конечно, скорее любопытные приёмы. Но если применить уже изученные подходы и синтаксис и добавить немного воображения, нетрудно представить, как с помощью ещё нескольких строк кода объекты-прокси могут создать целую систему состояния без громоздких фреймворков и сторонних инструментов:
Послушайте. Я не хочу добавлять сюда ещё больше клише, но признаю: есть соблазн закончить этот урок призывом к умеренности — предупредить, что объекты-прокси «настолько же опасны, насколько могущественны», поговорить о «большой силе и большой ответственности» и так далее, а затем добавить, что Reflect хотя бы даёт некоторую сухую утешительную защиту. Этот импульс взялся не из ниоткуда: изменение работы базовых строительных блоков языка на фундаментальном уровне может серьёзно всё сломать. Тут не поспоришь.
Но вы меня знаете: если бы я хотел тратить кортизол на «переживания из-за ошибок», я бы пошёл в медицинский или выучил PHP. По-моему, Proxy и Reflect — новые сияющие примеры неувядающего духа JavaScript: использовать язык, чтобы менять язык, и находить новые способы решать задачи, о которых даже не думали сотни людей, державших мяч в руках с 1995 года.
Объект Reflect даёт важные ограничители, которые помогут избежать головной боли в будущем. Конечно, ими стоит пользоваться — именно для этого они и существуют.
Но я не стану говорить вам «будьте осторожны». Идите и ломайте — лучшего способа учиться не существует. В конце концов, что не исправить перезагрузкой страницы?