ДоксВижн corporate blog
Website development
JavaScript
16 July 2018

Метапрограммирование в JavaScript

Tutorial
Метапрограммирование — вид программирования, связанный с созданием программ, которые порождают другие программы как результат своей работы, либо программ, которые меняют себя во время выполнения. (Википедия)

Говоря более простым языком, метапрограммированием в рамках 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 и многие другие.


Рассмотрим же их поподробнее.


  1. Генерация кода
  2. Работа с функциями
  3. Работа с объектами
  4. Reflect API
  5. Символы (Symbols)
  6. Прокси (Proxy)
  7. Заключение

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)


Символы являются новым неизменяемым типом данным, в основном использующийся для создания уникальных названий идентификаторов свойств объектов. У нас имеется возможность создавать символы двумя способами:


  1. Локальные символы — текст в параметрах функции Symbol не влияет на уникальность и нужен лишь для отладки:


    const sym1 = Symbol('name');
    const sym2 = Symbol('name');
    
    console.log(sym1 == sym2); // false

  2. Глобальные символы — символы хранятся в глобальном реестре, поэтому символы с одинаковым ключом равны:


    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.


+35
16.9k 202
Comments 16
Top of the day