Локализация шаблонов на клиенте в AngularJS

image

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

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

/**
 * @param {Settings} Settings
 * @constructor
 */
function Language(Settings) {
  this.Settings = Settings;
}
Language.$inject = ['Settings'];
app.service('Language', Language);

/**
 * @param {String} string
 * @returns {String}
 */
Language.prototype.translate = function(string) {

  var translation = __lang[this.Settings.getValue('lang')];

  if (typeof translation[string] !== 'undefined') {
    string = translation[string];
  }

  return string;
};


Зная AngularJS, первая же идея, которая приходит в голову — это использовать стандартные возможности выражений с фильтрами для перевода строк «на лету» вида:

<span>{{ 'some_english_string' | translate }}</span>

Но этот подход имеет свои недостатки:
— во-первых, все-таки увеличивается количество кода, исполняемого в каждом $digest цикле и если у вас сложное приложение, одновременно на странице отображается большое количество строк и/или ваш алгоритм перевода уже более сложный, чем просто поставление строки из словаря по ключу — то производительность будет снижаться.
— во-вторых, применение angular-выражений уже не будет таким удобным, если вы используете их, допустим, внутри атрибутов каких-то директив, которые сами по себе уже принимают выражения, так как исполнение выражений внутри других выражений работать не будет. В этом случае, конечно можно будет использовать глобальную функцию вместо фильтра для преобразования таких строк, но все же это дополнительные сложности.

Второй вариант, который рекомендуют использовать — это переводить шаблоны на сервере и с клиентской стороны уже иметь доступ к локализованным копиям вида some_partial_en.html, some_partial_ru.html. В таком случае для смены языка необходимо будет перезагружать приложение. К тому же, не всегда есть такая возможность: например, на моем прошлом проекте приложение использовало данные из стороннего сервиса, а написание бекенда вообще не предполагалось.

Но есть еще один способ — а именно переводить шаблоны на клиенте, но уже не средствами AngularJS. Например, можно использовать шаблонизатор из Lo-Dash / underscorejs и использовать конструкции вида:

<span><%= t('some_english_string') %></span>

Плюс в том, что при этом можно будет как использовать angular-выражения внутри перевода строки в словаре

__lang.ru =
{
    "some_english_string": "Русский перевод строки номер {{string.number}}"
};

так и наоборот, подставлять эту конструкцию внутрь других angular-выражений, допустим, в какой-нибудь своей директиве:

<div class="input-text-right">
	<input type="text"
	       my-num-format="00000.00"
	       my-validate="[
	        {
	            type: 'notGreaterThen',
	            compareTo: 'somecondition()',
	            message: '<%= t('some_validation_error_message') %>'
	        }
	       ]"
	       ng-model="model.value">
</div>


Для этого мы допишем метод для перевода шаблонов в наш сервис Language:

.....

/**
 * @param {String} template
 * @returns {String}
 */
Language.prototype.translateTemplate = function(template) {
  // подставляем наш метод перевода this.translate() в область видимости шаблона из Lo-Dash как t()
  return _.template(template, {
    t: angular.bind(this, this.translate)
  });
};


Теперь нам нужно перехватывать шаблоны, используемые angular-ом и переводить их до того, как он начнет их компиляцию. Отдельного сервиса для получения шаблонов в AngularJS нет, но везде, где это происходит (роутинг, директива ngInclude и т.д.) — используется $templateCache для кеширования. Вот его-то мы и переопределим:

// создаем angular-фабрику с таким же именем, как и у стандартной реализации фреймворка
app.factory('$templateCache', [
  '$cacheFactory',
  'Language',

  function($cacheFactory, Language) {

    /**
     * @constructor
     */
    function MyTemplateCache() {

      /**
       * @param {String} key
       * @param {*} value
       */
      this.put = function(key, value) {

        // если значение - это promise-объект, который возвращает сервис $http
        if (typeof value.then !== 'undefined') {
          // заменяем его своим promise-ом, который будет зарезолвен уже переведенным шаблоном
          value = value.then(function(response) {
            response.data = Language.translateTemplate(response.data);
            return response;
          });
        }
        // если значение - это массив-результат, который angular записывает в кеш при резолве promise-а $http
        else if (value instanceof Array) {
          value[1] = Language.translateTemplate(value[1]);
        }
        // если значение - это сам шаблон 
        // (в случае, когда шаблоны берутся со страницы из тегов <script type="text/ng-template"></script>
        // или вставляются вручную)
        else if (typeof value === 'string') {
          value = Language.translateTemplate(value);
        }

        // вызываем родительский метод put()
        MyTemplateCache.prototype.put(key, value);
      };
    }

    // наследуемся от стандартного объекта $templateCache
    MyTemplateCache.prototype = $cacheFactory('templates');

    return new MyTemplateCache();
  }
]);


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

$templateCache.removeAll();
$route.reload();

Единственное замечание: при перезагрузке роута будут сброшены все изменения, не сохраненные в состоянии, поскольку при перекомпиляции шаблонов будут пересозданы заново все скоупы.
  • +22
  • 11,3k
  • 8
Поделиться публикацией

Похожие публикации

Комментарии 8

    0
    Меня все же немного смущает такой способ… как минимум если используются штуки типа html2js для сборки своего $templateCache, то это работать не будет. Опять же не вижу профита именно в подобном переводе темплейтов на стороне клиента, когда их можно один раз собрать на сервере в свои папочки и при помощи декораторов, например, над сервисом $sce, менять урл темплейта в зависимости от локали.

    Мне больше нравится комбинация из фильтра translate. Для себя я просто написал отдельный модуль который подключает нужный мне $templateCache в зависимости от локали, а вьюшки уже собираются через grunt.
      +1
      … использовать стандартные возможности выражений с фильтрами…
      {{ 'some_english_string' | translate }}
      Это, конечно, неудобно, и накладывает перечисленные ограничения. Но это можно делать директивой, и тот же angular-translate это умеет.

      Что касается digest loop — а как без него сделать обновляемый без перекомпиляции plural gettext (1 файл, 2 файла, 10 файлов)?
        0
        Ну как вариант, механизм плюрализации и механизм перевода могут идти отдельно. То есть плюрализация-то достигается с помощью директивы или фильтра, но исходные данные (корень слова или разные слова на разные множители — не вдавался в подробности реализации) подставлять в нее можно уже переведенные. Это уже как словарь организуешь.

        __lang.ru = {
          'file': 'файл',
          'file_plural': {
            1: 'файл',
            2: 'файла',
            more: 'файлов',
          }
        }
        

        <span some-pluralization-directive="<%= t('file_plural') %>" number-of="2"></span>
        

        И, например, при переводе шаблонов, если перевод является объектом — функцией t() возвращать json-строку, а из директивы ее же $eval()-ом обратно превращать в объект и уже использовать эти данные для преобразования.

        link: function(scope, $elem, attrs){
          attrs.$observe('somePluralizationDirective', function(attrValue){    
            var pluralData = scope.$eval(attrValue);
            // дальше идет логика плюрализации
          });
        }
        
          0
          Так а чем плох вариант через тот же underscore генерить уже обработанные шаблоны при сборке проекта? Ибо выносить это на клиент как-то совсем нелогично — операция может быть выполнена один раз.
            0
            Ну я не говорил, что этот способ однозначно лучше или хуже. Есть свои плюсы и минусы. Просто, как я сказал в посте — на том конкретном проекте как-таковой серверной части не предполагалось вовсе, только веб-хост для самого приложения. И если клиенту, допустим, нужно будет поменять что-то в переводе — возмонжости пересобирать шаблоны у него не будет, разве что только вручную редактировать. Править один файл словаря все же легче.
        0
        мы у себя используем директивы такого вида:
        angular.module('app').directive('i18n', function(i18n) {
            return {
                restrict: 'A',
                link: function(scope, elem, attrs) {
                    elem.removeAttr('i18n');
                    var orig = elem.html();
                    orig = orig.replace(/^\s+|\s+$/g, ''); // trim
                    elem.html(i18n(orig));
                    scope.$on('i18n:language_changed', function() {
                        elem.html(i18n(orig));
                    });
                }
            };
        });
        


        Плюрализация легко добавляется.
          0
          Первый способ не всегда плох. Например, у меня в админках подгружаются все языки (пример) и отображаются через фильтр, чтобы можно было включить несколько языков сразу (удобно при переводе). Гость же получает всегда только один язык и чтобы переключиться нужно перезагрузить страницу. шаблон всегда один и тот же, слова присылаются сервером отдельно. Поэтому, нужно всего лишь два шаблона: для админки и обычный.
            0
            Лично мне понравилось это решение

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

            Самое читаемое