hexlet-courses
October 2, 2020

7 Организация текстов интерфейса

Интерфейс любого сайта включает в себя не только визуальные компоненты, но и текст. Это могут быть названия кнопок, пунктов меню, сообщения об ошибках в форме, различные тексты, разбросанные по всему сайту.

Важно, что они не хранятся в базе данных, а зашиты прямо в код в тех местах, где они используются.

Эти тексты со временем начинают причинять боль. Они расползаются по всем слоям приложения и засоряют его. Очень быстро появляется дублирование одних и тех же фраз. Становится сложно отслеживать их согласованность и адекватность. В конце концов, программисты становятся единственными людьми в компании, которые могут их поменять, потому что никто кроме них не понимает, как найти эти тексты.

При правильном подходе, подобные тексты хранятся в одном месте отдельно от кода. Такой способ значительно упрощает приложение:

  • Текстами проще управлять, выполнять массовое обновление, отслеживать то, что устарело.
  • Это могут делать не только программисты. Более того, тексты можно выгружать во внешние системы, которые дают возможность работать с ними множеству людей (об этом ниже).
  • Упрощается интернационализация и локализация.

Так как организовать хранение текстов? Ответ для многих программистов может показаться неожиданным. Даже если ваш сайт не собирается быть мультиязычным, для работы с текстами всё равно используют i18n-библиотеки. i18n – расшифровывается как интернационализация (internationalization). Этим термином в программировании обозначают всё, что связано с переводами. Как правило, речь идёт про специальные библиотеки, которые позволяют переводить интерфейсы, оставляя код приложения простым.

Самое интересное, что эти библиотеки дают все те возможности, которые были перечислены ранее, и не требуют от программистов обязательно добавлять несколько языков. Считайте, что мультиязычность – это приятное дополнение, которое можно задействовать, если вдруг понадобится. Кроме базовых задач, эти библиотеки решают ещё множество сопутствующих. Ниже мы их рассмотрим.

У Хекслета на GitHub открыто большое количество проектов на разных языках. Во всех этих проектах есть тексты, и все они подставляются в код через i18n-библиотеки. Большая часть этих библиотек интегрирована с фреймворками и поставляется из коробки. Вот несколько примеров:

В каждом из этих проектов свои способы организации переводов, это видно по разным форматам файлов. Одно остаётся неизменным: строки не разбросаны по коду. Они все собраны в одном месте и подставляются в нужных местах через i18n-библиотеки.

В мире JS наиболее популярной библиотекой для работы с текстами стала i18next. Это не просто библиотека, а целый фреймворк, имеющий интеграции со всеми популярными решениями, такими как Angular, React или Vue.js. Пример использования:

import i18next from 'i18next';
// Инициализация, выполняется ровно один раз в асинхронной функции, запускающей приложение
// Меняет объект 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: {
    { // Интерполяция не обязательна, зависит от задачи
      // Наименования ключей не соответствуют конкретным числам
      // Они обозначают разные группы чисел https://jsfiddle.net/sm9wgLze
      key_0: '{{count}} балл',
      key_1: '{{count}} балла',
      key_2: '{{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);

В любом случае, готовые строки формируются только при выводе.

Вопрос: 1Пройдено: 0 / 3

Какие тексты принято переводить через библиотеки i18n?

(нужно выбрать все корректные ответы)

Данные в базе

Элементы интерфейса

Сообщения об ошибках

Сообщения (flash, alerts)

Вопрос: 2Пройдено: 0 / 3

Что должно храниться внутри состояния при работе с ошибками?

Код ошибки (словесный или цифровой)

Ошибки в состоянии не хранят

Текст ошибки полученный из переводов

Вопрос: 3Пройдено: 0 / 3

В каком месте должна происходить инициализация i18next?

Каждый раз во время рендеринга

На старте программы один раз

Один раз во время рендеринга

В этой задаче вам предстоит реализовать грид. В интерфейсах так называется список выведенный в табличном виде, позволяющий выполнять разные действия над ним, например, сортировку.

В качестве данных нужно взять объект document.location, который содержит в себе информацию о браузере. Пример неполного вывода:

Name (Asc)

Value (Unsorted)

host

localhost

pathname

/

Вывести нужно только те свойства, которые удовлетворяют условиям:

  • Не функции
  • Не объекты
  • Не пустые

По умолчанию вывод происходит в алфавитном порядке по имени свойства. Этот порядок можно менять кликом по заголовку. Если происходит клик на другой столбец, то происходит сортировка по нему (в порядке возрастания). Повторный клик меняет порядок сортировки.

<div class="container m-3">
    <table class="table">
        <tbody>
            <tr>
                <th><a href="">Name (Asc)</a></th>
                <th><a href="">Value (Unsorted)</a></th>
            </tr>
            <tr>
                <td>host</td>
                <td>localhost</td>
            </tr>
            <tr>
                <td>hostname</td>
                <td>localhost</td>
            </tr>
            <tr>
                <td>href</td>
                <td>http://localhost/</td>
            </tr>
            <tr>
                <td>origin</td>
                <td>http://localhost</td>
            </tr>
            <tr>
                <td>pathname</td>
                <td>/</td>
            </tr>
            <tr>
                <td>protocol</td>
                <td>http:</td>
            </tr>
        </tbody>
    </table>
</div>

Рядом с каждым заголовком, в скобках, указано состояние столбца. Всего их три:

  • Не отсортирован
  • Прямой
  • Обратный

В один момент времени сортировка может быть выполнена только по одному столбцу.

src/application.js

Экспортируйте функцию по умолчанию, которая реализует всю необходимую логику. Тексты должны подставляться через библиотеку i18next.

Подсказки

  • Сравнение строк localeCompare
  • Переводы можно вкладывать друг в друга: I18n.t('key', { value: I18n.t('another key') })
  • Получить все свойства объекта (включая то что наследуется) можно через цикл for..in

application.js

// @ts-check
/* eslint no-restricted-syntax: ["off", "ForInStatement"] */
/* eslint no-param-reassign: ["error", { "props": false }] */
/* eslint-disable guard-for-in */

import i18next from 'i18next';
import onChange from 'on-change';
import resources from './locales';

// BEGIN (write your solution here)

// END

index.js

// @ts-check

import en from './en.js';

export default { en };