Organização de desenvolvimento de aplicações React em larga escala

Esta postagem é baseada em uma série sobre a modernização do frontend jQuery com React. Para entender melhor as razões pelas quais este artigo foi escrito, é recomendável dar uma olhada no primeiro artigo desta série. É muito fácil hoje em dia organizar o desenvolvimento de um pequeno aplicativo React ou começar do zero. Especialmente ao usar criar-reagir-app . Alguns projetos provavelmente precisarão de apenas algumas dependências (por exemplo, para gerenciar o estado do aplicativo e internacionalizar o projeto) e uma pasta que contém pelo menos um diretório







srccomponents... Acredito que essa é a estrutura com a qual a maioria dos projetos React começa. Normalmente, entretanto, conforme o número de dependências do projeto aumenta, os programadores se deparam com um aumento no número de componentes, redutores e outros mecanismos reutilizáveis ​​incluídos em sua composição. Às vezes, tudo se torna muito desconfortável e difícil de gerenciar. O que fazer, por exemplo, se não estiver mais claro por que certas dependências são necessárias e como elas se encaixam? Ou, e se o projeto acumulou tantos componentes que fica difícil encontrar o certo entre eles? O que fazer se um programador precisar encontrar um determinado componente cujo nome foi esquecido?



Estes são apenas alguns exemplos das perguntas para as quais tivemos que encontrar respostas ao retrabalhar o front-end no Karify . Sabíamos que o número de dependências e componentes do projeto poderia um dia ficar fora de controle. Isso significava que tínhamos que planejar tudo para que, à medida que o projeto crescia, pudéssemos continuar trabalhando com segurança. Esse planejamento incluiu um acordo sobre a estrutura de arquivos e pastas e a qualidade do código. Isso incluiu uma descrição da arquitetura geral do projeto. E o mais importante, era necessário fazer com que tudo isso pudesse ser facilmente percebido pelos novos programadores que chegam ao projeto, para que eles, para serem incluídos no trabalho, não precisassem estudar o projeto por muito tempo, entendendo todas as suas dependências e o estilo de seu código.



No momento em que este artigo foi escrito, nosso projeto tinha cerca de 1200 arquivos JavaScript. 350 deles são componentes. O código foi testado em 80% da unidade. Uma vez que ainda aderimos aos acordos que estabelecemos e trabalhamos no âmbito da arquitetura de projeto criada anteriormente, decidimos que seria bom compartilhar tudo isso com o público em geral. Foi assim que surgiu este artigo. Aqui, falaremos sobre como organizar o desenvolvimento de um aplicativo React em grande escala e quais lições aprendemos com a experiência de trabalhar nele.



Como organizo arquivos e pastas?



Só encontramos uma maneira de organizar convenientemente os materiais do front-end do React depois de passar por vários estágios do projeto. Inicialmente, iríamos hospedar os materiais do projeto no mesmo repositório onde o código de front-end baseado em jQuery estava armazenado. No entanto, devido aos requisitos para a estrutura de pastas impostos ao projeto pela estrutura de back-end que estamos usando, essa opção não funcionou para nós. Em seguida, pensamos em mover o código do front-end para um repositório separado. No início, essa abordagem funcionou bem, mas com o tempo começamos a pensar em criar outras partes do cliente do projeto, por exemplo, um front-end baseado no React Native. Isso nos fez pensar sobre a biblioteca de componentes. Como resultado, dividimos o novo repositório em dois repositórios separados. Um era para uma biblioteca de componentes e o outro era para o novo front-end React.Embora a princípio tenhamos pensado que essa ideia era bem-sucedida, sua implementação levou a uma séria complicação do procedimento de revisão de código. A relação entre as mudanças em nossos dois repositórios tornou-se obscura. Como resultado, decidimos mudar novamente para armazenar o código em um único repositório, mas agora era um repositório mono.



Optamos por um repositório mono porque queríamos introduzir uma separação entre a biblioteca de componentes e o aplicativo de frontend no projeto. A diferença entre nosso repositório mono e outros repositórios semelhantes é que não precisamos publicar pacotes dentro de nosso repositório. No nosso caso, os pacotes eram apenas um meio de garantir a modularidade do desenvolvimento e uma ferramenta para separação de interesses. É especialmente útil ter pacotes diferentes para variantes diferentes de seu aplicativo, pois isso permite definir dependências diferentes para cada um e aplicar scripts diferentes com cada um.



Configuramos nosso repositório mono usando espaços de trabalho yarn usando a seguinte configuração no arquivo raiz package.json:



"workspaces": [
    "app/*",
    "lib/*",
    "tool/*"
]


Alguns de vocês podem estar se perguntando por que simplesmente não usamos as pastas de pacotes, fazendo o mesmo que em outros monorepositórios. Isso se deve principalmente ao fato de que queríamos separar o aplicativo e a biblioteca de componentes. Além disso, sabíamos que precisávamos criar algumas de nossas próprias ferramentas. Como resultado, chegamos à estrutura de pastas acima. Veja como essas pastas funcionam em um projeto:



  • app: todos os pacotes nesta pasta estão relacionados a aplicativos front-end como o frontend Karify e alguns outros front-ends internos. Nossos materiais do Storybook também estão armazenados aqui .
  • lib: -, , . , , . , , typography, media primitive.
  • tool: , , Node.js. , , , . , , webpack, , ( « »).


Todos os nossos pacotes, independentemente da pasta em que estejam armazenados, possuem uma subpasta srce, opcionalmente, uma pasta bin. As pastas do srcpacote, armazenadas em diretórios appe lib, podem conter algumas das seguintes subpastas:



  • actions: Contém funções para a criação de ações cujos valores de retorno podem ser passados ​​para funções de envio de reduxou useReducer.
  • components: contém pastas de componentes com seu código, traduções, testes de unidade, instantâneos, históricos (se aplicável a um componente específico).
  • constants: esta pasta armazena valores que não foram alterados em diferentes ambientes. Os utilitários também são armazenados aqui.
  • fetch: é aqui que as definições de tipo são armazenadas para processar os dados recebidos de nossa API, bem como as ações assíncronas correspondentes usadas para receber esses dados.
  • helpers: , .
  • reducers: , redux useReducer.
  • routes: , react-router history.
  • selectors: , redux-, , API.


Esta estrutura de pastas nos permite escrever um código verdadeiramente modular, pois cria um sistema claro para dividir responsabilidades entre os vários conceitos definidos por nossas dependências. Isso nos ajuda a buscar no repositório por variáveis, funções e componentes, e, além disso, independente de quem os procura saber ou não da sua existência. Além disso, nos ajuda a manter o mínimo de conteúdo em pastas separadas, o que, por sua vez, facilita o trabalho com eles.



Quando começamos a aplicar essa estrutura de pastas, nos deparamos com o desafio de garantir uma aplicação consistente dessa estrutura. Ao trabalhar com pacotes diferentes, o desenvolvedor pode querer criar pastas diferentes nas pastas desses pacotes, organizar os arquivos nessas pastas de maneiras diferentes. Embora nem sempre seja uma coisa ruim, essa abordagem desorganizada levaria à confusão. Para nos ajudar a aplicar sistematicamente a estrutura acima, criamos o que pode ser chamado de "linter do sistema de arquivos". Vamos falar sobre isso agora.



Como você garante que o guia de estilo seja aplicado?



Buscamos uniformidade na estrutura de arquivos e pastas em nosso projeto. Queríamos conseguir o mesmo para o código. Naquela época, já tínhamos uma experiência bem-sucedida de resolver um problema semelhante na versão jQuery do projeto, mas tínhamos muito a melhorar, principalmente no que diz respeito a CSS. Como resultado, decidimos criar um guia de estilo do zero e certifique-se de usá-lo com um linter. As regras que não podiam ser aplicadas com um linter foram controladas durante a revisão do código.



A configuração de um linter em um repositório mono é feita da mesma maneira que em qualquer outro repositório. Isso é bom porque permite que você verifique todo o repositório em uma execução do linter. Se você não estiver familiarizado com linters, recomendo dar uma olhada em ESLint e Stylelint . Nós os usamos exatamente.



O linter JavaScript provou ser particularmente útil nas seguintes situações:



  • Garantir o uso de componentes construídos com acessibilidade de conteúdo em mente, ao invés de seus equivalentes em HTML. Ao criar o guia de estilo, introduzimos várias regras relativas à acessibilidade de links, botões, imagens e ícones. Então, precisávamos impor essas regras no código e garantir que, no futuro, não as esqueceríamos. Fizemos isso usando a reagir / proíbem-elementos governar a partir eslint-plugin-reagir .


Aqui está um exemplo de sua aparência:



'react/forbid-elements': [
    'error',
    {
        forbid: [
            {
                element: 'img',
                message: 'Use "<Image>" instead. This is important for accessibility reasons.',
            },
        ],
    },
],






Além de usar JavaScript e CSS, também temos nosso próprio "sistema de arquivos linter". É ele quem garante o uso uniforme da estrutura de pastas que escolhemos. Uma vez que esta é uma ferramenta que criamos nós mesmos, se decidirmos mudar para uma estrutura de pastas diferente, sempre podemos mudá-la de acordo. Aqui estão alguns exemplos das regras que controlamos ao trabalhar com arquivos e pastas:



  • Verificar a estrutura de pastas dos componentes: garantir que sempre existe um arquivo index.tse um .tsx.file com o mesmo nome da pasta.
  • Validação de arquivo package.json: garantir que haja um arquivo por pacote e que a propriedade privateseja definida truepara evitar a publicação acidental do pacote.


Que tipo de sistema você deve escolher?



Hoje em dia, a resposta à pergunta no título desta seção é provavelmente bastante direta para muitos. Você só precisa usar o TypeScript . Em alguns casos, independentemente do tamanho do projeto, a implementação do TypeScript pode retardar o desenvolvimento. Mas acreditamos que este é um preço razoável a pagar para melhorar a qualidade e o rigor do código.



Infelizmente, no momento em que começamos a trabalhar no projeto, o sistema prop-types ainda era muito usado.... No início do nosso trabalho, isso era suficiente para nós, mas à medida que o projeto foi crescendo, começamos a perder a capacidade de declarar tipos para entidades que não são componentes. Vimos que isso nos ajudará a melhorar, por exemplo, redutores e seletores. Mas a introdução de um sistema de tipagem diferente em um projeto exigiria muita refatoração de código para digitar toda a base de código.



No final, ainda equipamos nosso projeto com suporte de tipo, mas cometemos o erro de tentar primeiro o Flow .... Pareceu-nos que o Flow era mais fácil de integrar no projeto. Mesmo que fosse, nós regularmente tínhamos todos os tipos de problemas com o Flow. Este sistema não se integrou muito bem com nosso IDE, às vezes por alguma razão desconhecida ele não detectava alguns bugs, e criar tipos genéricos era um verdadeiro pesadelo. Por esses motivos, acabamos migrando tudo para o TypeScript. Se soubéssemos então o que sabemos agora, escolheríamos imediatamente o TypeScript.



Devido à direção em que o TypeScript se desenvolveu nos últimos anos, essa transição foi muito fácil para nós. A transição do TSLint para o ESLint foi especialmente útil para nós .



Como faço para testar o código?



Quando começamos a trabalhar no projeto, não estava muito claro para nós quais ferramentas de teste escolher. Se eu estivesse pensando nisso agora, diria que, para testes de unidade e integração, é melhor usar jest e cipreste, respectivamente . Essas ferramentas são bem documentadas e fáceis de usar. A única pena é que o cypress não suporta a API Fetch , o ruim é que a API desta ferramenta não foi projetada para usar a construção async / await . Nós, depois de começarmos a usar o cipreste, não entendemos isso imediatamente. Mas eu gostaria de esperar que a situação melhore em um futuro próximo.



No início, era difícil para nós encontrar a melhor maneira de escrever testes de unidade. Com o tempo, tentamos abordagens como teste de instantâneo , renderizador de teste , renderizador superficial . Tentamos Testing Library . Acabamos com a renderização superficial, usada para testar a saída do componente, e usamos a renderização de teste para testar a lógica interna dos componentes.



Acreditamos que a Biblioteca de Testes é uma boa solução para pequenos projetos. Mas o fato de que este sistema depende da renderização DOM tem um grande impacto no desempenho do benchmark. Além disso, acreditamos que as críticaso teste de instantâneo usando renderização de superfície é irrelevante quando se trata de componentes muito "profundos". Para nós, os instantâneos acabaram sendo muito úteis para verificar todas as opções possíveis para a saída de componentes. No entanto, o código do componente não deve ser complicado; você deve se esforçar para torná-lo conveniente para leitura. Isso pode ser toJSONobtido tornando os componentes pequenos e definindo um método para as entradas do componente que não estão relacionadas ao instantâneo.



Para não esquecer os testes de unidade, configuramos o limite de cobertura de código por testes... Com uma piada, isso é muito fácil de fazer e não há muito em que pensar. Basta definir um indicador da cobertura global do código pelos testes. Então, no início do trabalho, estabelecemos esse valor em 60%. Com o tempo, conforme a cobertura de teste de nossa base de código cresceu, aumentamos para 80%. Estamos satisfeitos com este indicador, pois não achamos que seja necessário buscar 100% de cobertura de código com testes. Atingir esse nível de cobertura de código com testes não parece realista para nós.



Como simplificar a criação de novos projetos?



Normalmente, o início dos trabalhos na aplicação Reagir é muito simples: ReactDOM.render(<App />, document.getElementById(‘#root’));. Mas no caso em que você precisa suportar SSR (renderização do lado do servidor, renderização do servidor), essa tarefa se torna mais complicada. Além disso, se as dependências do seu aplicativo incluírem mais do que apenas React, o código do cliente e do servidor pode precisar usar parâmetros diferentes. Por exemplo, usamos react-intl para internacionalização, react-redux para gerenciamento de estado global , react-router para roteamento e redux-saga para gerenciamento de ações assíncronas . Essas dependências precisam de alguns ajustes. O processo de configuração dessas dependências pode ser difícil.



Nossa solução para este problema foi baseada nos padrões de projeto " Strategy " e " Abstract Factory ". Costumávamos criar duas classes diferentes (duas estratégias diferentes): uma para a configuração do cliente e outra para a configuração do servidor. Ambas as classes receberam parâmetros do aplicativo criado, que incluíam o nome, logotipo, redutores, rotas, o idioma padrão, sagas (para redux-saga) e assim por diante. Redutores, rotas e sagas podem ser obtidos de diferentes pacotes de nosso mono-repositório. Essa configuração é então usada para criar o armazenamento redux, middleware sagas e objeto de histórico do roteador. Também é usado para carregar traduções e renderizar o aplicativo. Por exemplo, aqui estão as assinaturas das estratégias de cliente e servidor:



type BootstrapConfiguration = {
  logo: string,
  name: string,
  reducers: ReducersMapObject,
  routes: Route[],
  sagas: Saga[],
};
class AbstractBootstrap {
  configuration: BootstrapConfiguration;
  intl: IntlShape;
  store: Store;
  rootSaga: Task;
abstract public run(): void;
  abstract public render<T>(): T;
  abstract protected createIntl(): IntlShape;
  abstract protected createRootSaga(): Task;
  abstract protected createStore(): Store;
}
//   
class WebBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<ReactNode>(): ReactNode;
}
//   
class ServerBootstrap extends AbstractBootstrap {
  constructor(config: BootstrapConfiguration);
  public render<string>(): string;
}


Achamos esta separação de estratégias útil, uma vez que existem algumas diferenças na configuração de armazenamento, sagas, objetos de internacionalização e histórico, dependendo do ambiente em que o código é executado. Por exemplo, um redux store no cliente é criado usando dados pré-carregados do servidor e usando redux-devtools-extension . Nada disso é necessário no servidor. Outro exemplo é um objeto de internacionalização que, no cliente, obtém o idioma atual de navigator.languages , e no servidor do cabeçalho HTTP Accept-Language .



É importante observar que tomamos essa decisão há muito tempo. Embora as classes ainda fossem amplamente utilizadas em aplicativos React, não havia ferramentas simples para fazer a renderização de aplicativos no lado do servidor. Com o tempo, a biblioteca React deu um passo em direção a um estilo funcional e projetos como Next.js apareceram . Com isso em mente, se você está procurando uma solução para um problema semelhante, recomendamos que pesquise as tecnologias atuais. Isso, muito possivelmente, nos permitirá encontrar algo que será mais simples e mais funcional do que o que estamos usando.



Como manter a qualidade do seu código em alto nível?



Linters, testes, verificação de tipo - tudo isso tem um efeito benéfico na qualidade do código. Mas um programador pode facilmente esquecer de executar as verificações apropriadas antes de incluir o código em um branch master. A melhor maneira é fazer com que essas verificações sejam executadas automaticamente. Algumas pessoas preferem fazer isso em cada commit usando ganchos Git., que não permite que você faça commit até que o código passe em todas as verificações. Mas acreditamos que, com essa abordagem, o sistema interfere muito no trabalho do programador. Afinal, por exemplo, trabalhar em um determinado ramo pode levar vários dias, e todos esses dias ele não será reconhecido como adequado para envio para o repositório. Portanto, verificamos os commits usando o sistema de integração contínua. Apenas o código das ramificações associadas às solicitações de mesclagem é verificado. Isso nos permite evitar a execução de verificações que certamente não passarão, já que na maioria das vezes fazemos solicitações para incluir os resultados de nosso trabalho no código principal do projeto quando temos certeza de que esses resultados podem passar em todas as verificações.



O fluxo de validação automática de código começa com a instalação de dependências. Isso é seguido pela verificação de tipo, execução de linters, execução de testes de unidade, construção do aplicativo e execução de testes cipreste. Quase todas essas tarefas são realizadas em paralelo. Se ocorrer um erro em qualquer uma dessas etapas, todo o processo de checkout falhará e a ramificação correspondente não poderá ser incluída no código do projeto principal. Aqui está um exemplo de um sistema de revisão de código em funcionamento.





Verificação automática de código A



principal dificuldade que encontramos ao configurar este sistema foi acelerar a execução de verificações. Essa tarefa ainda é relevante. Realizamos muitas otimizações e agora todas essas verificações estão estáveis ​​em cerca de 20 minutos. Talvez este indicador possa ser melhorado paralelizando a execução de alguns testes de cipreste, mas por enquanto nos convém.



Resultado



Organizar o desenvolvimento de um aplicativo React em grande escala não é uma tarefa fácil. Para resolver isso, um programador precisa tomar muitas decisões, muitas ferramentas precisam ser configuradas. Ao mesmo tempo, não existe uma única resposta correta para a questão de como desenvolver tais aplicativos.



Nosso sistema é adequado para nós até agora. Esperamos que falar sobre isso ajude outros programadores que enfrentam as mesmas tarefas que enfrentamos. Se você decidir seguir nosso exemplo, primeiro certifique-se de que o que foi discutido aqui é adequado para você e sua empresa. Mais importante ainda, se esforce para o minimalismo. Não complique demais seus aplicativos e kits de ferramentas usados ​​para criá-los.



Como você abordaria a tarefa de organizar o desenvolvimento de um projeto React em grande escala?






All Articles