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


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

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

Вы можете помочь и перевести немного средств на развитие сайта



Комментарии (16):

  1. k12th
    /#18888209

    Object.entries — превращает объект в массив вида [[ключ, значение], [ключ, значение]]:

    Конечно же, не превращает, объект остается как был:)

  2. Iqorek
    /#18888241

    Хорошая памятка, спасибо. Некоторые вещи уже подзабыл.

  3. mayorovp
    /#18888759

    Все это чудесно, но при чем тут метапрограммирование?

  4. Drag13
    /#18888843

    const getFunctionBody = fn => {
             const restoreIndent = body => {
             ...

    Вот так делать тоже не очень хорошо — на каждый вызов создавать новые функции. Их можно вынести из тела функции (что даст возможность их заодно протестировать) но не экспортировать.


    А за подборку — спасибо, давно хотел посмотреть Metadata да никак.

    • faiwer
      /#18888977

      Вот так делать тоже не очень хорошо — на каждый вызов создавать новые функции

      Если это место не высоко-нагруженное, то так делать можно. Никаких проблем.


      Их можно вынести из тела функции

      Чаще всего такой подход (матрёшки) встречается ввиду использования замыканий. Поэтому просто так взять и вынести не получится. Нужно будет ещё расширять аргументы, передавая туда всё необходимое из scope. Насколько это резонно — зависит от конкретного участка кода.

      • Drag13
        /#18889007

        Если это место не высоко-нагруженное, то так делать можно. Никаких проблем.

        Согласен, но


        • Часто мы не знаем где будет использоваться наш код.
        • Если это переходит в кодстайл — вероятность использовать это не в том месте возрастает
        • Этот подход могут начать копировать другие участники проекта
        • Часто использование замыканий можно переписать на +1 аргумент что решит все проблемы и сделает функцию более тестируемой

        Поэтому я предпочитаю идти от обратного — писать отдельно и только если никак — объявлять функции внутри.

        • faiwer
          /#18889075

          Часто мы не знаем где будет использоваться наш код

          Очень и очень редко. Почти всегда мы очень отчётливо понимаем где будет использоваться наш код.


          вероятность использовать это не в том месте возрастает

          Очень надуманный предлог, не находите? Эдак можно пол языка срезать код-стайлом, без объективных на то причин. Важно понимание используемого инструмента, а не прятать голову в песок :)


          Этот подход могут начать копировать другие участники проекта

          Пускай копируют. Это одна из наиболее нативных вещей в JavaScript. Такими подходами JS и дышит. Это настолько распространённый паттерн… По сути это и есть JavaScript.


          сделает функцию более тестируемой

          Всё равно эту функцию никто не будет тестировать. Давайте заглянем правде в глаза. Тестировать каждый атом кодового пространства очень редко является разумной идеей. Более того, почти никогда — осуществимой. Вот тестирование её внешней обёртки — там да...


          Drag13, важно понимать, что у JavaScript-а несколько иные приоритеты. Это скриптовый слабо-динамически-типизированный язык. Это не Rust, не C++, не C#. Избегая его сильных сторон по надуманным причинам вы не выигрываете ровным счётом ничего, а теряете — почти всё.


          Посмотрите, к примеру, реализацию каррирования. Да и вообще исходники с использованием любых FP библиотек. Да даже код с использованием lodash-а, чего уж там. Все эти экономии на спичках, за счёт избегания замыканий и усложнения кодовой базы, просто меркнут на фоне того, что реально творится в сложных JS SPA. Спички экономим, а за окном лес горит.

          • Drag13
            /#18889137

            Наверное я не смог правильно выразиться.

            Ничего против каррирования и динамической типизации я не имею. Сову на глобус — Js на C# я натягивать не планировал :) Т.е. если замыкание оправдано — без проблем. Но если без него можно обойтись не усложняя себе жизнь — еще лучше!

            Я просто против подхода который упрощает выстрел в ногу там, где это не нужно. Вот и все. Ну это как использовать == вместо ===. Это же тоже возможность языка но почему то все против повсеместного ее использования.

            А по поводу SPA разговор отдельный, там своя специфика и ее нужно знать. Иначе за окном будет гореть не лес, а весь город. (Те же вотчеры в AngularJs, ChangeDetectionStrategy в Angular и еще многое о чем я даже не подозреваю)

            • faiwer
              /#18889247 / +1

              Т.е. если замыкание оправдано — без проблем

              Главным и зачастую единственным аргументом тут (в JS) выступает читаемость кода. И если с анонимкой в замыкании код читается легче, ей быть, если же нет — выносим отдельно. Поэтому я и написал выше, всё зависит от конкретного участка кода. Нет универсальных рецептов.


              Нагромождение матрёшек никто не любит. С ними неудобно работать. Так же как и с кодом, который пусть и сделан "плоско", но теперь изобилует лишней сложностью, в виде множества аргументов, которые приходится таскать друг за другом паровозиками. И то и другое понемногу убивает кодовую вазу. Приходится искать компромисы.


              Пример с == и === вы зря привели. В большинстве случаев == это зло без явной выгоды. У меня в коде вообще такого нет. А замыкания это сердце языка. Они везде. не надо их бояться. Это не "тоже возможность языка", а по сути центровая возможность языка. Избегать замыканий без объективных на то причин — просто ставить себе палки в колёса. Можно и без них, но чаще хуже, чем лучше. Тут стоит вопрос не "можно ли обойтись без?", тут стоит вопрос "а можно ли здесь упростить, используя замыкание". Это вполне себе желанный инструмент, который используется для упрощения кодовой базы и работы с ней. Это не антипаттерн.


              Но как и любой инструмент — использовать надо мудро. Иначе будут нечитаемые уродливые матрёшки. Такой код потом только выкинуть )

              • Drag13
                /#18889319

                Идеологические споры самые бесполезные споры в мире. Поэтому предлагаю сойтись на


                любой инструмент — использовать надо мудро

                И спор закончить.

  5. Odrin
    /#18888999

    deleted

  6. gnaeus
    /#18889477 / +1

    В разделе "Работа с функциями" есть два очень опасных совета:


    • Использование нестандартного Function.caller — даже в MDN по ссылке написано, что оптимизатор может заинлайнить вызывающую функцию, и мы получим совсем не то, что имеем в виду.
    • Парсить регулярками результат Function.toString — а что будет если кто-то пропустит код через минификатор?

    • kyoumur
      /#18889799

      Спасибо, внёс уточнения в статью.

  7. Smokin
    /#18895211 / +1

    Не совсем понятен пример из

    Symbol.species — позволяет получить/изменить ссылку на конструктор, использующийся для создания новых объектов:

    Что за arr?

    • kyoumur
      /#18895217

      Спасибо за замечание, поправил опечатку. Также дополнил абзац про Symbol.species для большей ясности как его можно использовать.