portfólio

← voltar

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.

Matheus Pergoli··8 min de leitura

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:

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:

  1. o JavaScript prepara o contexto em que aquele código vai executar
  2. 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:

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:

Exemplo base

Vamos usar este código como referência:

JS
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:

Mas eles não ficam todos no mesmo estado.

Para este exemplo, vale guardar o seguinte:

Didaticamente, podemos imaginar o começo do programa assim:

TXT
Global Execution Context (fase de criação)
------------------------------------------
num: TDZ
multiplicarPor2: <function>
output: TDZ
novoOutput: TDZ

Esse 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:

var e function declarations sofrem hoisting. let e const nã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:

TXT
Global Execution Context (fase de criação)
------------------------------------------
num: TDZ
multiplicarPor2: <function>
output: TDZ
novoOutput: TDZ

num, 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:

BindingSofre hoisting?Estado inicial
varundefined
function declarationfunção completa
letTDZ
constTDZ

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:

JS
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:

JS
const num = 3

Aqui, 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:

TXT
Global Execution Context (após {1})
-----------------------------------
num: 3
multiplicarPor2: <function>
output: TDZ
novoOutput: TDZ

3. E a função multiplicarPor2?

Na linha {2}, aparece a declaração da função:

JS
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:

4. Quando a chamada de função aparece

Na linha {3}, temos:

JS
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:

  1. buscar o valor de num
  2. chamar multiplicarPor2 com esse valor
  3. esperar o retorno da função
  4. só então inicializar output

Como num vale 3, a chamada fica, na prática:

JS
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:

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:

Conceitualmente:

TXT
Function Execution Context (fase de criação)
--------------------------------------------
number: 3
result: TDZ

Aqui temos dois conceitos importantes:

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:

JS
const result = number * 2

Como number vale 3, então result passa a valer 6.

O contexto local fica assim:

TXT
Function Execution Context (após {4})
-------------------------------------
number: 3
result: 6

7. O que o return faz de verdade

Na linha {5}, temos:

JS
return result

Aqui 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:

JS
multiplicarPor2(3)

passa a produzir o valor 6.

Com isso, a linha original:

JS
const output = multiplicarPor2(num)

agora pode finalmente ser concluída como:

JS
const output = 6

No contexto global, fica assim:

TXT
Global Execution Context (após {3})
-----------------------------------
num: 3
multiplicarPor2: <function>
output: 6
novoOutput: TDZ

O 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:

  1. entrar no contexto da função
  2. executar seu corpo
  3. obter o valor de retorno
  4. sair desse contexto
  5. 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:

TXT
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:

TXT
Call Stack
----------
Global()
multiplicarPor2(3)

Quando a função termina, esse contexto sai da stack, e o fluxo volta para o contexto global:

TXT
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}:

JS
const novoOutput = multiplicarPor2(10)

Mais uma vez, o fluxo se repete:

  1. o JavaScript cria um novo contexto para a chamada
  2. o parâmetro number recebe 10
  3. result passa a valer 20
  4. return result devolve 20
  5. novoOutput é inicializado com esse valor

Podemos representar o contexto da função assim:

TXT
Function Execution Context
--------------------------
number: 10
result: 20

E, depois da chamada, o contexto global fica assim:

TXT
Global Execution Context
------------------------
num: 3
multiplicarPor2: <function>
output: 6
novoOutput: 20

Fechando a ideia

Se a gente resumir os pontos mais importantes deste artigo, eles ficam assim:

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.

Próximo artigo

Event Loop no JavaScript: Web APIs, Task Queue e Microtask Queue

← voltar para o blog