Voltar ao Blog
Angular Development28 de janeiro de 20269 min de leitura

Construindo Apps Angular Bilíngues com ngx-translate

DR

Doug Ribeiro

9 min de leitura

Na banca canadense, bilíngue não é um pedido de feature. É um requisito legal. Para um banco que atende o Quebec, a obrigação vem da Charter of the French Language do Quebec, que o Projeto de Lei 96 estendeu explicitamente em 2022 às empresas reguladas federalmente que operam na província. No nível federal, o Projeto de Lei C-13 promulgou em 2023 o Use of French in Federally Regulated Private Businesses Act, mas essa lei ainda não está em vigor. O Official Languages Act em si obriga instituições federais, não bancos, então não existe mandato geral do OCOL sobre toda tela voltada ao cliente. Eu entreguei features bilíngues (EN/FR) com ngx-translate em um app Angular no BMO. Depois de ter construído nele, é a abordagem que eu escolheria de novo em vez do $localize nativo do Angular. O código deste post é um setup genérico para um codebase Angular atual, não o do banco.

Por Que ngx-translate em Vez do i18n Nativo do Angular

O $localize do Angular requer um build separado por locale. Isso significa dois artefatos de deploy, um pipeline de CI mais complexo e um reload completo da página para trocar idiomas. O ngx-translate carrega arquivos de tradução em tempo de execução via HTTP e troca idiomas sem recarregar. Para um portal bancário onde os usuários esperam alternar o idioma no meio da sessão, esse trade-off é claro. O custo em tempo de execução de carregar um arquivo JSON é pequeno nesse tamanho.

Setup

bash
npm install @ngx-translate/core @ngx-translate/http-loader
typescript
// app.config.ts (ngx-translate v17 provider API, unchanged in v18)
import { ApplicationConfig } from '@angular/core';
import { provideHttpClient } from '@angular/common/http';
import { provideTranslateService } from '@ngx-translate/core';
import { provideTranslateHttpLoader } from '@ngx-translate/http-loader';

export const appConfig: ApplicationConfig = {
  providers: [
    provideHttpClient(),
    provideTranslateService({
      fallbackLang: 'en',
      loader: provideTranslateHttpLoader({
        prefix: './assets/i18n/',
        suffix: '.json'
      })
    })
  ]
};
provideAppInitializer TranslateHttpLoader en.json / fr.json Cache do TranslateService TranslatePipe / instant()

Estrutura do Arquivo de Tradução

Não achate nada. Aninhe por domínio de feature. Um arquivo plano com 300 chaves é um pesadelo de manutenção quando duas pessoas estão editando simultaneamente.

json
// assets/i18n/en.json
{
  "common": {
    "save": "Save",
    "cancel": "Cancel",
    "loading": "Loading...",
    "error": "An error occurred. Please try again."
  },
  "accounts": {
    "title": "My Accounts",
    "balance": "Available Balance",
    "noTransactions": "No transactions found."
  },
  "lending": {
    "applyNow": "Apply Now",
    "creditLimit": "Credit Limit",
    "pendingReview": "Your application is under review."
  }
}
json
// assets/i18n/fr.json
{
  "common": {
    "save": "Enregistrer",
    "cancel": "Annuler",
    "loading": "Chargement...",
    "error": "Une erreur s'est produite. Veuillez réessayer."
  },
  "accounts": {
    "title": "Mes comptes",
    "balance": "Solde disponible",
    "noTransactions": "Aucune transaction trouvée."
  },
  "lending": {
    "applyNow": "Faire une demande",
    "creditLimit": "Limite de crédit",
    "pendingReview": "Votre demande est en cours d'examen."
  }
}

LanguageService com Estado via Signal

Eu persisto a preferência no localStorage aqui (para usuários logados, um ajuste no perfil no servidor acompanha a pessoa entre dispositivos) e atualizo o atributo html[lang] a cada troca. Leitores de tela usam esse atributo para selecionar o perfil de voz correto. Esquecer esse passo significa que seu conteúdo em francês será lido com uma voz em inglês.

typescript
import { DOCUMENT, inject, Injectable, signal } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';

export type Language = 'en' | 'fr';

@Injectable({ providedIn: 'root' })
export class LanguageService {
  private translate = inject(TranslateService);
  private document = inject(DOCUMENT);

  currentLanguage = signal<Language>(
    (localStorage.getItem('lang') as Language) ?? 'en'
  );

  constructor() {
    this.translate.addLangs(['en', 'fr']);
    this.setLanguage(this.currentLanguage());
  }

  setLanguage(lang: Language): void {
    this.translate.use(lang);
    this.currentLanguage.set(lang);
    localStorage.setItem('lang', lang);
    this.document.documentElement.lang = lang;
  }
}

Usando TranslatePipe nos Templates

typescript
@Component({
  standalone: true,
  // TranslatePipe is standalone. TranslateModule still works on v17 but was removed in v18,
  // so import the pipe and the sample survives the upgrade.
  imports: [TranslatePipe, CurrencyPipe],
  template: `
    <h1>{{ 'accounts.title' | translate }}</h1>
    <p>{{ 'accounts.balance' | translate }}: {{ balance | currency:'CAD' }}</p>
    <!-- interpolation -->
    <p>{{ 'greeting' | translate:{ name: userName } }}</p>
  `
})
export class AccountsComponent {
  balance = 4250.00;
  userName = 'Marie';
}

Traduzindo aria-labels no Código do Componente

O pipe só funciona em templates. Para valores de aria-label vinculados a partir do TypeScript, injete o TranslateService. Não envolva instant() em um getter e vincule esse getter no template: um getter é uma chamada de método, e ele roda de novo a cada ciclo de detecção de mudanças. Resolva a string uma vez só, ou vincule um signal alimentado por stream(), que reemite quando o usuário troca de idioma.

typescript
import { Component, inject } from '@angular/core';
import { toSignal } from '@angular/core/rxjs-interop';
import { TranslateService } from '@ngx-translate/core';

@Component({
  standalone: true,
  template: `<button [attr.aria-label]="closeLabel()">X</button>`
})
export class ModalComponent {
  private translate = inject(TranslateService);

  // stream() emits the current translation and re-emits on every language change,
  // so the label stays correct without a getter running on every change detection pass.
  readonly closeLabel = toSignal(this.translate.stream('common.closeModal'), {
    initialValue: ''
  });
}

Pluralização

A pluralização básica funciona com formato de mensagem ICU, mas isso requer instalar o ngx-translate-messageformat-compiler. Para a maioria dos casos em inglês e francês, um padrão mais simples usando chaves separadas é mais fácil de manter e mais fácil para os tradutores trabalharem.

json
// en.json, separate keys (no extra package)
{
  "transactions": {
    "none": "No transactions",
    "one": "1 transaction",
    "many": "{{count}} transactions"
  }
}

// ICU alternative (needs ngx-translate-messageformat-compiler)
{
  "transactionCount": "{count, plural, =0 {No transactions} =1 {1 transaction} other {# transactions}}"
}

O Problema de Layout Sobre o Qual Ninguém Te Avisa

Strings em francês são consistentemente 20-30% mais longas que seus equivalentes em inglês. Isso significa que todo botão com largura fixa, todo label truncado, toda célula de tabela com ellipsis vai ficar errado em francês. A forma clássica de aprender isso é uma rodada de QA tardia em francês que revela quebras de layout em várias páginas de uma vez. A solução é testar com conteúdo em francês desde o primeiro sprint, não o último.

  • Use min-width em vez de larguras fixas em botões
  • Use text-overflow: ellipsis com tooltip para conteúdo truncado, não cortes bruscos
  • Execute seu Storybook ou testes de componentes com dados de fixture em francês por padrão
  • Não faça hardcode de comprimentos de linha ou suposições de quebra no CSS
  • Para breakpoints mobile, conteúdo em francês frequentemente requer um breakpoint extra em relação ao inglês

Pré-carregando Traduções para Renderização Inicial Mais Rápida

Por padrão, o ngx-translate busca os arquivos de tradução depois que o app é inicializado. Usuários em conexões lentas veem brevemente as chaves cruas. Bloqueie a primeira renderização nessa busca com provideAppInitializer. O APP_INITIALIZER está depreciado desde o Angular 19 e a forma de função exige menos cerimônia de qualquer jeito: sem factory, sem array de deps, inject() direto lá dentro.

typescript
// app.config.ts
import { inject, provideAppInitializer } from '@angular/core';
import { TranslateService } from '@ngx-translate/core';
import { firstValueFrom } from 'rxjs';

// Goes in the same providers array as provideTranslateService from the setup block.
// It runs in an injection context, so inject() works directly.
provideAppInitializer(() => {
  const translate = inject(TranslateService);
  const lang = (localStorage.getItem('lang') as 'en' | 'fr') ?? 'en';
  translate.addLangs(['en', 'fr']);
  // The fallback is already configured as fallbackLang in provideTranslateService. To change it
  // at runtime, call setFallbackLang, which replaced setDefaultLang in v17.
  return firstValueFrom(translate.use(lang));
});

Bilíngue não é um sprint no final. Projete suas chaves de tradução, seu sistema de layout e seus contratos de dados com ambos os idiomas desde a primeira semana.

Referências

Angulari18nngx-translateTypeScriptAccessibility