Skip to content

Instantly share code, notes, and snippets.

@MarcDiethelm
Last active May 23, 2016 08:08
Show Gist options
  • Star 1 You must be signed in to star a gist
  • Fork 0 You must be signed in to fork a gist
  • Save MarcDiethelm/7092718 to your computer and use it in GitHub Desktop.
Save MarcDiethelm/7092718 to your computer and use it in GitHub Desktop.
Ein sauberes Pattern für Terrific JS Module

Ein sauberes Pattern für Terrific JS Module

Terrific.js ist ein grossartiges Tool um Frontend Code zu strukturieren und füllt damit eine der grossen Lücken die jQuery offen lässt. Weil Terrific auf jQuery basiert und aufbaut übernimmt es auch eine der Eigenschaften von jQuery. Eine Eigenschaft, die robuste und lesbare objektorientierte Programmierung erschwert.

Das Problem

jQuery verwendet die Konvention, dass in den meisten Callbacks, Event Handlers zum Beispiel, der Kontext this ein DOM-Objekt ist. Das führt zur unangenehmen Situation dass in einem Terrific Modul die Bedeutung von this ständig wechseln kann:

on: function(callback) {
	this.$ctx.find('.stuff').on('click', function() {
		this.doStuff() // Arghh! this has no method doStuff...
	});
},

doStuff: function() {}

Innerhalb eines Moduls sollte der Kontext this aber praktisch immer das Modul selber sein.

Warum? Es sollte immer möglich sein innerhalb eines Moduls Properties und Methoden des Moduls mit this anzusprechen. Weil in unserem Code ständig Situationen auftauchen wo this die Bedeutung wechselt greifen wir zu Variabeln wie that, self oder _this und umgehen so mit Hilfe von Closures das Problem, dass das Modul nicht mehr direkt verfügbar ist. Diese Hilfsvariablen sind aber nur Krücken und machen den Code schwerer lesbar:

on: function(callback) {
	var that = this;
	this.$ctx.on('click', function() {
		that.doStuff() // Closure, works...
	});
},

doStuff: function() {
	// this == ??
}

Als Entwickler muss ich immer verfolgen was in einem Codeblock der Kontext ist und immer wieder neue Hilfsvariablen definieren um den Zugang zum Modul nicht zu verlieren.

Ein kleiner Abstecher in die JavaScript Prototypes

Man kann jedes instanzierbare Objekt mit folgendem Pattern erstellen. Es gibt andere Möglichkeiten aber diese kommt Terrific ziemlich nahe. (In Wirklichkeit ist es komplizierter, weil Terrific mit einer Klassen-Vererbungs-Emulation von John Resig arbeitet.)

// Constructor
var MyObject = function() {};

// Prototype
MyObject.prototype = {
	property: 'foo',
	method: function() {
		return this.property;
	},
	// Bsp. aus Terrific:
	on: function(callback) {
		callback();
	}
};

var myObject = new MyObject();		
myObject.method(); // ==> foo

Wenn wir ein Terrific JS Modul entwickeln dann arbeiten wir in einem sogenannten Prototype eines instanzierbaren Objekts. Bei app.registerModule() macht Terrific im Hintergrund new Tc.Module[modName]. Wir erhalten dabei eine Instanz des Constructors Tc.Module mit unserem prototype, der (fast) alle Properties und Methoden der Instanz definiert.

In OOP erwarten wir eigentlich, dass in einem instanzierten Objekt this sich immer auf das Objekt bezieht. jQuery macht uns hier einen Strich durch die Rechnung – this kann plötzlich ein DOM Element sein. jQuery bietet aber auch eine Lösung für dieses Missgeschick.

Function.bind und jQuery.proxy

Um saubere Module zu schreiben möchten wir also alle Methoden "an das Modul binden".

JavaScript bietet verschiedene Möglichkeiten um den Kontext einer Funktion oder Methode zu verändern: myFunction.call und myFunction.apply wenn eine Funktion aufgerufen wird und seit JavaScript 1.8.5 myFunction.bind um den Kontext dauerhaft zu setzen. bind wäre für unser Problem sehr nützlich ist aber leider nur in neueren Browsern verfügbar.

jQuery stellt uns zum Glück diese Funktionalität in Form jQuery.proxy zur Verfügung!

myFunction = $.proxy(myFunction, myContext);

In myFunction ist jetzt folgendes immer wahr: this === myContext.

Das Pattern

Nun können wir jQuery Code in Terrific Modulen aufräumen.

Tc.Module.MyModule = Tc.Module.extend({

	init: function($ctx, sandbox, modId) {
		// call base constructor
		this._super($ctx, sandbox, modId);
		
		// overwrite event handlers with versions bound to module context
		this.onClick = $.proxy(this, onClick);
		this.onMyGlobalEvent = $.proxy(this, onMyGlobalEvent);
		
		this.$articles = this.$ctx.find('.js-article');
	},
	
	openArticlesCount: 0,

	on: function(callback) {
		$(document).on('myGlobalEvent', this.onMyGlovalEvent);
		this.$articles.on('click', this.onClickArticle);
		
		// show first article initially
		this.doStuff( this.$articles.eq(0) );
		
		callback();			
	},
		
	// Event Handlers
	////////////////////////
	
	onClickArticle: function(ev) {
		var $target = $(ev.target);
		ev.preventDefault();
		$target.is(':hidden') ?
			this.showArticle($target) :
			this.closeArticle($target)
		;
	},
	
	onMyGlobalEvent: function(ev, data) {
		this.openArticlesCount && this.closeArticle(this.$articles);
		this.showArticle( this.$articles.eq(data.index) );
	},
	
	// Methods
	////////////////////////
	
	showArticle: function($article) {
		$article.slideDown();
		this.openArticlesCount++;
	},
	
	closeArticle: function($article) {
		$article.slideUp();
		this.openArticlesCount > 0 && this.openArticlesCount--;
	}
});

Mit Hilfe von jQuery.proxy können wir Modul-Methoden und Properties direkt ohne Closure-Krücken verwenden. Alle Event Handler werden mit $.proxy mit einer Kopie des Handlers überschrieben die an Modulkontext gebunden ist. (Die Elemente auf die wir "hören" ändern sich dabei nicht.)

Alle Event Handler werden mit $.proxy ans Modul gebunden. Wir schreiben jeden Event Handler als eine "named function" und schreiben alle Handlers untereinander vor den übrigen Methoden. Die Handler funktionieren wie Controller, sie verwalten eingehende Events und steuern was weiter geschieht, mehr nicht. event.target oder event.delegatedTarget sagen uns auf welchem Element der Event ausgelöst wurde.

Danach listen wir alle anderen Methoden auf. Dass sich Methoden gegenseitig aufrufen sollte tendenziell vermieden werden.

In der on Methode setzen wir im Wesentlichen nur die nötigen Event Listeners auf und teilen Terrific mit wenn wir ready sind.

Die Listeners werden untereinander aufgelistet verweisen nur auf die Handlers.

Mit diesem Pattern ist es uns möglich sehr schnell zu erkennen was das Modul in welchen Situationen macht. Der Code ist lesbarer, strukturierter und robust. Kein Mischmasch von Listeners, Handlers, Closures in anonymen Funktionen und Methoden mit unklarem Scope. Die Chance das Änderungen an einer Stelle unerwartete Auswirkungen an einer anderen Stelle haben ist minimiert.

(Dieses Pattern ist ausserdem effizienter als die Verwendung von anonymen Funktionen als Event Handler, die bei jedem Aufruf neu initialisiert werden müssen.)


Sugar

Da wir nun verstehen was ein Prototype ist und this immer das Modul ist, können wir alle Terrific Module problemlos erweitern um uns Arbeit abzunehmen. Folgender Code kommt in den Terrific Bootstrap, vor app.registerModules():

/**
 * Select elements in the module context. Usage: this.$$(selector)
 * @param {string} selector
 * @returns {jQuery} – jQuery collection
 */
Tc.Module.prototype.$$ = function $$(selector) {
	return this.$ctx.find(selector);
};

/**
 * Bind methods to Terrific module context.  Usage: this.bindAll(funcName [,funcName…])
 * @param {...string} methods - Names of methods each as a param.
 * @return {boolean|undefined} - Returns true if binding succeeds, throws an exception otherwise. 
 */
Tc.Module.prototype.bindAll = function bindAll(methods) {
	var i = 0,
		args = arguments,
		argLen = args.length,
		methodName
	;
	
	for (i; i < argLen; i++) {
		methodName = args[i];
		if (typeof this[methodName] == 'function') {
			this[methodName] = $.proxy(this, methodName);
		}
		else {
			throw new TypeError('Tc.Module.'+ this.getName() +'.'+ methodName +' is not a function');
		}
	}
	return true;
};

/**
 * Get the name of the Terrific module
 * @returns {string}
 */
Tc.Module.prototype.getName = function() {
	var property;
	if (!this._modName) {
		for (property in Tc.Module) {
			if (Tc.Module.hasOwnProperty(property) && property !== 'constructor' && this instanceof Tc.Module[property]) {
				this._modName = property;
			}
		}
	}
	return this._modName;
};
  • this.$$(selector) ist ein Shortcut für this.$ctx.find(selector) und $(selector, this.$ctx)
  • this.bindAll(methodName [,methodName…] ) ist ein Shortcut für wiederholte Aufrufe von $.proxy. Inspiriert von Underscore.js.
  • this.getName ist einfach "nice to have". :-)

Jetzt können wir z.B. den init im Pattern-Beispiel so schreiben:

init: function($ctx, sandbox, modId) {
	// call base constructor
	this._super($ctx, sandbox, modId);
	
	this.bindAll(
		 'onClick'
		,'onMyGlobalEvent'
	);
	this.$articles = this.$$('.js-article');
}
@christianhaller
Copy link

Sehr hilfreich. Danke!

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment