Skip to content

Instantly share code, notes, and snippets.

@bterlson
Last active August 29, 2015 14:00
Show Gist options
  • Star 3 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save bterlson/fc94a81a3811f48187b6 to your computer and use it in GitHub Desktop.
Save bterlson/fc94a81a3811f48187b6 to your computer and use it in GitHub Desktop.

ClassMix

ClassMix implements a mixin pattern in ~30SLOC that enables classes to extend multiple classes with reasonably simple semantics:

  • The mixed class's super-class prototype will look up properties on the mixins' prototypes in mixin order, with the first property found winning.
  • The mixed class's super-constructor will look up properties on the mixins' constructors in mixin order, with the first property found winning.
  • The mixed class's super-constructor implementation will forward all arguments to all super-constructors.

This library is implemented using Proxies, WeakMaps, Maps, Sets, and a variety of ES6 syntax.

Examples

EventTarget and Disposable

class EventTarget { /* impl of add/removeEventListener */ }
class Disposable { /* impl of dispose() */}

class Element extends mix(EventTarget, Disposable) {
    // default constructor forwards arguments, which works in this case.
}

// can specialize Element further with subclasses
class MyElement extends Element {  }

// and by adding further mixins
class MyEnumerableElement extends mix(MyElement, Enumerable) {  }

Mixing unrelated things

class Person() {
    constructor(name) { this.name = name }
    talk(){};
}

class Claxon {
    constructor(volume) {
        this.volume = volume;
    }

    sound(){};
}

class LoudPerson extends mix(Person, Claxon) {
    constructor(name, volume) {
        // Person and Claxon added to super-class prototype for convenience
        super.Person(name);
        super.Claxon(volume);
    }
}

var bob = new LoudPerson("Bob", 11);
bob.talk(); bob.sound();

Diamond Pattern

class A { a() {} }
class B extends A { b() {} }
class C extends A { c() {} }
class D extends mix(B, C) { d() {} }

var obj = new D();
obj.a(); obj.b(); obj.c(); obj.d(); // all work

Best practices

  • Avoid conflicting names unless you know what you're doing.
  • Avoid @@create. First @@create found will be used to allocate the this object, and since most everything will have @@create, it's a best practice to put the class with the @@create behavior you want first in the mix list.
  • Classes designed to be mixed into other classes should store private state in a weakmap to avoid conflicts with other mixins.

Benefits

Unlike mixin approaches that copy properties, this method uses prototypical inheritance and preserves all prototype chains. This means that augmenting a mixin's prototype works identically to, for example, augmenting a built-in prototype.

Very little syntax - just the mix function.

Mixed classes are highly composable. You can mix two classes together, and mix that class with a third. I believe most compositions will work as expected.

Drawbacks

No implementation has optimized proxies to a degree that makes this sane. Even a theoretically optimal implementation would incur some overhead in property lookup, which is usually a hot path.

class A {
constructor() { console.log('constructing a') }
aMethod() { console.log('a method') }
static aStaticMethod() { console.log('static a method') }
}
class B {
constructor() { console.log('constructing b') }
bMethod() { console.log('b method') }
static bStaticMethod() { console.log('static b method') }
}
class Foo extends mix(A, B) {
constructor() {
super.A();
super.B();
}
fooMethod() {
console.log('fooMethod');
}
aMethod() {
console.log('subclass aMethod');
super();
}
static bStaticMethod() {
console.log('subclass static bMethod');
super();
}
}
// Can call any method on A.prototype or B.prototype
// (first one wins)
var foo = new Foo();
foo.fooMethod();
foo.aMethod();
foo.bMethod();
// Dynamic changes to prototypes works as expected
A.prototype.newAMethod = function() { console.log('new method');};
B.prototype.newBMethod = function() { console.log('new method');};
foo.newAMethod();
foo.newBMethod();
// Static methods are also inherited
Foo.aStaticMethod();
Foo.bStaticMethod();
function objectUnion(target, objs) {
objs = [target].concat(objs);
var proxyHandler = {
getOwnPropertyDescriptor(t, k) {
var obj = findProp(k);
if(obj) return Object.getOwnPropertyDescriptor(obj, k);
},
has(t, k) {
var obj = findProp(k);
return !!obj;
},
get(t, k, r) {
var obj = findProp(k);
if(obj) return Reflect.get(obj, k, r);
}
}
return new Proxy(target, proxyHandler);
function findProp(name) {
for(var obj of objs) {
if(name in obj) return obj;
}
return null;
}
}
function mix(... classes) {
var C = function() {
classes.forEach(c => this[c.name].apply(this, arguments))
}
classes.forEach(c => C.prototype[c.name] = c);
C.prototype = objectUnion(C.prototype, classes.map(c => c.prototype));
return objectUnion(C, classes);
}
var etState = new WeakMap();
class EventTarget {
constructor() {
var state = {
events: new Map()
}
etState.set(this, state);
}
addEventListener(type, listener) {
var state = etState.get(this);
var listeners = state.events.get(type);
if(!listeners) {
listeners = new Set();
state.events.set(type, listeners);
}
listeners.add(listener);
}
removeEventListener(type, listener) {
var state = etState.get(this);
var listeners = state.events.get(type);
if(listeners) {
listeners.delete(listener)
}
}
dispatchEvent(evt) {
var state = etState.get(this);
var listeners = state.events.get(evt.type);
if(listeners) {
listeners.forEach(function(listener) {
listener.call(evt, evt);
})
/*
When Supported
for(listener of listeners) {
listener.call(evt, evt);
}
*/
}
}
}
var dispState = new WeakMap();
class Disposable {
constructor() {
var state = { isDisposed: false };
dispState.set(this, state);
}
dispose() {
var state = dispState.get(this);
state.isDisposed = true;
}
get isDisposed() {
var state = dispState.get(this);
return !!state.isDisposed;
}
}
var actState = new WeakMap();
class Activatable {
constructor() {
var state = { isActive: false };
actState.set(this, state);
}
activate() {
var state = actState.get(this);
state.isActive = true;
}
deactivate() {
var state = actState.get(this);
state.isActive = false;
}
get isActive() {
var state = actState.get(this);
return !!state.isActive;
}
}
class Widget extends mix(EventTarget, Disposable, Activatable) {
constructor() {
super();
this.x = 0;
this.y = 0;
this.width = 10;
this.height = 10;
}
area() {
return this.width * this.height;
}
}
var w = new Widget();
w.addEventListener('render', function(e) { console.log('rendered') });
w.dispatchEvent({type: 'render'});
console.log(w.area());
console.log(w.isDisposed);
w.dispose();
console.log(w.isDisposed);
console.log(w.isActive);
w.activate();
console.log(w.isActive);
w.deactivate();
console.log(w.isActive);
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment