Published: 2018-01-20
Updated: 2018-02-04
Web: http://fritzthecat-blog.blogspot.com/2018/01/es6-mixin-trees.html
ES6 mixin is a concept that allows you to build together a class from a lot of very small reusable units of logic. A mixin is not a normal class. You use mixins via factories, and such a factory receives a given class and returns a "mixin class" that extends the given class, all done at runtime.
const A = (SuperClass = Object) => class extends SuperClass { }
That means, the result of a call to mixin-factory A
is a sub-class of given SuperClass
!
In case no SuperClass
is given, it would be Object
.
Anyway, when working with mixins it is not important to think in terms of inheritance any more.
Just when methods or properties of same name occur within the mixed classes, the extension order plays a role.
What about overrides?
Because a mixin never knows what class it will be extending,
it shouldn't do overrides,
except when its definition itself includes a mix()
call
(as shown by the "Complex Tree" example below).
There are lots of implementations around how to mix classes. In this Blog I want to present a function that merges a class with a list of mixins.
Using this function you won't have to extend by combining the factories directly with each other, like in
class ABC extends A(B(C())) { }
Instead you give a comma-separated list of things to extend:
class ABC extends mix(A, B, C) { }
A
can be a class or a factory, but both B
and C
must be mixin-factories.
That means, there can be only one concrete super-class in a mix()
call,
all other parameters (except the first) must be mixin-factories.
Here is the mix()
function:
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 | /** * Mixin inheritance builder. * @param SuperClassOrFactory required, the basic super-class that given factories * should extend, or a factory in case no basic class to extend is needed. * @param classFactories optional, arbitrary number of arguments that are class-factories. * @return a class created by extending SuperClass (when given) and all classes created * by given factories. */ function mix(SuperClassOrFactory, ...classFactories) { const superClassIsFactory = (SuperClassOrFactory.prototype === undefined) const factories = superClassIsFactory ? [ SuperClassOrFactory, ...classFactories ] : classFactories let resultClass = superClassIsFactory ? undefined : SuperClassOrFactory /* "last wins" override strategy */ for (const classFactory of factories) { if (classFactory.prototype !== undefined) throw new Error("Class factory seems to be a class: "+classFactory) resultClass = classFactory(resultClass) } return resultClass } |
This mixes together an optional super-class with a list of class-factories (mixin-factories). It returns a class that extends that optional first parameter, and all mixins created by factories.
The first parameter SuperClassOrFactory
can not be undefined
,
it gives the basic super-class, or a factory in case no basic class is needed (mixins-only mix).
The second parameter ...classFactories
is a
spread-expression
denoting all remaining parameters, which is a list of class-factories.
Why is this first parameter needed? Study the "Small Tree" example below.
You will find that not only classes want to extend several mixins, also mixins want to extend several other mixins.
But a mixin has, other than a class, the parameter SuperClass
it needs to satisfy.
Thus all mixins will pass their SuperClass
as first parameter to their own mix()
call!
Initially the function checks whether SuperClassOrFactory
is a factory or a class.
The distinction criterion is that a class always has a prototype
.
When is it a factory, the result-class is set to undefined
,
while the factory is inserted into the list of class-factories.
Otherwise the result-class is set to be the first parameter.
The subsequent loop takes every class-factory and calls it with the result-class as super-class. Whatever the factory returns is assigned to the result-class again. This gives a chain of extensions where the last mixin will be the lowest sub-class, prevailing over any methods or properties of same name in super-classes ("last wins" principle).
Finally the result-class is returned. Called with just one parameter which is a class, this function would return just that class, called with a factory, it would return the factory's return. Remember that a factory creates a class extending a given super-class.
Mind that this function is the only way how mixins can extend 1-n other mixins by definition (the mixin specifies its super-mixins). This is needed when a mixin depends on another mixin, see "Small Tree" example.
Click onto one of the left-side buttons to see an example script and its description.
Below you find an output area for console.log()
messages.
You can also edit the script and execute it again by using the "Execute Script" button.
I don't believe that ES6 mixins ever will be statically analyzable. That means you will not be able to use static type checks for your ES6 mixins, like it was promised recently for normal ES6 classes.
Another thing to consider with mixins is that private fields in a constructor will not work, because they are bound to the constructor function scope, and then the diamond-problem hits you. If you want privacy for mixins, you should move each of them into its own module and do it by non-exported variables and functions. But mind that these will not be instance-bound!
Using constructors in mixins generally is not recommendable. The diamond-problem will hit you, because mixins can be combined in many and unpredictable ways. And as soon as you have a mixin two times in an inheritance tree, its constructor also will be called two times. (For example, don't install mouse- or keyboard-listeners in mixin constructors!)
ES6 mixins look really seducing. You can weave in any aspect that you need on a class. The portion of reusable code in your project may increase dramatically. It is an unfamiliar technique for an OO programmer used to single inheritance, but it offers interesting new possibilities.
This is a short example of what was explained in the introduction.
Just two independent mixin-factories Being
and Movable
get combined into a class Animal
.
Mind that any mixin-class that has a constructor must call super(args)
with its own parameters,
because it doesn't know its super-class,
and the ES6 interpreter would throw an error when extending a class but not calling super()
.
The console-output shows that both properties species
and location
are present in the resulting class Animal
.
This example shows that a mixin can define its super-mixins by definition, even several ones.
The final result class ActualAnimal
is mixed together from two mixins Animal
and Born
,
and Animal
is again built together from three items:
it mixes, by definition, its dynamically passed super-class with Soundable
and Movable
.
Mind that you always need to pass SuperClass
as first parameter
to the mix()
call of a mixin-factory like Animal
!
This example also provides a common Mixin
super-class.
It could hold functionality that you want to be in every mixin-instance, for example a unique id.
Use Mixin
as default super-class in every factory-definition instead of Object
.
The "Diamond Problem"
describes the ambiguity that occurs when you mix
two classes that both have an instance-field or method with same name.
In the example below, both Mars
and Saturn
derive Base
,
and all three of them define a property identity
and a method getId()
.
Which one will survive?
The loop direction of function mix()
makes the last win.
When you change it to for (const classFactory of factories.reverse())
, then Mars
will prevail.
You can have the same effect when changing the extends-clause to extends mix(Saturn, Mars)
.
Please mind that Base
constructor is called twice, once from Mars
and once from Saturn
.
If you install event listeners in a mixin constructor,
they will be installed as often as that mixin has been used as super-mixin!
This example shows that everything works even on a bigger scale.
Here is the
UML class diagram of this inheritance tree:
ɔ⃝ Fritz Ritzberger, 2018-01-20