Go to English Blog

Escrevendo JavaScript modular

Leia em 3 minutos

Você começa escrevendo algumas linhas de código, a coisa começa a tomar forma e quando você vai ver, toda a funcionalidade que você implementou está contida em uma única função, monolítica, que você terá dificuldades de entender o que fez em pouco tempo. Quem usa jQuery já passou por isso.

Estou fazendo o fórum do HOWTO e comecei a trabalhar no textarea de resposta. Minha ideia foi seguir a mesma linha do Disqus. Veja o objetivo final abaixo:

Postbox do fórum do HOWTO

Sempre que preciso implementar esse tipo de coisa, começo pelo jeito mais simples, que é usar o “modo jQuery” de fazer as coisas. Primeiro vamos definir o markup (ou uma versão simplificada dele; na versão real estou usando atributos data para fazer algumas coisas com AJAX).

<div class="postbox">
  <textarea class="pb-input" placeholder="Escreva sua resposta..."></textarea>
  <button class="reply-button pb-button">Enviar esta resposta</button>
</div>

Todas as animações são feitas com CSS3. Note que o CSS abaixo não contém as versões com prefixo, apenas para simplificar (eu uso Bourbon para isso).

.postbox {
  background: #f3f6f7;
  border: 1px solid #ccc;
  overflow: hidden;
  position: relative;
}

.postbox .pb-button {
  background: #606b73;
  border: none;
  bottom: -1px;
  color: #fff;
  display: none;
  float: right;
  overflow: hidden;
  padding: 10px 15px;
  position: relative;
  right: -1px;
}

.postbox .pb-input {
  box-sizing: border-box;
  border: none;
  height: 50px;
  outline: none;
  padding: 8px 10px;
  resize: none;
  width: 100%;

  -webkit-transition: height .2s ease-in;
     -moz-transition: height .2s ease-in;
          transition: height .2s ease-in;
}

.postbox.did-focus .pb-input {
  height: 100px;
}

.postbox .pb-input.is-expanded {
  height: 200px;
}

.postbox.did-focus .pb-button {
  display: block;
}

.postbox.is-contracted .pb-input {
  height: 50px;
}

O textarea possui três alturas distintas. Quando ele não teve foco, sua altura é de 50px. Se ele teve foco, sua altura muda para 150px. Por fim, quando atingir 5 quebras de linha, ele mudará para 300px. Todas essas mudanças são feitas com classes CSS (ou a ausência delas).

Para lidar com o foco do textarea, comecei com a implementação abaixo. Toda vez que um textarea recebe o foco, adicionamos a classe did-focus e removemos a classe is-contracted.

$(".postbox textarea")
  .on("focus", function(){
    $(this).closest(".postbox")
      .addClass("did-focus")
      .removeClass("is-contracted")
    ;
  })
;

O próximo passo é ouvir o evento keyup, para fazer a contagem de linhas do textarea. Se tiver pelo menos cinco linhas, adicionamos a classe is-expanded; caso contrário, removemos esta mesma classe.

$(".postbox textarea")
  .on("focus", function(){
    $(this).closest(".postbox")
      .addClass("did-focus")
      .removeClass("is-contracted")
    ;
  })

  .on("keyup", function(){
    var lines = this.value.split(/\r?\n/);
    var container = $(this).closest(".postbox");

    if (lines.length >= 5) {
      container.addClass("is-expanded");
    } else {
      container.removeClass("is-expanded");
    }
  })
;

Por fim, precisamos ouvir o evento blur para saber se o textarea possui algum conteúdo ou não e adicionar a classe is-contracted ao container .postbox de acordo com essa verificação.

$(".postbox textarea")
  .on("focus", function(){
    $(this).closest(".postbox")
      .addClass("did-focus")
      .removeClass("is-contracted")
    ;
  })

  .on("keyup", function(){
    var lines = this.value.split(/\r?\n/);
    var container = $(this).closest(".postbox");

    if (lines.length >= 5) {
      container.addClass("is-expanded");
    } else {
      container.removeClass("is-expanded");
    }
  })

  .on("blur", function(){
    if (!this.value) {
      $(this).closest(".postbox").addClass("is-contracted");
    }
  })
;

Esta implementação funciona, mas ela não é das melhores. Normalmente eu prefiro escrever códigos mais modulares, que são mais fáceis de testar, entender e dar manutenção.

Para começar, vamos definir um módulo deste componente, que irá receber o container .postbox durante sua inicialização. Além disso, iremos extrair os elementos usados pelo componente, fazendo caching e facilitando a referência aos elementos.

Module("HOWTO.Postbox", function(Postbox){
  Postbox.fn.initialize = function(container) {
    this.container = container;
    this.input = container.find(".pb-input");
    this.button = container.find(".pb-button");
  };
});

Eu estou usando uma biblioteca chamada Module.js. Note que o objeto fn é apenas um atalho para prototype.

Vamos adicionar os eventos do input. Os bindings são feitos através da função HOWTO.Postbox.fn.addEventListeners.

Module("HOWTO.Postbox", function(Postbox){
  Postbox.fn.initialize = function(container) {
    this.container = container;
    this.input = container.find(".pb-input");
    this.button = container.find(".pb-button");

    this.addEventListeners();
  };

  Postbox.fn.addEventListeners = function() {
    this.input
      .on("focus", this.onInputFocus.bind(this))
      .on("keyup", this.onInputKeyUp.bind(this))
      .on("blur", this.onInputBlur.bind(this))
    ;
  };
});

Perceba que estou usando a função Function.prototype.bind para forçar o contexto this para a instância de HOWTO.Postbox. Isso é uma coisa que sempre faço, e que é um dos motivos de confusão do JavaScript.

O modelo de eventos do JavaScript faz com que o contexto this seja sempre o elemento que lançou o evento. No exemplo acima, seria sempre o textarea, mas isso impossibilitaria o acesso à instância de HOWTO.Postbox.

Agora podemos implementar as funções que lidarão com os eventos. Elas permanecem essencialmente como na implementação original. A única diferença é que precisamos acessar o elemento através de event.target, já que o contexto this não é mais o próprio textarea.

Module("HOWTO.Postbox", function(Postbox){
  Postbox.fn.initialize = function(container) {
    this.container = container;
    this.input = container.find(".pb-input");
    this.button = container.find(".pb-button");

    this.addEventListeners();
  };

  Postbox.fn.addEventListeners = function() {
    this.input
      .on("focus", this.onInputFocus.bind(this))
      .on("keyup", this.onInputKeyUp.bind(this))
      .on("blur", this.onInputBlur.bind(this))
    ;
  };

  Postbox.fn.onInputFocus = function(event) {
    this.container
      .addClass("did-focus")
      .removeClass("is-contracted")
    ;
  };

  Postbox.fn.onInputKeyUp = function(event) {
    var lines = event.target.value.split(/\r?\n/);

    if (lines.length >= 5) {
      this.container.addClass("is-expanded");
    } else {
      this.container.removeClass("is-expanded");
    }
  };

  Postbox.fn.onInputBlur = function(event) {
    if (!event.target.value) {
      this.container.addClass("is-contracted");
    }
  };
});

Para usar o nosso componente, você pode ter algo assim:

$(".postbox").each(function(){
  HOWTO.Postbox($(this));
});

Finalizando

O código final é ligeiramente maior que a implementação original, mas tem uma vantagem: ele é mais simples de entender e manter. Isso porque as responsabilidades de cada função são bem definidas, em vez de termos um espaguete de código dentro de uma única função.

Lembre-se que em projetos maiores, seu JavaScript não é composto por poucas linhas e, no fim, a legibilidade e facilidade de manutenção são muito mais importantes que todo o resto.