![](https://habrastorage.org/getpro/habr/upload_files/d83/473/6b9/d834736b9aefab9ef09b6684cd59646d.jpg)
В современных приложениях и сервисах часто нужна интернационализация (i18n, от англ. internationalization). Она позволяет создавать интерфейсы с учётом культурных и языковых особенностей пользователей из разных стран. Это требует не только простого перевода, но и некоторых технических решений, порой довольно сложных.
Меня зовут Володин Сергей, я работал руководителем команды разработки VK WorkMail. Сегодня расскажу о том, как инфраструктура переводов позволяет поддерживать 10 языков в нашем продукте и какие технические нюансы стоит учитывать при работе с разными языками.
Интернационализация интерфейсов — довольно комплексная и непростая история, хотя на первый взгляд может показаться не слишком приоритетной задачей. Более того, о ней нужно начинать думать в самом начале разработки, чтобы впоследствии не наломать дров.
Определение локали
Начнём с того, как определить, интерфейс на каком языке нам показать пользователю? Представим сервис, который поддерживает некоторое множество языков, и пользователя, который тоже знает определённое количество языков, причём какими-то он владеет лучше, а какими-то хуже. Наша задача как сервиса — найти лучшее сочетание этих множеств и показать пользователю наиболее релевантный интерфейс. Для этого нам понадобятся идентификаторы языков. Для их обобщения существует стандарт BCP47, который вводит понятие language tag, или «локаль».
Здесь важно помнить, что мы не просто переводим текст, а занимаемся интернационализацией. Интернационализация — это адаптация нашего интерфейса под культурные особенности людей из различных стран. Чтобы удовлетворять этим особенностям нужно знать о пользователе чуть больше, чем язык, на котором он разговаривает. Поэтому для нас будет не менее важно знать, что language tag может состоять из нескольких subtag’ов. Эти сабтеги конкретизируют информацию о языке. Чаще всего локаль имеет два subtag'а, например, en-US — американский английский. Но бывают и более сложные конструкции, такие как hy-Latn-IT-arevela — восточноармянский, используемый в Италии, с латинской письменностью.
На практике это помогает нам узнать немного больше о том, какой именно интерфейс будет наиболее подходящим для пользователя. Так, например, в США и Великобритании говорят на английском языке, но отображение дат в этих странах разное: 3/11 для американца — это 11 марта, а для британца — 3 ноября. И чтобы сделать сервис удобным, такие подробности стоит учитывать.
Как же наш сервис сможет понять, на каком языке отобразить интерфейс? Вариантов несколько:
По IP. Вроде бы просто: IP российский — показываем русскоязычный интерфейс. Но есть нюансы. Случился отпуск, вы поехали, допустим, в Азию. Открываете почту, а там иероглифы. Явно не то, чего вы ожидаете.
По выбору пользователя. Казалось бы, оптимальный вариант, ведь пользователь точно знает, какой язык для него более подходящий. Но, опять-таки, есть нюансы. При первом подключении пользователя к сервису у нас ещё нет сессии, но всё равно нужно показать какой-то язык. И если вы говорите по-испански, а в интерфейсе получите арабскую вязь, то поменять язык будет довольно сложно.
Заголовок HTTP, accept-language. В этом заголовке передаётся список локалей, отсортированный по предпочтениям. Мы можем проанализировать этот список, сопоставить с нашим и показать наиболее релевантную локаль. Вот как этот заголовок может выглядеть:
Accept-Language: *
Accept-Language: de
Accept-Language: de-CH
Accept-Language: en-US,en;q=0.5
Accept-Language: fr-CH, fr;q=0.9,
en;q=0.8, de;q=0.7, *;q=0.5
Accept-Language: ru-RU, ru;q=0.9, en-
US;q=0.8, en;q=0.7
Хорошая новость в том, что про этот заголовок мы, фронтенд-разработчики, можем даже не знать. Браузер проставляет его автоматически, на все запросы за ресурсами, основываясь на информации об устройстве. Например, язык по умолчанию в моем смартфоне — английский, а клавиатуру я меняю на русскую. Значит, в заголовке на первом месте будет eng
, а на втором ru
.
Анализ заголовка accept-language
лучше всего подходит для мультиязычного сервиса. С его помощью мы можем определить наиболее релевантную локаль, запомнить и показывать подходящий пользователю интерфейс. Без нюансов. Почти: нужно обязательно оставлять пользователю возможность самостоятельно выбрать локаль. Если я живу в России, но при этом язык интерфейса на телефоне у меня английский, это не значит, что у меня не может возникнуть желание поставить русский язык в каком-то конкретном сервисе.
Перевод и локализация текста
Как определять локаль мы разобрались. Теперь перейдём непосредственно к переводу. Для него всегда есть два варианта: доверить всё машине или человеку. Качество машинного перевода сейчас сильно выросло, но с ним всё ещё есть одна проблема — трудности с передачей контекста. Естественные языки — далеко не самые строгие системы. Вариантов перевода слова или целой фразы всегда целое море. Автоперевод часто не понимает контекста, что приводит к некачественному интерфейсу или весёлым локальным мемам.
![](https://habrastorage.org/getpro/habr/upload_files/15b/f12/968/15bf129686ccde47de9871f930c741ee.jpg)
![](https://habrastorage.org/getpro/habr/upload_files/ea1/c5d/2d3/ea1c5d2d3edff33d9b77b2d8b8262872.jpg)
С «человеческим» переводом тоже всё не так просто. Дать переводчику доступ к репозиторию нельзя, этот процесс требует более формальной организации. Мы для этого используем специальный интерфейс, админку переводов:
![](https://habrastorage.org/getpro/habr/upload_files/368/6f1/fba/3686f1fbabff5fd53e98de0b79c5b684.png)
В него можно загрузить текст, который подхватят штатные переводчики или сообщество, неравнодушное к судьбе сервиса. У нас это решение, разработанное внутри компании, но также существуют готовые варианты: Lokalise, Crowdin и другие.
На этом особенности переводов не заканчиваются. Интерфейсные тексты в современных приложениях — это почти всегда динамика. И как во многих динамических структурах подход «в лоб» не то что бы работает. Разберём частные случаи.
Языковые различия
Существуют лингвистические различия языков, к примеру три формы множественного числа в русском языке (плюральные формы): одно письмо, два письма, пять писем. В других языках их количество иное. С гендерными формами тоже всё неоднозначно: в русском их три, а в некоторых языках шесть. Подобных ситуаций огромное количество, и писать собственную реализацию для каждого различия и для каждой локали — не самое разумное решение.
Пора вспомнить, что мы не лингвисты, а программисты. И счастье в том, что лингвисты тоже не сидят без дела. Есть, к примеру, проект консорциума Unicode CLDR, который может помочь нам в решении нашей задачи.
![](https://habrastorage.org/getpro/habr/upload_files/5bc/1af/6bb/5bc1af6bb18f5e9630f40380810e4727.png)
CLDR
CLDR — большая база данных о более чем 500 локалях. В ней прописаны не только грамматические правила языков, но и метаинформация о разных регионах: в каком формате пишут даты, как сокращают числа, какую используют валюту. Стандарт i18n в индустрии — создавать библиотеки, которые читают CLDR. Репозиторий проекта хранится на GitHub. Это JSON- и XML-файлы, в которых есть нужная для локализации информация. Например, так выглядят правила образования плюральных форм в русском и украинском языке:
<pluralRules locales="ru uk">
<pluralRule count="one">
v = 0 and i % 10 = 1 and i % 100 != 11 @integer
1, 21, 31, 41, 51, 61, 71, 81, 101, 1001, …
</pluralRule>
<pluralRule count="few">
v = 0 and i % 10 = 2..4 and i % 100 != 12..14
@integer 2~4, 22~24, 32~34, 42~44, 52~54, 62, 102, 1002,
…
</pluralRule>
<pluralRule count="many">
v = 0 and i % 10 = 0 or v = 0 and i % 10 = 5..9
or v = 0 and i % 100 = 11..14 @integer 0, 5~19, 100,
1000, 10000, 100000, 1000000, …
</pluralRule>
<pluralRule count="other">
@decimal 0.0~1.5, 10.0, 100.0, 1000.0, 10000.0,
100000.0, 1000000.0, …
</pluralRule>
</pluralRules>
Intl API
Как вы знаете, официальное название языка JavaScript — EcmaScript. Есть стандарт — ecma262, это спецификация языка, которая каждый год обновляется. Её ведет комитет tc39 внутри Ecma. Интересная особенность заключается в том, что Intl API (полное название EcmaScript Internationalization API) — не часть этого стандарта, а отдельный стандарт ecma402, который ссылается на ecma262 и тоже выходит раз в год. Очень рекомендую его почитать.
EcmaScript Internationalization API содержит готовые решения для интернационализации на всех популярных языках. С точки зрения API это глобальный объект Intl
, у которого есть много разных конструкторов. В качестве параметров они принимают локаль и настройки, а дальше позволяют решать разные задачи. Например, вот как выглядит код для выбора правильной плюральной формы слова:
const suffixes = new Map([
// Примечание: в настоящем мире
// вы не хардкодите плюральные формы как тут
// об этом будет чуть позже :)
['one', 'cat'],
['other', 'cats'],
]);
const pr = new Intl.PluralRules('en-US');
const formatCats = n => {
const rule = pr.select(n);
const suffix = suffixes.get(rule);
return `${n} ${suffix}`;
};
formatCats(1); // '1 cat’
formatCats(0); // '0 cats'
formatCats(0.5); // '0.5 cats'
formatCats(1.5); // '1.5 cats'
formatCats(2); // '2 cats'
Есть форматирование дат:
const date = new Date(Date.UTC(2012, 11, 20, 3, 0, 0));
// В американском английском используется порядок месяц-
день-год
new Intl.DateTimeFormat('en-US').format(date);
// → «12/20/2012"
// В британском английском используется порядок день-
месяц-год
new Intl.DateTimeFormat('en-GB').format(date);
// → "20/12/2012"
// В корейском используется порядок год-месяц-день
new Intl.DateTimeFormat('ko-KR').format(date);
// → "2012. 12. 20."
// В большинстве арабоговорящих стран используют
настоящие арабские цифры
new Intl.DateTimeFormat('ar-EG').format(date);
// → "٢٠/١٢/٢٠١٢"
Есть форматирование числительных. Вторым аргументом можно передавать не только локаль, но и определенные настройки:
new Intl.NumberFormat('ru', {notation: 'compact'})
.format(1234.56); // 1,2 тыс.
new Intl.NumberFormat('en', {notation: 'compact'})
.format(1234.56); // 1.2K
new Intl.NumberFormat('ko', {notation: 'compact'})
.format(1234.56); // 1.2천
В примере мы хотим сократить число и в функцию 1200. JS Intl API уже знает, что в русском языке числа сокращают через запятую и добавляют слово «тысяча». В английском языке она поставит точку и добавит букву k. В корейском поставит нужный иероглиф. И ничего не надо придумывать самостоятельно.
Уверен, многие не знают, что в английском языке при перечислении трёх и более объектов перед and нужно ставить запятую. В русском же языке запятая не нужна. JS всё это известно. Кроме того, в китайском языке он вообще склеивает все слова, добавляет новый иероглиф и ставит запятую в другую сторону. Вот как выглядит форматирование списков:
new Intl.ListFormat('en')
.format(['Frank', 'Christine', 'Flora'])
// Frank, Christine, and Flora
new Intl.ListFormat('ru', { type: 'disjunction' })
.format(['Ваня', 'Петя', 'Катя'])
// Ваня, Петя или Катя
new Intl.ListFormat('zh')
.format(['永鋒', '新宇', '芳遠'])
// 永鋒、新宇和芳遠
Также есть сегментация нативного языка на графемы, слова и предложения:
const str = '吾輩は猫である。名前はたぬき。';
const segmenterJa = new Intl.Segmenter('ja-JP', {
granularity: 'word',
});
const segments = segmenterJa.segment(str);
Array.from(segments);
// [
// {segment: '吾輩', index: 0, isWordLike: true},
// {segment: 'は', index: 2, isWordLike: true},
// ...,
// etc.
// ]
Часто бывает так, что в JS нужно обработать натуральный пользовательский ввод и разбить текст на слова. Но делить на пробел не всегда удобно. Есть азиатские языки, в которых пробел не ставится. Но бывает, что к словам прикрепляются разные знаки препинания, и их необходимо исключить. API же позволяет получить слова, предложения или любые другие графемы.
Intl API в реальной жизни
В настоящей разработке использование интернационализации иногда приводит к не самым очевидным положительным эффектам. Приведу пример.
Существует библиотека momentJS, которая позволяет работать с датами. В нашем продукте есть UI-компонент — календарь, и раньше для работы с датой мы использовали именно эту библиотеку. Оттуда мы брали названия месяцев и дней недели для разных языков, и всё это грузилось на клиент. Весило это великолепие достаточно много. Решение оказалось максимально прозаичным и очень подходящим по духу языку JavaScript: мы просто заменили весь код на intlAPI:
function daysForLocale(localeName) {
const format = new Intl
.DateTimeFormat(localeName, {weekday: 'short'})
.format
;
return Array.from(
{length: 7},
(_, day) =>
format(new Date(Date.UTC(2021, 5, day))),
);
}
daysForLocale('ru-RU');
// ['пн', 'вт', 'ср', 'чт', 'пт', 'сб', ‘вс']
daysForLocale('en-GB');
// ['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', ‘Sun']
daysForLocale('ja-JP');
// ['⽉', '⽕', '⽔', '⽊', '⾦', '⼟', '⽇']
Теперь не нужно загружать что-либо на клиент, названия для локалей можно получать сразу. Это позволило сократить размер Почтового UI-kit на 35 %.
RTL-языки
Продолжая разговор про различие языков, мы неизбежно приходим к такому явлению, как RTL (Right to left). То есть к языкам, которые нужно читать справа налево: арабскому, персидскому или ивриту. Причём, рассматривая адаптацию вашего сайта для RTL-языка важно отметить, что это не просто перевод текста на арабский. Речь о том, чтобы сделать каждый аспект вашего интерфейса RTL-friendly. Основной каверзный момент тут заключается в том, что разработчику необходимо научится думать «справа налево»: основные UI-элементы (причём тут есть исключения) при правильном RTL будут как бы отзеркалены по горизонтали, в отличие от классического LTR.
В VK WorkMail мы всерьёз рассматриваем добавление поддержки RTL-языков, так как на них говорит более 400 миллионов человек на планете. Это огромная аудитория.
Что касается WEB, то у нас есть две основные возможности включить RTL-отображение: атрибут dir в HTML и свойство direction
в CSS. Если в них прописать RTL, то вы увидите автоматическое отражение по горизонтали, которое предоставляет браузер. Просто для примера:
![](https://habrastorage.org/getpro/habr/upload_files/0cb/5d0/55d/0cb5d055dcf2ae3a5e1660eb3d9585a9.jpg)
Но автоматическое отображение не всегда работает корректно, поэтому может понадобиться написать отдельные стили для RTL, связанные с позиционированием элементов.
Контекст для переводчиков
Когда-то давным-давно, когда я ещё был стажером, мне дали задачу: сделать интерфейс. Я сконкатенировал строку вот так:
'На ' + phone + ' отправлено СМС'
На это старшие коллеги на Code Review мне сказали, что так нельзя. Почему? Потому что разбитую строку в админку переводов придётся отправлять по отдельности и перевести её не получится, ведь может понадобиться поменять порядок слов. На помощь тут приходит ICU message format. Это специальный DSL (синтаксис), стандартный для большинства языков программирования. Вот как это выглядит:
'На номер {phone} отправлено СМС'
->
'SMS is sent to {phone} number'
То есть разбивать строки нельзя, нужно делать placeholder’ы внутри строк. Плохая новость в том, что JS Intl API пока не умеет анализировать этот формат из коробки, нужно пользоваться библиотеками. Они должны работать на основе API, существующего в языке, расширять его с использованием CLDR и предоставлять возможность форматирования строк в ICU Message Format. Для JavaScript есть много таких библиотек, расскажу про одну из них.
Format JS — это семейство библиотек, которое мы используем в VK. В нём есть решения как для Pure JS, так и для интеграции с различными библиотеками и фреймворками для рендеринга, в частности для React и View. Если вы пишете на Angular, то там есть свои модули, которые умеют в ICU Message Format. Вот как использование библиотеки может выглядеть в коде:
// место объявления интерфейсной строки
// интерфейсные строки выгружаются в админку переводов,
// а затем собираются переводы во время сборки проекта
// под конкретную локаль
const interfaceMsg = 'На номер {phone} отправлено СМС';
// код внутри компонента/view
const msg = i18nLib.formatMessage(interfaceMsg, {
phone: '+7 965 408-**-**',
});
// msg === 'На номер +7 965 408-**-** отправлено СМС'
То есть где-то есть строка на каком-то языке. Нас, как разработчиков, не интересует, на каком она языке; всё что нам нужно знать — что в ней есть заглушка. Мы в вёрстке (в компоненте или view) вставляем заглушку и уже в runtime получаем результирующую строку. Если бы в ICU Message Format были только такие заглушки, то, наверное, не нужна была бы никакая библиотека, мы могли бы использовать String.prototype.replace()
. Но эта библиотека сделана для того, чтобы не разрывать контекст с учётом всех лингвистических особенностей языка. Разберём на примерах.
Сохранение контекста с помощью ICU Message Format
Представим, что нам нужно в интерфейсе вывести строку: «У вас N писем». У этого синтаксиса есть заглушка, плюс можно использовать функцию для описания. В данном случае у нас есть заглушка letters
и функция plural
, то есть плюральные формы. Дальше мы описываем, как нужно работать с этими формами. Также мы можем указывать какие-то значения по умолчанию или исключения. Допустим, для нуля.
'У вас {letters, plural,
=0 {нет писем}
one {# письмо}
few {# письма}
many {# писем}
}.'
Вот как это выглядит в админке переводов:
![](https://habrastorage.org/getpro/habr/upload_files/70a/01f/2ee/70a01f2eeeaffc67ce809d976be816d1.png)
В русском языке три плюральные формы, а в английском остаётся две. В коде мы подставим заглушку letters
, отправим число — и больше нас ничего не интересует. Нужную форму выберет библиотека.
Если вы работаете с людьми, то переводчики тоже должны знать синтаксис ICU message format. Им надо понимать, какую часть строки нужно просто переводить, а в какой учитывать грамматические особенности языка,
Ещё в этом синтаксисе есть select
. С его помощью можно, например, выбирать гендерные формы слова:
'{g, select,
male {# он отправил}
female {# она отправила}
} Вам письмо.'
При этом на американский английский мы две гендерные формы переводим просто как they:
![](https://habrastorage.org/getpro/habr/upload_files/4f5/16a/bd1/4f516abd197b5c61d1e90fdc93384e4d.png)
Интернационализация — это не просто правильный перевод текста. Это адаптация нашего интерфейса к различным культурным особенностям региона.
Трудности фронтенда
Допустим, с лингвистическими особенностями и контекстом мы разобрались. Но в реальной работе иногда приходится проделывать всякие манипуляции: часть текста выделить жирным или курсивом, вставить иконку и так далее.
Допустим, нам нужно адрес электронной почты выделить в тексте жирным шрифтом. Мы могли бы просто вставить необходимую разметку в innerHTML
:
// место объявления интерфейсной строки
const interfaceMsg =
'На почту {email} отправлено письмо';
// код внутри компонента/view
const msg = i18nLib.formatMessage(interfaceMsg, {
email: `<b>${email}</b>`,
});
container.innerHTML = msg;
Но так делать нельзя, если вы не хотите посреди ночи чинить XSS. В данном случае адрес брался из get-параметра — это пользовательский ввод. Если без дополнительной проверки или escaping вставить в DOM, то будет антипаттерн безопасности в сети. Так можно исполнить на странице вредоносный код, который сделает что-то плохое с пользовательскими данными. Что тогда делать? Мы применили к адресу escape
:
// место объявления интерфейсной строки
const interfaceMsg =
'На почту {bo}{email}{bc} отправлено п...';
// код внутри компонента/view
const msg = i18nLib.formatMessage(interfaceMsg, {
email: htmlEscape(email),
bo: '<b>',
bc: '</b>',
});
container.innerHTML = msg;
После этого случая мы поменяли библиотеку так, чтобы по умолчанию escape
применялся ко всему. Если очень надо что-то исключить, то нужно указать отдельным параметром, так будет лучше. Но ещё круче, когда библиотека интернационализации интегрирована с фреймворком, на котором вы пишете. В нашем случае это React Intl
, одна из библиотек семейства Format JS:
<FormattedMessage
id="app.greeting"
defaultMessage="<b>Hello there ${icon}</b>"
values={{
b: chunks => (
<MyBoldComponent>
{chunks}
</MyBoldComponent>
),
icon: <svg />,
}}
/>
В нём вы можете не просто вставить какую-то разметку, но и обернуть в свой компонент. При этом в defaultMessage
мы не разрываем строку, то есть переводчику попадает вся необходимая информация. А у себя можно наворотить любую вёрстку, вставить иконку или картинку, добавить нужные компоненты. Это безопасно, потому что в JSX автоматически ко всему применяется escape
.
Developer journey
Последнее, о чём стоит поговорить — о месте перевода в процессе разработки. Процесс решения задачи у нас обычно такой:
![](https://habrastorage.org/getpro/habr/upload_files/bf4/58d/2c5/bf458d2c501e9e5659166daa22f15c94.png)
Куда в этой истории вставить переводы? Кажется логичным переводить между тестированием и развёртыванием. Но это может очень сильно замедлить релизный цикл. В нашем проекте поддерживается 10 языков, и перевод на них занимает много времени. В таком случае мы будем развёртывать в лучшем случае раз в неделю, а сейчас делаем это три раза в день.
В итоге мы пришли к такой схеме:
![](https://habrastorage.org/getpro/habr/upload_files/d81/f1e/421/d81f1e421a01e8a4171ade8ca6616312.png)
Когда разработчик отправляет код в репозиторий, тот передаётся на тестовый стенд, то есть происходит CI/CD. В этот же момент у нас новые строки автоматически улетают на перевод. Иными словами, всё начинается на этапе разработки.
Дальше идёт тестирование, сборка, итерации, а переводчики переводят. И в последний момент, когда мы нажимаем кнопку deploy, проходят всякие проверки, в том числе на наличие перевода. Если его нет, развёртывание блокируется — нужно будет в админке добавить отсутствующие строки.
Особенность нашего подхода, в том числе, в том, что мы не ждём все 10 языков. Развёртывание заблокируется, только если не готов перевод на английский. То есть главное, чтобы текст был на английском и на русском,остальное подождёт. По умолчанию для языков СНГ мы проверяем на наличие русскоязычного текста, а для остальных стран — англоязычного. Если кто-то пользуется почтой VK на немецком, он может встретить у себя строку на английском, и, скорее всего, её поймёт, а в следующей итерации она обновится, когда перевод будет готов.
Статический анализ кода
Переводы — сложная история со множеством контрактов. Все строки нужно отправлять в админку, правильно оформлять, избегать конкатенации, оборачивать в специальную функцию. Даже если всё где-то задекларировано, есть риск человеческого фактора. Обязательно нужно проверять контракты, например, юнит-тестами или линтингом. Но важно, чтобы это было автоматизировано.
Например, мы написали собственное линтинг-правило, которое проверяет оформление интерфейсных строк. Давайте посмотрим на if
из этого правила:
Literal: (node: Literal) => {
if (node.value.match(CYRILLIC_REG)) {
context.report({
node,
message: 'Do not hardcode Cyrillic text, it won`t be translated',
});
}
},
Здесь мы проверяем строку на содержание кириллических символов с помощью простой регулярки. Дело в том, что очень удобно находить интерфейсные строки, если сервис русскоязычный. При статическом анализе кода отловить все интерфейсные довольно сложно, поскольку такую строку можно написать самыми разными способами: внутри JSX-компонента, или в простом JS-файле, а потом использовать эту переменную, вставив в интерфейс. Но поскольку общение с сервером не может быть на кириллице, найти интерфейсные строки можно с помощью банальной регулярки.
Автотесты
Для проверки сервисов с интернационализацией могут также понадобиться автотесты на разных локалях. В e2e UI-автотестах очень важно никогда не завязываться на текст. По моему мнению, делать это нельзя, даже когда у нас моноязычный сервис. Вместо наличия конкретного текста на странице необходимо, например, проверять наличие у элемента определенного data-атрибута или его значения. Так наши автотесты не ломаются при смене локали или текста.
Текст, возвращаемый в API с Backend’а
Также хочу немного поговорить про зоны ответственности. Частенько встречаю сервисы, в которых некоторые строки (например, текст ошибки) возвращаются с сервера как есть. На мой взгляд, так делать не стоит. Текст интерфейса должен быть зоной ответственности клиентской части приложения. Если это не так, то всю логику работы с интернационализацией придётся дублировать в двух местах: на frontend и backend. Поэтому, возвращаясь к примеру с текстом ошибки, лучше вернуть её код, по которому клиент выдаст нужную строку на нужном языке.
Таким образом, бэкенд вообще не интересует, какая локаль у пользователя, а фронтенд не становится сложнее, так как ему в любом случае нужно работать с другими строками в интерфейсе.
Сборка интернационального приложения
Сборку i18n-приложения можно изобразить в виде схемы:
![](https://habrastorage.org/getpro/habr/upload_files/86b/4fc/3cf/86b4fc3cf8f498c9f8771572822a7a1e.png)
Из исходников нам нужно не просто собрать бандл, а ещё как-то выделить строки интерфейса. В общем случае для этого потребуется построить AST-дерево и как-то из него это извлечь.
Хорошая новость в том, что AST-дерево мы и так строим при сборке проекта. Артефактом сборки является bundle: набор строк на русском языке. Дальше мы ходим в API-интерфейс переводчика, грузим туда строки на русском, которые нам нужны, получаем все переводы и потом развёртываем.
Выводы
Интернационализация — очень комплексная задача. Если не подумать о её решении заранее, на стадии проектирования сервиса, то добавить поддержку нескольких языков позже будет сложно.
JavaScript — прекрасный язык. В него встроена интернационализация, которая помогает решать разные задачи. Даже если вы поддерживаете только русский, с её помощью можно кое-что сделать корректнее.
Я считаю, что в России очень классные интерфейсы и frontend-решения. Обидно, что не так много российских сервисов популярны за рубежом. Может быть, причина как раз в том, что многие не задумываются об интернационализации. И если уделить ей внимание, наши сервисы могли бы получить большую популярность на мировом рынке.