Desenvolvimento

8 ago, 2016

Modularize aplicações Angular com webpack

Publicidade

Desde o ano passado, a minha equipe vem trabalhando em um dashboard modular construído como uma aplicação AngularJS de uma única página agrupada usando webpack. Mas o projeto passou por algumas transformações para chegar onde está hoje, já que migramos o aplicativo para sua forma modular atual.

Quando o nosso projeto começou, o dashboard era um aplicativo relativamente pequeno (a julgar pelo número de arquivos de origem, pelo menos). Nós rápida e facilmente escrevemos em JavaScript e imediatamente invocamos funções de expressões (IIFEs), e acrescentamos esse código-fonte em nosso dashboard através da tag <script>. O processo de compilação produziu um único arquivo, minified.

Ao modularizar sua fonte, você cria peças desacopladas do código que tem melhor desempenho e são mais fáceis de manter enquanto o projeto cresce.

Esse padrão era familiar para muitos de nós, e funcionou muito bem. Mas nós podíamos prever algumas questões: o que aconteceria quando os scripts necessários fossem removidos ou consolidados, ou não fossem necessários quando a página fosse carregada – ou se eles fossem necessários por uma página completamente diferente? Quem se lembraria de como essas dependências foram todas interligadas?

O framework Angular já inclui o conceito de módulos: um módulo Angular é uma coleção de artefatos Angular – tais como diretivas, controladores e serviços – que dita como uma aplicação é bootstrapped. Mas não caia na armadilha de pensar que, porque uma aplicação angular é organizada em módulos, você não precisa criar mais modularização sistemática.

Para resolver essas questões, decidimos modularizar a aplicação através da atualização do código-fonte e usando o módulo bundler webpack. Ao modularizar sua fonte, você cria peças dissociadas de código que têm melhor desempenho e são mais fáceis de manter quando o projeto crescer. Sem modularidade, normalmente você está preso à manutenção de uma longa lista de tags <script>, tentando se lembrar de remover as que não são mais usadas, ou se certificando de que você fez referência aos scripts certos quando você adicionar um novo código. Com a modularização, você pode pegar subconjuntos do gráfico de dependência e reutilizá-los em outros lugares. Além disso, a modularização permite a possibilidade de características de carregamento lento, levando a uma experiência altamente otimizada para os usuários.

Este artigo relembra nossa experiência com modularização para guiar você através do processo de criação de um aplicativo Angular modularizado – seja você modularizando a partir do zero ou migrando uma aplicação existente.

Escolhendo um módulo bundler

Seria ótimo se os módulos de navegador nativo existissem e você pudesse eliminar o passo de compilação do módulo, mas, por enquanto, você precisa usar um módulo bundler como parte de seu fluxo de compilação. Os bundlers disponíveis incluem webpack, RequireJS, Browserify e Rollup, para citar alguns.

Cada bundler tem vantagens e desvantagens, mas todos eles executam da mesma forma a tarefa de atravessar a árvore de dependência do seu código-fonte para a produção de um conjunto mínimo de arquivos JavaScript necessários para executar o aplicativo. Por exemplo, imagine que você tenha um arquivo a.js que tem uma dependência no código que você escreveu em b.js e c.js. Com base no seu formato de módulo, é possível definir essas dependências entre arquivos. O bundler em seguida, produz um único arquivo que tem o conteúdo de a.js, b.js e c.js. Se o código que foi trazido como dependência de b.js for removido a.js, e o bundle for construído novamente em seguida, ele terá somente o conteúdo de a.js e c.js.

Nós resolvemos usar webpack como o módulo bundler para o nosso projeto, com base no seu rico ecossistema de plugin, comunidade ativa, flexibilidade no suporte para o formato de módulo, desempenho e capacidade de produzir múltiplos bundles.

Escolhendo um formato de módulo

Quando decidimos sobre um formato de módulo, enfrentamos um debate de longa data: CommonJS versus definição de módulo assíncrono (AMD). Muitos de nós estavam familiarizados com AMD, mas nós escolhemos CommonJS porque mais membros da equipe tinham experiência com ele através do uso de Node.js. Nós sacrificamos o fluxo assíncrono da AMD para a sintaxe síncrona concisa do CommonJS, sabendo que o nosso resultado final seria um número mínimo de módulos empacotados. Felizmente, webpack compreende ambos os formatos nativamente, por isso, se qualquer uma das bibliotecas que usamos se agrupasseem usando AMD, o nosso código poderia continuar trabalhando.

Configurando o webpack

Com nosso formato e módulo bundler escolhidos, passamos à criação do webpack. Nosso processo de construção faz uso pesado do Grunt, por isso fazia sentido para nós continuar usando Grunt quando começamos a incorporar o webpack. Instalar o webpack – localmente, pelo menos – é essencial; o plugin grunt-webpack é útil quando você está usando Grunt no seu build. Para instalar ambos, execute o comando:

npm install --save-dev grunt-webpack webpack

A configuração do webpack dita quase tudo que o bundler faz quando começa a processar os módulos que você jogue no seu caminho. As várias propriedades de configuração normalmente vão em um arquivo webpack.config.js.

Nós definimos duas configurações: desenvolvimento e distribuição. Elas compartilham muitas propriedades comuns; a diferença é que o desenvolvimento ativa o em mapeamento de origem para ajudar na depuração, e a distribuição minifica os bundles resultantes para reduzir o tamanho dos arquivos transferidos para o usuário.

Olhando a totalidade de opções de configuração do webpack e as nossas escolhas para cada um seria um exercício muito grande. Em vez disso, eu vou explicar apenas as opções que eram de interesse para os objetivos do projeto.

Pontos de entrada

A entrada para a aplicação define quais bundles sairão para o webpack e são necessários para webpack iniciar o seu processo. A partir desse ponto de entrada, o webpack percorre a hierarquia de dependência, construindo um mapa de dependências de modo que quando um módulo precisa de outro, o módulo necessário é carregado.

Você pode definir várias entradas, como fizemos em nosso projeto. O ponto de entrada principal é a nossa aplicação, que define o módulo do dashboard. Esse módulo então faz a requisição de todos os outros módulos, cada um dos quais faz a requisição de todos os serviços, factories, diretivas e controladores. O outro ponto de entrada é o vendor. Esse ponto de entrada é isolado com bibliotecas de terceiros que usamos no projeto. Foi vantajoso colocar essa entrada em seu próprio bundle, uma vez que as próprias bibliotecas não mudam muito, mas nossa aplicação sim. Se tivéssemos colocado tudo em um bundle, ele deveria conter o código do aplicativo alterado mais o código do vendor inalterado, inflando assim o tamanho do download, porque o cache do navegador não está sendo aproveitado adequadamente.

Carregamento lento

Às vezes, um usuário não precisa de um recurso ou se depara com ele bem após o carregamento da página inicial. Para oferecer uma experiência de desempenho melhor, o webpack permite que os módulos de carregamento lento inicializem no ponto em que eles são necessários. Nós aplicamos esse recurso para os aspectos de analytics do nosso dashboard. As bibliotecas de visualização de dados são relativamente grandes, de modo que a remoção de conteúdo do download inicial pode levar a um início mais rápido para um usuário que não está preocupado com analytics. Contamos com required.ensure para entregar essa experiência para os usuários. Em nossa diretiva para o gráfico original, o código seguinte fez com que o bundle de aplicativos incluísse c3 e todas as suas dependências:

link: function () {
  var c3 = require('c3');
  // Use c3
}

Mudamos a diretiva para:

link: function () {
  require.ensure([], function ()
    var c3 = require('c3');
    // Use c3
  }, 'charts');
}

Agora, o webpack criou um novo bundle gráfico que só é baixado quando a diretiva está relacionada.

ProvidePlugin

O webpack ProvidePlugin – um plugin que substitui ocorrências de variáveis globais com as exportações explícitas de um módulo carregado associado – provou ser essencial para o nosso retrofit. Porque o nossa aplicação não foi modularizada na maior parte do ciclo de desenvolvimento, ela incluiu muitas referências globais para angular, $, moment, e outras bibliotecas – por exemplo:

moment().add(2, 'days');

ProvidePlugin mudou o código anterior para:

require('moment')().add(2, 'days');

O uso do ProvidePlugin poupou a necessidade de localizar e substituir todas as ocorrências dessas variáveis globais em vários arquivos.

html-loader

Um dos benefícios do webpack é a sua capacidade de transformar o conteúdo não-JavaScript em um módulo JavaScript para que ele possa ser usado como qualquer outro recurso JavaScript. Carregadores webpack estão disponíveis para muitos tipos de tarefas; aproveitamos o html-loader. Sem o html-loader, teria sido necessária uma etapa de compilação para procurar todos os arquivos HTML e injetá-los no Angular $templateCache, para que quando as diretivas usassem a propriedade templateUrl, o HTML fosse encontrado.

Passar por todas as diretivas e substituir templateUrl: ‘/directive/markup.html’ por require (‘./markup.html’) deu um pouco de trabalho, mas o resultado final é muito mais fácil de manter. E agora nós saberíamos que, durante o desenvolvimento, se referenciássemos o template incorretamente, em vez de descobrir durante uma compilação que o caminho referido foi desativado por um nível de diretório.

Para usar o html-loader em seu projeto, instale-o executando o comando:

npm install --save-dev html-loader

Criando as dependências

Quando tínhamos estabelecido uma base de código no momento em que modularizamos nossa fonte, quase todos os arquivos fonte precisavam ser modificados de alguma forma. Nós não queríamos que as nossas mudanças fizessem muita poeira, por isso estabelecemos um padrão: o arquivo que define um módulo Angular iria carregar todos os arquivos de origem associados ao módulo; cada arquivo fonte, então, exigiria aquele módulo pai. Mesmo quando foi necessária apenas uma única factory ou diretiva, o seu módulo associado, por conseguinte, deveria ser definido primeiro. Essa abordagem funcionou bem, porque cada módulo Angular já estava fora em seu próprio diretório, com as diretivas, serviços e todos eles abrangidos nos diretórios dentro desse diretório.

Este é o padrão geral de que seguimos no arquivo de definição de módulo:

var angular = require('angular'); 
var load = require.context('./', true, /\.js$/);
load.keys().forEach(load); 
module.exports = angular.module('pi.utils', []).name;

Esse padrão utiliza a API require.context do webpack para construir uma lista de arquivos para carregar como módulos. Poderíamos, então, manter uma lista atualizada de recursos que o módulo mantém sem ter que manter manualmente uma lista de requisições. Em seguida, exportamos o nome do módulo Angular do módulo.

Em nossos arquivos factory, directive e controller, seguimos um padrão de exigir o arquivo no qual o módulo Angular foi definido, que exporta o nome do módulo Angular:

var angular = require('angular');
angular.module(require('../utils')).factory('logger', function () {});

Testando a aplicação

Nós já tínhamos um conjunto de testes que precisávamos para garantir que continuasse trabalhando depois que modularizamos a fonte. Originalmente, usamos algo semelhante ao nosso dashboard, no qual mantivemos manualmente a lista de scripts incluídos. Sim, nós necessitávamos manter o controle de nossa lista de scripts em dois lugares.

Continuamos usando Karma como nosso test runner e Istanbul como nossa ferramenta de cobertura de código, mas nós tivemos que fazer alguma configuração extra para que os testes pudessem ser executados como antes e gerar uma cobertura significativa.

Voltando para o ponto de entrada

Nosso ponto de entrada exige a nossa aplicação e puxa todos os nossos arquivos de teste. Uma vez que a aplicação requer todas as suas dependências (e essas dependências exigem suas dependências), acabamos recebendo tudo o que precisamos. E, como com o padrão que usamos em nossos módulos de origem, foi utilizado require.context para carregar todos os arquivos de teste usando o padrão /\.spec\.js$/ (você pode ou não ter um padrão como este para seus arquivos de teste):

require('../../app/scripts/app');
var load = require.context('./', true, /\.spec\.js$/);
load.keys().forEach(load);

Atualizando a configuração para testes

Em nossa configuração Karma, nós então atualizamos a lista de arquivos a serem incluídos na suíte, especificando o caminho para o arquivo de entrada que nós criamos. Também definimos o webpack como um pré-processador para o conjunto de testes e incluímos a configuração comum do webpack que usamos para o desenvolvimento da aplicação:

var webpackConfig = require('../webpack.config');
module.exports = function (config) {
  return {
    // ... webpack related configuration ...
    webpack: webpackConfig,
 
    files: ['test/spec/suite.js'],
 
    preprocessors: {
      'test/spec/suite.js': ['webpack']
    }
    // ... end webpack related configuration ...
  }
}

Para esse código funcionar, você precisa ter o plugin karma-webpack instalado. Para instalar, execute:

npm install --save-dev karma-webpack

Se os nossos testes forem executados agora, tudo provavelmente funcionaria. Mas a cobertura de código estaria quebrada. Então, adicione istambul-instrumenter-loader:

npm install --save-dev istanbul-instrumenter-loader

E na configuração webpack, adicione istambul-instrumenter à lista de módulo postloaders:

var webpackConfig = require('../webpack.config');
 
// Use istanbul-instrumenter to make sure we're covering individual files 
// instead of bundles
webpackConfig.module.postLoaders = webpackConfig.module.postLoaders || [];
webpackConfig.module.postLoaders.push({
  test: /\.js$/,
  // Exclude the things we don't need to report coverage on
  exclude: /(test|node_modules|bower_components)\//,
  loader: 'istanbul-instrumenter'
});

Considerações adicionais (injeção de dependência)

Uma das características brilhantes do Angular é a injeção de dependência (DI). Mas depois que você modularizar seus scripts, a DI perde um pouco do seu brilho. Com a modularização, você pode fazer tudo o que você fez com a DI, e muito mais. E DI a vem com armadilhas:

  • Você pode ficar pesquisando em seu código-fonte para localizar onde declarou myCoolFactory.
  • Vamos supor que você, inadvertidamente, criou duas factories com o mesmo nome. O aplicativo pode começar a agir estranhamente, porque todas as dependências do aplicativo são armazenadas em um hash global, e uma das factories foi substituída por outra – não é uma situação divertida de depuração.
  • Com DI você precisa se repetir, ou adicionar uma nova etapa de compilação, no caso de o seu código ser deturpado como parte do processo de minificação e essas dependências injetadas não funcionarem mais.

É difícil nos afastarmos completamente da DI a favor do CommonJS puro ou dependências da AMD. Além disso, sem dúvida, vai contra o “caminho angular”, e você pode acabar com uma base de código tipo Frankenstein. Mas evitar DI é algo a considerar se você quer tirar proveito de todo o trabalho que você fez para modularizar a sua aplicação.

Conclusão

Quanto mais cedo em seu projeto você começar com modularização, melhor. Se você tiver um aplicativo Angular legado que quer modularizar, você precisa ter algum trabalho, e o reequipamento pode consumir ciclos extra enquanto você tenta lembrar como os arquivos dependem uns dos outros. Mas quando você tiver acabado, terá uma solução muito mais administrável. Se você está começando do zero, não há melhor momento do que agora para começar a modularização de seu código.

Recursos para download

PDF

Tópicos relacionados

***

Nick Sandonato é autor do artigo. A tradução foi feita pela redação iMasters, e você pode acompanhar o artigo em inglês no link: http://www.ibm.com/developerworks/library/wa-modularize-angular-apps-with-webpack-trs/index.html