Концепцию декораторов можно рассматривать с разных сторон. Это и шаблон проектирования описанный в известной книге GOF и подход применяемый в функциональном программировании для построения композиции функций. Как правило, когда говорят о декораторах в контексте ECMA Script, то под этим подразумевают функции, которые могут применяться к другим функциям или объектам для изменения или расширения их поведения. Иначе говоря, эти функции декорируют (оборачивают) другие функции или объекты.
Тему декораторов мы разобьём на 2 части. В данной статье попробуем разобраться в сути декораторов, для чего они могут быть полезны, а также как реализованы декораторы в современном JavaScript. Во второй части мы посмотрим на реализацию декораторов в TypeScript.
Декораторы функций
Давайте начнём знакомство с декораторами на примере написания простого декоратора функции, который позволит логировать вызов декорируемой функции. Реализация такого декоратора может выглядеть следующим образом:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 |
// Функция сложения 2-x чисел const sum = (a, b) => { return a + b; }; // Функция-декоратор для логирования имени вызываемой функции const logName = (fn) => { // Здесь мы возвращаем функцию в теле которой выводим // наименование функции-аргумента return (...args) => { console.log(`Call function "${fn.name}"`); return fn(...args); } } // Применение декоратора const wrappedSum = logName(sum); const result = wrappedSum(2, 3); console.log(result); // 5 |
В коде выше мы объявили простую функцию sum, которая возвращает сумму 2-х переданных чисел. Далее мы объявили функцию-декоратор logName. Такой декоратор всего лишь возвращает другую функцию в теле которой мы логируем имя функции-аргумента, а затем вызываем переданную функцию и возвращаем результат вызова. Получается, что наш декоратор никак не модифицирует переданную функцию, а только добавляет некое дополнительное поведение.
Данный декоратор мы сможем применить к любому типу функций, т.к. он отвечает только за вывод сообщения, но не влияет на вызов функции и не знает дополнительных особенностей переданной функции-аргумента.
Давайте посмотрим на более сложный пример использования. Раз у нас есть функция сложения 2-х чисел, то можно написать декоратор, который будет валидировать переданные аргументы функции на соответствие целым числам. Такой декоратор может быть реализован, например, вот так:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 |
// Декоратор для валидации переданных аргументов функции const integerArgs = (fn) => { return (...args) => { if (args.some((arg) => !Number.isInteger(arg))) { throw new Error('Переданные аргументы должны быть целыми числами'); } return fn(...args); } } // Применение декоратора const wrappedSum = integerArgs(sum); const result = wrappedSum('2', 3); // Error: Переданные аргументы должны быть целыми числами console.log(result); |
Здесь мы описали функцию, которая принимает в качестве параметра функцию для которой нам нужно проверить валидность переданных аргументов. При этом сама проверка происходит внутри другой функции, которую мы возвращаем из нашей функции-декоратора. Если хотя бы один переданный аргумент функции не будет являться целым числом, то выбросится ошибка. В противном случае мы перейдём к выполнению декорируемой функции и вернём результат её вызова. Теперь мы можем попробовать применить несколько декораторов к одной функции, однако порядок применения в данном случае будет важен:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
// Пример простого декоратора, который логирует имя переданной функции const logName = (fn) => { // code... } // Декоратор для валидации переданных аргументов функции const integerArgs = (fn) => { // code... } // Функция сложения 2-x чисел const sum = (a, b) => { return a + b; }; // Применяем несколько декораторов к одной функции const wrappedSum = integerArgs(logName(sum)); const result = wrappedSum(2, 3); // Call function "sum" console.log(result); // 5 |
Здесь мы можем наблюдать ещё одно преимущество декораторов — для каждой задачи мы можем создать отдельный декоратор, который будет делать что-то одно, но делать это хорошо, что позволит создавать комбинации декораторов и применять их к различного вида сущностям (в нашем случае это функция).
Примеры, описанные выше, довольно просты, но позволяют нам понять саму суть декораторов и то как мы можем использовать их для функций. Но что если нам хочется пойти дальше и использовать декораторы, например, для классов?
Современная спецификация декораторов
Для разработки и включения в состав ECMA Script новых фич существует специальный комитет именуемый TC 39 (technical committee 39). Прежде чем попасть в стандарт каждое предложение рассматривается TC 39 и проходит несколько этапов (4 основных), начиная от идеи новой фичи до утверждённой комитетом и включенной в стандарт спецификации. Более подробно про то как TC 39 принимает новые возможности в стандарт можно прочитать в этой статье.
К сожалению, спецификация декораторов всё ещё находится на stage 2 (черновик) и официально не включена в стандарт ECMA Script. Однако мы можем использовать декораторы в своём коде применяя транспиляцию через Babel или TypeScript. В следующем разделе рассматривается как настроить поддержку декораторов используя Babel. Вы можете пропустить этот раздел и ознакомиться с готовой конфигурацией в репозитории, содержащем примеры к этой статье.
Настройка использования декораторов через Babel
Чтобы иметь возможность использовать декораторы в своём JavaScript коде мы должны воспользоваться специальным Babel плагином, реализующем поддержку декораторов. Если мы начинаем настройку с нуля, то для начала нам нужно проинициализировать package.json файл проекта:
1 2 3 |
# Создаём package.json с дефолтными значениями полей npm init -y |
Затем необходимо установить нужные зависимости:
1 2 3 4 5 6 7 8 9 10 11 12 |
# Устанавливаем babel-core и babel-cli npm i @babel/core @babel/cli -D # Устанавливаем @babel/preset-env npm i @babel/preset-env -D # Устанавливаем плагин для поддержки декораторов npm i @babel/plugin-proposal-decorators -D # Устанавливаем nodemon для перезапуска скомпилированных скриптов npm i nodemon -D |
Для того чтобы работать с Babel мы поставили 2 основных пакета: @babel/core, который обеспечивает базовую функциональность транспилятора и @babel/cli — для работы с Babel из терминала. Далее мы установили пакет @babel/preset-env, который содержит стандартный набор плагинов для поддержки последних фич JavaScript и которые можно настраивать через один конфиг. Более подробно про пресеты можно узнать из документации к Babel. Следующей командой устанавливаем отдельный плагин для поддержки декораторов — @babel/plugin-proposal-decorators. Ну и напоследок нам нужно установить nodemon для запуска скриптов, которые будут скомпилированы Babel.
После установки зависимостей нам нужно создать файл настроек babel.config.json
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
{ "presets": [ ["@babel/preset-env", { "shippedProposals": true, "loose": true }] ], "plugins": [ [ "@babel/plugin-proposal-decorators", { "decoratorsBeforeExport": true } ] ] } |
В этом файле мы подключаем и конфигурируем установленные ранее пресет и плагин для использования декораторов.
Заключительным этапом будет настройка npm команды для запуска компиляции исходных файлов в режиме отслеживания. Для этого в файле package.json есть специальный блок scripts в котором мы сможем разместить следующую команду:
1 2 |
babel src --out-dir dist --watch |
Этой командой мы указываем Babel компилировать файлы из директории src с выводом в директорию dist. Флаг —watch будет запускать компиляцию при каждом изменении исходных файлов в директории src. На этом основная настройка заканчивается. Чтобы проверить конфигурацию нам нужно создать файл с кодом в директории src (например index.js) после чего запустить команду: npm start. Если всё настроено верно, то после запуска команды будет создана директория dist в которой окажется скомпилированный файл index.js.
Чтобы автоматически каждый раз запускать скомпилированные файлы нам нужно открыть отдельный терминал, перейти в директорию dist и выполнить команду: nodemon filename (опционально). Nodemon запущенный в текущей директории по умолчанию ищет файл с именем index.js. Если файл изменится, то nodemon автоматически перезапустит скрипт.
Использование декораторов классов в JavaScript
Так как же работают декораторы в текущей спецификации? Начнём с того как это реализовано на сегодняшний день в JavaScript с использованием плагина для Babel. Декоратор это по прежнему просто функция, но есть отличия в том как эта функция будет применяться и что в качестве аргументов будет принимать. Давайте более детально посмотрим на простой декоратор класса:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 |
// Объявляем простой декоратор класса const classDecorator = (target) => { console.log(target); // Object [Descriptor] { kind: 'class', elements: [] } } // Объявляем класс и применяем к нему декоратор @classDecorator class User { firstname; secondname; constructor(firstname, secondname) { this.firstname = firstname; this.secondname = secondname; } } |
В коде выше мы объявили функцию classDecorator, которая будет являться декоратором нашего класса. В качестве аргумента декоратор класса принимает дескриптор, описывающий объект к которому применяется декоратор. Для нашего класса такой объект имеет два поля: kind — тип объекта и elements — массив элементов класса. Теперь нам нужно применить декоратор. Для этого мы используем специальный символ @ перед его именем. Остановимся подробнее на поле elements объекта-дескриптора. В данном случае в поле будут находиться доступные элементы класса (методы, акцессоры, поля), описанные каждый своим дескриптором. К примеру, если мы добавим в класс метод, то он станет доступен в массиве elements:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 |
const classDecorator = (target) => { console.log(target); // В классе появился метод, который теперь доступен в массиве elements // Object[Descriptor] { // kind: 'class', // elements: [ // Object[Descriptor] { // kind: 'method', // key: 'getFullName', // placement: 'prototype', // descriptor: [Object] // } // ] // } } @classDecorator class User { firstname; secondname; constructor(firstname, lastname) { this.firstname = firstname; this.lastname = lastname; } // Добавили метод, который возвращaет полное имя пользователя getFullName() { return this.firstname + ' ' + this.lastname; } } |
Таким образом, применяя декоратор к классу, мы сможем получить доступ к его элементам.
Декораторы методов в JavaScript
Раз мы объявили метод, то давайте теперь посмотрим на то, что будет доступно в декораторе метода:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 |
// Объявляем простой декоратор метода const methodDecorator = (target) => { console.log(target); // Object[Descriptor] { // kind: 'method', // key: 'getFullName', // placement: 'prototype', // descriptor: { // value: [Function: getFullName], // writable: true, // configurable: true, // enumerable: false // } // } } class User { // code... // Применяем декоратор для метода @methodDecorator getFullName() { return this.firstname + ' ' + this.lastname; } } |
Здесь мы можем видеть, что в декораторе метода также доступен объект target, но по сравнению с тем объектом, что передавался в декоратор класса, этот объект содержит немного другой набор полей, а именно, теперь отсутствует поле elements, но добавилось два новых поля: placement — содержит значение указывающее расположение декорируемого свойства; descriptor — специальный объект, являющийся дескриптором свойства.
Доступность дескриптора свойства внутри нашего декоратора позволяет нам изменять поведение или характеристики свойства. Например, давайте напишем декоратор для запрета переопределения метода в прототипе (что для JavaScript вполне легально):
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23 24 25 26 27 28 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 |
'strict mode' // Объявляем декоратор запрещающий переопределять свойство const readonly = (target) => { // Запрещаем перезаписывать свойство target.descriptor.writable = false; // После изменения дескриптора обязательно возвращаем target return target; } class User { firstname; secondname; constructor(firstname, lastname) { this.firstname = firstname; this.lastname = lastname; } // Применяем readonly декоратор для метода @readonly getFullName() { return this.firstname + ' ' + this.lastname; } } // Создаём нашего пользователя const user = new User('John', 'Doue'); // Вызов метода до переопределения let userName = user.getFullName(); console.log(userName); // John Doue // Пытаемся переопределить метод // Если используется 'strict mode', то получим ошибку: // TypeError: Cannot assign to read only property 'getFullName' of object '#<User> user.getFullName = function() { return 'Overrided method'; } // Без 'strict mode' исполнение дойдёт до этого места, // но результат будет как и до переопределения userName = user.getFullName(); console.log(userName); // John Doue |
Подробнее про дескриптор свойства
Данный дескриптор описывает различные параметры свойства объекта такие как: value — текущее значение (в нашем случае это функция); writable — возможность перезаписывать свойство (например, назначать другую функцию); enumerable — доступность свойства при итерации по объекту; configurable — возможность переопределять непосредственно сам дескриптор данного свойства. При этом вышеописанные параметры относятся к т.н. дескриптору данных, который не описывает поведение при обращении или изменении свойства. Чтобы описать такое поведение используется дескриптор доступа, который содержит два поля: get — для указания функции, которая будет вызываться при чтении свойства и set — для указания функции, которая будет вызываться при изменении свойства. Ещё один важный момент — дескриптор свойства может относиться только к одному из указанных выше типов. При описании дескриптор должен описывать либо только данные, либо только поведение при доступе к этим данным (указание get и set методов). Более подробно ознакомиться с дескриптором свойства, а также тем как его определять, можно в документации на MDN.
Декораторы полей в JavaScript
Аналогично методу, декоратор можно применить также и для полей класса или акцессоров (get/set). Давайте используем уже существующий декоратор @readonly для одного из полей класса:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 |
'strict mode' // Объявляем декоратор запрещающий переопределять свойство const readonly = (target) => { console.log(target); // Object [Descriptor] { // kind: 'field', // key: 'firstname', // placement: 'own', // descriptor: { configurable: true, writable: true, enumerable: true }, // initializer: [Function: value] } // code... } class User { @readonly firstname = 'Chuck'; // code... } |
В примере выше мы объявили класс со свойством firstname, которое уже проинициализировано значением и для этого свойства применён декоратор readonly. Использование данного декоратора, также как и в случае с методом, запретит присваивать другое значение этому свойству.
На текущий момент в актуальной версии черновика ECMA Script поддерживаются следующие декораторы: декораторы классов, методов, полей и акцессоров. Существует также отдельный документ, который описывает так называемые EXTENSIONS, которые содержат к примеру: альтернативный синтаксис аннотации декораторов, декораторы параметров, let и const декораторы, а также другие различные расширения использования декораторов. Как и основное предложение этот документ находится в статусе черновика, поэтому нет уверенности в том, что вообще что-либо описанное в этом документе попадёт в стандарт.
Заключение
На этом мы завершим обзор того как устроены декораторы в JavaScript на сегодняшний день. В следующей части мы перейдём к рассмотрению устройства декораторов в TypeScript и попробуем понять в чём основная разница между этими двумя реализациями. Надеюсь, что к моменту выхода статьи спецификация ещё не устареет =)
Спасибо за внимание!