Como o navegador entende e executa JavaScript?
Do código-fonte à máquina virtual
No artigo anterior, nosso navegador encontrou uma tag <script>, mas deixou seu conteúdo intocado. Agora abriremos essa caixa. Construiremos o Janela Script 0.3 e acompanharemos cada transformação: caracteres se tornarão tokens, tokens formarão uma árvore, a árvore virará bytecode e uma máquina virtual produzirá o primeiro resultado.

VOCABULÁRIO VISUAL
Antes de avançar, conheça as peças deste capítulo.
No Como o navegador abre um site?, construímos URL, DNS, TCP, HTTP, parser HTML e DOM. Quando o parser encontra:
<script src="app.js"></script>
ele descobre outro recurso, solicita seus bytes e os transforma em texto. Mas o texto let total = 2 + 3; ainda é somente uma sequência de caracteres. A CPU não reconhece diretamente palavras como let nem entende que + representa uma soma.
Quais representações intermediárias precisamos criar para sair de um texto escrito por uma pessoa e chegar a operações que um programa consegue executar?
JavaScript é uma linguagem de programação. Uma linguagem possui vocabulário, regras de escrita e significado. let, por exemplo, pertence ao vocabulário. Exigir um nome depois de let é uma regra de escrita. Guardar um valor sob esse nome é o significado da instrução.
| Camada | Responsabilidade | Exemplo |
|---|---|---|
| HTML | estrutura do documento | <button> |
| CSS | aparência e layout | button { color: blue; } |
| JavaScript | cálculo e comportamento | let total = 2 + 3; |
O componente que compreende e executa JavaScript costuma ser chamado de motor JavaScript. V8 é o motor usado no Chrome; SpiderMonkey é usado no Firefox; JavaScriptCore aparece no Safari. Não copiaremos nenhum deles. Construiremos um motor pequeno para enxergar as ideias essenciais.
Os nomes técnicos serão: lexer para separar, parser para organizar, compilador para traduzir e máquina virtual para executar. Primeiro construiremos cada solução; só depois conectaremos o pipeline.
let largura = 320;
let dobro = largura * 2;
dobro + 40;
A versão 0.3 compreenderá let, nomes de variáveis, números, textos, parênteses e as quatro operações aritméticas. A última expressão produzirá 680. Ainda não teremos funções, objetos, arrays, DOM, Promises ou temporizadores.
O padrão ECMAScript que define JavaScript possui muitos recursos e regras de compatibilidade. Nosso objetivo não é afirmar que três arquivos equivalem ao V8; é isolar uma linha completa de execução que o leitor consiga inspecionar.
l e t t o t a l = 2 + 3 ;
Um caractere é uma unidade de texto, como l, 2 ou +. A String Rust guarda a sequência em UTF‑8. Já explicamos bytes e UTF‑8 no artigo “Como o navegador abre um site”; agora partimos do texto já decodificado.
pub struct Lexer {
chars: Vec<char>,
current: usize,
}
chars guarda os caracteres. current guarda a posição da próxima leitura. Esse número é um cursor: começa em zero e avança sem voltar.
Ao ler 2 e 0 lado a lado, não queremos dois números separados; queremos o valor 20. Ao ler l, e e t, queremos reconhecer uma palavra especial. Uma peça classificada recebe o nome de token.
#[derive(Clone, Debug, PartialEq)]
pub enum Token {
Let,
Identifier(String),
Number(f64),
Plus,
Equal,
Semicolon,
Eof,
}
enum representa alternativas. Let não precisa carregar informação adicional. Identifier(String) precisa guardar o nome encontrado. Number(f64) guarda o valor numérico. Eof significa end of file: chegamos ao fim da entrada.
Lexer, também chamado de analisador léxico, é o componente que percorre caracteres e produz tokens. Ele ignora espaços que apenas separam peças e examina o próximo caractere útil:
match character {
'+' => Ok(Token::Plus),
'=' => Ok(Token::Equal),
';' => Ok(Token::Semicolon),
'0'..='9' => self.number(character),
c if c.is_alphabetic() || c == '_' => Ok(self.identifier(c)),
other => Err(format!("caractere inesperado: {other}")),
}
match compara o caractere com várias possibilidades. Símbolos de um único caractere viram tokens imediatamente. Um algarismo chama number, que continua lendo enquanto houver dígitos. Uma letra chama identifier, que continua enquanto houver letras, números ou sublinhado.
let tokens = Lexer::new("let total = 2 + 3;").scan_all()?;
[Let, Identifier("total"), Equal,
Number(2), Plus, Number(3), Semicolon, Eof]Palavra-chave é uma palavra reservada pela linguagem, como let. Identificador é um nome escolhido pelo programador, como total. Ambos começam com letras, então o lexer primeiro lê o texto completo:
match text.as_str() {
"let" => Token::Let,
_ => Token::Identifier(text),
}
Se o texto for exatamente let, devolvemos a palavra-chave. O padrão _ significa “qualquer outro caso”; nesse caso, preservamos o nome como identificador.
Em let idade = 42;, 42 é um literal numérico: o valor foi escrito diretamente. Em "olá", temos um literal de texto. O lexer remove a sintaxe externa e guarda o conteúdo no token.
pub enum Value {
Number(f64),
String(String),
Undefined,
}
Value representa valores durante a execução. Undefined é o valor usado quando não há outro resultado. JavaScript real possui também booleanos, null, BigInt, símbolos, objetos e várias conversões; ainda não precisamos deles.
Os tokens Let, Equal e Number(2) são válidos isoladamente. A sequência let = 2 não é: depois de let esperamos um identificador. Uma gramática é o conjunto de regras que descreve combinações permitidas.
declaração → "let" IDENTIFICADOR "=" expressão ";"
expressão → número | texto | variável | expressão OPERADOR expressão
A seta pode ser lida como “é formada por”. A primeira regra diz que uma declaração começa com let, recebe um nome, um sinal de igual, uma expressão e um ponto e vírgula.
Parser, ou analisador sintático, recebe os tokens e verifica se eles obedecem à gramática. Já construímos parsers na linguagem Pulso e no HTML do artigo anterior. O mecanismo reaparece porque o problema reaparece: transformar uma fila em estrutura.
if self.matches(&Token::Let) {
let name = match self.advance() {
Token::Identifier(name) => name,
token => return Err(format!(
"esperava um nome após let; encontrei {token:?}"
)),
};
self.consume(&Token::Equal, "esperava = após o nome")?;
let value = self.expression()?;
return Ok(Statement::Let { name, value });
}
matches verifica o próximo token e avança quando ele corresponde. advance retira o próximo token. consume exige uma peça específica e produz um erro naquele ponto se ela não aparecer.
Em 2 + 3 * 4, o resultado esperado é 14, não 20. Precedência é a regra que dá prioridade à multiplicação sobre a soma. O parser resolve isso usando funções em níveis:
fn expression(&mut self) -> Result<Expression, String> {
self.addition()
}
fn addition(&mut self) -> Result<Expression, String> {
let left = self.multiplication()?;
// depois procura + ou -
}
fn multiplication(&mut self) -> Result<Expression, String> {
let left = self.primary()?;
// depois procura * ou /
}
addition precisa terminar de ler uma multiplicação antes de procurar +. Parênteses chamam expression novamente e permitem substituir a ordem: (2 + 3) * 4 produz 20.
O resultado do parser chama-se AST, sigla de Abstract Syntax Tree, ou Árvore Sintática Abstrata. É uma árvore porque cada operação possui filhos. É abstrata porque não guarda todos os detalhes de escrita: o ponto e vírgula cumpriu sua função e pode desaparecer.
2 + 3 * 4
Binary(Add)
├── Number(2)
└── Binary(Multiply)
├── Number(3)
└── Number(4)
A multiplicação aparece mais abaixo e forma um resultado antes de ser combinada com o 2. A árvore transforma uma regra invisível de precedência numa relação explícita.
pub enum Expression {
Number(f64),
String(String),
Variable(String),
Binary {
left: Box<Expression>,
operator: BinaryOperator,
right: Box<Expression>,
},
}
Box guarda um filho em uma região separada da memória, permitindo que uma expressão contenha outras expressões sem possuir tamanho infinito.
Uma expressão produz um valor: 2 + 3 produz 5. Uma declaração realiza uma ação estrutural: let total = 2 + 3; cria o nome total e associa 5 a ele.
pub enum Statement {
Let { name: String, value: Expression },
Expression(Expression),
}
A declaração Let carrega um nome e uma expressão. A segunda alternativa permite escrever uma expressão isolada, como a última linha do nosso exemplo, cujo valor será devolvido ao usuário.
| Entrada | Etapa | Mensagem |
|---|---|---|
2 @ 3 | lexer | caractere inesperado: @ |
let = 3 | parser | esperava um nome após let |
nome + 1 | execução | nome não foi definida |
Separar erros evita a mensagem genérica “JavaScript inválido”. O usuário descobre se o texto contém um símbolo desconhecido, se a estrutura está incompleta ou se o programa tentou usar um valor que não existia.
Poderíamos caminhar pela AST toda vez que o programa executasse. Em vez disso, faremos uma tradução antecipada para instruções pequenas. Essa representação recebe o nome de bytecode. Ela não é código de máquina da CPU; é código para a máquina virtual que nós mesmos definiremos.
pub enum Instruction {
Constant(usize),
LoadName(String),
StoreName(String),
Add,
Subtract,
Multiply,
Divide,
Pop,
Return,
}
Cada alternativa é uma instrução. Constant(0) coloca a constante de índice zero na pilha. Add retira dois valores, soma e devolve o resultado. Return encerra a execução.
Já construímos um bytecode para a linguagem Pulso em Construindo um compilador e uma máquina virtual do zero em Rust. Aqui repetimos a fundação com a sintaxe e os valores do nosso subconjunto JavaScript.
Compilador não significa obrigatoriamente gerar um executável nativo. É um programa que traduz uma representação para outra. Nosso compilador recebe AST e produz bytecode.
Expression::Binary { left, operator, right } => {
self.expression(left)?;
self.expression(right)?;
self.chunk.emit(match operator {
BinaryOperator::Add => Instruction::Add,
BinaryOperator::Subtract => Instruction::Subtract,
BinaryOperator::Multiply => Instruction::Multiply,
BinaryOperator::Divide => Instruction::Divide,
});
}
Primeiro compilamos o filho esquerdo; depois, o direito. Assim ambos os valores estarão disponíveis. Por último emitimos a instrução do operador. emit apenas acrescenta uma instrução ao fim do vetor de bytecode.
constantes: [Number(2), Number(3), Number(4)]
bytecode:
Constant(0)
Constant(1)
Constant(2)
Multiply
Add
Return
A instrução carrega um índice, não o valor inteiro. O conjunto formado por instruções e constantes chama-se Chunk em nosso projeto — um bloco executável do programa.
pub struct Chunk {
pub code: Vec<Instruction>,
pub constants: Vec<Value>,
}Uma máquina virtual, ou VM, é um programa que simula uma máquina adequada ao nosso bytecode. Ela não simula necessariamente um computador completo com monitor e disco. Nossa VM precisa apenas de quatro peças:
- bytecode: instruções a executar;
- instruction pointer: índice da próxima instrução;
- stack: pilha de valores temporários;
- globals: tabela que associa nomes a valores.
pub struct Vm {
chunk: Chunk,
instruction_pointer: usize,
stack: Vec<Value>,
globals: HashMap<String, Value>,
}
Construímos uma VM e depois uma CPU didática em Como uma CPU funciona por dentro. O mesmo ciclo buscar–decodificar–executar reaparece aqui.
Stack, ou pilha, é uma coleção com regra LIFO: o último a entrar é o primeiro a sair. Pense numa pilha de pratos. push coloca um valor no topo; pop retira o topo.
Instruction::Add => {
let right = self.pop()?;
let left = self.pop()?;
self.stack.push(add(left, right)?);
}
O valor direito sai primeiro porque foi colocado por último. Depois retiramos o esquerdo, calculamos e empilhamos o resultado.
loop {
let instruction = self.chunk.code
.get(self.instruction_pointer)
.cloned()
.ok_or("o bytecode terminou sem Return")?;
self.instruction_pointer += 1;
match instruction {
Instruction::Constant(index) => { /* empilha */ }
Instruction::Add => self.add()?,
Instruction::Return => return Ok(self.stack.pop()),
// outras instruções
}
}
instruction_pointer é o ponteiro de instrução: um índice que indica onde estamos. Ele avança antes da execução para que a próxima volta busque a instrução seguinte. O match é o decodificador: escolhe o comportamento correspondente ao opcode.
Ao executar let largura = 320, a instrução StoreName("largura") pega o valor no topo e o guarda numa tabela. HashMap é a coleção Rust que encontra um valor usando uma chave.
Instruction::StoreName(name) => {
let value = self.peek()?.clone();
self.globals.insert(name, value);
}
Instruction::LoadName(name) => {
let value = self.globals.get(&name)
.cloned()
.ok_or_else(|| format!("{name} não foi definida"))?;
self.stack.push(value);
}
StoreName preserva o valor na pilha para que a declaração ainda possa ter um resultado interno. LoadName procura, copia e empilha. Escopos locais e closures exigirão ambientes encadeados; já exploramos essa ideia em Como closures funcionam por dentro.
2 + 3 // Number(5)
"Janela " + "Script" // String("Janela Script")
Tipo é a categoria do valor e determina quais operações fazem sentido. Na versão 0.3, + aceita dois números ou dois textos:
match (left, right) {
(Value::Number(a), Value::Number(b)) => Value::Number(a + b),
(Value::String(a), Value::String(b)) => Value::String(a + &b),
_ => return Err("+ exige dois números ou dois textos".into()),
}
JavaScript real realiza conversões implícitas em combinações como "2" + 3. Essas regras surpreendem até programadores experientes. Nosso primeiro motor rejeita a mistura para deixar a decisão visível; ampliaremos a semântica depois.
let total = 2 + 3 * 4;
| Etapa | Representação |
|---|---|
| Fonte | let total = 2 + 3 * 4; |
| Tokens | Let Id(total) = Num(2) + Num(3) * Num(4) |
| AST | Let(total, Add(2, Multiply(3, 4))) |
| Bytecode | Const 2 · Const 3 · Const 4 · Mul · Add · Store total |
| Execução | globals["total"] = Number(14) |
Nenhuma etapa adivinha o trabalho da seguinte. Lexer classifica; parser relaciona; compilador lineariza; VM executa.
let source = document.download_script(script_url)?;
let result = janela_script::execute(&source)?;
A primeira linha pertence ao navegador: resolve a URL e baixa o recurso, como construímos no artigo “Como o navegador abre um site”. A segunda pertence ao motor: lexer, parser, compilador e VM. Essa fronteira evita misturar HTTP com regras de JavaScript.
A especificação ECMAScript define a linguagem e sua biblioteca padrão, mas não define rede, janela ou DOM. Esses recursos vêm do host, o ambiente que incorpora o motor. A especificação oficial do ECMAScript chama essas entradas e saídas de operações fornecidas pelo host.
Ainda não permitiremos que o script altere o DOM. Para isso o motor precisará receber um objeto hospedeiro, como document, cujas operações chamam o navegador. O navegador, por sua vez, precisa marcar layout ou pintura como desatualizados. O artigo “Como o runtime mantém uma página viva” mostrará esse contrato sem fingir que document pertence ao núcleo da linguagem.
| Conceito | Em palavras comuns | Quando entra |
|---|---|---|
| Funções completas e objetos | chamadas, propriedades e valores duráveis | artigos “Como o runtime mantém uma página viva” e “Como um motor JavaScript fica rápido” |
| Heap | memória para objetos duráveis | artigo “Como o runtime mantém uma página viva” |
| Garbage collector | recupera objetos inalcançáveis | artigo “Como o runtime mantém uma página viva” |
| Event loop | coordena tarefas ao longo do tempo | artigo “Como o runtime mantém uma página viva” |
| Promise e microtask | continua trabalho assíncrono | artigo “Como o runtime mantém uma página viva” |
| JIT | gera código nativo durante a execução | artigo “Como um motor JavaScript fica rápido” |
| Otimização | especializa caminhos observados | artigo “Como um motor JavaScript fica rápido” |
Cada pacote isola a nova camada para que ela possa ser executada e testada sem exigir um navegador industrial completo. A continuidade está nos contratos e estruturas reaproveitados; os artigos não afirmam que o ZIP 0.5 seja uma réplica integrada do Chrome.
Já construímos um coletor mark-and-sweep em Quem limpa a memória?. O artigo “Como o runtime mantém uma página viva” reaproveitará essa base, mas explicará como ela se conecta ao runtime de uma página.
#[test]
fn multiplicacao_tem_precedencia_sobre_soma() {
let result = execute("2 + 3 * 4;").unwrap();
assert_eq!(result.to_string(), "14");
}
#[test]
fn variavel_pode_ser_lida() {
let result = execute("let largura = 320; largura * 2;").unwrap();
assert_eq!(result.to_string(), "640");
}
Também testamos o lexer diretamente. Isso é importante: um teste completo que devolve 14 prova o caminho feliz, mas não mostra se o lexer classificou let corretamente. Testes pequenos documentam os contratos entre as etapas.
janela-script/
├── src/
│ ├── token.rs # tipos de token
│ ├── lexer.rs # caracteres → tokens
│ ├── ast.rs # nós do programa
│ ├── parser.rs # tokens → AST
│ ├── value.rs # valores em execução
│ ├── bytecode.rs # instruções e constantes
│ ├── compiler.rs # AST → bytecode
│ ├── vm.rs # execução por pilha
│ ├── lib.rs # conecta o pipeline
│ └── main.rs # executável
├── exemplos/pagina.js
└── tests/pipeline.rs
pub fn execute(source: &str) -> Result<Value, String> {
let tokens = Lexer::new(source).scan_all()?;
let program = Parser::new(tokens).parse_program()?;
let chunk = Compiler::compile(&program)?;
Vm::new(chunk).run()
}
Agora cada linha possui um significado construído durante o capítulo. Para executar:
cargo run
cargo run -- exemplos/pagina.js
cargo test
resultado: 680. Não veio de eval, de uma biblioteca JavaScript ou de código pronto escondido. Veio do lexer, parser, AST, compilador, bytecode e VM que acabamos de construir em Rust.
| Termo | Significado neste artigo |
|---|---|
| Token | peça de texto já classificada |
| Lexer | transforma caracteres em tokens |
| Gramática | regras de combinação da linguagem |
| Parser | valida a gramática e cria estrutura |
| AST | árvore que guarda relações e significado |
| Bytecode | instruções para nossa máquina virtual |
| Compilador | traduz AST em bytecode |
| VM | programa que executa o bytecode |
| Stack | pilha de valores temporários |
| Instruction pointer | posição da próxima instrução |
Começamos com caracteres. O lexer reconheceu tokens. O parser aplicou a gramática e construiu a AST. O compilador emitiu bytecode. A máquina virtual percorreu instruções, usou a pilha, armazenou variáveis e devolveu o primeiro resultado.
Executar uma expressão é apenas o começo. No artigo “Como o runtime mantém uma página viva” construiremos o runtime que mantém a página viva: call stack, heap, garbage collector, tarefas, temporizadores, event loop, fila de microtasks e Promises — novamente uma peça de cada vez.
PROJETO COMPLETO · CÓDIGO DO CAPÍTULO
Execute e investigue o Janela Script 0.3.
O pacote contém o pipeline construído no capítulo: tokens, lexer, AST, parser com precedência, bytecode, tabela de constantes, compilador, máquina virtual de pilha, variáveis, valores, exemplos e testes. Cada módulo está formatado e comentado na ordem de leitura do artigo.