Contextos em Javascript

Contextos em Javascript

Uma das linguagens de programação mais populares desde o início dos anos 2000 (se não a mais popular), Javascript é considerada uma linguagem orientada a objetos. Para aqueles que não estão familiarizados com o termo, isso significa que em Javascript podemos aplicar naturalmente alguns dos conceitos principais da programação orientada a objetos, que utiliza a declaração de classes e objetos e usa suas propriedades e métodos (funções internas de objetos).

Vamos observar a classe Vehicle como exemplo:

class Vehicle {
    name;
    numberOfPassengers;
    move;

    constructor(name, numberOfPassengers, move){
        this.name = name;
        this.numberOfPassengers = numberOfPassengers;
        this.move = move;
    }
}

Vemos que um Vehicle tem algumas variáveis definidas: name, numberOfPassengers, e move. Por padrão, todas as classes têm um método construtor, que serve para construir um novo objeto da classe fornecida. O método construtor de nossa classe Vehicle recebe parâmetros que são atribuídos às suas variáveis internas do objeto.

Observe que, para atribuir os parâmetros às variáveis do objeto, o construtor usa a palavra this. antes do nome das variáveis. Essa palavra-chave this se refere ao contexto que envolve aquela função, e engloba tudo dentro das chaves de definição da classe.

A maneira de construir um veículo é usando a palavra-chave new seguido de uma chamada ao método construtor da classe(No nosso caso Vehicle()) com os argumentos necessários, atribuindo o valor a uma variável, como no exemplo abaixo:

function drive(){
    console.log("dirigindo um " + this.name)
}
var car = new Vehicle("Porshe", 2, drive)

console.log(car.name)
console.log(car.numberOfPassengers)
console.log(car.move())

// Saída esperada:
// Porshe
// 2
// dirigindo um Porshe

Podemos ver que nosso novo objeto Vehicle é um Porshe que pode transportar até 2 passageiros. Ele também recebe uma função, drive, que imprime no console. Podemos acessar as propriedades do carro e imprimi-las no console, assim como podemos chamar seu método move e o resultado também é impresso no console.

No entanto, vamos considerar a função ride abaixo. Ela é muito semelhante à função drive do código anterior.

function ride(){
    console.log("passeando de " + this.name)
}
ride()

// Saída esperada:
// passeando de undefined

Se chamarmos a função ride da forma como está, obtemos a mensagem "passeando de undefined" no console. Isso acontece porque, quando a chamamos, não há uma variável name nesse contexto para ser referenciada. Ao construir um objeto e passar para ele uma função que usa o contexto envolvente, a função o detecta automaticamente e o utiliza. É assim que nosso objeto plane abaixo funciona corretamente, assim como o nosso objeto car anterior.

function fly(){
    console.log("pilotando um " + this.name)
}

var plane = new Vehicle("Boeing 777-300", 368, fly)
plane.move()

// Saída esperada:
// pilotando um Boeing 777-300

Essa é uma maneira muito simples de entender como contextos funcionam no Javascript, mas às vezes encontramos situações mais complexas. Por exemplo, digamos que você queira observar alterações em um arquivo e exibir o conteúdo desse arquivo sempre que ele for alterado. Para isso, usamos a função watch do módulo fs do Node. Digamos que sua classe FileHandler seja semelhante à apresentada abaixo:

import { watch, promises } from "fs";

class FileHandler {
    watch(event, filename) {
        this.showContent(filename);
    }

    async showContent(filename) {
        // o conteúdo é retornado como um buffer, então convertemos para string
        const content = await promises.readFile(filename).toString()
        console.log(content);
    }
}

const fileHandler = new FileHandler()
watch(__filename, fileHandler.watch)

Se tentarmos passar o método fileHandler.watch como no trecho acima, ele será ignorado pela linguagem. Isso ocorre devido a outro conceito da programação orientada a objetos que é aplicado naturalmente em Javascript, que é a herança ( segundo a herança uma classe pode herdar propriedades e métodos de outra classe, permitindo o reuso de código e relacionamentos hierárquicos entre classes).

Em vez disso, a linguagem tentará usar o método watch do objeto FSWatcher, que está dentro do módulo fs, e possui um método watch interno. No entanto, esse objeto não possui um método showContent como nosso fileHandler, o que resultará em um erro.

Esse é um problema de contexto que pode ser resolvido especificando em qual contexto a função que estamos tentando chamar está. Para isso, temos três métodos herdados por todos os objetos Javascript: bind, call e apply.

Bind

O método bind cria uma cópia de uma função vinculada ao contexto fornecido como primeiro argumento. Se quisermos resolver nosso problema com esse método, podemos fazer da seguinte maneira:

watch(__filename, fileHandler.watch.bind(fileHandler));

Pode parecer redundante à primeira vista, mas dessa forma nos certificamos de que, durante a execução, o método watch que estamos chamando será aquele contido no objeto fileHandler. Como o método bind cria uma cópia da função, também podemos atribuí-lo a uma nova variável passando-a logo em seguida, assim:

var boundFileHandlerWatch = fileHandler.watch.bind(fileHandler)
watch(__filename, boundFileHandlerWatch);

Call

O método call chama a função alterando o contexto para o contexto passado como primeiro argumento. Resolver nosso problema com esse método ficaria assim:

watch(__filename, fileHandler.watch.call(fileHandler));

Apply

O método apply, de forma semelhante ao método anterior call, chama a função alterando o contexto para o contexto passado como primeiro argumento.

A única diferença aqui é como passamos os argumentos para o método que estamos alterando o contexto. Enquanto o método call recebe os argumentos individualmente, o método apply recebe um array de valores como segundo argumento.

Como exemplo, observe nosso fileHandler. Seu método watch recebe dois argumentos: o evento e o nome do arquivo:

class FileHandler {
    watch(event, filename) {
        this.showContent(filename);
    }

    async showContent(filename) {
        const content = await promises.readFile(filename).toString();
        console.log(content);
    }
}

Para simular a forma como fs.watch chama nosso método fileHandler.watch, podemos alterar o contexto do nosso método para outro contexto onde há um método showContent e passar os argumentos como nos exemplos abaixo:

var callContext = { showContent: () => console.log("hello call!") };
fileHandler.watch.call(callContext, "change", __filename);

var applyContext = { showContent: () => console.log("hello apply!") };
fileHandler.watch.apply(applyContext, ["change", __filename]);

// Saída esperada:
// hello call!
// hello apply!

Vemos que tanto call quanto apply são muito semelhantes. Ao usá-los, basta ter cuidado para passar os argumentos na ordem correta, pois os parâmetros serão recebidos na ordem respectivamente.

É isso aí! Da próxima vez que você passar métodos entre contextos, certifique-se de especificá-los corretamente para evitar problemas como esse.