javascriptclassmultiple-inheritancemixinsentity-component-system

Mixins as an instance of a class?


I'm using an entity-component system. I've defined some components as ES6 classes, and I can create instances of those components by calling their constructors with new. I'm trying to use these class instances as mixins. I've found that using Object.assign doesn't copy the methods of those instances onto my target object because the methods are bound to the object's prototype.

I've found a hacky solution as follows:

function getAllPropertyNames(obj) {
  return Object
    .getOwnPropertyNames(obj)
    .concat(Object.getOwnPropertyNames(obj.__proto__))
    .filter(name => (name !== "constructor"))
}

function assembleFromComponents(obj, ...cmp) {
  for(let i of cmp) {
    for(let j of getAllPropertyNames(i)) {
      obj[j] = i[j]
    }
  }
}

This method is not ideal because it doesn't access the component's full prototype chain, although I don't think I'll need it anyway. However, upon inspection, getters and setters don't appear to work.

Is there a better way to use a class instance as a mixin?


Solution

  • I probably wouldn't define mixins using class syntax. I'd define them as objects:

    const myMixin = {
        doThis() {
            // ...
        },
        doThat() {
            // ...
        },
        // ...
    };
    

    One issue with class syntax is that super may not work as you expect, because even after being copied, the methods will still refer to their original home object. (Or that may be what you expect in which case you're fine.) More on this below.

    But if you want to use class syntax, you can define an Object.assign-like function that applies all of the methods and other properties from the entire chain via Object.defineProperties and Object.getOwnPropertyDescriptors, which will copy getters and setters. Something like (off the cuff, not tested):

    function assignAll(target, source, inherited = false) {
        // Start from the prototype and work upward, so that overrides work
    
        let chain;
        if (inherited) {
            // Find the first prototype after `Object.prototype`
            chain = [];
            let p = source;
            do {
                chain.unshift(p);
                p = Object.getPrototypeOf(p);
            } while (p && p !== Object.prototype);
        } else {
            chain = [source];
        }
        for (const obj of chain) {
            // Get the descriptors from this object
            const descriptors = Object.getOwnPropertyDescriptors(obj);
            // We don't want to copy the constructor or __proto__ properties
            delete descriptors.constructor;
            delete descriptors.__proto__;
            // Apply them to the target
            Object.defineProperties(target, descriptors);
        }
        return target;
    }
    

    Using it:

    assignAll(Example.prototype, Mixin.prototype);
    

    Live Example:

    function assignAll(target, source, inherited = false) {
        // Start from the prototype and work upward, so that overrides work
    
        let chain;
        if (inherited) {
            // Find the first prototype after `Object.prototype`
            chain = [];
            let p = source;
            do {
                chain.unshift(p);
                p = Object.getPrototypeOf(p);
            } while (p && p !== Object.prototype);
        } else {
            chain = [source];
        }
        for (const obj of chain) {
            // Get the descriptors from this object
            const descriptors = Object.getOwnPropertyDescriptors(obj);
            // We don't want to copy the constructor or __proto__ properties
            delete descriptors.constructor;
            delete descriptors.__proto__;
            // Apply them to the target
            Object.defineProperties(target, descriptors);
        }
        return target;
    }
    
    class Example {
        method() {
            console.log("this is method");
        }
    }
    const mixinFoos = new WeakMap();
    class Mixin {
        mixinMethod() {
            console.log("mixin method");
        }
        get foo() {
            let value = mixinFoos.get(this);
            if (value !== undefined) {
                value = String(value).toUpperCase();
            }
            return value;
        }
        set foo(value) {
            return mixinFoos.set(this, value);
        }
    }
    
    assignAll(Example.prototype, Mixin.prototype, true);
    
    const e = new Example();
    e.foo = "hi";
    console.log(e.foo);
    // HI

    Here's an example where the mixin is a subclass and uses super, just to demonstrate what super means in that context:

    function assignAll(target, source, inherited = false) {
        // Start from the prototype and work upward, so that overrides work
    
        let chain;
        if (inherited) {
            // Find the first prototype after `Object.prototype`
            chain = [];
            let p = source;
            do {
                chain.unshift(p);
                p = Object.getPrototypeOf(p);
            } while (p && p !== Object.prototype);
        } else {
            chain = [source];
        }
        for (const obj of chain) {
            // Get the descriptors from this object
            const descriptors = Object.getOwnPropertyDescriptors(obj);
            // We don't want to copy the constructor or __proto__ properties
            delete descriptors.constructor;
            delete descriptors.__proto__;
            // Apply them to the target
            Object.defineProperties(target, descriptors);
        }
        return target;
    }
    
    class Example {
        method() {
            console.log("this is Example.method");
        }
    }
    
    class MixinBase {
        method() {
            console.log("this is MixinBase.method");
        }
    }
    
    class Mixin extends MixinBase {
        method() {
            super.method();
            console.log("this is Mixin.method");
        }
    }
    
    assignAll(Example.prototype, Mixin.prototype, true);
    
    const e = new Example();
    e.method();
    // "this is MixinBase.method"
    // "this is Mixin.method"


    You've said you want to use class instances as mixins. The above works just fine doing that. Here's an example:

    function assignAll(target, source, inherited = false) {
        // Start from the prototype and work upward, so that overrides work
    
        let chain;
        if (inherited) {
            // Find the first prototype after `Object.prototype`
            chain = [];
            let p = source;
            do {
                chain.unshift(p);
                p = Object.getPrototypeOf(p);
            } while (p && p !== Object.prototype);
        } else {
            chain = [source];
        }
        for (const obj of chain) {
            // Get the descriptors from this object
            const descriptors = Object.getOwnPropertyDescriptors(obj);
            // We don't want to copy the constructor or __proto__ properties
            delete descriptors.constructor;
            delete descriptors.__proto__;
            // Apply them to the target
            Object.defineProperties(target, descriptors);
        }
        return target;
    }
    
    class Example {
        method() {
            console.log("this is Example.method");
        }
    }
    
    class MixinBase {
        method() {
            console.log("this is MixinBase.method");
        }
    }
    
    const mixinFoos = new WeakMap();
    class Mixin extends MixinBase {
        constructor(value) {
            super();
            this.value = value;
        }
        mixinMethod() {
            console.log(`mixin method, value = ${this.value}`);
        }
        get foo() {
            let value = mixinFoos.get(this);
            if (value !== undefined) {
                value = String(value).toUpperCase();
            }
            return value;
        }
        set foo(value) {
            return mixinFoos.set(this, value);
        }
        method() {
            super.method();
            console.log("this is Mixin.method");
        }
    }
    
    // Here I'm using it on `Example.prototype`, but it could be on an
    // `Example` instance as well
    assignAll(Example.prototype, new Mixin(42), true);
    
    const e = new Example();
    e.mixinMethod();
    // "mixin method, value = 42"
    e.method();
    // "this is MixinBase.method"
    // "this is Mixin.method"
    e.foo = "hi";
    console.log(e.foo);
    // "HI"

    But really, you can design it however you want to; assignAll is just an example, as are the runnable ones above. The key things here are:

    1. Use Object.getOwnPropertyDescriptors to get property descriptors and Object.defineProperties (or their singular counterparts, getOwnPropertyDescriptor and defineProperty), so that accessor methods get transferred as accessors.

    2. Work from the base prototype up to the instance, so that overriding at each level works correctly.

    3. super will continue to work in its original inheritance chain, not in the new location the mixin has been copied to.