Dynamic translation of a page into another language

    Hi, Habr.

    Today I will tell about my achievements in the field of instant page change - dynamic language change. I needed this thing quite recently, and since I do not trust third-party implementations (even somehow I did not find them), I had to write my own. During its use (somewhere around six months) I fixed all the most noticeable bugs (but this does not mean that they were no longer there :)), and now I present a working version.

    Someone will say that it is not advisable to make a transfer on the client, but I just got a situation that couldn’t be any different: for server-side translation, you have to force close web applications on the page so as not to lose data; in the case of a dynamic change of language, the texts on the elements are simply replaced and work continues. I think it wasn’t annoying me alone. "Settings will be applied upon reboot." Although my implementation is a bit complicated, it solves this problem.

    In order not to get confused, I will define the following list of terms for this article:
    Dictionary - a repository of keys used to access localization in a given language. In fact, it is a regular JavaScript object, where properties are access keys, and their values ​​are translated strings.
    A hash is an object that is the result of ordered merging of dictionaries; general dictionary from which translation is subsequently selected.

    Now in more detail.


    To speed up reading, I recommend skipping the chapters Source code and Description of the interface , as they give a description of the subject of the conversation from a software point of view. If desired, they will be available later if you are interested in the article.

    Source


    Immediately suggest the source code. Do not go into details yet, but we will return to it.
    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;
    })();
    


    Hierarchy


    Firstly, I want to note that in my implementation I have identified several types of dictionaries:
    1) downloadable : The
    keys of such a dictionary cannot be individually changed by the user: they can be downloaded or unloaded only in their entirety. Such a dictionary always takes precedence over other downloadable dictionaries. Named.
    2) custom :
    A built-in dictionary, the priority of which is always lower than the priority of any of the downloaded dictionaries. It cannot be loaded or unloaded in its entirety; it only changes on individual keys. Its meaning is to store the values ​​that were specified by the user, separately from the rest. Unloading all downloadable dictionaries will not affect the user dictionary and its values ​​will still be available.
    3) system :
    In terms of meaning, it completely repeats the user, but has the highest priority.


    Figure 1 - A list of downloaded dictionaries (in square brackets), as well as user and system dictionaries, arranged in order of increasing priority.

    So, when changing a user or system dictionary, as well as when changing the list of loaded dictionaries, the hash is updated. The algorithm is as follows:
    1) keys from the objects are copied to the hash object in the reverse order of the specified figure, i.e. in order of decreasing priority;
    2) if such a key already exists in the hash, then copying does not occur.

    Key \ Dictionary[User]CommonSystemApp1App2App3[System]Hash
    OkOkOkOk
    CANCELCancelCancelCancelCancel
    DONEDoneDoneDone
    STRINGStringString

    Table 1 - Transition of keys to a hash from higher priority dictionaries.

    By default, no dictionaries are loaded; user and system dictionaries are empty. By the way, a call to a nonexistent property of a dictionary returns not an empty string, but a string of the form "#key", where the hash symbol is followed by the name of the key that has been accessed. This is done so that the screen immediately shows which keys do not exist.

    Interface Description


    After executing the source code, the global variable lang will become available, the value of which is a function that associates the attributes of an element with a hash through a key. You can pass arguments to it as follows:
    lang(htmlTextNode,strKey);
    lang(htmlTextNode,strKey,arrParams);
    lang(htmlTextNode,objKeys);
    lang(htmlElement,objKeys);
    
    where:
    htmlTextNode - the text node with which the binding occurs;
    strKey - the key in the dictionary by which the call will occur;
    arrParams - parameters substituted into the translation (more on that later);
    objKeys - an object that contains in its properties the keys by which the call should occur, and in the values ​​- the attribute to bind (as a string). You can also specify the parameters to substitute in the translation in the value. For this, the value must be an array, where the first element is the attribute to bind, and the rest are parameter values.
    The lang variable has its own function properties get, set, load, unload, params .

    Using get :
    lang.get(strKey);
    
    where:
    strKey is the key whose value you want to get.
    Returns the translation string associated with the passed key.

    Using set :
    lang.set(strKey, strValue,boolSystem);
    
    where:
    strKey is the key whose value you want to set;
    strValue is the value to set. If it is empty or is not a string, then the corresponding key is deleted from the dictionary;
    boolSystem - if this parameter is converted to true , then the record occurs in the system dictionary, otherwise - in the user dictionary.
    Returns the recorded value or null if the key has been deleted.

    Using load :
    lang.load(strName,objData);
    
    where:
    strName - the name of the dictionary (string) or an object with many properties that are the names of dictionaries, and their meaning is the dictionaries themselves;
    objData - if the first argument is a string, then this argument is a dictionary.
    Returns the dictionary (s) that (s) were loaded (s).

    Using unload :
    lang.unload(strName);
    
    where:
    strName - name (string) or names (array) of dictionaries to be unloaded.
    Gets the downloaded dictionary (s).

    Using params :
    lang. params(htmlElem,strKey,arrParams);
    
    where:
    htmlElem - an element that has already been associated with the dictionary;
    strKey - dictionary access key;
    arrParams - an array of new parameters.
    Returns true if new parameters were set, or false otherwise.
    The example in the next chapter should bring clarity to the understanding of the interface.

    Interaction with elements


    In order to be able to interact with dictionaries, you need to bind a specific element or text node to the hash through one of the attribute of this element and the dictionary key. This attribute can be either innerHTML , or title, alt , etc. If a text node is bound, then the default binding passes through textContent . Below I will describe why.
    Consider an example:
    lang(document.body.appendChild(document.createElement("button")),{"just_a_text":"innerHTML" });
    //в только что созданной кнопке привязываем innerHTML к ключу just_a_text
    
    The button is connected through a key that does not yet exist (therefore, the text is "#just_a_text"). As for the technical implementation, the _lang property is created on the element , the properties of which are hash keys, and the values ​​are arrays, where the first elements are the attributes of this element, in which the hash values ​​will be written, and the rest are the parameters transferred to the translation.
    Now there are 3 options to write the key into the dictionary: add a new dictionary to the list of downloaded dictionaries or assign a value to the user or system dictionary. Consider all the options in order of increasing priority (I would advise doing this line by line in the console):
    lang.set("just_a_text","Текст в кнопке"); //запись в пользовательский словарь
    lang.load("def",{"just_a_text":"Текст из загружаемого словаря"});
    lang.set("just_a_text"," Текст из системного словаря",1); //запись в системный словарь
    
    The text on the button will change with each step specified in the code. Thus, in this case, all 3 types of dictionaries are used.
    Now we remove the keys from the dictionaries in the reverse order and make sure that the declared hierarchy really works:
    lang.set("just_a_text","",1); //удаление  из системного словаря
    lang.unload("def");//выгрузка словаря с именем def
    lang.set("just_a_text",""); // удаление  из пользовательского словаря
    
    Now the text on the button will again become “#just_a_text”.

    Parameters


    Often I had to not only substitute text from the dictionary, but also pass some parameters into it, so the possibility of parameter substitution is also implemented. They can be specified when associating an element with a key, or it can also be specified after binding using the lang.params function. To indicate the position of the parameter inside the string, the construction /% \ d + / is used , where the numbers indicate the number of the parameter to be transmitted. If the parameter was not passed, then an empty string is substituted.
    Particularly well, the replacement manifests itself in conjunction with innerHTML :
    lang(b=document.body.appendChild(document.createElement("button")),
    	{"pr1":"innerHTML" });
    lang.set("pr1","Значение 1: %1. Значение 2: %2. ");
    lang.params(b,"pr1",[100,500]);
    
    Now, inside the button, the transferred parameters will be highlighted. Link

    Performance


    In order to distribute the load (I already wrote about this here ), I use the interval function that processes an array of elements associated with the dictionary.
    Secondly, try to use innerHTML on elements as little as possible , use textContent on the text nodes of these elements instead . innerHTML is 20+ times slower, because the setter of this attribute parses the passed value as HTML code. textContent does not think about parsing HTML, but inserts the text as it is (you can even not change <,> to <,>), but, unfortunately, this is not always applicable, in particular, in the previous example.

    Running under IE8


    With a little thought, I realized that this thing can be run under the eighth IE. To do this, you have to resort to a couple of "dirty" hacks:
    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);
    	};
    

    Although, in fact, I believe that the IE line up to the eighth version inclusively needs to be buried for a long time. Internets are belong to us.

    Conclusion


    When designing this thing, I tried to optimize it as much as possible, for example, I minimized changes to the text inside the element. If the key value has not changed, then it is not updated. As a result, it turned out that changing the text inside the 10,000 buttons takes (with profiling enabled, through textContent ) about a second.

    There is a small minus: if you "pull" an element from the DOM, then the data on it may no longer be updated. To solve the problem, you will have to re-associate it with a hash.

    In principle, this implementation can be used not only to change the page language. Its main purpose is to change the specified attributes when changing the dictionary. Those. the scope is much wider than indicated in the title.

    The implementation is probably not the most successful. In the comments, I await ratings and improvement tips. And please, argue the cons. Oh yes, the MIT license.

    UPD An example can be found here . Just open the page with the Internet turned on.

    UPD2. Special thanks to lahmatiy for the instructions on the true path and for pointing out the shortcomings.

    Also popular now: