Привет, Хабр! Я работаю фронтенд-разработчиком в ЛАНИТ. В этой статье я поделюсь опытом изменения подхода к интернационализации и опытом внедрения автоматизированной проверки переводов, что позволяет снизить риски появления багов и ускорить релизы приложения.

Для начала давайте разберёмся с некоторыми важными понятиями.
Мультиязычность – способность приложения отображать интерфейс на разных языках.
Локализация (localization – l10n) – процесс адаптации приложения к конкретной стране с учётом культурных и языковых особенностей. Сюда можно отнести определение локали, перевод интерфейса, контроль формата даты, денежных единиц, символики, цветов и т. д.
Интернационализация (internationalization – i18n) – набор практик, позволяющих легко адаптировать приложение к определённым культурным и языковым особенностям страны. Сюда можно отнести независимое хранение файлов с переводами для удобной замены, использование универсальных кодировок и специальных констант для быстрой замены текста в соответствии с нужным языком и т. д. Вот хороший доклад про разработку в международных реалиях, который позволит глубже разобраться в теме.
Как работает интернационализация в современных приложениях
Рассмотрим механизм интернационализации на примере Angular-приложения с библиотекой ngx-translate
(без подробностей реализации). Важно отметить, что подход к интернационализации очень схож и для других технологий.
Для начала необходимо сконфигурировать ваше приложение для работы с выбранной библиотекой и инициализировать все доступные языки. У разных технологий может быть разный подход, поэтому на этом этапе вам поможет документация.
Далее в соответствии с конфигурацией и документацией нам необходимо создать JSON-файлы с переводами для всех доступных в приложении языков.
Существуют два основных подхода к формированию структуры таких файлов.
Плоская структура, в которой каждый ключ отвечает за один перевод
{
"title": "Habr demo app",
"showAppTitle": "Show app title"
}
Вложенная структура, в которой ключ может иметь свою иерархию
{
"app": {
"title": "Habr demo app",
"showAppTitle": "Show app title"
}
}
После добавления переводов мы можем использовать механизм интернационализации в коде (пример показан для плоской структуры файла переводов):
<!-- component.html -->
<button (click)="showAppTitle()">
{{ 'showAppTitle' | translate }}
</button>
// component.ts
showAppTitle(): void {
this.snackBar.open(this.translate.instant('title'));
}
Для смены языка приложения необходимо добавить некоторые кнопки, в обработчике которых будет код, изменяющий язык приложения. И всё. Теперь при смене языка в назначенные места будут подставляться переводы из нужного файла.
Наша проблема
После того, как мы разобрались с тем, как в общих чертах работает механизм интернационализации, перейдём к проблеме, с которой мы столкнулись.
Она заключалась в том, что у нас заводилось немалое количество багов, связанных с отсутствием некоторых переводов на какой-то из языков.

Время исправления такого бага составляет буквально пару минут, но весь процесс от заведения до закрытия занимает неприлично много времени и наличие даже двух таких багов может отодвинуть релиз, что не устраивало никого и стояла задача минимизировать их количество.
Причины
Подход к формированию файлов с переводами не соответствует общепринятому подходу
Файлы с переводами в качестве ключей использовали не стандартизированные ключи, а вполне конкретные русские слова и предложения, которые являлись переводами на русский язык:
// en.json
{
"Скачать": "Download",
"Установить": "Install"
}
// ru.json
{
"Скачать": "Скачать",
"Установить": "Установить"
}
Использование в коде выглядело так:
<!-- some-file.component.html -->
<p>{{ 'Скачать' | translate }}</p>
// some-file.component.ts
this.translate.instant('Установить');
В связи с этим, например, в случае отсутствия перевода в каком-либо из файлов, в интерфейсе отображалось именно то, что было написано в коде – русские слова и предложения.
Отсутствие автоматизированной проверки
В связи с тем, что при добавлении, редактировании или удалении текста переводов не производилось никакой автоматической проверки, человеческий фактор легко мог стать причиной бага – забыл добавить перевод в какой-то из файлов, забыл обернуть текст в функцию интернационализации и т. д.
Решение
Работы по решению проблемы производились в несколько этапов, поэтому отдельно рассмотрим каждый из них.
Первый этап – автоматизируем проверки
В самом начале мы стали решать проблему отсутствия автоматизированной проверки переводов для того, чтобы исключить человеческий фактор и доверить анализ машине.
Был найден хороший инструмент, который является плагином библиотеки ngx-translate, – ngx-translate-lint. Подробно о его настройке я рассказал в отдельной статье.
Этот пакет является плагином для линтера, который одновременно анализирует .json
, .html
, .ts
файлы и позволяет не допустить разработчику ряд ошибок. Не для каждой библиотеки можно найти готовый инструмент. Возможно, придётся написать правила для линтера самостоятельно.
Вот каких проблем можно избежать с помощью автоматизированной проверки.
Разработчик забыл добавить перевод в файл с переводами.
Разработчик забыл перевести текст и оставил пустой ключ в файле с переводами.
Разработчик удалил старый функционал, но забыл удалить связанные с этим функционалом переводы.
Разработчик не уверен, что файлы с переводами имеют все переводы.
Разработчик опечатался в ключе перевода.
Разработчик переживает, что потерял какой-либо перевод при решении конфликтов слияния.
Разработчик случайно изменил или удалил ключ перевода.
При внедрении нового языка у разработчика нет уверенности в том, что он не потерял какой-либо перевод.
Благодаря данному инструменту удалось удалить около 200 неиспользуемых, 20 повторяющихся и пустых, 10 недостающих переводов. Проверка была добавлена в CI-пайплайн, и теперь она запускается для каждого коммита.
За год работы этого функционала было заведено лишь два бага в локализации. В первом случае регулярное выражение анализатора не смогло обработать динамический ключ с использованием шаблонной строки, а во втором слово сохранялось на сервере на текущем языке системы и при его смене приходило на том языке, на котором было сохранено.
Звучит всё хорошо, добавили анализатор, багов нет – задача выполнена! Но не всё так просто.
В связи с тем, что ключи у нас были русскими словами и предложениями, это привело к внедрению некоторых неудобных договорённостей.
Оборачивать ключи переводов необходимо было исключительно одинарными кавычками, чтобы отделить их от комментариев, в которых допускалось использование только двойных, т. к. в комментарии мы могли использовать слово, которое не нужно переводить.
Из-за наличия знаков препинания в ключах переводов пришлось добавлять их в регулярное выражение для анализатора, что вызывало ложные срабатывания, которые добавили ряд ограничений при написании кода. Например, при инициализации массива
const array = ['one', 'two'];
анализатор выдавал ошибку о том, что ключа', '
не существует ни в одном из файлов с переводами и приходилось разносить элементы массива на разные строки.
Второй этап – перерабатываем подход к формированию ключей переводов
В связи с тем, что при написании кода необходимо было держать в голове некоторые договорённости, а также тот факт, что возможный выход из строя анализатора мог привести к отображению в интерфейсе русских слов и предложений из кода, было решено использовать некоторые стандартизированные ключи в файлах с переводами.
Был выбран ключ вида someTextInCamelCase_i18n
и сформулированы следующие правила его составления.
Текст ключа частично или полностью должен повторять английский перевод, чтобы в случае появления этой конструкции в интерфейсе она служила резервом и можно было хотя бы косвенно понять, что за текст скрывается за ней.
В конце необходимо добавлять постфикс
_i18n
для чёткого отделения ключа перевода от чего-либо другого. Это необходимо для удобного поиска, а также для работы анализатора из прошлого этапа.
Таким образом, ключ "Скачать отчёт"
превратился в "downloadReport_i18n"
. В процессе переименования также был унифицирован порядок переводов во всех файлах для удобства их поддержки и анализа.
Отображение такой конструкции в интерфейсе на этапе разработки или тестирования будет служить сигналом о забытом переводе. При прошлом подходе при условии разработки или тестирования в русском интерфейсе такого сигнала не было.
У данного типа ключа есть один недостаток – его хрупкость. При изменении переводов для английского языка необходимо обновлять ключи во всех местах использования. Для нас это не является большой проблемой, т.к. переводы изменяются не сильно часто, а в случае необходимости делается это не так сложно, тем более у нас есть защита от ошибки в виде автоматизированной проверки.
В результате на этом этапе удалось избавиться от неудобных договорённостей прошлого этапа и исключить большинство ложных срабатываний.
Третий этап – сводим вероятность ошибки к нулю
В самом конце мы решили немного улучшить Developer Experience и полностью предотвратить вероятность отображения в интерфейсе русского слова или предложения в случае, когда русский не выбран в качестве языка интерфейса.
Учитывая наведённый порядок в файлах с переводами, хотелось бы полностью исключить возможность внесения в них беспорядка.
Для этого было написано несколько юнит-тестов, которые проверяют одинаковый порядок ключей во всех файлах, их количество и формат ключа.
import ruTranslations from './i18n/ru.json';
import enTranslations from './i18n/en.json';
const findExtraTranslations = (langKeys1: string[], langKeys2: string[]): string[] => {
const langKeys1Set = new Set(langKeys1);
return langKeys2.filter(key => !langKeys1Set.has(key));
}
const validateTranslationKeysPostfix = (keys: string[]): boolean => {
return keys.every((key: string) => {
if (!key.endsWith('_i18n')) {
console.error('Недопустимый ключ перевода: ' + key + ', ключ должен заканчиваться на ' + '_i18n');
return;
}
return true;
});
};
const validateTranslationKeysBody = (keys: string[]): boolean => {
if (!/(^[a-zA-Z]+$)/.test(keys.join('').replace(/_i18n/g, ''))) {
console.error('Некорректный ключ перевод: ключ должен содержать только заглавные и строчные буквы английского алфавита');
return;
}
return true;
};
describe('Internationalization (i18n)', () => {
describe('translationsFiles', () => {
const ruKeys = Object.keys(ruTranslations);
const enKeys = Object.keys(enTranslations);
test('every translation file should contains valid keys (words with specific postfix)', () => {
// Assert
expect(validateTranslationKeysPostfix(ruKeys)).toEqual(true);
expect(validateTranslationKeysPostfix(enKeys)).toEqual(true);
});
test('every translation file should contains valid keys (only lowercase and uppercase letters of the English alphabet)', () => {
// Assert
expect(validateTranslationKeysBody(ruKeys)).toEqual(true);
expect(validateTranslationKeysBody(enKeys)).toEqual(true);
});
test('count, values and order of translations keys should be same', () => {
// Assert
expect(findExtraTranslations(ruKeys, enKeys)).toEqual([]);
expect(ruKeys.length).toEqual(enKeys.length);
expect(ruKeys.join(',')).toEqual(enKeys.join(','));
});
});
});
Как оказалось позднее, валидировать паттерн ключей можно было с помощью ESLint. Я не нашёл такого правила и решил предложить его создать, на что мне ответили, что такого же результата можно добиться с помощью правила no-restricted-syntax при использовании JSON-парсера, но из документации это было совершенно неочевидно. В связи с этим разработчики обновили её, и теперь об этом могут узнать другие. Я пока ещё не пробовал добавлять это правило, т.к. тесты полностью нас устраивают, но ради интереса когда-нибудь попробую.
После написания тестов, учитывая прошлый подход к созданию ключей переводов, хотелось запретить кириллицу в коде во избежание использования этого подхода по привычке или по незнанию, тем самым полностью исключив её случайное появление в интерфейсе.
Сделать это удалось с помощью кастомного правила no-cyrillic
для ESLint, которое анализирует .ts
и .html
файлы на наличие кириллицы, при этом допуская её использование в комментариях.
module.exports = {
meta: {
type: 'problem',
},
create: function (context) {
return {
// .ts
Literal: (node) => {
if (typeof node.value === 'string' && node.value.match(/[А-Яа-яЁё]/)) {
context.report({ node, message: Cyrillic characters are not allowed: '${node.value}' });
}
},
// .html (узел Program специфичен для Angular)
Program: (node) => {
const htmlWithoutComments = node.value?.replace(/<!--[\s\S]*?-->/g, '');
const cyrillicString = htmlWithoutComments?.match(/[А-Яа-яЁё]+/g);
if (cyrillicString?.length) {
context.report({ node, message: Cyrillic characters are not allowed: '${cyrillicString.join(', ')}' });
}
},
};
},
}
На правило также были написаны тесты.
import { RuleTester as TSRuleTester } from 'eslint';
import { RuleTester as HTMLRuleTester } from '@angular-eslint/test-utils';
import * as parser from '@angular-eslint/template-parser';
import rule from './no-cyrillic.js';
new TSRuleTester().run('no-cyrillic', rule, {
valid: [
{ code: 'var variable = \'name\';' },
{ code: 'this.translate.instant(\'argument\'); // Комментарий'},
],
invalid: [
{ code: 'var variable = \'имя\'', errors: [{ message: 'Cyrillic characters are not allowed: \'имя\'' }] },
{ code: 'this.translate.instant(\'аргумент-один\', \'аргумент-два\')', errors: [{ message: 'Cyrillic characters are not allowed: \'аргумент-один\'' }, { message: 'Cyrillic characters are not allowed: \'аргумент-два\'' }] },
],
});
new HTMLRuleTester({ languageOptions: { parser } } as any).run('no-cyrillic', rule as any, {
valid: [
{ code: '<div>English</div>' },
{ code: '<div class="class-name">{{ \'English\' | translate }}</div>' },
{ code: '<!-- Комментарий --><div>English</div>Hello' },
],
invalid: [
{ code: '<div>Русский</div>', errors: [{ message: 'Cyrillic characters are not allowed: \'Русский\'' }] },
{ code: '<div class="класс">{{ \'English\' | translate }}</div>', errors: [{ message: 'Cyrillic characters are not allowed: \'класс\'' }] },
{ code: '<div class="\'класс\'">Hello</div>Привет', errors: [{ message: 'Cyrillic characters are not allowed: \'класс, Привет\'' }] },
], } as any);
Доклад с пошаговой инструкцией по написанию собственного правила для ESLint.
Это правило гарантирует, что в интерфейс случайно не попадёт то, чего там быть не должно. При необходимости регулярное выражение правила можно адаптировать под новые требования, что делает его достаточно гибким.
Вывод
В результате всех этапов мы выполнили поставленную задачу – багов в локализации практически нет уже на протяжении года.
Также по части интернационализации наведён глобальный контролируемый порядок, написаны инструкции по добавлению новых и поддержанию существующих языков с одной точкой входа в коде.
Сейчас мы практически не возвращаемся к механизму интернационализации приложения, он живёт своей жизнью и на данный момент не требует вмешательств.
Не бойтесь наводить порядок в коде и автоматизировать то, что часто стреляет вам в ногу. Разработчикам должно быть удобно работать с кодом и при этом они не должны перегружать себя лишней информацией, а сосредотачиваться только на бизнес-логике. Всем хорошего кода!
Видео-доклад по мотивам этой статьи:
Как мы запретили писать код с багами в локализации, MoscowJS 68
Смотреть на YouTube
Смотреть на VK Видео
*Статья написана в рамках ХабраЧелленджа 4.0, который прошел в ЛАНИТ весной 2025 года. О том, что такое ХабраЧеллендж, читайте здесь.
DmitryI
Благодаря статье, я наконец понял, почему "i18n" :) Как-то руки раньше не доходили разобраться.
dananaprey Автор
Отлично, рад помочь!