Durandal (knockout) app with multilanguage support

后端 未结 4 1185
自闭症患者
自闭症患者 2021-02-01 08:18

I am building multilingual support for the app I\'m working on. After doing some research and reading SO (internationalization best practice) I am trying to integrate that in a

4条回答
  •  闹比i
    闹比i (楼主)
    2021-02-01 08:52

    For those who are still confused about best practices, those who feel that something is lacking, or those who are simply curious about how to implement things in a better way with regard to Durandal, Knockout, RequireJS, and client-side web applications in general, here is an attempt at a more useful overview of what's possible.

    This is certainly not complete, but hopefully this can expand some minds a little bit.

    First, Nov 2014 update

    I see this answer keeps being upvoted regularly even a year later. I hesitated to update it multiple times as I further developed our particular solution (integrating i18next to Durandal/AMD/Knockout). However, we eventually dropped the dependent project because of internal difficulties and "concerns" regarding the future of Durandal and other parts of our stack. Hence, this little integration work was canceled as well.

    That being said, I hopefully distinguished generally applicable remarks from specific remarks below well enough, so I think they keep offering useful (perhaps even well needed) perspectives on the matters.

    If you're still looking to play with Durandal, Knockout, AMD and an arbitrary localization library (there are some new players to evaluate, by the way), I've added a couple of notes from my later experiences at the end.

    On the singleton pattern

    One problem with the singleton pattern here is that it's hard to configure per-view; indeed there are other parameters to the translations than their locale (counts for plural forms, context, variables, gender) and these may themselves be specific to certain contexts (e.g. views/view models).

    By the way it's important that you don't do this yourself and instead rely on a localization library/framework (it can get really complex). There are many questions on SO regarding these projects.

    You can still use a singleton, but either way you're only halfway there.

    On knockout binding handlers

    One solution, explored by zewa666 in another answer, is to create a KO binding handler. One could imagine this handler taking these parameters from the view, then using any localization library as backend. More often than not, you need to change these parameters programmatically in the viewmodel (or elsewhere), which means you still need to expose a JS API.

    If you're exposing such an API anyway, then you may use it to populate your view model and skip the binding handlers altogether. However, they're still a nice shortcut for those strings that can be configured from the view directly. Providing both methods is a good thing, but you probably can't do without the JS API.

    Current Javascript APIs, document reloading

    Most localization libraries and frameworks are pretty old-school, and many of them expect you to reload the entire page whenever the user changes the locale, sometimes even when translation parameters change, for various reasons. Don't do it, it goes against everything a client-side web application stands for. (SPA seems to be the cool term for it these days.)

    The main reason is that otherwise you would need to track each DOM element that you need to retranslate every time the locale changes, and which elements to retranslate every time any of their parameters change. This is very tedious to do manually.

    Fortunately, that's exactly what data binders like knockout make very easy to do. Indeed, the problem I just stated should remind you of what KO computed observables and KO data-bind attributes attempt to solve.

    On the RequireJS i18n plugin

    The plugin both uses the singleton pattern and expects you to reload the document. No-go for use with Durandal.

    You can, but it's not efficient, and you may or may not uselessly run into problems depending on how complex your application state is.

    Integration of knockout in localization libraries

    Ideally, localization libraries would support knockout observables so that whenever you pass them an observable string to translate with observable parameters, the library gives you an observable translation back. Intuitively, every time the locale, the string, or the parameters change, the library modifies the observable translation, and should they be bound to a view (or anything else), the view (or whatever else) is dynamically updated without requiring you to do anything explicitly.

    If your localization library is extensible enough, you may write a plugin for it, or ask the developers to implement this feature, or wait for more modern libraries to appear.

    I don't know of any right now, but my knowledge of the JS ecosystem is pretty limited. Please do contribute to this answer if you can.

    Real world solutions for today's software

    Most current APIs are pretty straightforward; take i18next for example. Its t (translate) method takes a key for the string and an object containing the parameters. With a tiny bit of cleverness, you can get away with it without extending it, using only glue code.

    translate module

    define(function (require) {
        var ko = require('knockout');
        var i18next = require('i18next');
        var locale = require('locale');
    
        return function (key, opts) {
            return ko.computed(function () {
                locale();
                var unwrapped = {};
                if (opts) {
                    for (var optName in opts) {
                        if (opts.hasOwnProperty(optName)) {
                            var opt = opts[optName];
                            unwrapped[optName] = ko.isObservable(opt) ? opt() : opt;
                        }
                    }
                }
                return i18next.t(key, unwrapped);
            });
        }
    });
    

    locale module

    define(function (require) { return require('knockout').observable('en'); });
    

    The translate module is a translation function that supports observable arguments and returns an observable (as per our requirements), and essentially wraps the i18next.t call.

    The locale module is an observable object containing the current locale used globally throughout the application. We define the default value (English) here, you may of course retrieve it from the browser API, local storage, cookies, the URI, or any other mechanism.

    i18next-specific note: AFAIK, the i18next.t API doesn't have the ability to take a specific locale per translation: it always uses the globally configured locale. Because of this, we must change this global setting by other means (see below) and place a dummy read to the locale observable in order to force knockout to add it as a dependency to the computed observable. Without it, the strings wouldn't be retranslated if we change the locale observable.

    It would be better to be able to explicitly define dependencies for knockout computed observables by other means, but I don't know that knockout currently provides such an API either; see the relevant documentation. I also tried using an explicit subscription mechanism, but that wasn't satisfactory since I don't think it's currently possible to trigger a computed to re-run explicitly without changing one of its dependencies. If you drop the computed and use only manual subscription, you end up rewriting knockout itself (try it!), so I prefer to compromise with a computed observable and a dummy read. However bizarre that looks, it might just be the most elegant solution here. Don't forget to warn about the dragons in a comment.

    The function is somewhat basic in that it only scans the first-level properties of the options object to determine if they are observable and if so unwraps them (no support for nested objects or arrays). Depending on the localization library you're using, it will make sense to unwrap certain options and not others. Hence, doing it properly would require you to mimic the underlying API in your wrapper.

    I'm including this as a side note only because I haven't tested it, but you may want to use the knockout mapping plugin and its toJS method to unwrap your object, which looks like it might be a one-liner.

    Here is how you can initialize i18next (most other libraries have a similar setup procedure), for example from your RequireJS data-main script (usually main.js) or your shell view model if you have one:

    var ko = require('knockout');
    var i18next = require('i18next');
    var locale = require('locale');
    
    i18next.init({
        lng: locale(),
        getAsync: false,
        resGetPath: 'app/locale/__ns__-__lng__.json',
    });
    
    locale.subscribe(function (value) {
        i18next.setLng(value, function () {});
    });
    

    This is where we change the global locale setting of the library when our locale observable changes. Usually, you'll bind the observable to a language selector; see the relevant documentation.

    i18next-specific note: If you want to load the resources asynchronously, you will run in a little bit of trouble due to the asynchronous aspect of Durandal applications; indeed I don't see an obvious way to wrap the rest of the view models setup code in a callback to init, as it's outside of our control. Hence, translations will be called before initialization is finished. You can fix this by manually tracking whether the library is initialized, for example by setting a variable in the init callback (argument omitted here). I tested this and it works fine. For simplicity here though, resources are loaded synchronously.

    i18next-specific note: The empty callback to setLng is an artifact from its old-school nature; the library expects you to always start retranslating strings after changing the language (most likely by scanning the DOM with jQuery) and hence the argument is required. In our case, everything is updated automatically, we don't have to do anything.

    Finally, here's an example of how to use the translate function:

    var _ = require('translate');
    
    var n_foo = ko.observable(42);
    var greeting = _('greeting');
    var foo = _('foo', { count: n_foo });
    

    You can expose these variables in your view models, they are simple knockout computed observables. Now, every time you change the locale or the parameters of a translation, the string will be retranslated. Since it's observable, all observers (e.g. your views) will be notified and updated.

    var locale = require('locale');
    
    locale('en_US');
    n_foo(1);
    ...
    

    No document reload necessary. No need to explicitly call the translate function anywhere. It just works.

    Integration of localization libraries in knockout

    You may attempt to make knockout plugins and extenders to add support for localization libraries (besides custom binding handlers), however I haven't explored the idea, so the value of this design is unknown to me. Again, feel free to contribute to this answer.

    On Ecmascript 5 accessors

    Since these accessors are carried with the objects properties everywhere they go, I suspect something like the knockout-es5 plugin or the Durandal observable plugin may be used to transparently pass observables to APIs that don't support knockout. However, you'd still need to wrap the call in a computed observable, so I'm not sure how much farther that gets us.

    Yet again, this is not something I looked at a lot, contributions welcome.

    On Knockout extenders

    You can potentially leverage KO extenders to augment normal observables to translate them on the fly. While this sounds good in theory, I don't think it would actually serve any kind of purpose; you would still need to track every option you pass to the extender, most likely by manually subscribing to each of them and updating the target by calling the wrapped translation function.

    If anything, that's merely an alternative syntax, not an alternative approach.

    Conclusion

    It feels like there is still a lot lacking, but with a 21-lines module I was able to add support for an arbitrary localization library to a standard Durandal application. For an initial time investment, I guess it could be worse. The most difficult part is figuring it out, and I hope I've done a decent job at accelerating that process for you.

    In fact, while doing it right may sound a little complicated (well, what I believe is the right way anyway), I'm pretty confident that techniques like these make things globally simpler, at least in comparison to all the trouble you'd get from trying to rebuild state consistently after a document reload or to manually tracking all translated strings without Knockout. Also, it is definitely more efficient (UX can't be smoother): only the strings that need to be retranslated are retranslated and only when necessary.

    Nov 2014 notes

    After writing this post, we merged the i18next initialization code and the code from the translate module in a single AMD module. This module had an interface that was intended to mimick the rest of the interface of the stock i18next AMD module (though we never got past the translate function), so that the "KO-ification" of the library would be transparent to the applications (except for the fact that it now recognized KO observables and took the locale observable singleton in its configuration, of course). We even managed to reuse the same "i18next" AMD module name with some require.js paths trickery.

    So, if you still want to do this integration work, you may rest assured that this is possible, and eventually it seemed like the most sensible solution to us. Keeping the locale observable in a singleton module also turned out to be a good decision.

    As for the translation function itself, unwrapping observables using the stock ko.toJS function was indeed far easier.

    i18next.js (Knockout integration wrapper)

    define(function (require) {
        'use strict';
    
        var ko = require('knockout');
        var i18next = require('i18next-actual');
        var locale = require('locale');
        var namespaces = require('tran-namespaces');
        var Mutex = require('komutex');
    
        var mutex = new Mutex();
    
        mutex.lock(function (unlock) {
            i18next.init({
                lng: locale(),
                getAsync: true,
                fallbackLng: 'en',
                resGetPath: 'app/locale/__lng__/__ns__.json',
                ns: {
                    namespaces: namespaces,
                    defaultNs: namespaces && namespaces[0],
                },
            }, unlock);
        });
    
        locale.subscribe(function (value) {
            mutex.lock(function (unlock) {
                i18next.setLng(value, unlock);
            });
        });
    
        var origFn = i18next.t;
        i18next.t = i18next.translate = function (key, opts) {
            return ko.computed(function () {
                return mutex.tryLockAuto(function () {
                    locale();
                    return origFn(key, opts && ko.toJS(opts));
                });
            });
        };
    
        return i18next;
    });
    

    require.js path trickery (OK, not that tricky)

    requirejs.config({
        paths: {
            'i18next-actual': 'path/to/real/i18next.amd-x.y.z',
            'i18next': 'path/to/wrapper/above',
        }
    });
    

    The locale module is the same singleton presented above, the tran-namespaces module is another singleton that contains the list of i18next namespaces. These singletons are extremely handy not only because they provide a very declarative way of configuring these things, but also because it allows the i18next wrapper (this module) to be entirely self-initialized. In other words, user modules that require it will never have to call init.

    Now, initialization takes time (might need to fetch some translation files), and as I already mentioned a year ago, we actually used the async interface (getAsync: true). This means that a user module that calls translate might in fact not get the translation directly (if it asks for a translation before initialization is finished, or when switching locales). Remember, in our implementation user modules can just start calling i18next.t immediately without waiting for a signal from the init callback explicitly; they don't have to call it, and thus we don't even provide a wrapper for this function in our module.

    How is this possible? Well, to keep track of all this, we use a "Mutex" object that merely holds a boolean observable. Whenever that mutex is "locked", it means we're initializing or changing locales, and translations shouldn't go through. The state of that mutex is automatically tracked in the translate function by the KO computed observable function that represents the (future) translation and will thus be re-executed automatically (thanks to the magic of KO) when it changes to "unlocked", whereupon the real translate function can retry and do its work.

    It's probably more difficult to explain than it is to actually understand (as you can see, the code above is not overly long), feel free to ask for clarifications.

    Usage is very easy though; just var i18next = require('i18next') in any module of your application, then call i18next.t away at any time. Just like the initial translate function you may pass observable as arguments (which has the effect of retranslating that particular string automatically every time such an argument is changed) and it will return an observable string. In fact, the function doesn't use this, so you may safely assign it to a convenient variable: var _ = i18next.t.

    By now you might be looking up komutex on your favorite search engine. Well, unless somebody had the same idea, you won't find anything, and I don't intend to publish that code as it is (I couldn't do that without losing all my credibility ;)). The explanation above should contain all you need to know to implement the same kind of thing without this module, though it clutters the code with concerns I'm personally inclined to extract in dedicated components as I did here. Toward the end, we weren't even 100% sure that the mutex abstraction was the right one, so even though it might look neat and simple, I advise that you put some thoughts into how to extract that code (or simply on whether to extract it or not).

    More generally, I'd also advise you to seek other accounts of such integration work, as its unclear whether these ideas will age well (a year later, I still believe this "reactive" approach to localization/translation is absolutely the right one, but that's just me). Maybe you'll even find more modern libraries that do what you need them to do out of the box.

    In any case, it's highly unlikely that I'll revisit this post again. Again, I hope this little(!) update is as useful as the initial post seems to be.

    Have fun!

提交回复
热议问题