JavaScript por baixo dos panos · parte 1 de 3
Como o JavaScript executa código: execution context, memória e call stack
Entenda como o JavaScript prepara o contexto global, inicializa bindings, executa funções e usa a Call Stack para controlar o fluxo do código.
Quando a gente começa a estudar JavaScript, é comum resumir a história assim: o código é executado linha por linha, e os valores necessários ficam guardados na memória. Esse modelo ajuda no começo, mas, para entender de verdade Execution Context, Hoisting e Call Stack, ele precisa ficar um pouco mais preciso.
Então, neste artigo, a proposta é fazer duas coisas ao mesmo tempo:
- manter o modelo mental simples o suficiente para acompanhar o raciocínio
- ajustar esse modelo para ele continuar didático sem ficar tecnicamente enganoso
Vou usar Thread of Execution como um nome didático para o fluxo síncrono atual do código. Mas, do ponto de vista do modelo de execução, as peças mais importantes aqui são Execution Context, memória e Call Stack.
O ponto de partida
Quando um arquivo JavaScript começa a rodar, uma forma útil de pensar é esta:
- o JavaScript prepara o contexto em que aquele código vai executar
- depois ele começa a executar o código propriamente dito
Em outras palavras, antes de começar a percorrer as instruções do arquivo, o JavaScript cria um Global Execution Context.
Esse contexto global não fica “solto”. Ele é o primeiro contexto colocado na Call Stack, e é a partir dele que o restante do programa passa a existir do ponto de vista da execução.
Esse contexto organiza, entre outras coisas:
- quais bindings existem naquele escopo
- quais funções já ficam disponíveis
- qual é o estado atual da execução
- para onde o fluxo deve voltar quando uma função termina
Em explicações mais simplificadas, isso às vezes aparece só como “memória”. Como modelo inicial, ajuda. Mas, sendo um pouco mais preciso, o contexto também carrega as referências de escopo e o estado da execução atual.
Isso já ajuda a enxergar uma diferença importante:
- a fase de criação prepara o contexto
- a fase de execução roda as instruções
Exemplo base
Vamos usar este código como referência:
const num = 3 // {1}
function multiplicarPor2(number) { // {2}
const result = number * 2 // {4}
return result // {5}
}
const output = multiplicarPor2(num) // {3}
const novoOutput = multiplicarPor2(10) // {6}Agora vamos acompanhar esse código com um pouco mais de precisão.
1. Antes da primeira linha executar
Antes da fase de execução começar, o JavaScript cria o contexto global.
Nesse momento, os bindings do escopo global passam a existir.
Isso inclui:
nummultiplicarPor2outputnovoOutput
Mas eles não ficam todos no mesmo estado.
Para este exemplo, vale guardar o seguinte:
function declarations, comomultiplicarPor2, já ficam disponíveis no contexto- bindings declarados com
consteletexistem, mas ainda não podem ser acessados antes da linha de inicialização
Didaticamente, podemos imaginar o começo do programa assim:
Global Execution Context (fase de criação)
------------------------------------------
num: TDZ
multiplicarPor2: <function>
output: TDZ
novoOutput: TDZEsse TDZ - Temporal Dead Zone significa que o binding já existe, mas ainda não foi inicializado. Se tentarmos acessá-lo cedo demais, teremos um erro:
ReferenceError: can't access lexical declaration 'num' before initialization.
1.1 Isso tem um nome: hoisting
O que aconteceu na fase de criação acima tem um nome que você provavelmente já ouviu: hoisting.
A ideia central é que, antes da execução começar, o JavaScript "eleva" as declarações para o topo do escopo. Mas essa explicação, do jeito que costuma ser ensinada, carrega uma imprecisão que vale corrigir logo.
A versão comum diz mais ou menos assim:
Só
varefunction declarationssofrem hoisting.leteconstnão.
Isso está errado — e o diagrama que acabamos de ver já prova isso.
Quando representamos o contexto global antes da execução, vimos isso:
Global Execution Context (fase de criação)
------------------------------------------
num: TDZ
multiplicarPor2: <function>
output: TDZ
novoOutput: TDZnum, output e novoOutput foram declarados com const. E mesmo assim, eles já existem no contexto antes de qualquer linha executar. Isso é hoisting acontecendo com const.
A diferença real não é se o hoisting acontece, mas em que estado cada binding fica após ser içado:
| Binding | Sofre hoisting? | Estado inicial |
|---|---|---|
var | ✅ | undefined |
function declaration | ✅ | função completa |
let | ✅ | TDZ |
const | ✅ | TDZ |
var é içado e já recebe undefined imediatamente. Por isso, acessar um var antes da linha de declaração não lança erro — você só recebe undefined.
let e const são içados, mas ficam no estado uninitialized até que a execução chegue na linha de declaração. Acessar antes disso lança um ReferenceError.
A TDZ não é uma ausência de hoisting. É o resultado dele.
A prova mais convincente
Se você ainda tiver dúvida, esse exemplo deixa claro:
let x = 'global';
{
console.log(x); // ReferenceError: can't access 'x' before initialization
let x = 'local';
}Se let não sofresse hoisting, o console.log dentro do bloco leria o x do escopo externo e imprimiria 'global' normalmente.
O erro acontece justamente porque o x local foi içado para o topo do bloco. O JavaScript já sabe que aquele binding existe ali — mas ainda não foi inicializado. Então ele bloqueia o acesso.
Hoisting acontece para todos. O que muda é o que você encontra quando tenta acessar antes da hora.
2. A execução começa de fato
Agora sim a fase de execução começa, e o JavaScript passa pelas instruções do arquivo.
Na linha {1}, temos:
const num = 3Aqui, o binding num, que já existia mas ainda não estava inicializado, passa a receber o valor 3.
Se quisermos representar isso conceitualmente, fica assim:
Global Execution Context (após {1})
-----------------------------------
num: 3
multiplicarPor2: <function>
output: TDZ
novoOutput: TDZ3. E a função multiplicarPor2?
Na linha {2}, aparece a declaração da função:
function multiplicarPor2(number) {
const result = number * 2
return result
}Aqui entra um detalhe importante.
Em explicações muito simplificadas, é comum dizer que "agora o JavaScript guardou a função na memória". Como imagem mental, isso ajuda. Mas, sendo mais preciso, o binding dessa function declaration já tinha sido criado na fase de criação do contexto global.
Ou seja: quando a execução chega a essa linha, a função já está disponível para ser chamada.
O ponto mais importante continua sendo este:
- a função foi declarada
- ela já está disponível no escopo global
- mas seu corpo ainda não foi executado
4. Quando a chamada de função aparece
Na linha {3}, temos:
const output = multiplicarPor2(num)Nesse ponto, o JavaScript precisa resolver o lado direito da expressão antes de concluir a inicialização de output.
Então o fluxo é este:
- buscar o valor de
num - chamar
multiplicarPor2com esse valor - esperar o retorno da função
- só então inicializar
output
Como num vale 3, a chamada fica, na prática:
multiplicarPor2(3)5. Chamar uma função cria um novo Execution Context
Quando uma função é chamada, o JavaScript cria um novo Execution Context para aquela execução específica.
Esse ponto importa bastante.
Não nasce uma nova thread para aquela função. Continua existindo o mesmo fluxo síncrono de execução. O que muda é que agora existe um novo contexto, com sua própria memória local, empilhado na Call Stack.
Então vale guardar isto:
- o arquivo todo roda dentro de um contexto global
- cada chamada de função cria um novo contexto de execução
- esse novo contexto entra na
Call Stack
6. O que acontece dentro do contexto da função
Ao chamar multiplicarPor2(3), o JavaScript cria o contexto dessa função.
Assim como no contexto global, vale pensar em duas etapas:
Fase de criação do contexto da função
Antes de executar o corpo da função, o JavaScript prepara o contexto local.
Nesse caso:
- o parâmetro
numberjá recebe o argumento3 - o binding
resultpassa a existir, mas ainda não foi inicializado
Conceitualmente:
Function Execution Context (fase de criação)
--------------------------------------------
number: 3
result: TDZAqui temos dois conceitos importantes:
numberé o parâmetro3é o argumento passado na chamada
Fase de execução do contexto da função
Agora o JavaScript executa o corpo da função linha por linha.
Na linha {4}, temos:
const result = number * 2Como number vale 3, então result passa a valer 6.
O contexto local fica assim:
Function Execution Context (após {4})
-------------------------------------
number: 3
result: 67. O que o return faz de verdade
Na linha {5}, temos:
return resultAqui o JavaScript pega o valor associado a result dentro daquele contexto local e devolve esse valor para o ponto em que a função foi chamada.
Ou seja, a expressão:
multiplicarPor2(3)passa a produzir o valor 6.
Com isso, a linha original:
const output = multiplicarPor2(num)agora pode finalmente ser concluída como:
const output = 6No contexto global, fica assim:
Global Execution Context (após {3})
-----------------------------------
num: 3
multiplicarPor2: <function>
output: 6
novoOutput: TDZO ponto mais importante aqui é que o valor retornado pela função volta exatamente para o lugar onde a chamada apareceu.
8. Por que o restante do código espera
Enquanto multiplicarPor2(3) está sendo executada, o JavaScript não começa outra execução no meio desse mesmo fluxo.
Ele precisa:
- entrar no contexto da função
- executar seu corpo
- obter o valor de retorno
- sair desse contexto
- voltar ao contexto anterior
Só então o restante do código continua.
É exatamente aí que a Call Stack fica mais evidente.
9. Como a Call Stack acompanha a execução
A Call Stack é a estrutura que acompanha quais contextos estão ativos naquele momento.
Quando o arquivo começa a rodar, podemos pensar assim:
Call Stack
----------
Global()Ou seja: antes de qualquer chamada de função acontecer, o contexto global já está ocupando a stack.
Quando multiplicarPor2(3) é chamada, o contexto da função entra no topo da stack:
Call Stack
----------
Global()
multiplicarPor2(3)Quando a função termina, esse contexto sai da stack, e o fluxo volta para o contexto global:
Call Stack
----------
Global()Então a Call Stack não serve para guardar os valores finais do programa. Ela serve para acompanhar qual contexto está em execução naquele instante e para onde o JavaScript precisa voltar depois.
10. A segunda chamada segue o mesmo raciocínio
Agora chegamos em {6}:
const novoOutput = multiplicarPor2(10)Mais uma vez, o fluxo se repete:
- o JavaScript cria um novo contexto para a chamada
- o parâmetro
numberrecebe10 resultpassa a valer20return resultdevolve20novoOutputé inicializado com esse valor
Podemos representar o contexto da função assim:
Function Execution Context
--------------------------
number: 10
result: 20E, depois da chamada, o contexto global fica assim:
Global Execution Context
------------------------
num: 3
multiplicarPor2: <function>
output: 6
novoOutput: 20Fechando a ideia
Se a gente resumir os pontos mais importantes deste artigo, eles ficam assim:
- antes da execução propriamente dita, o JavaScript cria um
Execution Context - bindings com
leteconstexistem antes da inicialização, mas não podem ser acessados cedo demais function declarationsjá ficam disponíveis no contexto- cada chamada de função cria um novo contexto de execução
- a
Call Stackcontrola a ordem desses contextos - o valor de
returnvolta para o ponto exato onde a função foi chamada
Entender isso deixa muito mais fácil estudar hoisting, escopo, closures, assincronismo e vários outros assuntos da linguagem.
Antes de seguir para temas mais avançados, vale a pena ter essa base bem clara.
Quando esse modelo mental fica sólido, praticamente todo o resto do JavaScript começa a fazer mais sentido.
O próximo passo da série é entender como esse mesmo runtime, que só executa um contexto por vez, consegue lidar com timers, requisições e outras operações assíncronas sem travar tudo: Event Loop no JavaScript.