Pull to refresh

Динамический перевод страницы на другой язык

Reading time 11 min
Views 14K
Привет, Хабр.

Сегодня я расскажу о своих достижениях в области моментального изменения страницы — динамической смене языка. Эта штука понадобилась мне совершенно недавно, а так как сторонним реализациям я не доверяю (даже как-то и не нашёл их), то пришлось написать свою. За время её использования (где-то около полугода) я исправил все самые заметные баги (но это не означает, что их там больше не осталось :) ), и теперь представляю рабочую версию.

Кто-то скажет, что осуществлять перевод на клиенте нецелесообразно, но у меня получилось как раз такая ситуация, что по-другому никак нельзя: для серверного перевода приходится принудительно закрывать веб-приложения на странице, чтобы не потерять данные; в случае же с динамическим изменением языка, просто заменятся тексты на элементах и работа продолжается. Думаю, не меня одного раздражало «Настройки будут применены при перезагрузке». Моя реализация хоть и сложновата, но решает эту проблему.

Для того чтобы не путаться, я определю для данной статьи следующий перечень терминов:
Словарь — хранилище ключей, по которым осуществляется доступ к локализации на данном языке. По сути дела представляет собой обычный JavaScript-объект, где свойства — ключи доступа, а их значения — переведенные строки.
Хэш — объект, который является результатом упорядоченного слияния словарей; общий словарь, из которого впоследствии ведётся выборка перевода.

Теперь более детально.


Для ускорения чтения я рекомендую пропустить главы Исходный код и Описание интерфейса, т.к. они дают описание объекта разговора с программной точки зрения. При желании с ними можно будет позже ознакомиться в случае, если вас заинтересовала статья.

Исходный код


Сразу же предлагаю исходный код. Пока не вдавайтесь в подробности, но к нему еще будем возвращаться.
lang = (function init_lang() {
    var BODY = document.body,
        //тело документа
        LANG_HASH = {},
        //собственно, результирующий хэш
        LANG_HASH_LIST = [],
        //список загружаемых словарей
        LANG_HASH_INDEX = {},
        //список имён словарей
        LANG_HASH_USER = {},
        //пользовательский словарь
        LANG_HASH_SYSTEM = {},
        //системный словарь
        LANG_QUEUE_TO_UPDATE = [],
        //очередь элементов для обновления
        LANG_PROPS_TO_UPDATE = {},
        //перечень имён для обновления
        LANG_UPDATE_LAST = -1,
        //индекс последнего обновлённого элемента
        LANG_UPDATE_INTERVAL = 0,
        //интервал обновления
        LANG_JUST_DELETE = false; //неперестраивание хэша при удалении словаря
    var hash_rebuild = function hash_rebuild() { //функция перестраивания хэша
            var obj = {};
            obj = lang_mixer(obj, LANG_HASH_USER);
            for (var i = 0, l = LANG_HASH_LIST.length; i < l; i++)
            obj = lang_mixer(obj, LANG_HASH_LIST[i]);
            LANG_HASH = lang_mixer(obj, LANG_HASH_SYSTEM);
        },
        lang_mixer = function lang_mixer(obj1, obj2) { //функция расширения свойствами
            for (var k in obj2)
            obj1[k] = obj2[k];
            return obj1;
        },
        lang_update = function lang_update(data) { //функция, инициирующая обновление
            switch (typeof data) {
            default:
                return;
            case "string":
                LANG_PROPS_TO_UPDATE[data] = 1;
                break;
            case "object":
                lang_mixer(LANG_PROPS_TO_UPDATE, data);
            }
            LANG_UPDATE_LAST = 0;
            if (!LANG_UPDATE_INTERVAL) LANG_UPDATE_INTERVAL = setInterval(lang_update_processor, 100);
        },
        lang_update_processor = function lang_update_processor() { //функция обновления
            var date = new Date;
            for (var l = LANG_QUEUE_TO_UPDATE.length, c, k; LANG_UPDATE_LAST < l; LANG_UPDATE_LAST++) {
                c = LANG_QUEUE_TO_UPDATE[LANG_UPDATE_LAST];
                if(!c)
                    continue;
                if (!c._lang || !(c.compareDocumentPosition(BODY) & 0x08)) {
                    LANG_QUEUE_TO_UPDATE.splice(LANG_UPDATE_LAST, 1);
                    LANG_UPDATE_LAST--;
                    if (!LANG_QUEUE_TO_UPDATE.length) break;
                    continue;
                }
                for (k in c._lang)
                if (k in LANG_PROPS_TO_UPDATE) lang_set(c, k, c._lang[k]);
                if (!(LANG_UPDATE_LAST % 10) && (new Date() - date > 50)) return;
            }
            LANG_PROPS_TO_UPDATE = {};
            clearInterval(LANG_UPDATE_INTERVAL);
            LANG_UPDATE_INTERVAL = 0;
        },
        lang_set = function lang_set(html, prop, params) { //установка атрибута элемента
            html[params[0]] = prop in LANG_HASH ? LANG_HASH[prop].replace(/%(\d+)/g, function rep(a, b) {
                return params[b] || "";
            }) : "#" + prop + (params.length > 1 ? "(" + params.slice(1).join(",") + ")" : "");
        };

    var LANG = function Language(htmlNode, varProps, arrParams) { //связывание элемента с ключами
            var k;
            if (typeof htmlNode != "object") return;
            if (typeof varProps != "object") {
                if (typeof varProps == "string") {
                    k = {};
                    k[varProps] = [htmlNode.nodeType == 1 ? "innerHTML" : "nodeValue"].
                    concat(Array.isArray(arrParams) ? arrParams : [])
                    varProps = k;
                } else return;
            }
            if (typeof htmlNode._lang != "object") htmlNode._lang = {};
            for (k in varProps) {
                if (!(Array.isArray(varProps[k]))) varProps[k] = [varProps[k]];
                htmlNode._lang[k] = varProps[k];
                lang_set(htmlNode, k, varProps[k]);
            }
            if (LANG_QUEUE_TO_UPDATE.indexOf(htmlNode) == -1) LANG_QUEUE_TO_UPDATE.push(htmlNode);
        };
    lang_mixer(LANG, {
        get: function get(strProp) { //получение перевода из хэша
            return LANG_HASH[strProp] || ("#" + strProp);
        },
        set: function set(strProp, strValue, boolSystem) { //установка ключа в пользовательском
            //или системном словаре
            var obj = !boolSystem ? LANG_HASH_USER : LANG_HASH_SYSTEM;
            if (typeof strValue != "string" || !strValue) delete obj[strProp];
            else obj[strProp] = strValue;
            hash_rebuild();
            lang_update(strProp + "");
            return obj[strProp] || null;
        },
        load: function load(strName, objData) { //загрузка словаря(ей)
            switch (typeof strName) {
            default:
                return null;
            case "string":
                if (LANG_HASH_INDEX[strName]) {
                    LANG_JUST_DELETE = true;
                    LANG.unload(strName);
                    LANG_JUST_DELETE = false;
                }
                LANG_HASH_LIST.push(objData);
                LANG_HASH_INDEX[strName] = objData;
                break;
            case "object":
                objData = {};
                for (var k in strName) {
                    if (LANG_HASH_INDEX[k]) {
                        LANG_JUST_DELETE = true;
                        LANG.unload(k);
                        LANG_JUST_DELETE = false;
                    }
                    LANG_HASH_LIST.push(strName[k]);
                    LANG_HASH_INDEX[k] = strName[k];
                    objData[k] = 1;
                }

            }
            hash_rebuild();
            lang_update(objData);
            return typeof strName == "string" ? objData : strName;
        },
        unload: function unload(strName) { //выгрузка словаря(ей)
            var obj, res = {}, i;
            if (!(Array.isArray(strName))) strName = [strName];
            if (!strName.length) return null;
            for (i = strName.length; i--;) {
                obj = LANG_HASH_INDEX[strName[i]];
                if (obj) {
                    LANG_HASH_LIST.splice(LANG_HASH_LIST.indexOf(obj), 1);
                    delete LANG_HASH_INDEX[strName[i]];
                    res[strName[i]] = obj;
                    if (LANG_JUST_DELETE) return;
                }
            }
            hash_rebuild();
            lang_update(obj);
            return strName.length == 1 ? res : obj;
        },
        params: function params(htmlElem, strKey, arrParams) {
            if (typeof htmlElem != "object" || !htmlElem._lang || !htmlElem._lang[strKey]) return false;
            htmlElem._lang[strKey] = htmlElem._lang[strKey].slice(0, 1).concat(Array.isArray(arrParams) ? arrParams : []);
            lang_set(htmlElem, strKey, htmlElem._lang[strKey]);
            return true;
        }
    });
    return LANG;
})();


Иерархия


Во-первых, хочу отметить, что в своей реализации я выделил несколько типов словарей:
1) загружаемый:
Ключи такого словаря не могут быть изменены пользователем по отдельности: их можно загрузить или выгрузить только целиком. Такой словарь всегда имеет какой-либо приоритет относительно других загружаемых словарей. Именован.
2) пользовательский:
Встроенный словарь, приоритет которого всегда ниже приоритета любого из загружаемых словарей. Не может быть загружен или выгружен целиком, изменяется только по отдельным ключам. Его смысл в том, чтобы хранить значения, которые были указаны пользователем, отдельно от остальных. Если выгрузить все загружаемые словари, то это не затронет пользовательский словарь и его значения будут по-прежнему доступны.
3) системный:
По смыслу он полностью повторяет пользовательский, но обладает наивысшим приоритетом.


Рисунок 1 — Список загружаемых словарей (в квадратных скобках), а также пользовательский и системный словари, расставленные в порядке повышения приоритета.

Итак, при изменении пользовательского или системного словаря, а также при изменениях в списке загружаемых словарей происходит обновление хэша. Алгоритм следующий:
1) в объект хэша копируются ключи из объектов в последовательности, обратной указанной рисунке, т.е. в порядке понижения приоритета;
2) если такой ключ в хэше уже есть, то копирования не происходит.

Ключ\Словарь [User] Common System App1 App2 App3 [System] Хэш
OK OK OK OK
CANCEL Отмена Cancel Отмена Отмена
DONE Done Готово Готово
STRING String String

Таблица 1 — Переход ключей в хэш из более приоритетных словарей.

По умолчанию не загружено ни одного словаря; пользовательский и системный словари пустые. Кстати, обращение к несуществующему свойству словаря возвращает не пустую строку, а строку вида "#key", где за символом хэша следует имя ключа, к которому произведено обращение. Это сделано для того, чтобы на экране было сразу же видно, какие ключи не существуют.

Описание интерфейса


После выполнения исходного кода станет доступной глобальная переменная lang, значение которой — функция, связывающая атрибуты элемента с хэшем через ключ. В неё можно передать аргументы следующим образом:
lang(htmlTextNode,strKey);
lang(htmlTextNode,strKey,arrParams);
lang(htmlTextNode,objKeys);
lang(htmlElement,objKeys);
где:
htmlTextNode — текстовый узел, с которым происходит связывание;
strKey — ключ в словаре, по которому будет происходить обращение;
arrParams — параметры, подставляемые в перевод (об этом чуть позже);
objKeys — объект, содержащий в своих свойствах ключи, по которым должно происходить обращение, а в значениях — атрибут для связывания (в виде строки). В значении также можно указать параметры, подставляемые в перевод. Для этого значением должен быть массив, где первый элемент — атрибут для связывания, а остальные — значения параметров.
Переменная lang имеет собственные свойства-функции get, set, load, unload, params.

Использование get:
lang.get(strKey);
где:
strKey — ключ, значение которого нужно получить.
Возвращает строку перевода, связанную с переданным ключом.

Использование set:
lang.set(strKey, strValue,boolSystem);
где:
strKey — ключ, значение которого нужно установить;
strValue — значение для установки. Если оно пустое или не является строкой, то соответствующий ключ удаляется из словаря;
boolSystem — если этот параметр приводится к true, то запись происходит в системный словарь, иначе — в пользовательский.
Возвращает записанное значение или null, если ключ был удалён.

Использование load:
lang.load(strName,objData);
где:
strName — имя словаря (строка) или объект со множеством свойств, которые представляют собой имена словарей, а их значение — сами словари;
objData — если первый аргумент — строка, то этот аргумент является словарём.
Возвращает словарь(и), который(е) был(и) загружен(ы).

Использование unload:
lang.unload(strName);
где:
strName — имя (строка) или имена (массив) словарей, которые должны быть выгружены.
Вызращает выгруженный(ые) словарь(и).

Использование params:
lang. params(htmlElem,strKey,arrParams);
где:
htmlElem — элемент, который уже был связан со словарём;
strKey — ключ доступа к словарю;
arrParams — массив новых параметров.
Возвращает true, если новые параметры были установлены, или false в противном случае.
Пример в следующей главе должен привнести ясности в понимание интерфейса.

Взаимодействие с элементами


Для того чтобы можно было взаимодействовать со словарями, нужно привязать конкретный элемент или текстовый узел к хэшу через один из атрибутом этого элемента и ключ словаря. Этим атрибутом может быть, как innerHTML, так и title, alt и др. Если связывается текстовый узел, то связывание по умолчанию проходит через textContent. Ниже я опишу, почему.
Рассмотрим пример:
lang(document.body.appendChild(document.createElement("button")),{"just_a_text":"innerHTML" });
//в только что созданной кнопке привязываем innerHTML к ключу just_a_text
Кнопка связана через ключ, которого пока не существует (поэтому текст равен "#just_a_text"). Что касается технической реализации, то на элементе создаётся свойство _lang, свойствами которого являются ключи хэша, а значениями — массивы, где первыми элементами являются атрибуты этого элемента, в которые будут записаны значения хэша, а остальными — параметры, передаваемые в перевод.
Теперь есть 3 варианта записать ключ в словарь: пополнить список загружаемых словарей новым словарём либо присвоить значение в пользовательский или системный словарь. Рассмотрим все варианты в порядке возрастания приоритета (я бы посоветовал выполнять это построчно в консоли):
lang.set("just_a_text","Текст в кнопке"); //запись в пользовательский словарь
lang.load("def",{"just_a_text":"Текст из загружаемого словаря"});
lang.set("just_a_text"," Текст из системного словаря",1); //запись в системный словарь
Текст на кнопке будет с каждым шагом меняться на указанный в коде. Таким образом в данном случае используются все 3 вида словарей.
Теперь уберём ключи из словарей в обратной последовательности и убедимся, что заявленная иерархия действительно работает:
lang.set("just_a_text","",1); //удаление  из системного словаря
lang.unload("def");//выгрузка словаря с именем def
lang.set("just_a_text",""); // удаление  из пользовательского словаря
Теперь текст на кнопке снова станет «#just_a_text».

Параметры


Часто мне приходилось не просто подставлять текст из словаря, но и передавать в него некоторые параметры, поэтому возможность подстановки параметров также реализована. Их можно указать при связывании элемента с ключом, а можно и после связывания с помощью функции lang.params. Для указания позиции параметра внутри строки используется конструкция /%\d+/, где цифры обозначают номер передаваемого параметра. Если параметр не был передан, то подставляется пустая строка.
Особенно хорошо замена проявляет себя в связке с innerHTML:
lang(b=document.body.appendChild(document.createElement("button")),
	{"pr1":"innerHTML" });
lang.set("pr1","Значение 1: <b>%1</b>. Значение 2: <b>%2</b>. ");
lang.params(b,"pr1",[100,500]);
Теперь внутри кнопки переданные параметры будут выделены. Ссылка

Производительность


Для того чтобы распределить нагрузку (я уже писал об этом здесь), я использую интервальную, функцию, которая обрабатывает массив элементов, связанных со словарём.
Во-вторых, старайтесь как можно реже использовать innerHTML на элементах, вместо его используйте textContent на текстовых узлах этих элементов. innerHTML работает в 20+ раз медленнее, потому сеттер этого атрибута выполняет парсинг переданного значения как HTML кода. textContent не задумывается над парсингом HTML, а вставляет текст как есть (можно даже <,> на &lt;,&gt; не менять), но, к сожалению, это не всегда применимо, в частности, в прошлом примере.

Запуск под IE8


Немного поразмыслив, я понял, что эту штуку можно запустить и под восьмым IE. Для этого придётся прибегнуть к паре «грязных» хаков:
if (typeof Array.isArray != 'function') Array.isArray = function (value) {
    return Object.prototype.toString.call(value) === '[object Array]';
}
if (typeof Array.prototype.indexOf != 'function') Array.prototype.indexOf = function (value, offset) {
    offset = parseInt(offset);
    for (var i = offset > 0 ? offset : 0, l = this.length; i < l; i++)
    if (this[i] === value) return i;
    return -1;
};
//исправление невозможности присвоить новое свойство текстовому узлу для IE8
Text.prototype._lang = null;
//частично эмулируем функцию сравнения позиции, используя решение Джона Резига
if(typeof Element.prototype.compareDocumentPosition!="function")
	Text.prototype.compareDocumentPosition = Element.prototype.compareDocumentPosition = function compareDocumentPosition(node) {
		// Compare Position - MIT Licensed, John Resig
		function comparePosition(a, b) {
			return a.compareDocumentPosition ? a.compareDocumentPosition(b) : a.contains ? (a != b && a.contains(b) && 16) + (a != b && b.contains(a) && 8) + (a.sourceIndex >= 0 && b.sourceIndex >= 0 ? (a.sourceIndex < b.sourceIndex && 4) + (a.sourceIndex > b.sourceIndex && 2) : 1) + 0 : 0;
		}
		return comparePosition(this,node);
	};

Хотя на самом деле я считаю, что линейку IE до восьмой версии включительно нужно давно похоронить. Internets are belong to us.

Заключение


Проектируя эту штуку, я старался максимально её оптимизировать, например, свёл к минимуму изменения текста внутри элемента. Если значение по ключу не менялось, то оно не обновляется. В итоге получилось, что изменение текста внутри 10000 кнопок занимает (со включенным профилированием, через textContent) примерно секунду.

Есть небольшой минус: если «выдернуть» элемент из DOM, то данные на нём могут больше не обновляться. Для решения проблемы придётся повторно связать его с хэшем.

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

Реализация, наверняка, не самая удачная. В комментариях я жду оценок и советов по улучшению. И, пожалуйста, аргументируйте поставленные минусы. Ах, да, лицензия MIT.

UPD. Пример можно глянуть тут. Просто откройте страницу при включенном интернете.

UPD2. Отдельная благодарность lahmatiy за наставления на путь истинный и за указание на недостатки.
Tags:
Hubs:
+29
Comments 27
Comments Comments 27

Articles