12k
All articles

10 Perguntas de Entrevista sobre JS e o Que Elas Realmente Avaliam

10 perguntas de entrevista JavaScript sobre hoisting, closures, this, event loop e coerção, com respostas e o que elas testam.

OpenReplay Team
OpenReplay Team
10 Perguntas de Entrevista sobre JS e o Que Elas Realmente Avaliam

A maioria das perguntas de entrevista sobre JavaScript não busca saber qual é a saída — elas avaliam se você compreende o modelo que a produz. Um entrevistador sênior que escreve console.log(x); var x = 5; no quadro branco já sabe que o resultado é undefined. O que ele está avaliando é se você consegue explicar o porquê sem recorrer à frase “o hoisting move a declaração para o topo”, porque essa frase é o modelo mental, não o mecanismo. Os candidatos que avançam são aqueles capazes de nomear o comportamento em tempo de execução, prever a saída e articular o raciocínio em voz alta em duas frases claras.

Este artigo aborda dez perguntas que mapeiam dez fundamentos distintos — o modelo de execução, a Temporal Dead Zone, closures, escopo léxico, binding de this, o event loop e coerção — e divide cada uma em quatro partes: o trecho de código, a saída exata e o motivo, o que o entrevistador realmente está avaliando e o follow-up que você vai receber em seguida. Todas as saídas são definidas pela especificação e reproduzíveis em qualquer engine atual. Os comportamentos em si são estáveis em todas as engines atuais — são semânticas da linguagem, não peculiaridades específicas de versão.

Principais Conclusões

  • A pergunta sobre hoisting com var não testa se você sabe que a saída é undefined — ela testa se você entende que o JavaScript cria bindings de variáveis ao entrar em um escopo, antes de qualquer linha ser executada.
  • let e const também sofrem hoisting, mas permanecem não inicializados na Temporal Dead Zone até a linha de declaração, portanto lê-los antes lança um ReferenceError em vez de retornar undefined.
  • No clássico bug do setTimeout dentro de um loop for, var registra o valor final do loop (3 3 3) porque todos os callbacks compartilham um único binding com escopo de função, enquanto let registra o valor de cada iteração (0 1 2) porque cria um novo binding por iteração.
  • this não é determinado onde uma função é escrita — é determinado por como a função é chamada; arrow functions são a exceção porque não possuem this próprio.
  • Callbacks de Promise (microtasks) sempre são executados antes de callbacks de setTimeout (macrotasks), mesmo com um delay de 0ms.

Perguntas de entrevista sobre JavaScript: hoisting e o modelo de execução

1. Por que var registra undefined antes da atribuição?

console.log(x); // undefined
var x = 20;
console.log(x); // 20

A primeira linha registra undefined, não um ReferenceError. A explicação popular é que a declaração é “movida para o topo”, mas isso é uma metáfora. O que realmente acontece: quando a engine entra em um escopo, ela instancia os bindings daquele escopo antes de executar qualquer instrução, e um binding var é inicializado como undefined nesse momento. A atribuição = 20 permanece em sua linha original e é executada em ordem. Essa etapa de criação de bindings está definida na ECMAScript Language Specification sob a instanciação do ambiente de variáveis — nada é fisicamente relocado.

O que realmente está sendo avaliado: se você entende que o JS possui uma fase de criação antes da fase de execução. A resposta undefined é trivial; o modelo de execução em duas fases é a competência. Consulte a entrada do glossário de Hoisting do MDN para a formulação canônica.

Follow-up provável: “E se fosse let em vez de var?” — que é a pergunta 2.

2. Por que let lança uma exceção em vez de registrar undefined?

console.log(y); // ReferenceError: Cannot access 'y' before initialization
let y = 20;

let e const sofrem hoisting — o binding é criado na entrada do escopo — mas são deixados não inicializados até que a execução alcance a linha de declaração. A janela entre a entrada no escopo e a linha de declaração é a Temporal Dead Zone, e ler um binding dentro dela lança ReferenceError: Cannot access 'y' before initialization. Essa é a distinção fundamental: var é inicializado como undefined na criação; let/const não são inicializados de forma alguma até que sua linha seja executada. O MDN documenta isso na seção Temporal Dead Zone do let.

O que realmente está sendo avaliado: se você separa criação de binding de inicialização de binding. Um candidato que diz “let não sofre hoisting” tem o modelo errado; a resposta correta é que ele sofre hoisting, mas permanece não inicializado.

Follow-up provável: “Então por que a TDZ existe?” — diga que ela torna o const aplicável e transforma o uso antes da declaração em um erro explícito em vez de um undefined silencioso.

Aqui está a tabela de referência que os entrevistadores esperam que você reconstrua verbalmente:

Comportamentovarletconst
Sofre hoisting (binding criado na entrada do escopo)SimSimSim
Inicializado na criaçãoundefinedNão (TDZ)Não (TDZ)
Leitura antes da declaraçãoundefinedReferenceErrorReferenceError
EscopoFunçãoBlocoBloco
Redeclarável no mesmo escopoSimNãoNão
ReatribuívelSimSimNão

3. Hoisting de declaração de função vs. expressão de função

foo(); // "I run"
bar(); // TypeError: bar is not a function

function foo() { console.log("I run"); }
var bar = function () { console.log("I don't"); };

Uma declaração de função sofre hoisting completo — o nome e o corpo — portanto foo() funciona antes de sua definição. Uma expressão de função atribuída a var bar eleva apenas o binding bar, inicializado como undefined. Chamar undefined lança um TypeError, não um ReferenceError — o binding existe, mas ainda não é uma função. Acertar o tipo de erro é o diferencial aqui. O MDN cobre essa distinção em declarações de função.

O que realmente está sendo avaliado: se você compreende que declarações elevam valores enquanto expressões elevam apenas bindings. Essa é a mesma distinção das perguntas 1 e 2, aplicada a funções.

Follow-up provável: “E se bar fosse declarado com let?” — então a chamada antecipada lança um ReferenceError da TDZ em vez de um TypeError.

Perguntas de entrevista sobre JavaScript: closures e escopo

4. O clássico setTimeout dentro de um loop

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

for (let i = 0; i < 3; i++) {
  setTimeout(() => console.log(i), 0); // 0 1 2
}

O loop com var registra 3 3 3. Existe um único i com escopo de função; quando os callbacks são disparados (após o loop síncrono terminar), i é 3, e todas as três closures leem esse mesmo binding. O loop com let registra 0 1 2 porque let cria um novo binding para cada iteração, portanto cada callback fecha sobre seu próprio i. Esse é o trecho de código mais mal respondido da web, e ele empilha três conceitos: closures, escopo e o event loop (os callbacks são adiados, portanto o loop termina primeiro). O guia de Closures do MDN documenta o binding por iteração do let.

O que realmente está sendo avaliado: se você consegue raciocinar sobre quando uma closure lê uma variável versus quando ela foi criada. Isso não é trivial — é a mesma classe de bug que vai para produção como estado de stale-closure em event handlers e effects do React. Session replays de handlers com stale-closure frequentemente revelam o registro de um valor capturado de uma renderização anterior.

Follow-up provável: “Corrija sem usar let.” — envolva o corpo em uma IIFE que recebe i como argumento, criando um novo escopo por iteração.

5. O contador privado

function makeCounter() {
  let count = 0;
  return {
    increment: () => ++count,
    value: () => count,
  };
}

const c = makeCounter();
c.increment(); // 1
c.increment(); // 2
c.value();     // 2
// count é inacessível de fora

Uma closure é uma função agrupada com as variáveis às quais ela tinha acesso onde foi definida, não onde é chamada — é por isso que os métodos retornados ainda acessam count após makeCounter ter retornado. Como nada externo expõe count diretamente, ele é efetivamente privado. Isso é encapsulamento construído a partir do escopo, não de um campo #private.

O que realmente está sendo avaliado: se você entende closures como uma ferramenta de memória e encapsulamento, não apenas como uma resposta de quiz. O follow-up investiga se você conhece o custo.

Follow-up provável: “Isso causa vazamento de memória?” — a variável count permanece ativa enquanto c for acessível, porque a closure mantém uma referência; isso é intencional aqui, mas closures não controladas sobre objetos grandes são uma fonte real de vazamento.

6. Escopo léxico: definido, não chamado

const x = 10;

function outer() {
  const x = 20;
  return inner;
}

function inner() {
  console.log(x); // 10
}

outer()(); // 10

inner registra 10, não 20. O JavaScript resolve variáveis livres pelo local onde uma função é definida no código-fonte, não onde é chamada. inner é definida no nível superior, portanto seu x resolve para o 10 do nível superior — o x dentro de outer é irrelevante para ela. Isso é escopo léxico (estático), e é o que torna closures previsíveis.

O que realmente está sendo avaliado: se você confunde a call stack com a cadeia de escopo. Linguagens com escopo dinâmico registrariam 20; o JavaScript não faz isso.

Follow-up provável: “Agora mova a declaração de inner para dentro de outer.” — então ela resolve para 20, porque seu local de definição mudou.

Perguntas de entrevista sobre JavaScript: binding de this

7. A que this se refere aqui?

const user = {
  name: "Ada",
  greet() { return this.name; },
};

const fn = user.greet;
user.greet(); // "Ada"
fn();         // undefined (ou lança exceção no strict mode)

this não é determinado onde uma função é escrita — é determinado por como a função é chamada. Chamada como user.greet(), o objeto no call-site é user, portanto this.name é "Ada". Atribuída a fn e chamada de forma isolada, não há objeto no call-site, portanto this é o objeto global no modo não-estrito (fazendo this.name ser undefined) ou undefined no strict mode (fazendo this.name lançar uma exceção). As quatro regras de binding estão resumidas na referência de this do MDN.

O que realmente está sendo avaliado: se você sabe que this é dinâmico e determinado pelo call-site, não fixado lexicamente.

Follow-up provável: “Como você fixa this em user?” — fn.call(user), fn.apply(user) ou user.greet.bind(user).

Forma de chamadaA que this é vinculado
fn() (isolada)undefined (strict) / objeto global (não-estrito)
obj.fn() (método)obj (o objeto à esquerda do ponto)
fn.call(o) / fn.apply(o) / fn.bind(o)o (explícito)
new Fn()a instância recém-criada
arrow functionherdado do escopo léxico envolvente

8. this dentro de um callback

const timer = {
  seconds: 0,
  startBroken() {
    setInterval(function () { this.seconds++; }, 1000); // this está errado
  },
  startFixed() {
    setInterval(() => { this.seconds++; }, 1000); // this é timer
  },
};

Em startBroken, a function comum passada para setInterval é chamada pelo mecanismo do timer sem objeto no call-site, portanto this não é timerthis.seconds++ muta o objeto errado. Em startFixed, a arrow function não possui this próprio e o herda do escopo de startFixed, onde this é timer. Esse é o motivo clássico pelo qual arrow functions existem para callbacks. Consulte o MDN sobre arrow functions.

O que realmente está sendo avaliado: se você entende o this léxico e por que as arrow functions substituíram o antigo workaround var self = this. O bug de this perdido em handlers aparece constantemente em class components e event listeners em produção.

Follow-up provável: “Por que você não pode usar uma arrow function como construtor ou como método de objeto que precisa de seu próprio this?” — porque ela não possui binding de this para atribuir, portanto new lança uma exceção e uma arrow no nível de método captura o this externo em vez do objeto.

Perguntas de entrevista sobre JavaScript: o modelo de runtime

9. Preveja a ordem no console (event loop)

console.log("1");
setTimeout(() => console.log("2"), 0);
Promise.resolve().then(() => console.log("3"));
console.log("4");
// Saída: 1 4 3 2

Callbacks de Promise (microtasks) sempre são executados antes de callbacks de setTimeout (macrotasks), mesmo com um delay de 0ms. O rastreamento de execução:

  1. console.log("1") é executado de forma síncrona → 1.
  2. setTimeout agenda seu callback na fila de macrotasks.
  3. Promise.resolve().then(...) agenda seu callback na fila de microtasks.
  4. console.log("4") é executado de forma síncrona → 4.
  5. A call stack está agora vazia. A engine esvazia a fila inteira de microtasks antes de qualquer macrotask → 3.
  6. Somente então ela processa a próxima macrotask → 2.

O guia de microtasks do MDN descreve essa ordenação.

O que realmente está sendo avaliado: se você entende que o event loop possui dois níveis de prioridade, não uma única fila. O event loop com dois níveis é o modelo por trás de todo bug do tipo “meu estado atualizou um tick atrasado”.

Follow-up provável: “Onde async/await se encaixa nisso?” — o código após um await é executado como uma microtask, portanto é enfileirado com a mesma prioridade que um callback .then().

10. == vs. === e coerção

0 == "0";        // true
0 === "0";       // false
null == undefined; // true
NaN === NaN;     // false
[] == ![];       // true

== realiza coerção de tipo antes de comparar, enquanto === não faz isso, razão pela qual 0 == "0" é true mas 0 === "0" é false. Os casos contraintuitivos: null == undefined é true por uma regra especial (e eles não são iguais a mais nada sob ==); NaN nunca é igual a nada, incluindo a si mesmo; e [] == ![] é true porque ![] é false, que coerce para 0, e [] coerce para "" e depois para 0. O algoritmo completo está no guia de comparações de igualdade do MDN.

O que realmente está sendo avaliado: se você consegue prever a coerção — não se você consegue recitar “sempre use ===”. O entrevistador quer o mecanismo, depois a regra prática.

Follow-up provável: “E quanto à atribuição a uma variável não declarada?” — se value = 42 (sem var/let/const) lança uma exceção ou cria silenciosamente um global depende do modo: o strict mode e os módulos ES lançam um ReferenceError; scripts no modo não-estrito criam um global implícito. Mesmo trecho, duas respostas corretas — identificar essa distinção é em si um sinal de senioridade.

O que separa uma contratação de uma reprovação

O fio condutor entre todas as dez perguntas: os entrevistadores avaliam a explicação, não apenas a saída. Prever 3 3 3 ou 1 4 3 2 prova que você já viu o problema; nomear o modelo de binding, a cadeia de escopo, a regra do call-site e o event loop com dois níveis prova que você entende a linguagem bem o suficiente para depurá-la sob pressão. Pratique essas dez até conseguir dizer o porquê em duas frases sem consultar anotações — e como nenhum desses comportamentos depende de versão, você pode verificar cada trecho de código em qualquer engine atual e confiar no resultado.

Perguntas Frequentes

Qual é a diferença entre a Temporal Dead Zone e uma variável simplesmente sendo undefined?

Uma variável na Temporal Dead Zone sofreu hoisting mas ainda não foi inicializada, portanto lê-la lança um ReferenceError, enquanto um var undefined sofreu hoisting e foi inicializado com o valor undefined, portanto lê-lo retorna undefined. Apenas let e const criam uma TDZ; ela vai desde a entrada no escopo até a linha de declaração ser executada. A distinção é entre criação de binding e inicialização de binding.

Por que arrow functions falham quando usadas como métodos de objeto ou construtores?

Arrow functions não possuem binding de this próprio, portanto não podem servir a funções que exigem um. Usada como método de objeto, uma arrow captura this do escopo léxico envolvente em vez do objeto, portanto this.property não aponta para o objeto. Usada com new, a engine lança um TypeError porque não há binding de this para atribuir à nova instância. Use funções regulares em ambos os casos.

O bug do setTimeout dentro do loop se comporta da mesma forma em todas as engines JavaScript?

Sim. A versão com var registra o valor final do loop em todas as engines atuais porque var cria um único binding com escopo de função compartilhado por todos os callbacks, e a versão com let registra valores por iteração em todos os lugares porque a especificação define um novo binding por iteração para let em um loop for. Esse comportamento é definido pela especificação no ECMA-262, não é específico de engine, portanto Node.js, V8, SpiderMonkey e JavaScriptCore produzem saída idêntica.

async e await alteram a prioridade no event loop em comparação com Promise.then?

Não. O código após um await é executado como uma microtask, com exatamente a mesma prioridade que um callback Promise.then, portanto é executado antes de qualquer macrotask de setTimeout. Um await efetivamente pausa a função e agenda a continuação na fila de microtasks quando o valor aguardado é resolvido. Isso significa que uma continuação aguardada e um callback then enfileirado no mesmo ponto são resolvidos na ordem do código-fonte, e ambos são executados antes de qualquer callback de timer definido para zero milissegundos.

Open-source session replay

Complete picture for complete understanding

Capture every clue your frontend is leaving so you can instantly get to the root cause of any issue with OpenReplay — the open-source session replay tool for developers. Self-host it in minutes, and have complete control over your customer data.

Star on GitHub12k

We use cookies to improve your experience. By using our site, you accept cookies.