Usando as transformações TypeScript para enriquecer o código de tempo de execução

Florian Rappl Blocked Unblock Seguir Seguindo 2 de janeiro

Quando comecei a desenvolver, ninguém estava falando sobre “compilar” suas fontes JavaScript. Todos apenas escreveram alguns scripts e os referenciaram diretamente.

Com o passar do tempo, a minificação e os aprimoramentos adicionais se tornaram o padrão de fato no desenvolvimento da web, portanto, fazer algum processamento depois de ter escrito o código tornou-se uma prática bastante normal. Atualmente, todos aceitam que o desenvolvimento da Web de front-end requer algum processamento, principalmente usando um bundler que nos ajuda a domar a fera da web.

No que diz respeito aos "processadores" de JavaScript, existem dois fluxos principais. Nós usamos o TypeScript ou o Babel com alguns plugins. Algumas pessoas podem usar o TypeScript e o Babel (ou, atualmente, exclusivamente o Babel para o seu código TypeScript). No entanto, normalmente, você vê apenas um ou outro, dependendo se o projeto está usando o TypeScript ou qualquer outra coisa que possa ser transferida usando o Babel. Claro, existem outras linguagens e ferramentas disponíveis, mas estas duas são as dominantes.

Babel é uma história de sucesso. Tudo começou com uma missão muito simples; abrindo um analisador JavaScript para extensibilidade. Com o passar do tempo, o ecossistema cresceu e se tornou a ferramenta padrão para fornecer saída compatível aos navegadores de destino sem qualquer restrição sobre quais recursos usar durante o desenvolvimento.

Ótimo! Melhor ainda, também fomentou alguma metaprogramação e sintaxe de açúcar em cima do JavaScript padrão, como o JSX, que permitia escrever um código de frontend elegante no React ou em bibliotecas e estruturas de UI semelhantes.

Até agora, parece que essa grandeza é exclusiva da Babel, no entanto, isso foi alterado pela introdução das transformações do TypeScript. Agora, o TypeScript pode ser aprimorado externamente. Neste post, veremos porque (e quando) estender o TypeScript faz sentido e como fazê-lo.

Motivação

Embora o TypeScript tenha adotado uma abordagem diferente da de Babel, também se tornou uma história de sucesso. Inicialmente, muitas pessoas duvidaram das aspirações da Microsoft no mundo do JavaScript. No entanto, devido à sua natureza de código aberto e sua abordagem de solução de problemas (“começa com JavaScript e termina com JavaScript”), as pessoas começaram a fazer a transição rapidamente para muitos projetos.

Pessoalmente, tenho usado o TypeScript para qualquer projeto relacionado a JavaScript, mas posso entender por que muitas pessoas só o usam em projetos maiores.

Os oponentes do TypeScript geralmente nos dão os seguintes dois argumentos contra usá-lo:

  1. As garantias que são feitas pelo TypeScript são superficiais na melhor das hipóteses – elas podem ser facilmente contornadas e são muito limitadas para trabalhar com
  2. Todo o trabalho que você coloca na criação de suas tipificações é perdido em tempo de compilação

Enquanto o primeiro é um efeito colateral do TypeScripts, muito forte (100%) de compatibilidade com o JavaScript “comum” (ou seja, aqui um trade-off foi escolhido e o trade-off foi muito bem escolhido), o último é definitivamente válido.

Atualmente, não há como trazer algumas informações de metadados (sistema de tipos) do tempo de compilação para o tempo de execução. Existem muitas razões pelas quais estaríamos interessados em um mecanismo de informações de tipo de tempo de execução ( RTTI ), apesar da natureza dinâmica do JavaScript. Primeiramente, isso nos permitiria verificar tipos de código estrangeiro em tempo de execução (por exemplo, dados de entrada em APIs de pontos de extremidade de serviço, a resposta JSON de uma solicitação).

Reclamar sobre a falta de RTTI do TypeScript não nos ajudará muito. Em vez disso, tentaremos usar as transformações do TypeScript para fornecer o RTTI sob demanda. Idealmente, o código pode ser algo como isto:

onde as informações adicionais podem ser recuperadas assim:

Só para ficar claro – neste post, vamos nos concentrar apenas na primeira parte, a segunda parte (expondo o RTTI) não será discutida.

Então, vamos tentar abordar uma solução para gerar o RTTI, introduzindo transformações do TypeScript.

Transformações de TypeScript

Uma transformação de TypeScript é apenas uma função simples que é definida da seguinte forma:

Essencialmente, o que obtemos é alguma informação de contexto para retornar uma função que poderia ser usada para qualquer tipo de transformação em um arquivo de origem. Aqui está um exemplo de tal função:

Antes de prosseguirmos, precisamos olhar para o que transformamos. Como o TypeScript é um compilador (ou transpilador ), estamos lidando com estruturas de dados manipuladas por um compilador. Um pipeline de compilador no nível alto consiste em um frontend (fazendo sentido do código-fonte e transformando-o nas estruturas de dados principais) e em um backend (aplicando transformações como otimizações nas estruturas de dados e serializando-as em um formato de saída).

Para alcançar as estruturas de dados principais (geralmente disponíveis na forma de uma árvore, chamada de árvore de sintaxe abstrata ou AST ), algumas transformações já foram aplicadas:

  1. Transformar um fluxo linear de caracteres em um fluxo linear de tokens bem definidos
  2. Transformar o fluxo linear de tokens em uma estrutura de árvore obedecendo a gramática da linguagem

Isso também é ilustrado aqui:

Transformações de dados (pipeline) para analisar o código-fonte

Usando as transformações do TypeScript, só podemos ter um transformador de backend que nos permita realizar modificações no AST existente.

Noções básicas sobre AST

A AST é uma árvore que consiste em nós representando grupos de informações de sintaxe e arestas representando seus relacionamentos. Por exemplo, um código de exemplo trivial como este:

se transforma em um AST assim:

Aqui nós escolhemos a chamada especificação ES Tree para representar a saída no formulário JSON. Existem duas coisas, que são comuns entre todas as implementações da AST:

  1. Como existem muitos nós diferentes, precisamos de um discriminador de tipos para distinguir entre os diferentes tipos de nós. Normalmente, a propriedade relacionada é chamada de type
  2. O nó raiz é o próprio programa, que possui um corpo que consiste em todas as instruções. Uma declaração é composta de expressões. As expressões computam as coisas e podem ser compostas, as instruções são apenas instruções e não permitem a composição (somente agregação, por exemplo, em um programa, uma instrução de bloco ou um corpo de função)

A maneira mais eficaz de trabalhar com uma AST é voltar aos padrões clássicos de design de software e usar o padrão de visitantes . Esse padrão nos permite atravessar uma árvore sem ter que implementar / conhecer todas as informações na árvore.

Considere a árvore de exemplo acima. Para percorrê-lo sem o padrão de visitantes, precisaríamos saber que:

  • Uma operação binária tem dois nós filhos, um à left e outro à right
  • Um declarator de variável tem dois nós filhos, um em id e um em init , sendo este último opcional
  • Uma declaração de variável tem uma matriz de declarações dentro exposta pela propriedade declarations
  • Existe uma declaração de expressão (que consiste em apenas uma expressão sem qualquer outra instrução), que contém a expressão em sua propriedade de expression
  • Uma expressão de chamada consiste em duas propriedades callee (expressão para definir o valor chamado) e arguments (matriz de expressões para computar os argumentos)
  • Uma expressão de membro consiste em duas expressões contidas no object e na property

Este é apenas um exemplo simples, mas já destaca que muita informação seria necessária para percorrê-lo. Generalizar isso exigiria um enorme esforço que é mitigado usando o padrão de visitantes.

Por exemplo, o código a seguir seria tudo o que é necessário para alterar cada identificador relacionado a a em b :

Este código de exemplo ilustra que ter uma função de visitante sendo aplicada na árvore reduzirá o código do nosso lado apenas para a parte em que estamos interessados. Em vez de fazer todo o trabalho básico de percorrer a árvore, podemos nos concentrar no que deveria acontecer apenas para nós identificadores.

Implementações do mundo real podem parecer diferentes. Por exemplo, o exposto no TypeScript é usado da seguinte forma:

Por motivos de desempenho, um visitante do TypeScript requer métodos auxiliares para acionar o percurso.

Um transformador simples

O primeiro passo para nós é criar o transformador que está sendo exportado. Como trabalhamos com tipos, faz sentido obter uma instância de verificador de tipos a partir do código-fonte avaliado. O código é algo como isto:

Então criamos uma função simples que leva todo o programa como entrada de transformação. Além disso, aceitamos algumas opções (que não usamos neste momento, mas é bom saber o local onde uma configuração definida pelo usuário pode ser passada). Finalmente, retornamos a função mencionada anteriormente, aceitando o contexto de transformação e retornando o transformador de arquivo de origem real.

A implementação do transformador de arquivo de origem usa a função visitNode fornecida pelo TypeScript, bem como nossa própria implementação. O visitante muito específico que é aplicado a cada nó ainda precisa ser implementado. Olhando para o nosso código de exemplo, estamos interessados em chamadas para uma função generateRtti .

O código a seguir representa uma implementação sólida para o que queremos:

Se vemos um nó que é uma expressão de chamada para generateRtti com alguns argumentos de tipo, transformamos o nó para se tornar:

Neste ponto, duas questões devem surgir:

  • Como é a implementação do getDescriptor ?
  • Onde é que o __rtti__generate (ou o nome que damos a esta função) vem?

Vamos tentar responder a essas perguntas uma por uma. Primeiro, a implementação do getDescriptor precisa funcionar em todos os tipos que esperamos. Mais importante, ele precisa lidar com referências de tipo e interfaces. Em nosso exemplo acima, obtivemos uma referência de tipo, que é vinculada a uma interface.

Uma implementação simples pode ser algo como isto:

Para simplificar, omitimos muitos casos (por exemplo, produzindo boolean ) e apenas mantivemos o que é realmente necessário para fazer nosso exemplo funcionar. Vemos que as primitivas são produzidas diretamente (por exemplo, um literal 'string' é fornecido quando vemos um tipo de string sendo usado), enquanto tipos combinados, como uma declaração de interface, usam recursivamente a função getDescriptor .

No que diz respeito à segunda questão, existem várias maneiras. Nós poderíamos:

  • definir a função em nossa biblioteca e adicionar uma referência a ela nas instruções de importação, ou
  • definir a função dentro do novo módulo (se a função foi usada / referenciada)

O primeiro pode ser simples de mudar ou brincar, no entanto, ele vem com alguns grandes problemas. Pela primeira vez a nossa transformação é após a fase de ligação, ou seja, precisaríamos cuidar de qualquer módulo que nos ligue. Normalmente, isso não é um grande problema se conhecermos o alvo (especialmente para os módulos do ES6), no entanto, suportando todos os sistemas de módulos possíveis, isso pode ser bastante incômodo. Além disso, isso deixará a dependência de tempo de execução lá, o que ainda pode trazer nosso código de transformador para o pacote (dependendo da potência do agitador de árvore usado).

Portanto, devemos optar pela segunda opção, que também é usada pelo TypeScript. Como sabemos, o TypeScript gosta de gerar código para funções introduzidas também. Nós podemos fazer o mesmo. Uma modificação simples em nossa função visitante nos permite fazer algumas alterações no arquivo de origem depois que aplicamos todas as transformações:

O valor da propriedade seen deve ser alterado dentro de nosso visitante, se precisarmos gerar a função mais tarde. A função em si (neste exemplo) será colocada apenas no topo do arquivo.

Devido ao içamento, também podemos colocá-lo na parte inferior sem qualquer impacto negativo. A declaração de função em si é bastante espetacular – é apenas longa e essencialmente compila para o seguinte código JavaScript:

Mais detalhes podem ser vistos no repositório de amostra no GitHub .

O que o nosso transformador simples não cobre no momento são referências cíclicas. Portanto, uma interface que se auto-referencia (potencialmente indiretamente através de alguma outra interface referenciada) definitivamente não será detectada e leva ao estouro de pilha tradicional. Contornar isso seria possível colocando as interfaces contidas no nível superior também.

Usando o transformador

No momento, o uso de transformadores no TypeScript é infelizmente limitado. Existem dois problemas:

  1. O aplicativo tsc não consome / considera os transformadores, por exemplo, via tsconfig.json
  2. O pipeline de emissão para arquivos de declaração ( d.ts ) é independente do transformador

Ambas as questões estão no radar da equipe TypeScript, no entanto, no momento não tem a maior prioridade. Embora a questão 2 não seja fácil de resolver de maneira eficiente, o número 1 pode ser contornado de várias maneiras.

A maneira mais popular é fornecer um script de corredor adicional. Isso também pode ser generalizado com um pequeno script de wrapper sendo o resultado . Agora, em vez de chamar tsc chamamos ttsc . Espero que, no futuro, o TypeScript suporte as transformações / plugins diretamente nos deixando cair a dependência extra.

Se estivermos interessados principalmente em usar nossas transformações em um bundler, como o webpack, há boas notícias. Carregadores padrão, como o ts-loader ou os awesome-typescript-loader suporte do awesome-typescript-loader sem necessidade de ttsc . No entanto, como esses carregadores também podem trabalhar com "versões personalizadas do TypeScript", também podemos usá-los em conjunto com o ttsc , o que nos dá a liberdade de agrupar ou transpilar diretamente.

Conclusão

Agora vimos como as transformações do TypeScript podem ser usadas e o que elas poderiam trazer para a tabela. Em nosso exemplo, exploramos a capacidade de anotar o código com chamadas de função adicionais para declarar quais tipos queremos gerar informações adicionais. Paramos na geração sem olhar para a criação de alguma API para acessar o RTTI contido.

As possibilidades que vêm de tais plugins são bastante ilimitadas. Temos visto muitas pessoas criativas e inteligentes apresentando grandes aprimoramentos no ecossistema Babel (e também no TypeScript). Embora o uso de informações do sistema de tipos possa ser de uma maneira, outro pode ser uma extensão simples usando alguma introspecção no código usado.

Como exemplo, existe um plugin ( typescript-plugin-inner-jsx ) para colocar os componentes que foram utilizados dentro de um componente no componente, permitindo uma maneira mais fácil (implícita) de estilizar o componente. Existem muitos outros grandes transformadores TypeScript, embora o ecossistema TypeScript não seja dependente deles, como o ecossistema Babel.

Quais idéias você tem para os transformadores TypeScript? Você acha que isso é útil? O que está faltando atualmente que os tornaria muito mais úteis? Deixe-nos saber nos comentários!

Plug: LogRocket , um DVR para aplicativos da web

https://logrocket.com/signup/

LogRocket é uma ferramenta de registro de front-end que permite que você repita problemas como se eles tivessem ocorrido em seu próprio navegador. Em vez de adivinhar por que os erros ocorrem ou solicitar aos usuários capturas de tela e log dumps, o LogRocket permite que você repita a sessão para entender rapidamente o que deu errado. Ele funciona perfeitamente com qualquer aplicativo, independentemente do framework, e possui plugins para registrar o contexto adicional do Redux, Vuex e @ ngrx / store.

Além de registrar as ações e o estado do Redux, o LogRocket registra logs do console, erros de JavaScript, rastreamentos de pilha, solicitações / respostas de rede com cabeçalhos + corpos, metadados do navegador e logs personalizados. Ele também instrumenta o DOM para gravar o HTML e CSS na página, recriando vídeos com pixels perfeitos até mesmo dos aplicativos de página única mais complexos.

Experimente Grátis.