Suportando Decorators com vanilla JavaScript
Não há suporte nativo a decorators na linguagem JavaScript (ano base 2018), apesar de existir formalmente uma proposta em andamento. Neste artigo Implementaremos uma solução padronizada para que possamos utilizar decorators com vanilla JavaScript hoje.
O problema
Temos a seguinte classe Person
:
// app/models/person.js;
export class Person {
constructor(name, surname) {
this._name = name;
this._surname = surname;
}
speak(phrase) {
return `${this._name} is speaking... ${phrase}`
}
getFullName() {
return `${this._name} ${this._surname}`;
}
}
Queremos logar o tempo de execução dos métodos speak
e getFullName
. Uma solução é alterarmos os métodos adicionando todo o código necessário para cronometrar o tempo:
// app/models/person.js;
export class Person {
constructor(name, surname) {
this._name = name;
this._surname = surname;
}
speak(phrase) {
console.time('speak');
const result = `${this._name} is speaking... ${phrase}`;
console.timeEnd('speak');
return result;
}
getFullName() {
console.time('speak');
const result = `${this._name} ${this._surname}`;
console.timeEnd('speak');
return result;
}
}
Não precisamos meditar muito para verificarmos que duplicamos nosso código. Aliás, teríamos ainda mais código duplicado se outras classes da nossa aplicação precisassem cronometrar também a execução de seus métodos.
Uma solução é isolar o código duplicado em um único lugar e aplicá-lo aos métodos da classe. Aliás, outras linguagem já resolveram essa questão adicionando em sua sintaxe o suporte a decorators.
Decorators
Decorators são suportados nativamente em linguagens como Python, Java, C# e por outras linguagens, inclusive TypeScript. Em suma, um decorator nada mais é do que um trecho de código isolado aplicável em uma ou mais funções, inclusive em métodos de classe. A lógica a ser aplicada depende do problema a ser resolvido, mas o mais importante é que teremos essa lógica em um lugar apenas, evitando assim duplicação de código.
Vejamos a seguir um exemplo do uso de decorators na linguagem TypeScript.
TypeScript e decorators
Em TypeScript, podemos isolar um código e aplicá-lo em métodos de classes através da sintaxe @nomeDoDecorator
:
// app/models/person.js;
export class Person {
constructor(name, surname) {
this._name = name;
this._surname = surname;
}
@logExecutionTime
speak(phrase) {
return `${this._name} is speaking... ${phrase}`
}
@logExecutionTime
getFullName() {
return `${this._name} ${this._surname}`;
}
}
A implementação da função
logExecutionTime
em TypeScript foi omitida, pois focaremos na implementação da nossa solução.
Será que podemos chegar a um resultado semelhante diretamente na linguagem JavaScript, inclusive sem o uso de um transcompilador como Babel? Vamos tentar.
Primeira solução
Pela característica dinâmica da linguagem JavaScript, podemos alterar diretamente no prototype de Person
o método speak
. Para isso, precisamos guardar o método original para então chamá-lo na nova função que o substituirá:
// app/app.js
import { Person } from './models/person.js';
// guardou o método original
const method = Person.prototype.speak;
// substitui o método por uma função
Person.prototype.speak = function (...args) {
console.log(`Argumentos do método: ${args}`);
console.time('speak');
// executa o código original
const result = method.bind(this)(...args);
console.log(`Resultado do método: ${result}`);
console.timeEnd('speak');
return result;
};
const person = new Person('Flávio', 'Almeida');
person.speak('Canganceiro JavaScript');
Foi utilizada a sintaxe de carregamento nativo de módulos suportado pelo Google Chrome. Saiba mais no artigo “Importação nativa de módulos no browser”, deste mesmo autor.
Vamos analisar o código. Guardarmos uma referência para o método definido no prototype da classe Person
na variável method
.
Não esqueça que ao declararmos uma classe com a sintaxe
class
as definições de seus métodos são adicionadas em seu prototype. É por isso que acessarmosPerson.prototype.speak
e nãoPerson.speak
.
Com o método original guardado, substituímos Person.prototype.speak
por uma função que redefiná o método. Ela recebe um número indeterminado de parâmetros através do Rest operator, pois não sabemos quantos parâmetros o método original recebe.
Vale ressaltar que utilizamos uma função no lugar de uma arrow function pois necessitamos de um escopo dinâmico, isto é, precisamos que o this
seja a instância que invoca o método naquele momento.
Dentro da função que define o novo método, executamos um código arbitrário antes e depois da chamada do método original, todavia, um trecho de código merece destaque:
const result = method.bind(this)(...args);
Através de method.bind(this)
criamos uma nova referência para o método original que tem como contexto o this
da nova função. Lembre-se que esse this
referenciará a instância que estiver invocando o novo método. Em seguida, passamos através do spread operator cada parâmetro recebido no array args
como parâmetros individuais para a função.
Executando nosso código, a saída do console será:
Método chamado com os parâmetros: Canganceiro JavaScript
Resultado do método: Flávio is speaking... Canganceiro JavaScript
speak: 0.494873046875ms
Excelente, mas essa estratégia deixa a desejar. Lembrem-se que precisamos aplicar a mesma lógica ao método getFullName
e em outras classes quando necessário. Sendo assim, precisamos padronizar nossa solução em algo que seja fácil de usar pelos demais desenvolvedores da equipe.
Isolando decorators e definindo uma API
Para facilitar a vida do desenvolvedor, queremos a seguinte solução:
// app/app.js
import { Person } from './models/person.js';
// as demais funções ainda não existem!
import { decorate } from './utils/decorate.js';
import { logExecutionTime } from './models/decorators.js';
decorate(Person, {
speak: logExecutionTime,
getFullName: logExecutionTime
});
const person = new Person('Flávio', 'Almeida');
person.speak('Canganceiro JavaScript');
Na solução que acabamos de ver temos a função utilitária decorate
receberá dois parâmetros. O primeiro é a classe que desejamos decorar seus métodos. O segundo é o objeto handler. As propriedades do objeto handler equivalem aos nomes dos métodos que desejamos decorar na classe. Seu valor será o decorator que desejamos aplicar.
Vamos dar uma olhada na implementação do decorator logExecutionTime
:
// app/models/decorators.js
export const logExecutionTime = (method, property, args) => {
console.log(`Método decorado: ${property}`);
console.log(`Argumentos do método ${args}`);
console.time(property);
const result = method(...args);
console.timeEnd(property);
console.log(`resultado do método: ${result}`)
return result;
};
É a função utilitária decorate
que passará os parâmetros que nosso decorator precisa. Em ordem, esses parâmetros são:
- O método original a ser decorado, com seu contexto já modificado
- O nome do método
- Os parâmetros que o método recebe (ou não)
O mais interessante é que definimos uma API para criação de decorators. Essa padronização é importante, pois permitirá que a solução seja utilizada por outros desenvolvedores mais facilmente. Sabendo o que cada parâmetro representa, fica fácil entender a lógica do nosso decorator.
A grande questão agora é a implementação da função utilitária decorate
, centro nervoso da nossa solução.
Implementando a função decorate
Vamos criar o módulo app/utils/decorate.js
. Nele declararemos a função decorate
que receberá a clazz
e o handler
:
// app/utils/decorate.js
export const decorate = (clazz, handler) => // falta o resto
A primeira coisa que faremos é listar todas as chaves do objeto handler
, pois são elas que indicam qual método decorar da classe.
Todavia, só podemos iterar nas propriedades do próprio objeto handler
(aquelas que adicionamos ao criá-lo) e não do seu prototype, no caso Object
. Se iteramos nas propriedades do prototype
acabaremos encontrando outras chaves que não dizem respeito aos métodos que desejamos decorar. A função Object.keys()
nos atende muito bem:
// app/utils/decorate.js
export const decorate = (target, handler) =>
// retorna todas as propriedades enumeráveis do objeto
Object.keys(handler).forEach(property => {
// falta implementar
});
Em cada iteração precisaremos guardar o método original, um dos parâmetros que nossos decorators dependem:
// app/utils/decorate.js
export const decorate = (target, handler) =>
Object.keys(handler).forEach(property => {
const method = clazz.prototype[property];
});
Armazenamos na variável method
uma referência para o método que será decorado. Agora precisamos fazer a mesma coisa, mas dessa vez para armazenar o decorator do método:
// app/utils/decorate.js
export const decorate = (target, handler) =>
Object.keys(handler).forEach(property => {
const method = clazz.prototype[property];
const decorator = handler[property];
});
Ótimo! Só precisamos modificar o método original da classe para que invoque nosso decorator. Lembrando que o decorator receberá como parâmetro uma referência para o método original, inclusive já associado ao this
da função que substituiu o método na classe:
// app/utils/decorate.js
export const decorate = (clazz, handler) => {
Object.keys(handler).forEach(property => {
const method = clazz.prototype[property];
const decorator = handler[property];
// Adiciona novo método que ao ser chamado
// chamará o decorator por debaixo dos panos
clazz.prototype[property] = function (...args) {
return decorator(method.bind(this), property, args);
};
});
};
Excelente, o código que escrevemos até agora é suficiente para que nosso decorator seja aplicado. Mas se quisermos aplicar mais de um decorator por método?
Métodos com mais de um decorator
O decorator logExecutionTime
esta com muita responsabilidade. Extrairemos dele o código que loga os dados da função como o seu nome, parâmetros recebidos e seu retorno em um novo decorator que chamaremos de inspectMethod
:
Alterando app/models/decorators.js
:
// app/models/decorators.js
export const logExecutionTime = (method, property, args) => {
console.time(property);
const result = method(...args);
console.timeEnd(property);
return result;
};
export const inspectMethod = (method, property, args) => {
console.log(`Método decorado: ${property}`);
console.log(`Argumentos do método ${args}`);
const result = method(...args);
console.log(`resultado do método: ${result}`)
return result;
};
Reparem que nossos decorators seguem a mesma API, fantástico!
Agora, alterando app/app.js
para fazer uso do nosso novo decorator:
import { Person } from './models/person.js';
import { decorate } from './utils/decorate.js';
import { logExecutionTime, inspectMethod } from './models/decorators.js';
// passando um array de decorators
decorate(Person, {
speak: [inspectMethod, logExecutionTime],
getFullName: [logExecutionTime]
});
const person = new Person('Flávio', 'Almeida');
person.speak('Canganceiro JavaScript');
Recebemos um erro no console:
Uncaught TypeError: decorator is not a function
at Person.clazz.(anonymous function)
Infelizmente a função decorate
não esta preparada para lidar com um Array
de decorators. Precisamos alterá-la para que funcione:
// app/utils/decorate.js
export const decorate = (clazz, handler) => {
Object.keys(handler).forEach(property => {
const decorators = handler[property];
decorators.forEach(decorator => {
// o método já pode ter sido decorado antes
const method = clazz.prototype[property];
clazz.prototype[property] = function (...args) {
return decorator(method.bind(this), property, args);
};
});
});
};
Excelente, agora podemos combinar decorators em um mesmo método.
Ordem dos decorators
Se analisarmos atentamente a aplicação dos decorators, veremos que são aplicados da direita para a esquerda. Para facilitar o entendimento, vamos alterar nosso código para que o primeiro decorator da lista seja o primeiro a ser aplicado e assim por diante.
// app/util/decorate.js
export const decorate = (clazz, handler) => {
Object.keys(handler).forEach(property => {
// faz reverse
const decorators = handler[property].reverse();
decorators.forEach(decorator => {
const method = clazz.prototype[property];
clazz.prototype[property] = function (...args) {
return decorator(method.bind(this), property, args);
};
});
});
};
Agora, vamos alterar a ordem dos decorators em app/app.js
:
// app/app.js
// código anterior omitido
// agora aplicará da esquerda para a direita
decorate(Person, {
speak: [logExecutionTime, inspectMethod],
getFullName: [logExecutionTime]
});
// código posterior omitido
Agora fica mais fácil para quem lê o código entender a ordem de aplicação dos decorators.
Tudo excelente, mas se nosso decorator precisar receber uma configuração incial?
Decorators que recebem parâmetros
Criamos o decorator inspectMethod
, mas nem sempre queremos logar o resultado do método, apenas seus parâmetros. Podemos atender a este requisito facilmente. Primeiro, vamos alterar o decorator inspectMethod
:
export const inspectMethod = ({ excludeReturn } = {}) =>
(method, property, args) => {
console.log(`Método decorado: ${property}`);
console.log(`Argumentos do método ${args}`);
const result = method(...args);
if(!excludeReturn) console.log(`resultado do método: ${result}`)
return result;
};
Vejam que nosso decorator recebe como parâmetro um objeto JavaScript que ao sofrer o destructuring disponibilizará a variável excludeReturn
, utilizada para controlar a exibição da variável result
. Por fim, o retorno do decorator será a função que adere à API que define nossos decorators.
Alterando o módulo app/app.js
:
import { Person } from './models/person.js';
import { decorate } from './utils/decorate.js';
import { logExecutionTime, inspectMethod } from './models/decorators.js';
decorate(Person, {
speak: [inspectMethod({ excludeReturn: true }), logExecutionTime],
getFullName: [logExecutionTime]
});
const person = new Person('Flávio', 'Almeida');
person.speak('Canganceiro JavaScript');
person.getFullName();
Se não passarmos parâmetro nenhum para inspectMethod
o padrão continuará sendo inspecionar o retorno do método.
Podemos tornar ainda melhor a maneira pela qual aplicamos nossos decorators.
Aplicando o decorator diretamente na definição da classe:
É possível decorar a classe diretamente no módulo no qual foi declarada. Vamos alterar app/models/person.js
:
import { decorate } from '../utils/decorate.js';
import { logExecutionTime, inspectMethod } from './decorators.js';
export class Person {
constructor(name, surname) {
this._name = name;
this._surname = surname;
}
speak(phrase) {
return `${this._name} is speaking... ${phrase}`
}
getFullName() {
return `${this._name} ${this._surname}`;
}
}
decorate(Person, {
speak: [inspectMethod({ excludeReturn: true }), logExecutionTime],
getFullName: [logExecutionTime]
});
Por fim, nosso nódulo app/app.js
ficará dessa forma:
// app/app.js
import { Person } from './models/person.js';
const person = new Person('Flávio', 'Almeida');
person.speak('Canganceiro JavaScript');
person.getFullName();
Nosso módulo dependerá dos decorators e da função decorate
. Se o JavaScript suportasse nativamente decorators, dependeríamos apenas dos decorators, além de podermos aplicá-los com a sintaxe @
, aliás, muito sexy.
Código no Github
Você encontra o código completo deste artigo no meu github.
Conclusão
Apesar de o suporte a decorator ter sido especificado na linguagem JavaScript, ainda não há um consenso sobre sua implementação. Alguns frameworks do mercado, inclusive a linguagem TypeScript suportam este recurso com sintaxe @
, a mesma utilizada na linguagem Python que há anos suporta nativamente este recurso. Todavia, nada nos impede de implementá-lo em vanilla JavaScript. E você? Deixe sua opnião!