A jornada da Figma na migração para TypeScript
(figma.com)- A Figma migrou todo o código em Skew para TypeScript quando sua linguagem interna, usada em renderização móvel e no visualizador de protótipos, encontrou limitações em onboarding, integração e ecossistema
- As condições para a transição foram criadas pela ampliação do suporte a WebAssembly nos navegadores móveis, pela substituição do engine em C++ nos caminhos críticos e pelo crescimento das equipes de prototipagem e mobile
- A migração aconteceu em 3 fases:
Write Skew, build Skew→Write Skew, build TypeScript→Write TypeScript, build TypeScript, com um transpilador de Skew para TypeScript preservando o fluxo de desenvolvimento - No processo real de migração, surgiram problemas de compatibilidade como desempenho de desestruturação de arrays, diferenças na remoção de chamadas virtuais (devirtualization), ordem de inicialização, encadeamento de source maps e ausência de compilação condicional
- No fim, a empresa obteve uma base em TypeScript que passava nos testes unitários e tinha desempenho parecido com o de Skew, e vê como próximos passos a integração entre códigos internos e externos, a gestão de pacotes e o aproveitamento do ecossistema TypeScript
Por que sair de Skew para TypeScript
- Skew começou como um projeto paralelo nos primeiros dias da Figma e atendia à necessidade de criar rapidamente um visualizador de protótipos com suporte para web e mobile
- Ao evoluir para uma linguagem compilada para JavaScript, ofereceu otimizações avançadas e tempos rápidos de compilação, mas o custo de manutenção cresceu à medida que o código se acumulava no visualizador de protótipos
- era difícil para novos funcionários aprenderem
- não se integrava facilmente com o restante da base de código
- não havia um ecossistema de desenvolvedores fora da Figma
- Com a adoção de TypeScript, os ganhos esperados eram ampliar a velocidade de desenvolvimento e o alcance da colaboração
- integração mais simples entre código interno e externo com imports estáticos e gerenciamento nativo de pacotes
- uso de ferramentas criadas por uma grande comunidade de desenvolvedores, como linters, bundlers e analisadores estáticos
- uso de recursos modernos de JavaScript, como async/await, e de um sistema de tipos mais flexível
- redução da barreira para onboarding de novos desenvolvedores e participação de outras equipes
Condições que só recentemente tornaram a transição viável
- Quando a Figma criou sua primeira base de código mobile, os navegadores móveis ainda não tinham suporte a WebAssembly, e também era difícil carregar bundles grandes com bom desempenho
- O próprio TypeScript ainda estava em estágio inicial, então o Skew, com tipagem estática e um sistema de tipos mais rigoroso, era na época uma escolha mais prática
- O WebAssembly ganhou suporte amplo em mobile até 2018 e, nos testes da Figma, o desempenho em mobile chegou a um nível confiável em 2020
- Um dos pontos fortes iniciais do Skew era a combinação de otimizações clássicas de compilador, como constant folding e remoção de chamadas virtuais, com otimizações voltadas à web, como gerar JavaScript usando operações com inteiros reais
- Em benchmarks de 2020, foi confirmado que carregar protótipos da Figma com TypeScript no Safari podia ser quase 2 vezes mais lento, e isso bloqueava a transição porque, no iOS, o Safari era o único engine de navegador permitido
- no iOS 17.4, a Apple abriu outros engines de navegador para usuários da UE, mas para usuários de outras regiões o WebKit continua sendo o único engine permitido
- Depois disso, muitos componentes centrais do engine em Skew foram substituídos por componentes equivalentes no engine em C++ e o risco de desempenho da migração para TypeScript diminuiu
- isso incluía os caminhos de código mais críticos, como carregamento de arquivos
- com isso, a empresa ganhou confiança de que poderia absorver a perda de desempenho ao migrar para TypeScript
- À medida que as equipes de prototipagem e mobile cresceram, a Figma passou a ter recursos para investir em migração automática e melhoria da experiência do desenvolvedor
A transição da base de código em 3 fases
- No primeiro protótipo de migração, em 2020, o desempenho em TypeScript era quase 2 vezes mais lento
- Depois que o suporte a WebAssembly e a migração do engine mobile para C++ amadureceram o suficiente, a Figma aproveitou a Maker Week para corrigir o protótipo existente e demonstrar uma migração funcional que passava em todos os testes
- O objetivo era converter toda a base para TypeScript, mas uma reescrita manual poderia gerar queda na velocidade de desenvolvimento, erros em runtime e regressões de desempenho
- Skew e TypeScript tinham diferenças de semântica maiores do que uma simples transição entre linguagens da família “JavaScript com tipos”
- no TypeScript, namespaces e classes só são inicializados depois que o arquivo é importado
- se a ordem dos imports não for a esperada, podem ocorrer erros em runtime
- no Skew, todos os símbolos ficam disponíveis em runtime para toda a base assim que o carregamento acontece
- Com base em um trabalho que Evan Wallace havia iniciado no passado, a Figma desenvolveu um transpilador de Skew para TypeScript
-
Fase 1: escrever em Skew e gerar Skew
- o transpilador foi desenvolvido mantendo o processo de build existente
- o código TypeScript gerado era versionado no GitHub para que os desenvolvedores pudessem ver a forma da nova base de código
-
Fase 2: escrever em Skew e gerar TypeScript
- depois que passou a existir um bundle em TypeScript capaz de passar em todos os testes unitários, o tráfego de produção foi gradualmente direcionado para builds da base em TypeScript
- os desenvolvedores continuavam escrevendo em Skew, e o transpilador convertia o código Skew para TypeScript, atualizando o código TypeScript no GitHub
- os erros de tipo no código gerado também continuaram sendo corrigidos
- o TypeScript conseguia gerar um bundle válido mesmo com erros de tipo
-
Fase 3: escrever em TypeScript e gerar TypeScript
- depois que todos os usuários passaram pelo processo de build em TypeScript, o código TypeScript se tornou a única fonte de verdade do desenvolvimento
- a equipe encontrou um momento sem merges em andamento para interromper o processo de geração automática e remover o código Skew da base
- numa sexta-feira à noite, fizeram o merge da remoção da geração automática e da mudança nas tarefas de CI para executar diretamente os arquivos TypeScript
- durante o rollout gradual, uma quebra no recurso Smart Animate foi detectada internamente e, graças ao modelo de deploy com gate, foi possível desligar o rollout, corrigir o problema e revisar o plano
Problemas revelados no transpilador
- Compiladores normalmente são compostos por frontend e backend
- o frontend faz parsing do código de entrada, executa verificação de tipos e de sintaxe, e converte o resultado para uma representação intermediária (IR)
- o backend converte a IR para outra linguagem
- o backend do compilador Skew gerava JavaScript ofuscado e minificado
- Um transpilador é um compilador especial cujo backend gera código legível por humanos, e a Figma precisava gerar TypeScript legível a partir da IR do Skew
- A implementação inicial, fortemente inspirada no backend JavaScript do Skew, avançou com relativa facilidade, mas mais adiante surgiram vários problemas difíceis de rastrear e tratar
-
Desempenho da desestruturação de arrays
- ao investigar a diferença de desempenho offline entre Skew e TypeScript em um protótipo de exemplo, a equipe observou uma taxa de frames menor em TypeScript
- a causa era a desestruturação de arrays em JavaScript
- em operações como
const [a, b] = function_that_returns_an_array(), o JavaScript não indexa o array diretamente: ele cria um iterator e o percorre - a Figma usava essa abordagem para obter argumentos a partir da palavra-chave
argumentsdo JavaScript, e isso tornava alguns casos de teste mais lentos - ao gerar código que indexava diretamente o array de argumentos em vez de usar desestruturação, a latência por frame melhorou em até 25%
-
Otimização de remoção de chamadas virtuais no Skew
- o compilador Skew executava uma otimização de devirtualization que, em certas condições, movia funções de dentro de classes para fora, elevando-as a funções globais
myObject.myFunc(a, b)podia virarmyFunc(myObject, a, b)- o TypeScript não faz essa otimização
- a quebra no Smart Animate acontecia quando
myObjectera null - a chamada devirtualized funcionava normalmente, mas a chamada sem devirtualization gerava exceção por acesso a null
- para encontrar outros pontos com o mesmo problema, a Figma adicionou logging a todas as funções que podiam participar da devirtualization
- após manter esse logging ativo por um curto período, a equipe analisou os logs de produção e corrigiu os pontos problemáticos
-
Diferenças na ordem de inicialização
- no Skew, variáveis, classes, namespaces e definições de função podiam ser declarados em qualquer lugar do código sem preocupação com a ordem
- no TypeScript, a ordem de inicialização de variáveis globais ou definições de classes importa
- inicializar variáveis estáticas de uma classe antes da definição da própria classe gera erro em tempo de compilação
- no início, o transpilador mantinha um comportamento parecido com o de Skew sem usar namespaces, achatando todas as funções em um escopo global
- o código resultante era difícil de ler, e depois o transpilador foi ajustado para emitir código TypeScript na ordem correta e reintroduzir namespaces para melhorar a legibilidade
- no fim, a equipe construiu um transpilador capaz de passar nos testes unitários e compilar código TypeScript com desempenho equivalente ao do Skew
- alguns problemas menores foram resolvidos manualmente no código-fonte Skew ou deixados para depois da migração para TypeScript, em vez de acrescentar novas correções ao transpilador
Mantendo a experiência de depuração com source maps
- A Figma tratou os source maps como algo essencial para que os desenvolvedores pudessem continuar depurando sem interrupções durante a migração para TypeScript
- O debugger do navegador entende apenas JavaScript, mas os desenvolvedores colocam breakpoints no código-fonte em Skew ou TypeScript
- Source maps conectam posições no JavaScript compilado às posições no código-fonte original
- por exemplo, pode haver um mapeamento como
helper → c,myInt → a,arrayOfInts → b
- por exemplo, pode haver um mapeamento como
- Em geral, um único arquivo de source map com extensão
.mapé associado ao bundle final de JavaScript - A infraestrutura existente já gerava source maps de Skew → JavaScript, mas na Fase 2 o pipeline mudou para um fluxo Skew → TypeScript → bundling com esbuild
- Se os source maps antigos fossem usados como estavam, o mapeamento entre JavaScript e código Skew ficaria incorreto, impedindo a depuração
- O novo processo de build montava os source maps em 3 etapas
- Etapa 1: o esbuild gera o source map TypeScript → JavaScript
ts-to-js.map - Etapa 2: o transpilador gera, para cada arquivo Skew, um source map Skew → TypeScript
- Etapa 3: os dois source maps são compostos para gerar o source map final Skew → JavaScript
- Etapa 1: o esbuild gera o source map TypeScript → JavaScript
- Com esse source map final, foi possível mapear o novo bundle JavaScript de volta ao código Skew e preservar a experiência do desenvolvedor também na Fase 2
Como a compilação condicional foi tratada no TypeScript
- O Skew oferecia suporte a compilação condicional com instruções
ifno topo do arquivo e com a opçãodefinespassada ao compilador - Isso permitia gerar bundles diferentes a partir da mesma base de código
- o bundle real distribuído aos usuários
- um bundle exclusivo para testes unitários
- funções ou classes com implementações diferentes em builds de debug ou release
- Como o TypeScript não tem compilação condicional, a Figma passou a usar
definesdo esbuild e dead code elimination depois da verificação de tipos, na etapa de bundling - Nesse modelo, os
definesnão podem influenciar a verificação de tipos- por isso, código como
testOnlyFunction, que só existe quandoBUILD == "TEST", não podia continuar existindo dessa forma em TypeScript
- por isso, código como
- A solução foi converter o padrão para que classes e métodos estivessem sempre definidos, com desvio interno baseado no valor de
BUILD- se
testOnlyFunctionfosse chamada em um build que não fosse de teste, seria lançada a exceçãoUnexpected call to test-only function
- se
- O JavaScript final ainda podia ser compilado de forma idêntica ao JavaScript que o código original em Skew gerava
- Ainda assim, alguns símbolos que antes só existiam em modos específicos de compilação passaram a existir em todos os modos, deixando o bundle final um pouco maior
- Nos testes, o aumento no tamanho do bundle ficou em um nível aceitável, e símbolos de topo não exportados podiam ser removidos com tree-shaking
O rumo depois da transição
- Ao migrar todo o código em Skew para TypeScript, a Figma modernizou uma de suas bases de código centrais
- A integração com código interno e externo ficou mais fácil, e os desenvolvedores passaram a trabalhar com mais eficiência
- Dadas as necessidades e capacidades da Figma na época, a decisão de começar essa base em Skew foi adequada
- Com o amadurecimento da tecnologia, o TypeScript, que antes não era a escolha certa, passou a ser uma opção apropriada
- Como trabalho futuro, a Figma está explorando possibilidades como
- integração com o restante da base de código
- gerenciamento de pacotes muito mais simples
- uso direto de novos recursos do ecossistema ativo de TypeScript
- Ao longo da transição, a equipe aprendeu vários aspectos do TypeScript, incluindo resolução de imports, sistema de módulos e geração de código JavaScript
1 comentários
Opiniões do Hacker News
Já é surpreendente que o Figma tivesse uma linguagem customizada para JS, e é ainda mais surpreendente que ela fosse mais rápida que TS
Mas também é interessante que, no fim, tenham migrado para o TS, que era mais lento
Esse tipo de coisa aparece com bastante frequência: uma empresa cria sua própria tecnologia no começo e, depois de crescer, migra para algo mais “padrão”
Quando a equipe e o produto ficam muito maiores, acabam levando a plataforma de volta para uma stack mais familiar e amplamente usada
O sucesso desse tipo de transição depende muito de quão sólida é a cultura de engenharia da organização. Não tenho provas, mas parece que Evan e os fundadores criaram uma excelente cultura de engenharia no Figma, e ela parece ter resiliência suficiente para corrigir erros quando eles surgirem
Nesse ponto, só sobrou o código de cola, e a linguagem customizada deixou de ter razão para existir
Então chega um momento em que a comunidade alcança esse nível e passa a ter uma força de tração maior que a da empresa; aí faz sentido migrar para a implementação padrão
Também vejo o caminho do Borg do Google para o k8s como algo um pouco parecido. Só que, nesse caso, o Google convidou a comunidade para se reunir em torno de um padrão que eles próprios criaram
É preciso comparar o custo de desenvolvedores capazes de olhar para um framework totalmente novo pela primeira vez e aprendê-lo com o valor de desenvolvedores que já têm experiência documentada na plataforma ou tecnologia usada
Com uma solução totalmente interna, é possível extrair desempenho enorme de qualquer linguagem ou framework, mas, quanto mais situações o projeto precisa lidar, maior é o número de desenvolvedores necessário para manutenção, até chegar a um ponto em que software de prateleira se torna melhor, mesmo sendo mais lento e não se encaixando perfeitamente nos requisitos
Se, no longo prazo, for possível manter critérios de contratação de nível unicórnio, talvez dê para sustentar uma stack totalmente customizada. Mas a maioria das organizações precisa migrar para algo que uma contratação nova mediana consiga manter no futuro, e isso geralmente significa produtos de fornecedores de software famosos e entediantes
Para pessoas ambiciosas, isso pode ser um motivo para sair; também dificulta a contratação e alonga o onboarding. Mais tarde, quando a linguagem customizada for abandonada, será necessário um grande projeto de transição
Às vezes pode valer a pena se o ganho de desempenho for enorme, se permitir escrever código mais seguro ou se a intenção for contratar apenas pessoas que gostam de aprender linguagens novas
Trabalhei nesse projeto. Escrevi um pouco mais no Twitter: https://twitter.com/andrew_k_chan/status/1786769203912925477
O título do post é enganoso. No Figma, usamos Typescript em outras partes da codebase por quase 10 anos e, durante praticamente todo esse período, havia mais código em Typescript do que em Skew
Como o post do blog explica, o Skew foi usado no motor mobile, depois no player de prototipagem, no recurso de espelhamento e em uma ou duas áreas de produto das quais não me lembro
O Skew não era apenas um pouco mais rápido que o TypeScript
Segundo Evan Wallace, ex-CTO do Figma, ele era 1,5 a 2 vezes mais rápido graças a otimizações possibilitadas por um sistema de tipos mais estrito
Parece que havia desafios bem grandes, mas reduzir parte da dinamicidade para permitir otimizações mais previsíveis soa como uma boa troca para apps de produção
Se os caminhos de execução mais críticos forem otimizados, há uma boa chance de que o ganho de 2 vezes desapareça
Claro, aí seria preciso escrever JS otimizado à força em vários lugares, e dá para dizer que isso afeta a legibilidade. É verdade, mas a alternativa era usar uma linguagem de compilação para JS completamente nova, com pouco suporte de ferramentas e pouca adoção
Olhando agora, parece que não valeu tanto a pena, e o post do blog dá a impressão de estar embelezando um pouco uma escolha técnica anterior que era controversa
Fico curioso se seria possível restringir o uso de TypeScript ao subconjunto que entrega esse desempenho
Ao completar uma operação como
const [a, b] = function_that_returns_an_array(), é interessante notar que o JavaScript não indexa diretamente o array, mas cria um iterador que percorre o arrayPor que o JS simplesmente não indexa arrays diretamente na atribuição por desestruturação?
Symbol.iterator, e a desestruturação precisa funcionar também nesses objetos. Até o próprioSymbol.iteratorde arrays pode ser alterado, então a VM precisa lidar com issoArray.prototype[Symbol.iterator] = function*() { yield 1; yield 2; yield 3; }[...[4, 5, 6]][1, 2, 3]O desempenho terrível do protocolo de iteradores já foi discutido na época, mas foi ignorado sob o argumento de que a análise de escape resolveria isso [0]. Quase 10 anos depois, a análise de escape ainda não resolveu, usa muito GC e continua ruim. É uma especificação ruim, projetada por pessoas que não tinham desempenho em mente
Se o usuário não tiver alterado
Symbol.iterator, poderia fazer sentido o engine especializar a desestruturação e o splicing de arrays para eliminar o overhead do protocolo de iteradores, mas isso é outro problema complicado[0] https://esdiscuss.org/topic/performance-of-iterator-next-as-...
O mais estranho é que, em alguns engines JavaScript, ao desestruturar arrays com dois ou três elementos, a desestruturação de objeto no estilo
{0: foo, 1: bar}pode ser mais rápidaO texto fala apenas das conversões feitas uma vez durante a migração da base de código, e não aborda muito o impacto contínuo na experiência do desenvolvedor causado pela perda das conveniências do Skew
Por exemplo, em TypeScript é fácil acabar em situações em que você precisa importar arquivos na ordem correta, ou tudo pode quebrar. Além disso, desestruturação é lenta, então não deve ser usada se desempenho importar minimamente
Depois de usar TypeScript por alguns anos, vejo que há dezenas dessas armadilhas; algumas vêm do JavaScript e outras não. Se há muitos engenheiros, no mínimo é necessário um guia de estilo enorme
Fico curioso se houve engenheiros que lamentaram o desaparecimento do Skew
Assim, os desenvolvedores podiam ver nos reviews de PR como o resultado ficaria e relatar problemas
Em termos de desempenho e correção em runtime, é verdade que o TS tem algumas armadilhas. Problemas como desestruturação de arrays foram detectados com instrumentação e monitoramento rigoroso
A perda de alguns recursos do Skew, como sobrecarga de operadores e tipos inteiros, certamente fez falta. Mas a migração acabou sendo uma decisão tomada pela equipe inteira, e eu também acho que foi a decisão certa
O texto fala em “recursos modernos de JavaScript, como async/await, e um sistema de tipos mais flexível”; então o Skew só tinha callbacks?
Promise?Como alguém que não conhece bem o Figma, fico curioso sobre por que usam WebAssembly
O Figma faz isso extremamente bem. Mesmo em canvases muito grandes, com muitas telas de UI complexas, ele é incrivelmente responsivo
Hoje em dia, quase nenhum aplicativo desktop roda tão bem assim. Tenho certeza de que esse tipo de otimização foi uma parte importante do sucesso do Figma
Há detalhes interessantes em outro post do blog sobre como o Figma criou uma DSL TypeScript + compilador customizados para resolver um problema de segurança, isto é, permissões
https://www.figma.com/blog/how-we-rolled-out-our-own-permiss...
Isso dói um pouco. Toda grande empresa tem suas próprias ferramentas internas, linguagens, algo tipo Kubernetes. Por que não compartilham?
Se o Skew fosse open source, talvez pudesse ter se tornado um TypeScript melhor
Ser open source não significa que contribuições gratuitas simplesmente aparecem. Cada PR não trivial precisa passar por uma revisão longa, discussão e talvez até reescrita
Gosto de TypeScript e tenho até sistemas full-stack em TypeScript, mas ele não é perfeito. Configurar TypeScript em um monorepo é um pesadelo.
Para fazer tudo ficar compreensível junto com os pacotes internos dentro de um monorepo pnpm, é preciso ajustar manualmente muitos
tsconfig.jsonpara que todos os caminhos batam entre si.E, até o excelente pacote
tsxaparecer, a cadeia de ferramentas de produção era praticamente impossível de manter.Além disso, é absurdamente lento. Havia um problema em que o Zod deixava o desempenho do language server do TypeScript bem mais lento, então no fim tivemos que adotar referências de projeto e desativar o redirecionamento de referências de projeto.
No geral, há muito espaço para melhorar o TypeScript. Especialmente em monorepos e em desempenho.
O problema do monorepo soa como se vocês não estivessem usando a mesma configuração em todos os pacotes. Se for isso, eu corrigiria primeiro impondo a mesma configuração e padrões de código. Isso também não é um problema do TypeScript.
Por acaso vocês usavam
ts-nodeantes dotsx? Se sim,tsxé muito mais robusto. Simplesmente funciona bem.Opero um monorepo de porte médio, com vários apps e muitos serviços, em sua maioria em TypeScript, e mesmo com uma configuração tediosa baseada em npm workspaces ele funciona razoavelmente bem.
Isso não quer dizer, de forma alguma, que o TypeScript seja rápido, mas, como ele é escrito em JavaScript, também é difícil que seja rápido.
Ainda assim, deixar o TypeScript executar código para inferência de tipos em uma aplicação de grande porte parece um problema criado por você mesmo.