Durandal / Knockout - global observable

2019-08-14 22:03发布

Context

In my shell.html I have put a language selection into the header section. It looks like this:

    <select class="form-control select2"
      data-bind="options:languages,
      optionsText:'label',
      optionsValue:'code',
      value:selectedLanguage">
    </select>

My shell.js:

define(['plugins/router', 'durandal/app', 'knockout'], function (router, app, ko) {
    return {
        router: router,
        selectedLanguage: ko.observable(),
        languages: [
            {code: 'cs', label: 'čeština'},
            {code: 'sk', label: 'slovenský'},
            {code: 'en', label: 'english'},
            {code: 'de', label: 'deutsch'},
            {code: 'ru', label: 'русский'}
        ],
        activate: function () {
            return router.map([
               { route: ['', 'dashboard'],      moduleId: 'core/dashboard/pages/index',     title: 'Dashboard',         icon: 'fa fa-dashboard fa-fw',      nav: true },
               { route: 'attributes*details',   moduleId: 'core/attribute/pages/index',     title: 'Attribute',     icon: 'fa fa-puzzle-piece fa-fw',   nav: true, hash: '#/attributes' }
           ]).buildNavigationModel()
             .mapUnknownRoutes('core/dashboard/pages/index', 'not-found')
             .activate();
        }
    };
});

When I load other views, this always stays in the header and does not change.

Task

Whenever the user changes the language, I want to be able to register/notice this on any sub-page and then react accordingly - like showing the text in another language.

Question

I suppose this is more of a conceptual question. If I change the language, then I am sure that my observable in the shell.js will pick this up. What I do not understand is how to achieve this for my sub-views. Do I need to register some global-subscriber?

It is obviously not enough to just react in the shell.js because that view-model does not know which content needs to be loaded. Only the actual sub-views / modules know this. But in order to react, they need to know that the language has changed.

It would be great if someone could help me on this and give me a hint on how to best deal with these type of features in a durandal/knockout context.

[EDIT] Current Solution

I have tried a few things out now and this is what I have come up with:

main.js: creates an AppViewModel and adds it to the global myapp Object (that all pages require).

  define([
          'durandal/system', 
          'durandal/app', 
          'durandal/viewLocator', 
          'plugins/router', 
          'myapp/myapp',
          'myapp/appViewModel'
          ],
    function (
        system, 
        app, 
        viewLocator, 
        router, 
        myapp,
        AppViewModel
    ) {
      system.debug(true);

      // Global view-model which will be required by sub-pages.
      myapp.app = new AppViewModel({
        defaultLanguage: 'cs'
      });

      app.title = 'Control Panel';

      app.configurePlugins({
          router:true,
          dialog: true,
          widget: true
      });

      app.start().then(function() {
          viewLocator.useConvention();
          app.setRoot('shell', 'entrance');
      });
  });

AppViewModel: handles the language events

  //------------------------------------------------------------------
  // Global AppViewModel which holds the current language, 
  // listens for changes and notifies the current sub-page 
  // if is has registered a listener-function.
  //------------------------------------------------------------------
  define([ 'jquery', 'underscore', 'knockout' ], function($, _, ko) {

    "use strict";

    function AppViewModel(options) {

        if (!(this instanceof AppViewModel)) {
            throw new TypeError("AppViewModel constructor cannot be called as a function.");
        }

        this.options = options || {};   

        // Set the initial language.
        this.selectedLanguage = ko.observable(options.defaultLanguage);

        _.bindAll(this, 'languageChanged', 'onLanguageChange');
    }

    AppViewModel.prototype = {
        constructor: AppViewModel,
        //---------------------------------------------------------------
        // Calls listener when language changes. See shell.
        //---------------------------------------------------------------
        languageChanged: function(data, event) {
            if(!_.isNull(this.onLanguageChange)
                && !_.isUndefined(this.onLanguageChange) 
                && _.isFunction(this.onLanguageChange)) {
                this.onLanguageChange(data, event);
            }
        },
        //---------------------------------------------------------------
        // Listener that can be overridden by view-models of sub-pages.
        //---------------------------------------------------------------
        onLanguageChange: function(data, event) {
            // Override by ViewModel.
        },
        //---------------------------------------------------------------
        // Clear() should be called in deactivate callback of the 
        // view-model to stop being notified when the language changes.
        //---------------------------------------------------------------
        clear: function() {
            // Reset to empty function.
            this.onLanguageChange = function(data, event) {}
        }
    }

    return AppViewModel;    
  });

shell.js:

    define(['plugins/router', 'durandal/app', 'knockout', 'underscore', 'myapp/myapp'], function (router, app, ko, _, myapp) {

      return {
          router: router,
          languages: [
              {code: 'cs', label: 'čeština'},
              {code: 'sk', label: 'slovenský'},
              {code: 'en', label: 'english'},
              {code: 'de', label: 'deutsch'},
              {code: 'ru', label: 'русский'}
          ],
          app: myapp.app, // make app available to shell.html for the event handler. 
          activate: function () {
              return router.map([
               { route: ['', 'dashboard'],      moduleId: 'core/dashboard/pages/index',     title: 'Dashboard',         icon: 'fa fa-dashboard fa-fw',      nav: true },
               { route: 'attributes*details',   moduleId: 'core/attribute/pages/index',     title: 'Attribute',     icon: 'fa fa-puzzle-piece fa-fw',   nav: true, hash: '#/attributes' }
           ]).buildNavigationModel()
             .mapUnknownRoutes('core/dashboard/pages/index', 'not-found')
             .activate();
          }
    };
  });

shell.html: languageChanged() is now being called on the global AppViewModel

  <select class="form-control select2"
    data-bind="event: { change: app.languageChanged }, options:languages,
    optionsText:'label',
    optionsValue:'code',
    value:app.selectedLanguage"></select>       

And finally, one of the page view-models (which is now notified when the selecbox in the shell.html changes)

  define(['jquery', 'knockout', 'myapp/myapp'], function ($, ko, myapp) {

      return {
        activate: function(data) {

                myapp.app.onLanguageChange = function(data, event) {
                    // Handle language stuff ...
                }
        },
            deactivate : function() {
          // Stop listening for language changes
                myapp.app.clear();
            }
      }
  });

Hmm ... I wonder whether anyone reads enough to get to this line. If you do, thanks for your time.

So, is this a feasable way of doing it or is there a more "best practise" durandal way?

[EDIT] Improved solution with durandal publish/subscribe mechanism:

AppViewModel: now just sends message - no need for listener logic.

AppViewModel.prototype = {
    constructor : AppViewModel,
    // ---------------------------------------------------------------
    // Trigger message when language has changed.
    // ---------------------------------------------------------------
    languageChanged : function(data, event) {
        app.trigger('language:change', data);
    }
}

index.js: now just receives message - no need to register listener on the AppViewModel.

  define(['durandal/app', 'jquery', 'knockout'], function (app, $, ko) {

      return {
        _langSubscriber: {},
        activate: function(data) {
            _langSubscriber = app.on('language:change').then(function(language) {
                // Handle language stuff ...
            });
        },
        deactivate : function() {
            _langSubscriber.off();
        }
      }
  });

As Eric Taylor pointed out, it is important to unsubscribe again. If you don't you will end up with A LOT of subscriptions and most likely a memory leak.

Best regards, Michael

1条回答
Summer. ? 凉城
2楼-- · 2019-08-14 22:50

What we did was use a singleton config module. If you're not familiar with singleton versus instance modules in Durandal, then you should know this: If your module returns an object literal, it will be singleton and will remain in memory for the duration of your application's session.

If your module returns a constructor function, then Durandal will use that constructor function to instantiate an instance of the module each time it is visited or composed, and will release the module when it is no longer active.

Consider the following bare-bones config module:

define('config', ['durandal/app', 'knockout'],
    function (app, ko) {

        var currentUser = ko.observable('tester'),
            languages = ko.observableArray([
                {code: 'cs', label: 'čeština'},
                {code: 'sk', label: 'slovenský'},
                {code: 'en', label: 'english'},
                {code: 'de', label: 'deutsch'},
                {code: 'ru', label: 'русский'}
            ]);
    }

    return {
        currentUser: currentUser,
        languages: languages
    };
);

Everywhere throughout your modules that you need access to this information, you simply require the module. Also, make sure that any properties on your config module that need to update the UI, or respond to updates in the UI, are observables. So, for example, I noticed that your languages property is not an observableArray. It probably needs to be if the UI has to respond to updates in this property.

Use of the config Module

define('someViewModel', ['durandal/app', 'knockout', 'config'],
    function (app, ko, config) {

        var languages = config.languages();
    }
);

Note that I had to use the parentheses on config.languages() to de-reference the observable array.

If your local use of config.languages() also needs to be observable, then you would have:

var languages = ko.observableArray(config.languages());

Instead of the above, you could simply pass the config module right back out in your revealing object literal:

return {
    config: config
};

and then use that module directly in your view. But I don't know if I like this approach. My preference is to localize injected modules if I intend to use them in a view.

ANOTHER APPROACH

You could also use Durandal's pub/sub to send messages between viewmodels in response to changes in the languages, etc., rather than use the direct-connect of observables.

Either approach is valid; it just depends on your needs.

RESOURCES

Take a look at John Papa's course, Single Page Apps with HTML5, Web API, Knockout and jQuery, on Pluralsight. Then, take a look at the Durandal version of John's course: Single Page Apps JumpStart. You will learn a great deal about exactly what you're doing.

查看更多
登录 后发表回答