Skip to content

Instantly share code, notes, and snippets.

@twalker
Last active December 17, 2015 23:09
Show Gist options
  • Save twalker/5687541 to your computer and use it in GitHub Desktop.
Save twalker/5687541 to your computer and use it in GitHub Desktop.
utility for mixing methods into objects and backbone klasses.
/**
* Mixer of mixins.
* A utility to copy functionality from mixins to objects.
*/
define(['underscore'], function(lodash){
// Monkey patch a destination object (i.e. Model.prototype, View.prototype, etc.)
// by combining member values that are object literals (e.g. events, defaults),
// functions (e.g. initialize), or arrays (e.g.relations).
// Heavily inspired by: https://github.com/onsi/cocktail
function patch(dest){
var mixins = lodash(arguments).toArray().rest();
var collisions = {};
lodash(mixins).each(function(mixin) {
lodash(mixin).forOwn(function(value, key) {
if (lodash.isFunction(value)) {
// methods
if (dest[key]) {
collisions[key] = collisions[key] || [dest[key]];
collisions[key].push(value);
}
dest[key] = value;
} else if (lodash.isPlainObject(value)) {
// object literals
dest[key] = lodash.extend({}, value, dest[key] || {});
} else if(lodash.isArray(value)) {
// arrays
dest[key] = value.concat(dest[key] || []);
} else {
// primitives (string, date, regex), last in wins!?!
// mixins should be composed of functions, and rarely use primitives.
dest[key] = value;
}
});
});
lodash(collisions).forOwn(function(fnValues, propName) {
dest[propName] = wrapFuncs(fnValues);
});
}
// creates a wrapped function that invokes an array of functions,
// in the order given. Returns the last undefined return.
function wrapFuncs(funcs){
return function(){
var that = this,
args = arguments,
returnValue;
lodash(funcs).each(function(value) {
var returnedValue = lodash.isFunction(value) ? value.apply(that, args) : value;
returnValue = (returnedValue === undefined ? returnValue : returnedValue);
});
return returnValue;
};
}
return {
/**
* Non-destructively copies members to a destination object from source objects.
* A simple proxy to lodash.defaults.
*
* @example
* mixer.mixin(MyModel.prototype, modelMixin1, modelMixin2);
*/
mixin: lodash.defaults,
/**
* Destructively copies members to a destination object from source objects.
* A simple proxy to lodash.extend.
*
* @example
* mixer.assign(MyModel.prototype, modelMixin1, modelMixin2);
*/
assign: lodash.assign,
/**
* Monkey patches members from mixins into a destination object,
* merging colliding members (methods, object literals, and arrays).
* Typically used on Constructor (klass) prototypes when a mixin
* includes initialize, events, or other conflicting members.
*
* @example
* mixer.patch(MyModel.prototype, {
* initialize: function(){return "called after MyModel.initialize"},
* defaults: {labels: []}
* }, modelMixin2);
*/
patch: patch
};
});
require([
'mocha',
'chai',
'sinon',
'backbone',
'mixer'
], function(mocha, chai, sinon, Backbone, mixer){
// setup
var assert = chai.assert;
mocha.setup('bdd');
describe('mixer', function(){
var noop = function noop(){};
describe('.mixin(dest [, source1, source2, …])', function(){
var Mod, modMixin;
beforeEach(function(){
Mod = Backbone.Model.extend({
defaults: { first: 'sid'}
});
modMixin = {
defaults: {last: 'vicious'},
playBass: noop
};
mixer.mixin(Mod.prototype, modMixin, {rebel: noop});
});
it('should copy methods/properties to a destination object from sources', function(){
assert.isDefined(Mod.prototype.playBass);
assert.isDefined(Mod.prototype.rebel);
});
it('should not overwrite existing members', function(){
assert.deepEqual(Mod.prototype.defaults, {first:'sid'});
var bestPunk = {};
mixer.mixin(bestPunk, {band: 'sex pistols'}, {band: 'the clash'});
assert.equal(bestPunk.band, 'sex pistols');
});
});
describe('.assign(dest [, source1, source2, …])', function(){
var Mod, modMixin;
beforeEach(function(){
Mod = Backbone.Model.extend({
defaults: {first: 'sid'}
});
modMixin = {
defaults: {last: 'vicious'},
playBass: noop
};
mixer.assign(Mod.prototype, modMixin, {rebel: noop});
});
it('should copy methods/properties to a destination object from sources', function(){
assert.isDefined(Mod.prototype.playBass);
assert.isDefined(Mod.prototype.rebel);
});
it('should overwrite existing members', function(){
assert.deepEqual(Mod.prototype.defaults, {last:'vicious'});
var bestPunk = {};
mixer.assign(bestPunk, {band: 'sex pistols'}, {band: 'the clash'});
assert.equal(bestPunk.band, 'the clash');
});
});
describe('.patch(dest [, source1, source2, …])', function(){
var Mod, modMixin;
var initSpy, mixinInitSpy;
beforeEach(function(){
initSpy = sinon.spy();
mixinInitSpy = sinon.spy();
Mod = Backbone.Model.extend({
defaults: {first: 'sid'},
initialize: initSpy,
genre: ['punk']
});
modMixin = {
defaults: {last: 'vicious'},
playBass: noop,
initialize: mixinInitSpy,
genre: ['rock']
};
mixer.patch(Mod.prototype, modMixin, {rebel: noop});
});
it('should copy methods/functions to a destination object from sources', function(){
assert.isDefined(Mod.prototype.playBass);
assert.isDefined(Mod.prototype.rebel);
});
it('should merge colliding/existing members (functions, plain objects, and arrays)', function(){
assert.deepEqual(Mod.prototype.defaults, {first:'sid', last: 'vicious'});
assert.deepEqual(Mod.prototype.genre.sort(), ['punk', 'rock'].sort());
var options = {rock: 'hard'};
new Mod({}, options);
assert.isTrue(initSpy.called);
assert.isTrue(mixinInitSpy.called);
assert.isTrue(initSpy.calledWith({}, options));
assert.isTrue(mixinInitSpy.calledWith({}, options));
});
it('should merge N collisions in their source order', function(){
var thirdSpy = sinon.spy();
mixer.patch(Mod.prototype, {initialize: thirdSpy});
var m = new Mod({});
assert.isTrue(initSpy.called);
assert.isTrue(initSpy.calledBefore(mixinInitSpy));
assert(initSpy.calledOnce);
assert(initSpy.calledOn(m));
assert(mixinInitSpy.called);
assert(mixinInitSpy.calledBefore(thirdSpy));
assert(thirdSpy.called);
assert(thirdSpy.calledAfter(mixinInitSpy));
assert(thirdSpy.calledOn(m));
});
it('should not break the prototype chain', function(){
var parentSpy = sinon.spy(),
childSpy = sinon.spy(),
mixSpy = sinon.spy();
var ParentView = Backbone.View.extend({
initialize: parentSpy,
events: {'click':'onParent'}
});
var ChildView = ParentView.extend({
initialize: childSpy,
events: {'click':'onChild'}
});
var viewMix = {
initialize: mixSpy,
events: {'submit': 'onMix'}
};
mixer.patch(ChildView.prototype, viewMix);
var view = new ChildView();
assert.isFalse(parentSpy.called);
assert.isTrue(childSpy.called);
assert.isTrue(mixSpy.called);
assert.deepEqual(ParentView.prototype.events, {'click':'onParent'});
assert.deepEqual(ChildView.prototype.events, {'click':'onChild', 'submit': 'onMix'});
delete ChildView.prototype.events;
delete ChildView.prototype.initialize;
// child view should now use prototype of parent
assert.deepEqual(ChildView.prototype.events, ParentView.prototype.events);
assert.equal(ChildView.prototype.initialize, ParentView.prototype.initialize)
});
});
});
// Start runner
mocha.run();
});
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment