Интерфейс любого сайта включает в себя не только визуальные компоненты, но и текст. Это могут быть названия кнопок, пунктов меню, сообщения об ошибках в форме, различные тексты, разбросанные по всему сайту.
Важно, что они не хранятся в базе данных, а зашиты прямо в код в тех местах, где они используются.
Эти тексты со временем начинают причинять боль. Они расползаются по всем слоям приложения и засоряют его. Очень быстро появляется дублирование одних и тех же фраз. Становится сложно отслеживать их согласованность и адекватность. В конце концов, программисты становятся единственными людьми в компании, которые могут их поменять, потому что никто кроме них не понимает, как найти эти тексты.
При правильном подходе, подобные тексты хранятся в одном месте отдельно от кода. Такой способ значительно упрощает приложение:
- Текстами проще управлять, выполнять массовое обновление, отслеживать то, что устарело.
- Это могут делать не только программисты. Более того, тексты можно выгружать во внешние системы, которые дают возможность работать с ними множеству людей (об этом ниже).
- Упрощается интернационализация и локализация.
Так как организовать хранение текстов? Ответ для многих программистов может показаться неожиданным. Даже если ваш сайт не собирается быть мультиязычным, для работы с текстами все равно используют i18n-библиотеки. i18n расшифровывается как интернационализация (internationalization). Этим термином в программировании обозначают все, что связано с переводами. Как правило, речь идет про специальные библиотеки, которые позволяют переводить интерфейсы, оставляя код приложения простым.
Самое интересное, что эти библиотеки дают все те возможности, которые были перечислены ранее, и не требуют от программистов обязательно добавлять несколько языков. Считайте, что мультиязычность — это приятное дополнение, которое можно задействовать, если вдруг понадобится. Кроме базовых задач, эти библиотеки решают еще множество сопутствующих. Ниже мы их рассмотрим.
У Хекслета на GitHub открыто большое количество проектов на разных языках. Во всех этих проектах есть тексты, и все они подставляются в код через i18n-библиотеки. Большая часть этих библиотек интегрирована с фреймворками и поставляется из коробки. Вот несколько примеров:
- cv.hexlet.io
- sicp.hexlet.io
- code-basics.com
В каждом из этих проектов свои способы организации переводов, это видно по разным форматам файлов. Одно остается неизменным: строки не разбросаны по коду. Они все собраны в одном месте и подставляются в нужных местах через i18n-библиотеки.
В мире JS наиболее популярной библиотекой для работы с текстами стала i18next. Это не просто библиотека, а целый фреймворк, имеющий интеграции со всеми популярными решениями, такими как Angular, React или Vue.js. Пример использования:
import i18next from 'i18next';
// Инициализация, выполняется ровно один раз в асинхронной функции, запускающей приложение
const runApp = async () => {
await i18next.init({
lng: 'ru', // Текущий язык
debug: true,
resources: {
ru: { // Тексты конкретного языка
translation: { // Так называемый namespace по умолчанию
key: 'Привет мир!',
},
},
},
});
};
// Где-то в коде приложения обращаемся к ключу (key)
// Библиотека по умолчанию ищет так: <текущий язык>.translation.<ключ> => ru.translation.key
i18next.t('key'); // "Привет мир!"
Единственное место, где появляется понятие "язык" — это инициализация. Нужно указать текущий язык (lng
) и добавить тексты для этого языка. На этом все: дальше мы только управляем текстами. Если появляется новый текст, то для него придумывается ключ и добавляется в объект translation. Затем этот текст извлекается по указанному ключу. Из кода выше видно, что этот текст очень легко переиспользовать. Достаточно обратиться к этому же ключу в другом месте программы.
Когда текста становится больше, то его можно вынести в отдельный файл. В таком случае инициализация меняется на такую:
import i18next from 'i18next';
// Просто пример. Структура может быть любой.
import ru from './locales/ru.js';
await i18next.init({
lng: 'ru',
debug: true,
resources: {
ru,
},
});
В принципе, выносить тексты лучше сразу. Их никогда не бывает мало.
i18next поддерживает такое понятие как "бэкенды". Она позволяет загружать тексты из внешних источников, например, через AJAX-запрос (именно поэтому инициализация библиотеки — асинхронная). Подробнее в официальной документации.
Со временем вы заметите, что плоская структура key-value не всегда удобна. Иногда захочется делать вложенность, группировать ключи. К счастью, с этим нет никаких проблем. I18next поддерживает такую возможность из коробки.
{
translation: {
key: 'Привет мир!',
signUpForm: {
name: 'Имя',
email: 'Email',
}
}
}
i18next.t('signUpForm.name'); // Имя
i18next.t('signUpForm.email'); // Email
В некоторых ситуациях тексты зависят от различных динамических параметров, например, от имени пользователя. В таком случае используется встроенная интерполяция:
{
translation: {
greeting: 'Привет {{name}}!',
}
}
i18next.t('greeting', { name: 'Иван' }); // "Привет Иван!"
В более сложных ситуациях одной интерполяции недостаточно. Представьте себе, что нам надо выводить количество баллов, как на Хекслете. Слово "балл" будет меняться в зависимости от числа баллов: 1 балл, 2 балла, 10 баллов. Как это сделать? С помощью плюрализации!
{
translation: {
{ // Интерполяция не обязательна, зависит от задачи
// Наименования ключей не соответствуют конкретным числам
// Они обозначают разные группы чисел
key_one: '{{count}} балл',
key_few: '{{count}} балла',
key_many: '{{count}} баллов',
}
}
}
i18next.t('key', { count: 0 }); // "баллов"
i18next.t('key', { count: 1 }); // "балл"
i18next.t('key', { count: 2 }); // "балла"
i18next.t('key', { count: 5 }); // "баллов"
Связь текстов с состоянием приложения
Типичная ошибка при работе с текстами — хранить их прямо в состоянии:
// Ошибки — это всего лишь один из возможных примеров
// То же самое касается любых других текстов
if (!isEmailUnique) {
state.signUpForm.errors.email = i18next.t('signUpForm.errors.email.notUnique');
}
У такого подхода есть один очень серьезный недостаток. Он не сочетается с переключением языков. Представьте, что пользователь поменял язык интерфейса, а в состоянии в это время записаны тексты. Появляется проблема — как изменить тексты на правильный язык? В общем случае никак, потому что в строке текста нет информации, что это было. То есть невозможно сопоставить этот текст с ключом и найти соответствующий перевод в другом месте. Кроме того, сама задача очень непростая, текстов может быть много, они разбросаны по разным частям состояния. Придется писать специальную логику под каждую конкретную ситуацию (каждый конкретный кусок состояния).
Любые тексты, которые выводятся в зависимости от действий пользователя, не должны храниться в состоянии приложения. Эти тексты должны зависеть от состояния процессов:
// Где-то в представлении (View)
if (state.registrationProcess.finished) {
div.innerHTML = i18next.t('registration.success');
}
Только в некоторых ситуациях, где нужно явно знать, какие тексты использовать — можно хранить ключи, например, для перевода ошибок.
// В файле переводов:
{
translation: {
key: 'Привет мир!',
signUpForm: {
name: 'Имя',
email: 'Email',
errors: [/* тут переводы ошибок */]
}
}
}
// Где-то в приложении
const state = {
signUpForm: {
valid: false,
errors: {},
}
};
// Где-то в обработчике
if (!isEmailUnique) {
state.signUpForm.errors.email = 'signUpForm.errors.email.notUnique';
}
// Где-то во вью
div.innerHTML = i18next.t(state.signUpForm.errors.email);
В любом случае, готовые строки формируются только при выводе.
Инициализация
Вернемся к примеру из начала урока:
import i18next from 'i18next';
const runApp = async () => {
// Меняет объект i18next глобально
await i18next.init({
// конфигурация i18next
});
app(); // внутри приложения i18next.t теперь работает с выбранным языком и загруженными переводами.
};
// Где-то в коде приложения обращаемся к ключу (key)
i18next.t('key');
При инициализации глобальный объект i18next мутируется, и поэтому функция i18next.t
может импортироваться напрямую из библиотеки. Это удобно с точки зрения использования, но добавляет проблем при необходимости многократной инициализации. Когда необходимо инициализировать приложение несколько раз? Например, в тестах, где на каждый тест происходит новый запуск приложения "с нуля", или при серверном рендеринге, когда для каждого пользователя на сервере создается свой экземпляр приложения. Для таких случаев библиотека содержит функцию createInstance
, которая создает, как нетрудно догадаться, новый экземпляр i18next:
import i18next from 'i18next';
const runApp = async () => {
// Глобальный объект i18next не меняется и каждый запуск приложения будет независим от других.
const i18nextInstance = i18next.createInstance();
await i18nextInstance.init({
// конфигурация i18next
});
// инициализированный экземпляр необходимо передать в приложение
app(i18nextInstance);
};
// Где-то в коде приложения обращаемся к экземпляру:
i18nextInstance.t('key'); // "Привет мир!"
Такой подход с глобальным состоянием не уникален, например, библиотеку axios можно конфигурировать как глобально, так и создавать инстанс. В общем случае мутируемое глобальное состояние — зло и источник багов.
Остались вопросы? Задайте их в разделе «Обсуждение»
Вам ответят команда поддержки Хекслета или другие студенты
Для полного доступа к курсу нужен базовый план
Базовый план откроет полный доступ ко всем курсам, упражнениям и урокам Хекслета, проектам и пожизненный доступ к теории пройденных уроков. Подписку можно отменить в любой момент.