Доброго времени суток, хабравчане!


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



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


Предыстория

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


К тому же, я не очень люблю сторонние библиотеки из-за их склонности к разрастанию (это когда из всего функционала нам требуется лишь малая его часть).


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


В какой-то момент захотелось чего-то простого, лёгкого, понятного и бесконечно расширяемого для разных задач.


Постановка задачи


Тут вроде бы всё понятно. Или нет? Давайте подумаем, чего бы нам хотелось.


Нам нужно каким-то образом получать локализованные тексты. Тексты могут содержать переменные. Переменные тоже могут локализоваться?! По идее да. А если переменная является датой или числом?! А ещё бы поддержку markdown. И в завершение, какое-нибудь решение на случай, если текст не найден.


Реализация


В основе будет простой объект, где ключ — это код текста, а значение — собственно нужный текст, ничего сложного:


const textsBundle = {
  'button.open': 'Open',
  'button.save': 'Save',
};

function TextManager(texts) {
  this.getText = function(code) {
    return texts[code];
  };
}

const textManager = new TextManager(textsBundle);
textManager.getText('button.open');

Немного о наименовании ключей

Наименование ключей — это отдельная тема. Лучше сразу договориться о каком-то одном варианте, иначе разные ключи будут "подбешивать" :). Нет какого-то одного решения, выбирайте, как вам покажется удобнее и больше соответствует проекту. Лично мне нравится первый из предложенных:
'button.open.label'
'button.open.help_text'
или
'button.label.open'
'button.help_text.open'
или
'label.button.open'
'help_text.button.open'


Далее нам нужен механизм, который бы умел совершать какие-то манипуляции с текстом до того, как выдаст конечный результат, например, вставлять параметры. И тут мне пришла интересная идея — а что, если использовать middleware для манипуляций с текстом? Ведь таких решений я не встречал… ну или плохо искал :).


Определимся с требованиями к middleware: на входе middleware будет принимать текст и параметры, а выдавать — результирующий текст, после нужных манипуляций.
Первое middleware будет получать изначальный текст, а последующие — текст от предыдущего middleware. Допишем недостающий код:


function TextManager(texts, middleware) {
  function applyMiddleware(text, parameters, code) {
    if (!middleware) return text;

    return middleware.reduce((prevText, middlewareItem) => middlewareItem(prevText, parameters, code), text);
  }

  this.getText = function(code, parameters) {
    return applyMiddleware(texts[code], parameters, code);
  };
}

TextManager умеет выдавать текст по его коду. Также он может быть расширен использованием middleware, которое открывает много возможностей, например:


  • обработка случая, когда текст не найден
  • использование параметров в тексте
  • локализация параметров
  • использование markdown
  • экранирование текста и т.п.

Практика


Напишем пару необходимых middleware. Они вам 100% понадобятся.


InsertParams
Позволяет использовать параметры в текстах. Например нам нужно отобразить текст "Привет {{username}}". Следующее middleware это обеспечит:


function InsertParams(text, parameters) {
  if (!text) return text;
  if (!parameters) return text;

  let nextText = text;
  for (let key in parameters) {
    if (parameters.hasOwnProperty(key)) {
      nextText = text.replace('{{' + key + '}}', parameters[key]);
    }
  }

  return nextText;
}

UseCodeIfNoText
Позволяет вернуть код текста, вместо undefined, если текст не был найден:


function UseCodeIfNoText(text, parameters, code) {
  return text ? text : code;
}

Итого получаем примерно следующее использование:


const textsBundle = {
  'text.hello': 'Hello',
  'text.hello_with_numeric_parameter': 'Hello {{0}}',
  'text.hello_with_named_parameter': 'Hello {{username}}',
};

const textManager = new TextManager(textsBundle, [InsertParams, UseCodeIfNoText]);

textManager.getText('nonexistent.code') // 'nonexistent.code'
textManager.getText('text.hello') // 'Hello'
textManager.getText('text.hello_with_numeric_parameter', ['Vasya']) // 'Hello Vasya'
textManager.getText('text.hello_with_named_parameter', { username: 'Petya' }) // 'Hello Petya'

Пример использования в React приложении


Для начала инициализируем на топ-уровне TextManager и добавляем тексты.
На мой взгляд, лучше всего тянуть тексты с сервера, но для простоты я этого делать не буду.


const textsBundle = {
  'text.hello': 'Hello {{username}}'
}

function TextManagerProvider({ children }) {
  const textManager = new TextManager(textsBundle, [InsertParams, UseCodeIfNoText]);

  return (
    <TextManagerContext.Provider value={textManager}>
      {children}
    </TextManagerContext.Provider>
  )
}

Далее в компоненте используем textManager, например с помощью хука, и получаем нужный текст по коду.


function SayHello({ username }) {
  const textManager = useContext(TextManagerContext);

  return (
    <div>
      {textManager.getText('text.hello', { username })}
    </div>
  )
}

Локализация


Вы спросите "При чем тут локализация?".
Всё очень просто — при смене языка создаёте новый экземпляр TextManager, добавляете тексты и сразу получаете результат.


Глава предпоследняя :)


Как видно из примеров — использование предельно простое, а благодаря middleware расширять функционал можно до бесконечности.


Свою реализацию я выложил на github и планирую в дальнейшем развивать text-manager. Пользуйтесь, предлагайте улучшения и, как говорят у них там, You're welcome! :)


В заключение


Вот я и выполнил своё дааааавнее желание — написал статью на Хабр. Я очень надеюсь, что эта статья будет полезной и придётся по душе сообществу.


Спасибо за уделённое внимание.

Комментарии (6)


  1. TheGodfather
    02.07.2019 17:58

    Для тех кто в танке — а почему вообще JS? Ну т.е. вроде сгенерировать страничку с нужными текстами — это вполне себе бекенд, PHP тот же, разве нет?


    1. andreymal
      02.07.2019 18:31

      Вы отстали от моды лет на восемь, сейчас уже никто не генерирует странички на бекенде, даже мобильная версия Хабра рисуется целиком и полностью через JS


    1. JimmDiGreez
      02.07.2019 20:31

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

      Сейчас по разному может быть. Чаще всего бек не волнует вообще, какой там фронт и чем этот фронт занимается (сайт, нативка на мобильные платформы, десктоп, что угодно), а сам он занимается тем, чем беку положено — данными и бизнес-логикой. Потому вопросами красоты на клиентах (текстовой в том числе) он скорее всего не будет озабочен.
      В ряде проектов еще бек немало в этом участвует, но все же меньше, чем раньше.

      Ну а так, конечно, вопросы локализации далеко не только js на фронте касаются. Реализации везде есть, хотя проблемы примерно одинаковые и это можно рассматривать как единую проблему с разными способами решения.


  1. andreymal
    02.07.2019 18:36
    +1

    локализация параметров

    Не увидел этого в вашем решении. Как мне, меняя параметр-число, получать строки вида «1 комментарий», «4 комментария», «14 комментариев»?


    1. 7Sage Автор
      02.07.2019 20:58

      Не сразу понял, что вы имеете ввиду. Я этого аспекта не касался, т.к. на мой взгляд это тема для отдельной статьи. А решить это можно отдельным middleware для локализации параметров с использованием, например Intl.PluralRules

      upd. не хочу обещать, но в планах было заняться этим в ближайшем будущем


    1. EgorVolokitin
      02.07.2019 21:15

      Дели число на 10 и на 100. или следи за последними 2мя цифрами числа. если там 11-14 — окончание ев. в остальных случаях смотри последнюю цифру. там сам разберёшься. все повторяется