Como o JavaScript funciona?
Créditos ao artigo original (em inglês) que também fez os lindos GIFs de exemplo!
Inrodução 🤖
JavaScript tem seus problemas, mas ainda é uma linguagem que todo mundo ama odiar! Mas por trás dessa linguagem "simples", existe um sistema um tanto complexo que converte seu código simples em instruções para o computador. Por sorte, a engine V8 (usada no Node e navegadores que usam Chromium) é open-source, então podemos ver como tudo funciona por de baixo das linhas de código!
1. Carregamento
O parser de HTML primeiro encontra uma tag <script>
com um código-fonte. O código dessa fonte é então carregado pela network, cache, ou em um service worker instalado. A resposta é o script requisitado em forma de um stream de bytes, que é cuidado pelo decodificador de bytes! O byte stream decoder decodifica o stream de bytes enquanto é feito o download!
2. Tokens
O decodificador de byte stream cria tokens da stream de bytes decodificada. Por exemplo:
0066
-> f
0075
-> u
006e
-> n
0063
-> c
0074
-> t
0069
-> i
006f
-> o
006n
-> n
Seguido por um espaço em branco, você escreveu function
! Essa é uma keyword reservada pelo JavaScript. Um token é criado, e é enviado pro parser. (e pré-parser, que não aparece nos gifs mas é mostrado depois). O mesmo acontece pro resto da stream de bytes.
3. Parser
O V8 usa dois parsers: o pre-parser e o parser. Para reduzir o tempo que leva para carregar um website, a engine tenta evitar ler código que não é necessário no momento. O pre-parser cuida do código que talvez seja usado depois, enquanto o parser fica com o código que é imediatamente necessário! Se uma certa função só vai ser invocada depois de alguns cliques do usuário, não é necessário que esse código seja compilado imediatamente na hora de carregar o website. Se o usuário eventualmente acaba clicando no botão, o código necessário é enviado pro parser.
O parser cria nós baseados nos tokens que recebe do decodificador de byte stream. Com esses nós, ele cria uma Abstract Syntact Tree, ou AST. 🌳
4. Interpretador
Agora é hora do interpretador! O interpretador anda por toda a AST, e gera byte code baseado na informação que a AST contém. Quando o byte code é completamente gerado, a AST é deletada para limpar a memória. E finalmente, temos algo que a máquina consegue usar para trabalhar! 🎉
5. Otimização
Mesmo byte code sendo rápido, ele pode ser ainda mais veloz. Enquanto o byte code é executado, informação vai sendo gerada. Ele pode detectar quando um certo tipo de comportamento acontece frequentemente, e o tipo de dados que está sendo usado. Talvez você invoque uma função dezena de vezes: é hora de otimizar isso para ele executar ainda mais rápido! 🏃🏽
O byte code, junto com um feedback de tipo gerado, são enviados para um compilador de otimização. O compilador de otimização pega esse byte code e feedback de tipo, e gera um código de máquina altamente otimizado com eles. 🚀
JavaScript é uma linguagem de tipagem dinâmica, o que significa que o tipo dos dados podem mudar constantemente. Seria extremamente lento se a engine do JS tivesse que checar toda vez qual o tipo de dado que um valor possui.
Para reduzir o tempo que leva para interpretar o código, código de máquina otimizado só cuida dos casos que a engine já viu antes enquanto executava o byte code. Se nós repetidamente usamos uma certa peça de código que retorna o mesmo tipo de dado sempre, o código de máquina otimizado pode simplesmente ser reusado para acelerar as coisas!
No entanto, como JavaScript é dinâmicamente tipado, pode acontecer que o mesmo pedaço de código de repente retorne um tipo de dado diferente. Se isso acontece, o código de máquina é "desotimizado", e a engine volta a interpretar o byte code gerado.
Digamos que uma certa função é invocada 100 vezes e sempre retornou o mesmo valor até o momento. Ele vai assumir que ela vai também retornar esse valor na 101º vez que você invocá-la.
Vamos dizer que temos a seguinte função sum, que (até o momento) sempre foi chamada com valores numérios como argumentos toda vez:
Isso retorna o número 3
! Na próxima vez que invocarmos ela, ele vai assumir que estamos invocando novamente com dois valores numéricos.
Se isso realmente acontecer, nenhuma verificação dinâmica é necessária, e ele pode só re-usar o código de máquina otimizado. Caso contrário, ele vai reverter para o byte code original ao invés do código de máquina otimizado.
Por exemplo, na próxima vez que invocarmos ela, passamos uma string no lugar de um número. Como JavaScript é dinâmicamente tipado, podemos fazer isso sem nenhum erro!
Isso significa que o número 2
vai ser juntado à string, e a função vai retornar "12"
no lugar. Ele volta a executar o byte code interpretado e atualiza o feedback de tipo.
Finalização!
Espero que tenham gostado e achado útil esse conteúdo! Existem várias partes que não foram cobertas no artigo (JS heap, call stack, etc.), se gostar, considere pesquisar sobre e talvez criar um conteúdo no TabNews explicando também!
Leiam o artigo original também se souberem inglês.
Lembrando que o V8 é open source, então você pode analisar o código diretamente se preferir!