Mecanismos JavaScript: como eles funcionam? Da pilha de chamadas às promessas, (quase) tudo o que você precisa saber


Você já se perguntou como os navegadores leem e executam o código JavaScript? Parece misterioso, mas neste post você pode ter uma idéia do que está acontecendo sob o capô.

Começamos nossa jornada para o idioma com uma excursão ao maravilhoso mundo dos mecanismos JavaScript.

Abra o console no Chrome e vá para a guia Fontes. Você verá várias seções, e uma das mais interessantes é chamada Pilha de chamadas (no Firefox, você verá Pilha de chamadas quando colocar um ponto de interrupção no código):



O que é uma pilha de chamadas? Parece haver muita coisa acontecendo, mesmo para executar algumas linhas de código. De fato, o JavaScript não vem em uma caixa em todos os navegadores. Há um grande componente que compila e interpreta nosso código JavaScript - é um mecanismo JavaScript. Os mais populares são o V8, usado no Google Chrome e Node.js, SpiderMonkey no Firefox, JavaScriptCore no Safari / WebKit.

Atualmente, os mecanismos JavaScript são ótimos exemplos de engenharia de software e será quase impossível falar sobre todos os aspectos. No entanto, o principal trabalho de execução de código é realizado para nós por apenas alguns componentes dos mecanismos: pilha de chamadas (pilha de chamadas), memória global (memória global) e contexto de execução (contexto de execução). Pronto para conhecê-los?

Conteúdo:

  1. Mecanismos JavaScript e memória global
  2. Mecanismos JavaScript: como eles funcionam? Contexto de execução global e pilha de chamadas
  3. JavaScript é single-threaded e outras histórias divertidas
  4. JavaScript assíncrono, fila de retorno de chamada e loop de eventos
  5. Inferno de retorno de chamada e promete ES6
  6. Criando e trabalhando com promessas de JavaScript
  7. Tratamento de erros nas promessas do ES6
  8. Combinadores do ES6 Promise: Promise.all, Promise.allSettled, Promise.any e outros
  9. Promessas do ES6 e fila de microtask
  10. Mecanismos JavaScript: como eles funcionam? Evolução assíncrona: das promessas ao assíncrono / aguardar
  11. Mecanismos JavaScript: como eles funcionam? Sumário

1. Mecanismos JavaScript e memória global


Eu disse que o JavaScript é uma linguagem compilada e interpretada. Acredite ou não, os mecanismos JavaScript realmente compilam seu microssegundo de código antes de ser executado.

Algum tipo de mágica, hein? Essa mágica é chamada JIT (compilação Just in time). Por si só, é um grande tópico de discussão, mesmo os livros não serão suficientes para descrever o trabalho do JIT. Mas, por enquanto, vamos pular a teoria e focar na fase de execução, o que não é menos interessante.

Para começar, veja este código:

var num = 2; function pow(num) { return num * num; } 

Suponha que eu pergunte como esse código é processado em um navegador. O que você vai responder? Você pode dizer: “o navegador lê o código” ou “o navegador executa o código”. Na realidade, nem tudo é tão simples. Primeiro, o código é lido não pelo navegador, mas pelo mecanismo. O mecanismo JavaScript lê o código e, assim que define a primeira linha, coloca alguns links na memória global .

Memória global (também chamada heap) é a área na qual o mecanismo JavaScript armazena variáveis ​​e declarações de função. E quando ele ler o código acima, dois fichários aparecerão na memória global:



Mesmo que o exemplo contenha apenas uma variável e uma função, imagine que seu código JavaScript seja executado em um ambiente maior: em um navegador ou em Node.js. Em tais ambientes, existem muitas funções e variáveis ​​predefinidas que são chamadas globais. Portanto, a memória global conterá muito mais dados do que apenas num e pow , lembre-se.

Nada está funcionando no momento. Vamos agora tentar executar nossa função:

 var num = 2; function pow(num) { return num * num; } pow(num); 

O que vai acontecer? E algo interessante vai acontecer. Ao chamar a função, o mecanismo JavaScript destacará duas seções:

  • Contexto de execução global
  • Pilha de chamadas

O que eles são?

2. Mecanismos JavaScript: como eles funcionam? Contexto de execução global e pilha de chamadas


Você aprendeu como o mecanismo JavaScript lê variáveis ​​e declarações de função. Eles caem na memória global (heap).

Mas agora estamos executando uma função JavaScript, e o mecanismo deve cuidar disso. Como Cada mecanismo JavaScript possui um componente-chave chamado pilha de chamadas .

Essa é uma estrutura de dados empilhados : os elementos podem ser adicionados a partir de cima, mas eles não podem ser excluídos da estrutura enquanto houver outros elementos acima deles. É assim que as funções JavaScript funcionam. Na execução, eles não podem sair da pilha de chamadas se outra função estiver presente nela. Observe isso, pois esse conceito ajuda a entender a declaração "JavaScript é de thread único".

Mas voltando ao nosso exemplo. Quando uma função é chamada, o mecanismo a envia para a pilha de chamadas :



Eu gosto de apresentar a pilha de chamadas como uma pilha de chips Pringles. Não podemos comer batatas fritas do fundo da pilha até comermos as que estão em cima. Felizmente, nossa função é síncrona: é apenas uma multiplicação que é calculada rapidamente.

Ao mesmo tempo, o mecanismo coloca o contexto de execução global na memória, este é o ambiente global no qual o código JavaScript é executado. Aqui está o que parece:



Imagine um contexto de execução global na forma de um mar no qual as funções globais do JavaScript flutuam como peixes. Que doce! Mas isso é apenas metade da história. E se nossa função tiver variáveis ​​aninhadas ou funções internas?

Mesmo no caso simples, como mostrado abaixo, o mecanismo JavaScript cria um contexto de execução local :

 var num = 2; function pow(num) { var fixed = 89; return num * num; } pow(num); 

Observe que eu adicionei a variável fixed à função pow . Nesse caso, o contexto de execução local conterá uma seção para fixed . Não sou muito bom em desenhar pequenos retângulos dentro de outros pequenos retângulos, então use sua imaginação.

Um contexto de execução local aparecerá próximo a pow , dentro da seção retangular verde localizada dentro do contexto de execução global. Imagine também como, para cada função aninhada dentro da função aninhada, o mecanismo cria outros contextos de execução local. Todas essas seções retangulares aparecem muito rapidamente! Como uma boneca de ninho!

Vamos voltar à história single-threaded. O que isso significa?

3. JavaScript é single-threaded e outras histórias divertidas


Dizemos que o JavaScript é de thread único porque apenas uma pilha de chamadas lida com nossas funções . Deixe-me lembrá-lo de que as funções não podem sair da pilha de chamadas se outras funções esperarem execução.

Isso não é um problema se trabalharmos com código síncrono. Por exemplo, a adição de dois números é síncrona e é calculada em microssegundos. E as chamadas de rede e outras interações com o mundo exterior?

Felizmente, os mecanismos JavaScript são projetados para funcionar de forma assíncrona por padrão . Mesmo que eles possam executar apenas uma função por vez, funções mais lentas podem ser executadas por uma entidade externa - no nosso caso, é um navegador. Falaremos sobre isso abaixo.

Ao mesmo tempo, você sabe que quando o navegador carrega algum tipo de código JavaScript, o mecanismo lê esse código linha por linha e executa as seguintes etapas:

  • Coloca variáveis ​​e declarações de função na memória global (heap).
  • Envia uma chamada para cada função na pilha de chamadas.
  • Cria um contexto de execução global no qual as funções globais são executadas.
  • Cria muitos pequenos contextos de execução local (se houver variáveis ​​internas ou funções aninhadas).

Agora você tem um entendimento básico da mecânica de sincronização subjacente a todos os mecanismos JavaScript. No próximo capítulo, falaremos sobre como o código assíncrono funciona em JavaScript e por que funciona dessa maneira.

4. JavaScript assíncrono, fila de retorno de chamada e loop de eventos


Graças à memória global, ao contexto de execução e à pilha de chamadas, o código JavaScript síncrono é executado em nossos navegadores. Mas esquecemos algo. O que acontece se você precisar executar algum tipo de função assíncrona?

Por função assíncrona, quero dizer todas as interações com o mundo exterior, o que pode levar algum tempo para ser concluído. Chamar a API REST ou o timer é assíncrono, porque pode levar alguns segundos para executá-los. Graças aos elementos disponíveis no mecanismo, podemos processar essas funções sem bloquear a pilha de chamadas e o navegador. Não se esqueça, a pilha de chamadas pode executar apenas uma função de cada vez, e mesmo uma função de bloqueio pode literalmente parar o navegador . Felizmente, os mecanismos JavaScript são inteligentes e, com uma pequena ajuda do navegador, podem resolver as coisas.

Quando executamos uma função assíncrona, o navegador pega e executa para nós. Tome um cronômetro como este:

 setTimeout(callback, 10000); function callback(){ console.log('hello timer!'); } 

Tenho certeza de que, embora você já tenha visto setTimeout centenas de vezes, talvez não saiba que essa função não está embutida no JavaScript . Portanto, quando o JavaScript apareceu, não havia nenhuma função setTimeout . De fato, faz parte das chamadas APIs do navegador, uma coleção de ferramentas convenientes que o navegador nos fornece. Maravilhoso! Mas o que isso significa na prática? Como setTimeout pertence à API do navegador, essa função é executada pelo próprio navegador (por um momento, aparece na pilha de chamadas, mas é imediatamente excluída a partir daí).

Após 10 segundos, o navegador pega a função de retorno de chamada que passamos para ele e a coloca na fila de retorno de chamada . No momento, mais duas seções de retângulo apareceram no mecanismo JavaScript. Dê uma olhada neste código:

 var num = 2; function pow(num) { return num * num; } pow(num); setTimeout(callback, 10000); function callback(){ console.log('hello timer!'); } 

Agora, nosso esquema fica assim:



setTimeout é executado dentro do contexto do navegador. Após 10 segundos, o timer inicia e a função de retorno de chamada está pronta para execução. Mas primeiro, ele deve passar pela fila de retorno de chamada. Essa é uma estrutura de dados na forma de uma fila e, como o nome indica, é uma fila de funções ordenada.

Cada função assíncrona deve passar por uma fila de retorno de chamada antes de entrar na pilha de chamadas. Mas quem envia as funções a seguir? Isso cria um componente chamado loop de evento .

Até agora, o loop de eventos lida com apenas uma coisa: verifica se a pilha de chamadas está vazia. Se houver alguma função na fila de retorno de chamada e se a pilha de chamadas estiver livre, é hora de enviar uma chamada de retorno para a pilha de chamadas.

Depois disso, a função é considerada executada. Este é o esquema geral para processar código assíncrono e síncrono com o mecanismo JavaScript:



Digamos que callback() esteja pronto para execução. Quando pow() a pilha de chamadas é liberada e o loop de eventos envia callback() . E é isso aí! Embora tenha simplificado um pouco as coisas, se você entender o diagrama acima, poderá entender todo o JavaScript.

Lembre-se: APIs baseadas em navegador, filas de retorno de chamada e loops de eventos são os pilares do JavaScript assíncrono .

E se você estiver interessado, assista ao curioso vídeo “O que diabos é o loop de eventos de qualquer maneira”, de Philip Roberts. Essa é uma das melhores explicações para o loop de eventos.

Mas ainda não terminamos o tema JavaScript assíncrono. Nos próximos capítulos, consideraremos as promessas do ES6.

5. Inferno de retorno de chamada e ES6 promete


As funções de retorno de chamada são usadas em JavaScript em qualquer lugar, tanto no código síncrono quanto no assíncrono. Considere este método:

 function mapper(element){ return element * 2; } [1, 2, 3, 4, 5].map(mapper); 

mapper é uma função de retorno de chamada que é passada dentro do map . O código acima é síncrono. Agora considere este intervalo:

 function runMeEvery(){ console.log('Ran!'); } setInterval(runMeEvery, 5000); 

Esse código é assíncrono, porque dentro de setInterval passamos o retorno de chamada runMeEvery. Os retornos de chamada são usados ​​em todo o JavaScript; portanto, há anos temos um problema chamado "inferno de retorno de chamada" - "inferno de retorno de chamada".

O termo inferno de retorno de chamada em JavaScript é aplicado ao "estilo" de programação no qual os retornos de chamada são incorporados em outros retornos de chamada incorporados em outros retornos de chamada ... Devido à natureza assíncrona, os programadores de JavaScript há muito tempo caem nessa armadilha.

Para ser sincero, nunca criei grandes pirâmides de retorno de chamada. Talvez porque eu valorize um código legível e sempre tente manter seus princípios. Se você atingir o inferno de retorno de chamada, significa que sua função faz muito.

Não falarei em detalhes sobre o inferno de retorno de chamada, se você estiver interessado, vá para callbackhell.com , onde esse problema foi investigado em detalhes e várias soluções foram propostas. E falaremos sobre as promessas do ES6 . Este é um complemento do JavaScript desenvolvido para resolver o problema de retorno de chamada do inferno. Mas quais são as promessas?

Uma promessa de JavaScript é uma representação de um evento futuro . Uma promessa pode terminar com êxito ou, no jargão dos programadores, uma promessa será "resolvida" (resolvida). Mas se a promessa termina com um erro, dizemos que está no estado rejeitado. As promessas também têm um estado padrão: cada nova promessa começa em um estado pendente. Posso criar minha própria promessa? Sim Falaremos sobre isso no próximo capítulo.

6. Criando e trabalhando com promessas de JavaScript


Para criar uma nova promessa, você precisa chamar o construtor passando uma função de retorno de chamada para ele. Pode levar apenas dois parâmetros: resolve e reject . Vamos criar uma nova promessa que será resolvida em 5 segundos (você pode testar os exemplos no console do navegador):

 const myPromise = new Promise(function(resolve){ setTimeout(function(){ resolve() }, 5000) }); 

Como você pode ver, resolve é uma função que chamamos para que a promessa termine com sucesso. E reject criará uma promessa rejeitada:

 const myPromise = new Promise(function(resolve, reject){ setTimeout(function(){ reject() }, 5000) }); 

Observe que você pode ignorar a reject porque este é o segundo parâmetro. Mas se você pretende usar reject , não pode ignorar a resolve . Ou seja, o código a seguir não funcionará e terminará com uma promessa permitida:

 // Can't omit resolve ! const myPromise = new Promise(function(reject){ setTimeout(function(){ reject() }, 5000) }); 

Promessas não parecem tão úteis agora, certo? Esses exemplos não exibem nada para o usuário. Vamos adicionar algo. E permitidas, promessas rejeitadas podem retornar dados. Por exemplo:

 const myPromise = new Promise(function(resolve) { resolve([{ name: "Chris" }]); }); 

Mas ainda não vemos nada. Para extrair dados de uma promessa, você precisa associá-la ao método then . Ele recebe um retorno de chamada (que ironia!), Que recebe os dados atuais:

 const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]); }); myPromise.then(function(data) { console.log(data); }); 

Como desenvolvedor de JavaScript e consumidor de código de outras pessoas, você interage principalmente com promessas externas. Os criadores de bibliotecas geralmente envolvem código legado em um construtor Promise, assim:

 const shinyNewUtil = new Promise(function(resolve, reject) { // do stuff and resolve // or reject }); 

E, se necessário, também podemos criar e resolver uma promessa chamando Promise.resolve() :

 Promise.resolve({ msg: 'Resolve!'}) .then(msg => console.log(msg)); 

Então, deixe-me lembrá-lo: as promessas de JavaScript são um favorito para um evento que acontecerá no futuro. Um evento começa no estado "aguardando uma decisão" e pode ser bem-sucedido (permitido, executado) ou malsucedido (rejeitado). Uma promessa pode retornar dados que podem ser recuperados anexando then . No próximo capítulo, discutiremos como lidar com erros provenientes de promessas.

7. Tratamento de erros nas promessas do ES6


Lidar com erros no JavaScript sempre foi fácil, pelo menos no código síncrono. Veja um exemplo:

 function makeAnError() { throw Error("Sorry mate!"); } try { makeAnError(); } catch (error) { console.log("Catching the error! " + error); } 

O resultado será:

 Catching the error! Error: Sorry mate! 

Como esperado, o erro caiu no catch . Agora tente a função assíncrona:

 function makeAnError() { throw Error("Sorry mate!"); } try { setTimeout(makeAnError, 5000); } catch (error) { console.log("Catching the error! " + error); } 

Este código é assíncrono devido ao setTimeout . O que acontecerá se o executarmos?

  throw Error("Sorry mate!"); ^ Error: Sorry mate! at Timeout.makeAnError [as _onTimeout] (/home/valentino/Code/piccolo-javascript/async.js:2:9) 

Agora o resultado é diferente. O erro não foi detectado pelo catch , mas aumentou livremente a pilha. O motivo é que o try/catch funciona apenas com código síncrono. Se você quiser saber mais, esse problema é discutido em detalhes aqui .

Felizmente, com promessas, podemos lidar com erros assíncronos como se fossem síncronos. No último capítulo, eu disse que chamar reject leva a uma rejeição da promessa:

 const myPromise = new Promise(function(resolve, reject) { reject('Errored, sorry!'); }); 

Nesse caso, podemos lidar com erros usando o manipulador de catch puxando (novamente) um retorno de chamada:

 const myPromise = new Promise(function(resolve, reject) { reject('Errored, sorry!'); }); myPromise.catch(err => console.log(err)); 

Além disso, para criar e rejeitar uma promessa no lugar certo, você pode chamar Promise.reject() :

 Promise.reject({msg: 'Rejected!'}).catch(err => console.log(err)); 

Deixe-me lembrá-lo: o manipulador then é executado quando a promessa é executada e o manipulador catch é executado para promessas rejeitadas. Mas este não é o fim da história. Abaixo, veremos como o async/await funciona muito bem com o try/catch .

8. Combinadores das promessas do ES6: Promise.all, Promise.allSettled, Promise.any e outros


As promessas não foram projetadas para funcionar sozinhas. A API Promise oferece vários métodos para combinar promessas . Um dos mais úteis é o Promise.all , ele pega uma matriz de promessas e retorna uma promessa. O único problema é que Promise.all é rejeitado se pelo menos uma promessa na matriz for rejeitada.

Promise.race permite ou rejeita assim que uma das promessas da matriz recebe o status correspondente.

Nas versões mais recentes do V8, dois novos combinadores também serão introduzidos: Promise.allSettled e Promise.any . Promise.any ainda está em um estágio inicial da funcionalidade proposta, no momento da redação deste artigo não é suportado. No entanto, em teoria, ele será capaz de sinalizar se alguma promessa foi executada. A diferença do Promise.race é que Promise.any não é rejeitado, mesmo que uma das promessas seja rejeitada .

Promise.allSettled ainda mais interessante. Ele também aceita uma série de promessas, mas não "encurta" se uma das promessas é rejeitada. É útil quando você precisa verificar se todas as promessas em uma matriz foram aprovadas em algum estágio, independentemente da presença de promessas rejeitadas. Pode ser considerado o oposto de Promise.all .

9. Promessas ES6 e a fila de microtask


Se você se lembra do capítulo anterior, cada função de retorno de chamada assíncrona em JavaScript está na fila de retorno de chamada antes de atingir a pilha de chamadas. Mas as funções de retorno de chamada transmitidas ao Promise têm um destino diferente: elas são processadas pela fila de microtask, em vez da fila de tarefas.

E aqui você precisa ter cuidado: a fila de microtask precede a fila de chamadas . Os retornos de chamada da fila de microtask têm precedência quando o loop de eventos verifica se novos retornos de chamada estão prontos para entrar na pilha de chamadas.

Essa mecânica é descrita mais detalhadamente por Jake Archibald em Tarefas, microtarefas, filas e agendas , ótima leitura.

10. Mecanismos JavaScript: como eles funcionam? Evolução assíncrona: das promessas ao assíncrono / aguardar


O JavaScript está evoluindo rapidamente e estamos constantemente recebendo melhorias a cada ano. As promessas pareciam um final, mas com o ECMAScript 2017 (ES8) uma nova sintaxe apareceu: async/await .

async/await é apenas uma melhoria estilística que chamamos de açúcar sintático. async/await não altera o JavaScript de forma alguma (não esqueça que o idioma deve ser compatível com os navegadores antigos e não deve quebrar o código existente). Esta é apenas uma nova maneira de escrever código assíncrono com base em promessas. Considere um exemplo. Acima, já salvamos a promessa no correspondente then :

 const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]); }); myPromise.then((data) => console.log(data)) 

Agora, com async/await , podemos processar o código assíncrono para que, para o leitor da nossa listagem, o código pareça síncrono . Em vez de usá then podemos cumprir a promessa em uma função denominada async e await resultado:

 const myPromise = new Promise(function(resolve, reject) { resolve([{ name: "Chris" }]); }); async function getData() { const data = await myPromise; console.log(data); } getData(); 

Parece bom, certo? É engraçado que uma função assíncrona sempre retorne uma promessa e ninguém pode impedi-lo de fazer isso:

 async function getData() { const data = await myPromise; return data; } getData().then(data => console.log(data)); 

E os erros? Uma das vantagens do async/await é que essa construção pode nos permitir usar try/catch . Leia a introdução ao tratamento de erros nas funções assíncronas e seus testes .

Vamos dar uma olhada na promessa novamente, na qual lidamos com erros com o manipulador de catch :

 const myPromise = new Promise(function(resolve, reject) { reject('Errored, sorry!'); }); myPromise.catch(err => console.log(err)); 

Com funções assíncronas, podemos refatorar assim:

 async function getData() { try { const data = await myPromise; console.log(data); // or return the data with return data } catch (error) { console.log(error); } } getData(); 

No entanto, nem todo mundo mudou para esse estilo. try/catch pode complicar seu código. Há mais uma coisa a considerar. Veja como ocorre um erro dentro deste bloco try neste código:

 async function getData() { try { if (true) { throw Error("Catch me if you can"); } } catch (err) { console.log(err.message); } } getData() .then(() => console.log("I will run no matter what!")) .catch(() => console.log("Catching err")); 

E as duas linhas exibidas no console? Lembre-se de que try/catch é uma construção síncrona e nossa função assíncrona gera uma promessa . Eles seguem dois caminhos diferentes, como trens. ! , throw , catch getData() . , «Catch me if you can», «I will run no matter what!».

, throw then . , , Promise.reject() :

 async function getData() { try { if (true) { return Promise.reject("Catch me if you can"); } } catch (err) { console.log(err.message); } } Now the error will be handled as expected: getData() .then(() => console.log("I will NOT run no matter what!")) .catch(() => console.log("Catching err")); "Catching err" // output 

async/await JavaScript. .

, JS- async/await . . , async/await — .

11. JavaScript-: ?


JavaScript — , , . JS-: V8, Google Chrome Node.js; SpiderMonkey, Firefox; JavaScriptCore, Safari.

JavaScript- «» : , , , . , .

JavaScript- , . JavaScript: , - , (, ) .

ECMAScript 2015 . — , . . 2017- async/await : , , .

Source: https://habr.com/ru/post/pt452906/


All Articles