Escopo, Hoisting e Closures

[38] Escopo, Hoisting e Closures

Um laço com var e um setTimeout dentro imprime 4, 4, 4 — e explicar esse resultado é explicar a aula inteira. Estão aqui o escopo de bloco que o var ignora, a Temporal Dead Zone que troca o undefined silencioso por um erro na hora, e a closure que mantém viva a variável de uma função já encerrada.
Javascript

14 min de leitura

Este é um dos artigos mais importantes da série. Não porque seja o mais difícil — mas porque os conceitos aqui explicam como o JavaScript realmente funciona por baixo dos panos. Muitos bugs misteriosos, comportamentos inesperados e confusões com variáveis têm raiz exatamente nestes três temas: escopo, hoisting e closures.

Desenvolvedores que entendem isso escrevem código mais previsível, mais seguro e mais fácil de depurar. Vamos com calma.

Escopo — onde uma variável existe

Escopo é o contexto em que uma variável existe e pode ser acessada. Pense como se fosse um conjunto de paredes invisíveis ao redor do seu código.

Escopo Global

Uma variável declarada fora de qualquer função ou bloco tem escopo global — ela existe em todo o programa:

const mensagem = "Olá, mundo!"; // escopo global

function exibir() {
  console.log(mensagem); // acessa sem problema
}

exibir(); // Olá, mundo!
console.log(mensagem); // Olá, mundo!

Evite variáveis globais sempre que possível. Elas podem ser alteradas por qualquer parte do código, causando bugs difíceis de rastrear.

Escopo de Função

Variáveis declaradas dentro de uma função existem apenas dentro dela:

function calcular() {
  const resultado = 42; // escopo local da função
  console.log(resultado); // 42
}

calcular();
console.log(resultado); // ReferenceError: resultado is not defined

Cada chamada de função cria seu próprio escopo isolado — suas variáveis não vazam para fora.

Escopo de Bloco

Com let e const, variáveis também ficam restritas ao bloco {} em que foram declaradas:

if (true) {
  let x = 10;
  const y = 20;
  console.log(x); // 10
  console.log(y); // 20
}

console.log(x); // ReferenceError: x is not defined
console.log(y); // ReferenceError: y is not defined

Isso não acontece com var — e é exatamente por isso que var é problemático:

if (true) {
  var z = 30; // var ignora o escopo de bloco!
}

console.log(z); // 30 ← vaza para fora do bloco

Essa é uma das principais razões para nunca usar var em código moderno.

Cadeia de Escopos (Scope Chain)

Quando o JavaScript não encontra uma variável no escopo atual, ele sobe na cadeia de escopos procurando no escopo pai:

const idioma = "Português"; // escopo global

function configurar() {
  const tema = "escuro"; // escopo de configurar

  function exibirConfig() {
    // acessa variáveis do próprio escopo, do pai e do global
    console.log(idioma); // Português ← do escopo global
    console.log(tema);   // escuro    ← do escopo pai
  }

  exibirConfig();
}

configurar();

A busca vai sempre de dentro para fora — nunca de fora para dentro.

Hoisting — elevação de declarações

Hoisting é o comportamento do JavaScript de "elevar" declarações para o topo do seu escopo antes da execução. É como se o JavaScript fizesse uma passagem pelo código antes de rodá-lo, registrando todas as declarações.

Hoisting com function declaration

Funções declaradas com function sofrem hoisting completo — você pode chamá-las antes de declará-las:

// Chamada ANTES da declaração — funciona!
console.log(somar(3, 4)); // 7

function somar(a, b) {
  return a + b;
}

O JavaScript eleva a função inteira para o topo do escopo antes de executar qualquer linha.

Hoisting com var

Variáveis declaradas com var têm sua declaração elevada, mas não sua inicialização:

console.log(nome); // undefined (não dá erro, mas não tem valor)
var nome = "Ana";
console.log(nome); // Ana

O que o JavaScript efetivamente enxerga:

var nome; // declaração elevada
console.log(nome); // undefined
nome = "Ana"; // inicialização fica no lugar
console.log(nome); // Ana

Isso é confuso e imprevisível — mais um motivo para evitar var.

Hoisting com let e const

let e const também são elevados, mas ficam em uma Temporal Dead Zone (TDZ) — não podem ser acessados antes da declaração:

console.log(cidade); // ReferenceError: Cannot access 'cidade' before initialization
const cidade = "São Paulo";

Este erro é muito melhor do que o undefined silencioso do var. Ele avisa imediatamente que algo está errado.

Hoisting com function expression e arrow function

Expressões de função e arrow functions não sofrem hoisting:

// Erro! multiplicar ainda não foi inicializada
console.log(multiplicar(3, 4)); // ReferenceError: Cannot access 'multiplicar' before initialization

const multiplicar = (a, b) => a * b;

Isso reforça a boa prática de declarar antes de usar.

Closures — funções que lembram

Closure é o conceito mais poderoso desta aula. Uma closure é criada quando uma função lembra do escopo em que foi criada, mesmo depois que esse escopo não existe mais.

Vamos ver passo a passo:

function criarContador() {
  let contagem = 0; // variável do escopo de criarContador

  function incrementar() {
    contagem++; // acessa e modifica a variável do escopo pai
    console.log(contagem);
  }

  return incrementar; // retorna a função — não a chama!
}

const contador = criarContador();
// criarContador() terminou de executar
// mas a variável "contagem" continua viva dentro da closure

contador(); // 1
contador(); // 2
contador(); // 3

criarContador() já terminou sua execução, mas contagem continua existindo porque incrementar a referencia. Isso é uma closure.

Closures na prática — fábricas de funções

Closures permitem criar funções especializadas a partir de funções genéricas:

function criarMultiplicador(fator) {
  return (numero) => numero * fator;
}

const dobrar = criarMultiplicador(2);
const triplicar = criarMultiplicador(3);
const decuplicar = criarMultiplicador(10);

console.log(dobrar(5));     // 10
console.log(triplicar(5));  // 15
console.log(decuplicar(5)); // 50

Cada função criada tem sua própria closure com um fator diferente.

Closures para dados privados

Uma das aplicações mais elegantes de closures é simular dados privados — variáveis que só podem ser acessadas por funções específicas:

function criarContaBancaria(saldoInicial) {
  let saldo = saldoInicial; // privado — ninguém acessa diretamente

  return {
    depositar(valor) {
      if (valor <= 0) return console.log("Valor inválido.");
      saldo += valor;
      console.log(`Depositado R$ ${valor}. Saldo: R$ ${saldo}`);
    },

    sacar(valor) {
      if (valor > saldo) return console.log("Saldo insuficiente.");
      saldo -= valor;
      console.log(`Sacado R$ ${valor}. Saldo: R$ ${saldo}`);
    },

    verSaldo() {
      console.log(`Saldo atual: R$ ${saldo}`);
    },
  };
}

const minhaConta = criarContaBancaria(1000);

minhaConta.verSaldo();     // Saldo atual: R$ 1000
minhaConta.depositar(500); // Depositado R$ 500. Saldo: R$ 1500
minhaConta.sacar(200);     // Sacado R$ 200. Saldo: R$ 1300

// Tentativa de acesso direto — impossível
console.log(minhaConta.saldo); // undefined — protegido pela closure!

O bug clássico de closure com var em loops

Este é um dos erros mais famosos do JavaScript — entendê-lo solidifica seu conhecimento de closure e escopo:

// ❌ Comportamento inesperado com var
for (var i = 1; i <= 3; i++) {
  setTimeout(() => console.log(i), 1000);
}
// Após 1 segundo: 4, 4, 4 ← não é o que esperávamos!

Por quê? var não tem escopo de bloco — todas as callbacks compartilham a mesma variável i, que já vale 4 quando o timeout executa.

// ✅ Correto com let — cada iteração tem seu próprio escopo
for (let i = 1; i <= 3; i++) {
  setTimeout(() => console.log(i), 1000);
}
// Após 1 segundo: 1, 2, 3 ← correto!

let cria uma nova variável i para cada iteração do loop — cada closure captura o seu próprio valor.

Tudo junto — um exemplo real

function criarPlacar(nomeTime) {
  let pontos = 0;
  let jogos = 0;

  return {
    vencer() {
      pontos += 3;
      jogos++;
      console.log(`${nomeTime} venceu! ${pontos} pontos em ${jogos} jogos.`);
    },
    empatar() {
      pontos += 1;
      jogos++;
      console.log(`${nomeTime} empatou. ${pontos} pontos em ${jogos} jogos.`);
    },
    perder() {
      jogos++;
      console.log(`${nomeTime} perdeu. ${pontos} pontos em ${jogos} jogos.`);
    },
    status() {
      const media = jogos > 0 ? (pontos / jogos).toFixed(1) : 0;
      console.log(`${nomeTime}: ${pontos}pts | ${jogos} jogos | Média: ${media}pts/jogo`);
    },
  };
}

const time = criarPlacar("Grêmio");
time.vencer();  // Grêmio venceu! 3 pontos em 1 jogos.
time.vencer();  // Grêmio venceu! 6 pontos em 2 jogos.
time.empatar(); // Grêmio empatou. 7 pontos em 3 jogos.
time.perder();  // Grêmio perdeu. 7 pontos em 4 jogos.
time.status();  // Grêmio: 7pts | 4 jogos | Média: 1.8pts/jogo

Tarefa para você

Desafio 1: Crie uma função criarSerie que receba o título de uma série e retorne um objeto com métodos para adicionarEpisodio, marcarAssistido e progresso:

const serie = criarSerie("Breaking Bad");
serie.adicionarEpisodio(); // total: 1
serie.adicionarEpisodio(); // total: 2
serie.marcarAssistido();   // assistidos: 1
serie.progresso();         // "Breaking Bad: 1/2 episódios assistidos (50%)"

Desafio 2: Explique com suas palavras por que o código abaixo imprime 3, 3, 3 e como corrigir:

for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 500);
}
Ver solução — criarSerie com closure, e por que o var imprime 3, 3, 3
// ---------------------------------------------------------------
// Desafio 1 — criarSerie
// ---------------------------------------------------------------
function criarSerie(titulo) {
  // `total` e `assistidos` vivem aqui dentro. Nenhum código de fora
  // alcança essas variáveis — só os três métodos devolvidos, que
  // continuam enxergando o escopo mesmo depois que criarSerie retornou.
  let total = 0;
  let assistidos = 0;

  return {
    adicionarEpisodio() {
      total++;
      return total;
    },

    marcarAssistido() {
      // A guarda importa: sem ela dá para marcar mais assistidos do
      // que existem, e o progresso passa de 100%.
      if (assistidos >= total) {
        throw new RangeError(`${titulo}: não há episódio novo para marcar.`);
      }
      assistidos++;
      return assistidos;
    },

    progresso() {
      if (total === 0) {
        return `${titulo}: nenhum episódio cadastrado.`;
      }
      const porcentagem = Math.round((assistidos / total) * 100);
      return `${titulo}: ${assistidos}/${total} episódios assistidos (${porcentagem}%)`;
    },
  };
}

const serie = criarSerie("Breaking Bad");
serie.adicionarEpisodio(); // total: 1
serie.adicionarEpisodio(); // total: 2
serie.marcarAssistido();   // assistidos: 1
console.log(serie.progresso()); // Breaking Bad: 1/2 episódios assistidos (50%)

// O estado é mesmo privado:
console.log(serie.total);       // undefined
serie.assistidos = 999;         // não afeta nada
console.log(serie.progresso()); // continua 1/2 (50%)

// E cada chamada cria um estado novo, independente:
const outra = criarSerie("The Wire");
outra.adicionarEpisodio();
console.log(outra.progresso()); // The Wire: 0/1 episódios assistidos (0%)
console.log(serie.progresso()); // Breaking Bad: 1/2 (50%) — intocada

// ---------------------------------------------------------------
// Desafio 2 — por que imprime 3, 3, 3
// ---------------------------------------------------------------
for (var i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 500);
}
// Imprime 3, 3, 3.

// `var` tem escopo de FUNÇÃO, não de bloco: existe UMA variável `i`
// para o laço inteiro. As três arrows fecham sobre essa mesma variável.
// Meio segundo depois, quando os callbacks rodam, o laço já terminou
// havia muito tempo — e o que fez ele terminar foi `i` chegar a 3.
// Elas não guardaram o valor: guardaram a variável.

// Correção 1 — `let` cria um binding NOVO por iteração:
for (let j = 0; j < 3; j++) {
  setTimeout(() => console.log(j), 500); // 0, 1, 2
}

// Correção 2 — antes do ES6, se criava o escopo à mão, com uma função
// que recebe o valor por cópia:
for (var k = 0; k < 3; k++) {
  (function (valor) {
    setTimeout(() => console.log(valor), 500); // 0, 1, 2
  })(k);
}

// ---------------------------------------------------------------
// Hoisting: as três declarações não se comportam igual
// ---------------------------------------------------------------
console.log(comVar);   // undefined — a declaração sobe, o valor não
// console.log(comLet); // ReferenceError: Cannot access before initialization

var comVar = 1;
let comLet = 2;

// `let` e `const` também sobem, mas ficam na "zona morta temporal":
// existem e são inacessíveis até a linha da declaração. O erro claro é
// melhor que o `undefined` silencioso do var.

declarada(); // funciona: declaração de função sobe inteira
function declarada() {
  console.log("subi completa");
}

// expressao(); // TypeError: expressao is not a function
const expressao = () => console.log("eu não");

Closure não guarda o valor da variável — guarda a variável. Todo bug de "o callback pegou o último item da lista" é isso: um var compartilhado onde deveria haver um binding por iteração.

Closure não é um recurso que se liga: é a consequência de a função lembrar onde nasceu. Uma vez entendido isso, a fábrica de funções, o dado privado e o contador que sobrevive entre chamadas deixam de parecer truques e viram o mesmo mecanismo visto de ângulos diferentes. E o defeito clássico do var dentro do laço fica quase óbvio: havia uma variável só, compartilhada por todas as voltas, e o let passou a criar uma por iteração.

Fontes e Referências

Exercícios

Exercício 1

Quais das três últimas linhas imprimem um valor, e o que acontece nas outras?

if (true) {
  var z = 30;
  let x = 10;
  const y = 20;
}

console.log(z);
console.log(x);
console.log(y);
Ver resposta

✓ Resposta: Só a primeira imprime: 30. A segunda lança ReferenceError: x is not defined e interrompe a execução — a terceira nem chega a rodar. O var ignora as chaves: seu escopo é a função (ou o global), então z vaza para fora do if como se tivesse sido declarado do lado de fora. let e const respeitam o bloco e deixam de existir ao sair dele. É a razão número um para não usar var.

Exercício 2

Os dois trechos leem uma variável antes da linha que a declara. O que cada um produz, e qual dos dois comportamentos é preferível?

// trecho A
console.log(nome);
var nome = "Ana";

// trecho B
console.log(cidade);
const cidade = "São Paulo";
Ver resposta

✓ Resposta: O trecho A imprime undefined; o B lança ReferenceError: Cannot access 'cidade' before initialization. Nos dois casos a declaração foi elevada, mas com var a variável já existe valendo undefined, enquanto let e const ficam na Temporal Dead Zone até a linha que as inicializa. O comportamento preferível é o segundo: um erro na hora, apontando a linha, custa minutos; um undefined silencioso viaja pelo programa e só aparece muito depois, longe da causa.

Exercício 3

Usando a criarContador do artigo, o que as quatro chamadas imprimem?

const contadorA = criarContador();
const contadorB = criarContador();

contadorA(); contadorA(); contadorA();
contadorB();
Ver resposta

✓ Resposta: 1, 2, 3 e depois 1. Cada chamada de criarContador() cria um escopo novo, com sua própria variável contagem, e a função devolvida guarda esse escopo só para ela. As duas closures não se enxergam: contadorB não herda nem enxerga a contagem de contadorA. É exatamente esse isolamento que faz das closures uma fábrica de estados independentes.

Exercício 4

Por que o primeiro laço imprime 4, 4, 4 e o segundo imprime 1, 2, 3, se a única diferença é uma palavra?

for (var i = 1; i <= 3; i++) {
  setTimeout(() => console.log(i), 1000);
}

for (let i = 1; i <= 3; i++) {
  setTimeout(() => console.log(i), 1000);
}
Ver resposta

✓ Resposta: Com var existe uma única variável i para o laço inteiro, e as três funções agendadas guardam a mesma. Quando o segundo passa e elas finalmente rodam, o laço já terminou e i vale 4 — o valor que fez a condição falhar. Com let, cada volta cria uma variável nova, e cada função agendada captura a sua; por isso saem 1, 2 e 3. Note que o problema não é o setTimeout: é o adiamento revelando qual variável foi capturada.

Exercício 5

Por que minhaConta.saldo imprime undefined, e o que aconteceria se criarContaBancaria devolvesse { saldo, depositar, sacar, verSaldo }?

const minhaConta = criarContaBancaria(1000);
console.log(minhaConta.saldo);
Ver resposta

✓ Resposta: Porque saldo é uma variável do escopo de criarContaBancaria, e não uma propriedade do objeto devolvido — de fora, ela simplesmente não existe, e ler uma propriedade inexistente devolve undefined. Só os métodos, que nasceram dentro daquele escopo, a enxergam. Se o objeto passasse a devolver saldo, a proteção acabaria em dois sentidos: qualquer um poderia ler e escrever minhaConta.saldo = 999999, contornando as verificações de sacar. E haveria um segundo problema: essa propriedade seria uma cópia do número no momento da criação, congelada em 1000 enquanto o saldo real mudasse por dentro.

Comentários

Mais em Javascript

Revisão + Projeto Final: SPA Completa
Revisão + Projeto Final: SPA Completa

As cinco peças do módulo em uma aplicação só: rotas com layout e proteção…

Revisão do Módulo 8 + Projeto: Refatoração com Clean Architecture
Revisão do Módulo 8 + Projeto: Refatoração com Clean Architecture

O que muda ao fim do módulo não é o que se consegue fazer, é a distância de…

Trabalhando com JSON
Trabalhando com JSON

JSON parece um objeto JavaScript, mas é bem mais pobre: não tem data, não tem…