Singleton Pattern and Abstraction in JS

2019-08-07 16:41发布

问题:

Although the example below exploits ExtJS, one can easily extrapolate to another framework. I am a fan of abstraction and data hiding (and OO in general); does anyone else hide data and members/functions or do you consider this attempt to be overkill?

(Note: I believe strongly that DOM IDs should almost never be hardcoded. And, though I use prototypes for public methods of typical classes, you will notice a public function below created outside of the prototype.)

This note is interesting http://yuiblog.com/blog/2007/06/12/module-pattern/

Ext.ns('Foo.Bar');

/**
 * Foo.Bar.MainToolbar (singleton)
 */
Foo.Bar.MainToolbar = (function()
{
  // Temporary, private class used to create and return an object - a singleton
  var toolbarClass = Ext.extend( Ext.Container,
  {
    /**
     * constructor (public)
     */
    constructor: function( config )
    {
      config = config || {};

      // PRIVATE MEMBER DATA ========================================

      // Need IDs for animation anchors
      var accountId = Ext.id( null, 'ls-accountDiv-');
      var faqId = Ext.id( null, 'ls-faqDiv-');
      var logoutId = Ext.id( null, 'ls-logoutDiv-');
      var loginId = Ext.id( null, 'ls-loginDiv-');
      var rulesId = Ext.id( null, 'ls-rulesDiv-');

      var itemCls =
        'color: white; cursor: pointer; font-weight: bold; ' +
        'font-family:Helvetica,Arial,Verdana,Lucida Sans Unicode,Sans-serif;';


      // PUBLIC METHODS *********************************************

      /**
       * userLogin (public) -
       */
      this.userLogin = function( userName, password )
      {
        // Update title bar
        Ext.fly(accountId).update( userName );
        Ext.fly(loginId).hide(true);
        Ext.fly(logoutId).show(true);
      };

      // PRIVATE METHODS ********************************************

      /**
       * handleFaqClick (private) - handler for click on FAQ
       */
      var handleFaqClick = function( event )
      {
        var dialogMsg = '<div style="text-align: leftblah, blah</div>';

        Ext.Msg.show({
          title: 'FAQ',
          modal: true,
          msg: dialogMsg,
          animEl: faqId,
          buttons: Ext.Msg.OK,
          icon: Ext.MessageBox.QUESTION,
          minWidth: '700'
        });
      };

      /**
       * handleLogoutClick (private) - handler for click on logout
       */
      var handleLogoutClick = function( event )
      {
        Ext.fly(accountId).update('');
        Ext.fly(logoutId).hide(true);
        Ext.fly(loginId).show(true);
      };

      /**
       * handleRulesClick (private) - handler for click on RULES
       */
      var handleRulesClick = function( event )
      {
        var dialogMsg = 
          '<div style="text-align: left;"><br/><b>blah, blah</div>';

        Ext.Msg.show({
          title: 'Rules',
          modal: true,
          msg: dialogMsg,
          animEl: rulesId,
          buttons: Ext.Msg.OK,
          icon: Ext.MessageBox.INFO,
          minWidth: '700'
        });
      };


      // CONSTRUCTOR  ===============================================

      // Some parameters (possibly) offered by the user are ignored
      config.id = Ext.id( null, 'ls-mainToolbar-');
      config.layout = 'absolute';
      config.layoutConfig = {};
      config.height = 38;
      config.width = 968;

      config.items = [
      {
        id: Ext.id( null, 'ls-mainToolbar-'),
        xtype: 'box', x: 25, y: 0, height: 36, 
        autoEl: { tag: 'img', src: './images/top_toolbar.png' }

      },{
        id: Ext.id( null, 'ls-logo-'),
        xtype: 'box',
        x: 70, y: 8, height: 22, width: 200,
        autoEl: { style: itemCls, html: 'Foo Bar' }
      },{
        id: accountId,
        xtype: 'box',
        x: 470, y: 8, height: 22, width: 200,
        autoEl: { style: itemCls + ' text-align: right;', html: ' ' }
      },{
        id: logoutId,
        xtype: 'box', x: 730, y: 8, height: 22, width: 36,
        autoEl: {style: itemCls + ' visibility: hidden;', html: 'logout'},
        listeners:
          { render:
            function( cmp ){
              cmp.getEl().addListener('click', 
                handleLogoutClick.createDelegate(this))
            }.createDelegate(this)
          }
      },{
        id: loginId,
        xtype: 'box', x: 730, y: 8, height: 22, width: 36,
        autoEl: { style: itemCls, html: 'login' },
        listeners:
          { render:
            function( cmp ){
              cmp.getEl().addListener('click',
                Foo.Bar.LoginDialog.show.createDelegate(
                  Foo.Bar.LoginDialog, [Ext.emptyFn]))
            }
          }
      },{
        id: rulesId,
        xtype: 'box', x: 800, y: 8, height: 22, width: 36,
        autoEl: { style: itemCls, html: 'rules'},
        listeners:
          { render:
            function( cmp ){
              cmp.getEl().addListener( 'click', 
                handleRulesClick.createDelegate(this) )
            }.createDelegate(this)
          }
      },{
        id: faqId,
        xtype: 'box', x: 860, y: 8, height: 22, width: 26,
        autoEl: { style: itemCls, html: 'faq'},
        listeners:
          { render:
            function( cmp ){
              cmp.getEl().addListener( 'click', 
                handleFaqClick.createDelegate(this) )
            }.createDelegate(this)
          }
      }];

      toolbarClass.superclass.constructor.apply( this, [config] );

      Foo.Bar.LoginDialog.addListener(
        Foo.Bar.LoginDialog.LOGIN_SUCCESSFUL_EVENT(), 
          this.userLogin.createDelegate(this));
    }
  });

  return new toolbarClass();
})();

回答1:

Taking the care to hide data in JavaScript is usually an overkill, but it might be also a very good idea especially when you're creating a library and want consumers to use the library's public API and not mess around with the internals (lots of imaginative people like that).

In JavaScript the pattern for hiding data/methods is usually creating a closure where you have all your private stuff and let your public API methods with access to that closure.

Simplistic example:

var API = (function() {
    // internal stuff goes in here
    // ...
    // some public methods i'll expose later
    // all have access to the internals since they're inside the closure
    function method1() { ... }
    function method2() { ... }
    var somevar;
    return {
        public_method1: method1,
        public_method2: method2,
        public_var: somevar,
    };
})();

// use the API:
API.public_method1();


回答2:

I'll post a fragment of a large project of mine in the hope that it will be useful to you. I am not a professional so you may find things that are silly or badly done. The framework I'm using is Prototype.

Lots of code is missing, I hope you can understand the structure though.

CORE.ELEMENT.BaseInterface is a mixin.

Let me know if you have questions.

function getRandomNumber() {
    return 4; // Chosen by fair dice roll. Guaranteed to be random. Thanks to xkcd.com for this function.
}

/*  -------------------------------
    Core
    -------------------------------
*/

var CORE = function () {
    var mix = function () {
        /* Merge the properties of all the objects given as arguments into the first object, making sure only the first object is modified. Of all the properties with the same name belonging to different objects, the one belonging to the rightmost object wins; that is, precedence goes right to left. */
        var args = $A(arguments);
        if (!args[0]) {
            args[0] = {}; // probably got an empty prototype or something.
        }
        for (var l = args.length, i = 1; i < l; ++i) {
            Object.extend(args[0], args[i]);
        }
        return args[0];
    }

    return {
        mix: mix
    }
}();


var INTERFACE = (function(){

    Notifier = function () {
        CORE.mix(this, {
            max: 5, // max number of notifications shown
            timeout: 8 // a notification disappears after this number of seconds
        }, arguments[0] || {});
        this.elm = ELM.DIV({ // ELM.DIV is too long to explain, it's some trickery I got partly from script.aculo.us - the idea at least.
            attributes:{
                id:'notifier',
                style: 'display: none;'
            }
        })
    };

    CORE.mix(Notifier.prototype, CORE.ELEMENT.BaseInterface, {
        notify: function (msg) {
            if (this.elm) {
                var notes = this.elm.getElementsBySelector('.notification');
                while (notes.length >= this.max) {
                    notes.last().remove();
                }
                this.elm.insert({top: '<div class="notification" style="display: none;">' + msg + '</div>'});
                if (!this.elm.visible()) {
                    this.elm.setStyle('opacity: 0; display: block;');
                    this.elm.morph('opacity: 1;', {
                        duration: 1
                    });
                }
                var newNote = this.elm.down('div');
                newNote.setStyle('opacity: 0; display: block;');
                newNote.morph('opacity: 1;', {duration: 1});
                this.removeNotification.bind(this).delay(this.timeout, newNote);
            }
        },
        removeNotification: function (note) {
            note.remove();
            var notes = this.elm.getElementsBySelector('.notification');
            if (notes.length === 0) {
                this.elm.hide();
            }
        }
    });

    return {
        Notifier: new Notifier() //singleton
    };

})();

/*global Ajax, INTERFACE, CONFIG, CORE, Template $ */

var CONTENT = function () {

    var wallpapers = [];

    wallpapers.toJSON = function () { // needed if I want to send a list of wallpapers back to the server
        var tmp = [];
        this.each(function (wp) {
            if (wp.elm.visible()) {
                tmp.push(wp.toJSON());
            }
        });
        return '[' + tmp.join(', ') + ']';
    };

    var newWallpapers = []; // just a buffer

    Wallpaper = function () {
        CORE.mix(this, {
            thumbUrl: '',
            view: '',
            width: 0,
            height: 0,
            source: ''
        }, arguments[0] || {});
        this.aspect = this.width / this.height;
        switch (this.aspect) {
        case 1.6:
            this.aspect = 2;
            break;
        case 16 / 9:
            this.aspect = 2;
            break;
        case 5 / 4:
            this.aspect = 1;
            break;
        case 4 / 3:
            this.aspect = 1;
            break;
        default:
            if (this.width > 2500) {
                this.aspect = 3;
            } else {
                this.aspect = 0;
            }
        }
        this.dimension = this.width < 1280 ? 0 : (this.width < 1680 ? 1 : (this.width < 1920 ? 2 : 3 ));
        this.hr_aspect = CONFIG.labels.aspect[this.aspect];
        this.hr_source = CONFIG.labels.source[this.source].capitalize();
        this.html = '<div class="source">' + this.hr_source + '</div><a class="thumb" target="_BLANK" href="'+ this.view + '"><img class="thumb" src="' + this.thumbUrl + '" /></a><div class="info"><div class="resolution">' + this.width + 'x' + this.height + '</div><div class="aspect">' + this.hr_aspect + '</div></div>';
    };

    CORE.mix(Wallpaper.prototype, CORE.ELEMENT.BaseInterface, {
        fxParms: null,
        getElement: function () {
            this.elm = document.createElement('div');
            this.elm.className="wallpaper";
            this.elm.innerHTML = this.html;
            return this.elm;
        },
        postInsert: function () {
            if (this.thumbHeight) {
                var x = this.thumbHeight * 200 / this.thumbWidth;
                this.elm.down('img.thumb').setStyle('margin: ' + ((200 - x) / 2) + 'px 0 0;');
            }
            delete this.html;
        },
        toJSON: function () {
            return Object.toJSON({
                thumbUrl: this.thumbUrl,
                view: this.view,
                width: this.width,
                height: this.height,
                source: this.hr_source,
                aspect: this.hr_aspect
            });
        }
    });

    return {
        wallpapers: wallpapers, // wallpapers being shown
        newWallpapers: newWallpapers, // incoming wallpapers
        Wallpaper: Wallpaper // constructor
    };

}();

That's how I make namespaces at the moment. If I don't return something int he last "return" statement, it either survives thanks to a closure or it gets eaten by the garbage collector. Looks like a mess if you're not used to it, I guess. Well, let me know if you find anything in there that is worth asking about.


Just in case it's not evident (it probably isn't); in the return statement at the bottom of a namespace it works like this:

  • If it is capitalized (e.g. Wallpapers: Wallpapers) it's always a constructor
  • If it's a "new ..." statement, it's always a singleton
  • If it's not capitalized (e.g. newWallpapers: newWallpapers) it's either a simple function meant to be called without "new" or a simple object


回答3:

If you are writing application code for your own internal use, hiding members in JS isn't all that helpful. The only purpose is to prevent someone from accessing whatever you are hiding, and the only time when that would provide benefit is when you're specifically writing code for use by others and you want to (strongly) enforce an API. Note that even in the case of Ext JS, if you look at most of the classes, the API's are more often than not enforced by convention rather than by closures (private things are merely labeled as private), so that you can override and extend things when needed. There is private stuff when it really should not be messed with, but that's the exception. That's what makes it such a powerful framework -- almost everything is extendable. So it really depends on how rigid you want your end code to be.