Метапрограммирование — вид программирования, связанный с созданием программ, которые порождают другие программы как результат своей работы, либо программ, которые меняют себя во время выполнения. (Википедия)
Говоря более простым языком, метапрограммированием в рамках JavaScript можно считать механизмы, позволяющие анализировать и менять программу в режиме реального времени в зависимости от каких-либо действий. И, скорее всего, вы так или иначе используете их при написании скриптов каждый день.
JavaScript по своей природе является очень мощным динамическим языком и позволяет приятно писать гибкий код:
/**
* Динамическое создание save-метода для каждого свойства
*/
const comment = { authorId: 1, comment: 'Комментарий' };
for (let name in comment) {
const pascalCasedName = name.slice(0, 1).toUpperCase() + name.slice(1);
comment[`save${pascalCasedName}`] = function() {
// Сохраняем поле
}
}
comment.saveAuthorId(); // Сохраняем authorId
comment.saveComment(); // Сохраняем comment
Аналогичный код для динамического создания методов в других языках очень часто может потребовать специальный синтаксис или API для этого. Например, PHP тоже является динамическим языком, но в нём это потребует больше усилий:
<?php
class Comment {
public $authorId;
public $comment;
public function __construct($authorId, $comment) {
$this->authorId = $authorId;
$this->comment = $comment;
}
// Перехватываем все вызовы методов в классе
public function __call($methodName, $arguments) {
foreach (get_object_vars($this) as $fieldName => $fieldValue) {
$saveMethodName = "save" . strtoupper($fieldName[0]) . substr($fieldName, 1);
if ($methodName == $saveMethodName) {
// Сохраняем поле
}
}
}
}
$comment = new Comment(1, 'Комментарий');
$comment->saveAuthorId(); // Сохраняем authorId
$comment->saveComment(); // Сохраняем comment
В дополнение к гибкому синтаксису, у нас есть ещё и куча полезных функций для написания динамического кода: Object.create, Object.defineProperty, Function.apply и многие другие.
Рассмотрим же их поподробнее.
- Генерация кода
- Работа с функциями
- Работа с объектами
- Reflect API
- Символы (Symbols)
- Прокси (Proxy)
- Заключение
1. Генерация кода
Стандартным средством для динамического выполнения кода является функция eval, позволяющая выполнить код из переданной строки:
eval('alert("Hello, world")');
К сожалению, eval имеет много нюансов:
- если наш код написан в строгом режиме ('use strict'), то переменные, объявленные внутри eval, не будут видны в вызывающем eval коде. При этом сам код внутри eval всегда может менять внешние переменные.
- код внутри eval может выполняться как в глобальном контексте (если его вызвать через window.eval), так и в контексте функции, внутри которой произошёл вызов (если просто eval, без window).
- могут возникнуть проблемы из-за минификации JS, когда названия переменных заменяются на более короткие для уменьшения размера. Код, переданный в виде строки в eval, минификатор обычно не трогает, из-за этого мы можем начать обращаться к внешним переменным по старым неминифицированными названиям, что приведёт к трудноуловимым ошибкам.
Для решения этих проблем есть прекрасная альтернатива — new Function.
const hello = new Function('name', 'alert("Hello, " + name)');
hello('Андрей') // alert("Hello, Андрей");
В отличие от eval, мы всегда можем явно передавать параметры через аргументы функции и динамически указывать ей контекст this (через Function.apply или Function.call). К тому же создаваемая функция всегда вызывается в глобальной области видимости.
В старые времена, eval часто использовался для динамического изменения кода, т.к. JavaScript имел очень мало механизмов для рефлексии и без eval обойтись было нельзя. Но в современном стандарте языка появилось намного больше высокоуровневого функционала и eval теперь применяется намного реже.
2. Работа с функциями
JavaScript предоставляет нам множество прекрасных средств для динамической работы с функциями, позволяя как получать в рантайме различную информацию о функции, так и менять её:
Function.length — позволяет узнать количество аргументов у функции:
const func = function(name, surname) { console.log(`Hello, ${surname} ${name}`) }; console.log(func.length) // 2
Function.apply и Function.call — позволяют динамически менять контекст this у функции:
const person = { name: 'Иван', introduce: function() { return `Я ${this.name}`; } } person.introduce(); // Я Иван person.introduce.call({ name: 'Егор' }); // Я Егор
Отличаются они друг друга только тем, что в Function.apply аргументы функции подаются в виде массива, а в Function.call — через запятую. Эту особенность раньше часто использовали, чтобы передавать в функцию список аргументов в виде массива. Распространённый пример — это функция Math.max (по умолчанию она не умеет работать с массивами):
Math.max.apply(null, [1, 2, 4, 3]); // 4
С появлением нового spread-оператора можно просто писать так:
Math.max(...[1, 2, 4, 3]); // 4
Function.bind — позволяет создать копию функцию из существующей, но с другим контекстом:
const person = { name: 'Иван', introduce: function() { return `Я ${this.name}`; } } person.introduce(); // Я Иван const introduceEgor = person.introduce.bind({ name: 'Егор' }); introduceEgor(); // Я Егор
Function.caller — позволяет получить вызывающую функцию. Использовать её не рекомендуется, так как она отсутствует в стандарте языка и не будет работать в строгом режиме. Это было сделано из-за того, что если различные движки JavaScript реализуют описанную в спецификации языка оптимизацию tail call, то вызов Function.caller может начать приводить к неправильным результатам. Пример использования:
const a = function() { console.log(a.caller == b); } const b = function() { a(); } b(); // true
Function.toString — возвращает строковое представление функции. Это очень мощная возможность, позволяющая исследовать как содержимое функции, так и её аргументы:
const getFullName = (name, surname, middlename) => { console.log(`${surname} ${name} ${middlename}`); } getFullName.toString() /* * "(name, surname, middlename) => { * console.log(`${surname} ${name} ${middlename}`); * }" */
Получив строковое представление функции, мы можем его распарсить и проанализировать. Это можно использовать, чтобы, например, вытащить названия аргументов функции и, в зависимости от названия, автоматически подставлять нужный параметр. В целом, парсить можно двумя способами:
- Парсим кучей регулярок и получаем приемлимый уровень надёжности (может не работать, если мы не покроем все возможные виды записей функции).
- Получаем строковое представление функции и суём в готовый парсер JavaScript (например esprima или acorn), а затем работаем уже со структурированным AST. Пример разбора в AST через esprima. Также могу посоветовать хороший доклад про парсеры от Алексея Охрименко.
Простые примеры с парсингом функций регулярками:
/**
* Получить список параметром функции.
* @param fn Функция
*/
const getFunctionParams = fn => {
const COMMENTS = /(\/\/.*$)|(\/\*[\s\S]*?\*\/)|(\s*=[^,\)]*(('(?:\\'|[^'\r\n])*')|("(?:\\"|[^"\r\n])*"))|(\s*=[^,\)]*))/gm;
const DEFAULT_PARAMS = /=[^,]+/gm;
const FAT_ARROW = /=>.*$/gm;
const ARGUMENT_NAMES = /([^\s,]+)/g;
const formattedFn = fn
.toString()
.replace(COMMENTS, "")
.replace(FAT_ARROW, "")
.replace(DEFAULT_PARAMS, "");
const params = formattedFn
.slice(formattedFn.indexOf("(") + 1, formattedFn.indexOf(")"))
.match(ARGUMENT_NAMES);
return params || [];
};
const getFullName = (name, surname, middlename) => {
console.log(surname + ' ' + name + ' ' + middlename);
};
console.log(getFunctionParams(getFullName)); // ["name", "surname", "middlename"]
/**
* Получить строковое представление тела функции.
* @param fn Функция
*/
const getFunctionBody = fn => {
const restoreIndent = body => {
const lines = body.split("\n");
const bodyLine = lines.find(line => line.trim() !== "");
let indent = typeof bodyLine !== "undefined" ? (/[ \t]*/.exec(bodyLine) || [])[0] : "";
indent = indent || "";
return lines.map(line => line.replace(indent, "")).join("\n");
};
const fnStr = fn.toString();
const rawBody = fnStr.substring(
fnStr.indexOf("{") + 1,
fnStr.lastIndexOf("}")
);
const indentedBody = restoreIndent(rawBody);
const trimmedBody = indentedBody.replace(/^\s+|\s+$/g, "");
return trimmedBody;
};
// Получим список параметров и тело функции getFullName
const getFullName = (name, surname, middlename) => {
console.log(surname + ' ' + name + ' ' + middlename);
};
console.log(getFunctionBody(getFullName));
Важно заметить, что при использовании минификатора как сам код внутри парсируемой функции, так и её аргументы могут оптимизироваться и, поэтому, измениться.
3. Работа с объектами
В JavaScript имеется глобальный объект Object, содержащий множество методов для динамической работы с объектами.
Большинство таких методов оттуда уже давно существуют в языке и повсеместно используются.
Свойства объекта
Object.assign — для удобного копирования свойств одного или нескольких объектов в объект, указанный первым параметром:
Object.assign({}, { a: 1 }, { b: 2 }, { c: 3 }) // {a: 1, b: 2, c: 3}
Object.keys и Object.values — возвращает либо список ключей, либо список значений объекта:
const obj = { a: 1, b: 2, c: 3 }; console.log(Object.keys(obj)); // ["a", "b", "c"] console.log(Object.values(obj)); // [1, 2, 3]
Object.entries — возвращает список своих свойств в формате [[ключ1, значение1], [ключ2, значение2]]:
const obj = { a: 1, b: 2, c: 3 }; console.log(Object.entries(obj)); // [["a", 1], ["b", 2], ["c", 3]]
Object.prototype.hasOwnProperty — проверяет, содержится ли свойство в объекте (не в его прототипной цепочке):
const obj = { a: 1 }; obj.__proto__ = { b: 2 }; console.log(obj.hasOwnProperty('a')); // true console.log(obj.hasOwnProperty('b')) // false
Object.getOwnPropertyNames — возвращает список собственных свойств, включая как перечисляемые, так и неперечисляемые:
const obj = { a: 1, b: 2 }; Object.defineProperty(obj, 'c', { value: 3, enumerable: false }); // Создаём неперечисляемое свойство for (let key in obj) { console.log(key); } // "a", "b" console.log(Object.getOwnPropertyNames(obj)); // [ "a", "b", "c" ]
Object.getOwnPropertySymbols — возвращает список собственных (содержащихся именно в объекте, а не в его прототипной цепочке) символов:
const obj = {}; const a = Symbol('a'); obj[a] = 1; console.log(Object.getOwnPropertySymbols(obj)); // [ Symbol(a) ]
Object.prototype.propertyIsEnumerable — проверяет, является ли свойство перечисляемым (к примеру, доступно ли в циклах for-in, for-of):
const arr = [ 'Первый элемент' ]; console.log(arr.propertyIsEnumerable(0)); // true — элемент 'Первый элемент' является перечисляемым console.log(arr.propertyIsEnumerable('length')); // false — свойство length не является перечисляемым
Дескрипторы свойств объекта
Дескрипторы позволяют тонко настраивать параметры свойств. С помощью них мы можем удобно делать собственные перехватчики во время чтения/записи какого-либо свойства (геттеры и сеттеры — get/set), делать свойства неизменяемыми или неперечисляемыми и ряд других вещей.
Object.defineProperty и Object.defineProperties — создаёт один или несколько дескрипторов свойств. Создадим свой собственный дескриптор с геттером и сеттером:
const obj = { name: 'Михаил', surname: 'Горшенёв' }; Object.defineProperty(obj, 'fullname', { // Вызывается при чтении свойства fullname get: function() { return `${this.name} ${this.surname}`; }, // Вызывается при изменении свойства fullname (но не умеет перехватывать удаление delete obj.fullname) set: function(value) { const [name, surname] = value.split(' '); this.name = name; this.surname = surname; }, }); console.log(obj.fullname); // Михаил Горшенёв obj.fullname = 'Егор Летов'; console.log(obj.name); // Егор console.log(obj.surname); // Летов
В примере выше, свойство fullname не имело своего собственного значения, а динамически работало со свойствами name и surname. Необязательно определять одновременно геттер и сеттер — мы можем оставить только геттер и получить свойство, доступное только для чтения. Или можем в сеттере вместе с установкой значения добавить дополнительное действие, например, логгирование.
Кроме свойств get/set, дескрипторы имеют ещё несколько свойств для настройки:
const obj = {}; // Если не нужны свои обработчики get/set, то можно просто указать значение через value. Нельзя одновременно использовать get/set и value. По умолчанию — undefined. Object.defineProperty(obj, 'name', { value: 'Егор' }); // Указываем, что созданное свойство видно при итерации свойств объекта (for-in, for-of, Object.keys). По умолчанию — false. Object.defineProperty(obj, 'a', { enumerable: true }); // Можно ли в дальнейшем поменять созданное свойство через defineProperty или удалить его через delete. По умолчанию — false. Object.defineProperty(obj, 'b', { configurable: false }); // Можно ли будет менять значение свойства. По умолчанию — false. Object.defineProperty(obj, 'c', { writable: true });
Object.getOwnPropertyDescriptor и Object.getOwnPropertyDescriptors — позволяют получить нужный дескриптор объекта или их полный список:
const obj = { a: 1, b: 2 }; console.log(Object.getOwnPropertyDescriptor(obj, "a")); // { configurable: true, enumerable: true, value: 1, writable: true } /** * { * a: { configurable: true, enumerable: true, value: 1, writable: true }, * b: { configurable: true, enumerable: true, value: 2, writable: true } * } */ console.log(Object.getOwnPropertyDescriptors(obj));
Создание ограничений при работе с объектами
Object.freeze — "замораживает" свойства объекта. Следствием такой "заморозки" является полная неизменяемость свойств объекта — их нельзя изменять и удалять, добавлять новые, менять дескрипторы:
const obj = Object.freeze({ a: 1 }); // В строгом режиме следующие строчки кидают исключения, а в обычном просто ничего не происходит. obj.a = 2; obj.b = 3; console.log(obj); // { a: 1 } console.log(Object.isFrozen(obj)) // true
Object.seal — "запечатывает" свойства объекта. "Запечатывание" похоже на Object.freeze, но имеет ряд отличий. Мы также, как и в Object.freeze запрещаем добавлять новые свойства, удалять существующие, менять их дескрипторы, но в то же время можем менять значения свойств:
const obj = Object.seal({ a: 1 }); obj.a = 2; // Свойство a теперь равно 2 // В строгом режиме кинет исключение, а в обычном просто ничего не происходит. obj.b = 3; console.log(obj); // { a: 2 } console.log(Object.isSealed(obj)) // true
Object.preventExtensions — запрещает добавление новых свойств/дескрипторов:
const obj = Object.preventExtensions({ a: 1 }); obj.a = 2; // В строгом режиме следующие строчки кидают исключения, а в обычном просто ничего не происходит. obj.b = 3; console.log(obj); // { a: 2 } console.log(Object.isExtensible(obj)) // false
Прототипы объектов
Object.create — для создания объекта с указанным в параметре прототипом. Эту возможность можно использовать как для прототипного наследования, так и для создания "чистых" объектов, без свойств из Object.prototype:
const pureObj = Object.create(null);
Object.getPrototypeOf и Object.setPrototypeOf — для получения/изменения прототипа объекта:
const duck = {}; const bird = {}; Object.setPrototypeOf(duck, bird); console.log(Object.getPrototypeOf(duck) === bird); // true console.log(duck.__proto__ === bird); // true
Object.prototype.isPrototypeOf — проверяет, содержится ли текущий объект в прототипной цепочке другого:
const duck = {}; const bird = {}; duck.__proto__ = bird; console.log(bird.isPrototypeOf(duck)); // true
4. Reflect API
С появлением ES6, в JavaScript добавили глобальный объект Reflect, предназначенный для хранения различных методов, связанных с рефлексией и интроспекцией.
Большая часть его методов — это результат переноса существующих методов из таких глобальных объектов, как Object и Function в отдельное пространство имён с небольшим рефакторингом для более комфортного использования.
Перенос функций в объект Reflect не только облегчил поиск нужных методов для рефлексии и дал большую семантичность, но также позволил избежать неприятных ситуаций, когда наш объект не содержит в своём прототипе Object.prototype, но мы хотим использовать методы оттуда:
let obj = Object.create(null);
obj.qwerty = 'qwerty';
console.log(obj.__proto__) // null
console.log(obj.hasOwnProperty('qwerty')) // Uncaught TypeError: obj.hasOwnProperty is not a function
console.log(obj.hasOwnProperty === undefined); // true
console.log(Object.prototype.hasOwnProperty.call(obj, 'qwerty')); // true
Рефакторинг сделал поведение методов более явным и однообразным. К примеру, если раньше при вызове Object.defineProperty на некорректном значении (как число или строка) кидалось исключение, но в то же время вызов Object.getOwnPropertyDescriptor на несуществующем дескрипторе объекта молча возвращал undefined, то аналогичные методы из Reflect при некорректных данных всегда кидают исключения.
Также добавилось несколько новых методов:
Reflect.construct — более удобная альтернатива Object.create, позволяющая не просто создать объект с указанным прототипом, но и сразу проинициализировать его:
function Person(name, surname) { this.name = this.formatParam(name); this.surname = this.formatParam(surname); } Person.prototype.formatParam = function(param) { return param.slice(0, 1).toUpperCase() + param.slice(1).toLowerCase(); } const oldPerson = Object.create(Person.prototype); // {} Person.call(oldPerson, 'Иван', 'Иванов'); // {name: "Иван", surname: "Иванов"} const newPerson = Reflect.construct(Person, ['Андрей', 'Смирнов']); // {name: "Андрей", surname: "Смирнов"}
Reflect.ownKeys — возвращает массив свойств, принадлежащих именно указанному объекту (а не объектам в цепочке прототипов):
let person = { name: 'Иван', surname: 'Иванов' }; person.__proto__ = { age: 30 }; console.log(Reflect.ownKeys(person)); // ["name", "surname"]
Reflect.deleteProperty — альтернатива оператору delete, выполненная в виде метода:
let person = { name: 'Иван', surname: 'Иванов' }; delete person.name; // person = {surname: "Иванов"} Reflect.deleteProperty(person, 'surname'); // person = {}
Reflect.has — альтернатива оператору in, выполненная в виде метода:
let person = { name: 'Иван', surname: 'Иванов' }; console.log('name' in person); // true console.log(Reflect.has(person, 'name')); // true
Reflect.get и Reflect.set — для чтения/изменения свойств объекта:
let person = { name: 'Иван', surname: 'Иванов' }; console.log(Reflect.get(person, 'name')); // Иван Reflect.set(person, 'surname', 'Петров') // person = {name: "Иван", surname: "Петров"}
Более подробно с изменениями можно ознакомиться здесь.
Reflect metadata
Кроме перечисленных выше методов объекта Reflect, существует экспериментальный proposal для удобного привязывания различных метаданных к объектам.
Метаданными может быть любая полезная информация не относящаяся к объекту напрямую, например:
- TypeScript при включенном флаге emitDecoratorMetadata записывает в метаданные информацию о типах, позволяя получить к ним доступ в рантайме. Далее, эта информация может быть получена по ключу design:type:
const typeData = Reflect.getMetadata("design:type", object, propertyName);
- Популярная библиотека InversifyJS для инверсии контроля хранит в метаданных различную информацию об описанных связях.
В данный момент для его работы в браузерах используется этот полифилл
5. Символы (Symbols)
Символы являются новым неизменяемым типом данным, в основном использующийся для создания уникальных названий идентификаторов свойств объектов. У нас имеется возможность создавать символы двумя способами:
Локальные символы — текст в параметрах функции Symbol не влияет на уникальность и нужен лишь для отладки:
const sym1 = Symbol('name'); const sym2 = Symbol('name'); console.log(sym1 == sym2); // false
Глобальные символы — символы хранятся в глобальном реестре, поэтому символы с одинаковым ключом равны:
const sym3 = Symbol.for('name'); const sym4 = Symbol.for('name'); const sym5 = Symbol.for('other name'); console.log(sym3 == sym4); // true, символы имеют один и тот же ключ 'name' console.log(sym3 == sym5); // false, символы имеют разные ключи
Возможность создавать такие идентификаторы позволяет не бояться того, что мы можем затереть какое-то свойство в неизвестном нам объекте. Это качество позволяет создателям стандарта легко добавлять новые стандартные свойства в объекты, при этом не сломав совместимость с различными существующими библиотеками (которые уже могли определить такое же свойство) и пользовательским кодом. Поэтому существует ряд стандартных символов и часть из них даёт новые возможности для рефлексии:
Symbol.iterator — позволяет создавать собственные правила итерации объектов с помощью for-of или ...spread operator:
let arr = [1, 2, 3]; // Выводим элементы массива в обратном порядке arr[Symbol.iterator] = function() { const self = this; let pos = this.length - 1; return { next() { if (pos >= 0) { return { done: false, value: self[pos--] }; } else { return { done: true }; } } } }; console.log([...arr]); // [3, 2, 1]
Symbol.hasInstance — метод, определяющий, распознает ли конструктор некоторый объект как свой экземпляр. Используется оператором instanceof:
class MyArray { static [Symbol.hasInstance](instance) { return Array.isArray(instance); } } console.log([] instanceof MyArray); // true
Symbol.isConcatSpreadable — указывает, должен ли массив сплющиваться при конкатенации в Array.concat:
let firstArr = [1, 2, 3]; let secondArr = [4, 5, 6]; firstArr.concat(secondArr); // [1, 2, 3, 4, 5, 6] secondArr[Symbol.isConcatSpreadable] = false; console.log(firstArr.concat(secondArr)); // [1, 2, 3, [4, 5, 6]]
Symbol.species — позволяет указать какой конструктор будет использоваться для создания производных объектов внутри класса.
Например, у нас есть стандартный класс Array для работы с массивами и в нём есть метод .map, создающий новый массив на основе текущего. Для того, чтобы узнать какой класс нужно использовать для создания этого нового массива, Array обращается к this.constructor[Symbol.species] примерно так:
Array.prototype.map = function(cb) { const ArrayClass = this.constructor[Symbol.species]; const result = new ArrayClass(this.length); this.forEach((value, index, arr) => { result[index] = cb(value, index, arr); }); return result; }
Тем самым, переопределяя Symbol.species мы можем создать собственный класс для работы с массивами и сказать, чтобы все стандартные методы вроде .map, .reduce и др. возвращали не экземпляр класса Array, а экземпляр нашего класса:
class MyArray extends Array { static get [Symbol.species]() { return this; } } const arr = new MyArray(1, 2, 3); // [1, 2, 3] console.log(arr instanceof MyArray); // true console.log(arr instanceof Array); // true // Обычная реализация Array.map вернула бы экземпляр класса Array, но мы переопределили Symbol.species на this и теперь возвращается экземпляр класса MyArray const doubledArr = arr.map(x => x * 2); console.log(doubledArr instanceof MyArray); // true console.log(doubledArr instanceof Array); // true
Само собой, это работает не только с массивами, но и с другими стандартными классами. Более того, даже если мы просто создаём свой класс с методами, возвращающими новые экземпляры этого же класса, то мы по-хорошему должны использовать this.constructor[Symbol.species] для получения ссылки на констуктор.
Symbol.toPrimitive — позволяет указать каким образом нужно конвертировать наш объект в примитивное значение. Если ранее для приведения к примитиву нам нужно было использовали toString вместе с valueOf, то теперь всё можно сделать в одном удобном методе:
const figure = { id: 1, name: 'Прямоугольник', [Symbol.toPrimitive](hint) { if (hint === 'string') { return this.name; } else if (hint === 'number') { return this.id; } else { // default return this.name; } } } console.log(`${figure}`); // hint = string console.log(+figure); // hint = number console.log(figure + ''); // hint = default
Symbol.match — позволяет создавать свои собственные классы-обработчики для метода для функции String.prototype.match:
class StartAndEndsWithMatcher { constructor(value) { this.value = value; } [Symbol.match](str) { const startsWith = str.startsWith(this.value); const endsWith = str.endsWith(this.value); if (startsWith && endsWith) { return [this.value]; } return null; } } const testMatchResult = '|тест|'.match(new StartAndEndsWithMatcher('|')); console.log(testMatchResult); // ["|"] const catMatchResult = 'кот|'.match(new StartAndEndsWithMatcher('|')); console.log(catMatchResult) // null
Также существуют похожие символы — Symbol.replace, Symbol.search и Symbol.split для аналогичных методов из String.prototype.
Важно заметить, что символы (как и reflect-metadata из прошлой секции) можно использовать для присоединения своих метаданных к любому объекту. Ведь из-за уникальности создаваемых символов, мы можем не бояться, что случайно перезапишем имеющееся свойство в объекте. Для примера присоединим метаданные для валидации к объекту:
const validationRules = Symbol('validationRules');
const person = { name: 'Иван', surname: 'Иванов' };
person[validationRules] = {
name: ['max-length-256', 'required'],
surname: ['max-length-256']
};
6. Прокси (Proxy)
Proxy является принципиально новым функционалом, появившимся вместе с Reflect API и Symbols в ES6, предназначающийся для перехвата в любом объекте чтения/записи/удаления любых свойств, вызова функций, переопределения правил итерирования и других полезных вещей. Важно заметить, что прокси нормально не полифилятся.
С помощью проксей мы можем сильно расширить удобство использования кучи библиотек, например библиотек для data-binding вроде MobX из React, Vue и других. Рассмотрим пример до использования прокси и после.
С прокси:
const formData = {
login: 'User',
password: 'pass'
};
const proxyFormData = new Proxy(formData, {
set(target, name, value) {
target[name] = value;
this.forceUpdate(); // Перерисовываем наш React-компонент
}
});
// При изменении любого свойства также вызывается forceUpdate() для перерисовки в React
proxyFormData.login = 'User2';
// Такого свойства ещё не существует, но прокси всё-равно перехватит присваивание и корректно обработает
proxyFormData.age = 20;
Без прокси мы можем попробовать сделать нечно похожее, используя геттеры/сеттеры:
const formData = {
login: 'User',
password: 'pass'
};
const proxyFormData = {};
for (let param in formData) {
Reflect.defineProperty(proxyFormData, `__private__${param}`, {
value: formData[param],
enumerable: false,
configurable: true
});
Reflect.defineProperty(proxyFormData, param, {
get: function() {
return this[`__private__${param}`];
},
set: function(value) {
this[`__private__${param}`] = value;
this.forceUpdate(); // Перерисовываем наш React-компонент
},
enumerable: true,
configurable: true
});
}
// При изменении любого свойства также вызывается forceUpdate() для перерисовки в React
proxyFormData.login = 'User2';
// Такого свойства не существует и мы не сможем его обработать пока явно не зададим ещё одну пара геттеров-сеттеров через Reflect.defineProperty
proxyFormData.age = 20;
При использовании геттеров и сеттеров мы получаем кучу неудобного бойлерплейт-кода, а самый главный минус — при использовании Proxy мы создаём проксируемый объект один раз и он перехватывает все свойства (независимо от того, существуют они в объекте или ещё нет), а с использованием геттеров/сеттеров нам приходится для каждого нового свойства вручную создавать пару из геттера и сеттера, к тому же сеттером мы не можем отслеживать работу оператора delete obj[name].
7. Заключение
JavaScript является мощным и гибким языком и очень радует, что он больше не находится в застое как во времена ECMAScript 4, а постоянно улучшается и добавляет в себя всё больше новых и удобных возможностей. Благодаря этому мы можем писать всё более хорошие программы как с точки зрения пользовательского опыта, так и разработческого.
Для более детального погружения в тему рекомендую прочитать находящийся в свободном доступе раздел про метапрограммирование замечательной книги You Don't Know JS.