Plugin Grind Mixins is a tool for building ES6 class mixins that are fast, flexible, and free of dependency confusion. It is inspired by Grind Framework and has a handy interface for Grind projects. Note that features can be used as well in non-Grind projects.
Plugin Grind Mixins, internally, is built using mixin patterns suggested by Raganwald and justinfagnani, however it provides a much different interface.
First, add the plugin-grind-mixins
package via your preferred package manager:
npm install --save plugin-grind-mixins
plugin-grind-mixins grants access to two types of mixins: Inheritance and Merge. They can be used together, though they work quite differently.
Inheritance mixins are added to the inheritance chain of a class. Because they use inheritance, these mixins can use super()
(unlike Merge mixins or attributes added through Object.assign()
and the like).
Build the mixin by pointing a function at the class you want to mix in:
parentClass => class OptionalClassName extends parentClass {
// class logic
}
Apply Inhertance mixins at class declaration with:
mix(OptionalBaseClass).through(Mixin1, Mixin2, ...)
In this example, Bird and Predator classes are mixed into the Heron class so that the inheritance chain becomes Heron > Predator > Bird > Animal
:
//AnimalMixins.js
export const Predator = parentClass => class extends parentClass {
constructor() {
super()
this.isPredator = true
}
hunt(prey) {
return `The ${this.animalName} hunts ${prey} using ${this.uses()}`
}
}
export const Bird = parentClass => class extends parentClass {
uses() {
return 'talons'
}
}
//Heron.js
import { mix } from 'plugin-grind-mixins'
import { Bird, Predator } from 'AnimalMixins'
import { BaseAnimal } from 'Somewhere'
class Heron extends mix(BaseAnimal).through(Bird, Predator) {
constructor() {
super()
this.animalName = 'heron'
}
}
const heron = new Heron()
heron.isPredator
// true
heron.hunt(fish)
// The heron hunts fish using talons
Mixins are applied in order of entry, so Bird is a base class for Predator. Thus, Predator can use super()
to access functions in Bird, though not vice-versa.
Merge mixins add properties directly onto a declared class or class prototype. Note that they do not work through inheritance and, therefore, cannot use super()
.
Example that shows a number of Merge mixins (through a JSON MergeSchema):
// AnimalMixins.js
export const WaterTraits = {
swim(location) { return `Swims toward the ${location}` },
huntFish() { return 'Hunting Fish' },
eatFish(number) { /* code to asynchronously update model with the number of total fish eaten */ },
environments(types) { types.push('rivers') },
breathThroughGills() { return 'Breathing through gills' }
}
export const LandTraits = {
run(location) { return `Runs toward the ${location}` },
environments(types) { types.push('shores') }
}
// Alligator.js
import { mix } from 'plugin-grind-mixins'
import { WaterTraits, LandTraits } from 'AnimalMixins'
class Alligator {
static mergeMixins() {
return {
onPrototype: {
merge: [
{ WaterTraits, use: [ 'swim', 'huntFish' ] },
{ LandTraits, use: [ 'run' ] }
],
append: [
{ WaterTraits, use: [ 'environment' ] },
{ LandTraits, use: [ 'environment' ] },
],
awaitPrepend: [ { WaterTraits, use: [ 'eatFish' ]} ]
}
}
}
environments(types = [ ]) { return types }
eatFish(number) { return `Eats ${number} fish`}
}
mix(Alligator).register()
const alligator = new Alligator()
alligator.swim('shore')
// Swims toward the shore
alligator.run('horizon')
// Runs toward the horizon
alligator.environments([ ])
// [ 'rivers', 'shores' ]
await alligator.eatFish(3)
// (Alligator model total number of fish eaten is increased by 3)
// Eats 3 fish
alligator.breathThroughGills()
// TypeError: alligator.breathThroughGills is not a function
There are four types of merges: merge
, mergeOver
, prepend
, and append
adds new functions to the target. Errors if any of the functions already exist on the target.
overrides functions that already exist on the target. Automatically passes in the original function as the first argument. Errors if any of the functions do not yet exist on the target.
adds 'before hooks' to functions that already exist on the target. All arguments passed into the function are passed into the before hooks. Errors if any of the functions do not yet exist on the target. Before hooks run synchronously.
adds 'after hooks' to functions that already exist on the target. All arguments passed into the function are passed into the after hooks. Errors if any of the functions do not yet exist on the target. After hooks run synchronously.
For prepend and append, if the hooks should run asynchronously, use the special merges awaitPrepend
and awaitAppend
. Note, the methods these hooks are applied to will now each return a promise.
Merge mixins are constructed using MergeSchemas
, which detail how to apply functions found in Merge Objects
to the target class/prototype.
Merge Objects are simply objects with keys pointing to functions.
Example:
// AnimalTypeMixins.js
export const WaterTraits = {
// this function, intending to be mixed with mergeOver(), passes in the overridden method as its first argument
swim(overridenMethod, location) { return `Swims toward the ${location}` },
environments(types) { types.push('rivers') }
}
export const LandTraits = {
// this function, intending to be mixed with mergeOver(), passes in the overridden method as its first argument
run(overridenMethod, location) { return `Runs toward the ${location}` },
walks() { 'walks' }
environments(types) { types.push('grasslands') }
}
Merge Objects can specify class/instance methods or variables that their functions depend upon. An Error will throw when the mixin is applied to a class missing the dependency.
To add dependencies to a function, have its key point to an object with the function behind an action
key and the array of dependencies behind a depends
key.
Example
export const WaterTraits = {
swim: {
action(location) {
return `Swims toward the ${location} at speed: ${this.speed}`
},
depends: [ 'speed' ]
}
}
Note: When applying dependents to a class instance, it will only work with instance functions, not variables.
There are two ways to use a MergeSchema to apply a Merge Object to a class:
- Register a JSON schema - best for applying mixins across an entire class.
- Use API methods - best for a single class instance.
JSON schemas apply Merge Objects in accordance with a JSON MergeSchema.
JSON schemas are registered using:
mix(class).register(SchemaName)
:
import { mix } from 'plugin-grind-mixins'
mix(classObject).register(SchemaName)
classObject
- the class to register a JSON Schema for.SchemaName
- the name of the static variable or function that will return the JSON schema. Defaults to 'mergeMixins'.
import { Animal } from 'Somewhere'
import { LandTraits, WaterTraits} from 'Somewhere'
class Alligator extends Animal {
static MergeSchema = {
merge: [
{ LandTraits, use: [ 'run', 'walk' ] },
{ WaterTraits, use: [ 'transitionToLand' ] }
],
mergeOver: [
{ LandTraits, use: [ 'hasTail' ] }
],
prepend: [
{ LandTraits, use: [ 'environment' ] },
{ WaterTraits, use: [ 'environment' ]}
]
}
}
Additional features add control over dependencies and minimize method conflicts.
In the JSON schemas, you can pick and choose which functions to use from a Merge Object using the use
field. Even if using all the functions in a Merge Object, best practice is to always explicitly name each function in the use
field for the sake of clarity.
static MergeSchema = {
merge: [
{ LandTraits, use: [ 'run', 'walk' ] }
]
}
You can use a Merge Object function but alias it to a different name.
In the use
field, put 'name as alias'
.
static MergeSchema = {
merge: [
{ LandTraits, use: [ 'run as runFast', 'walk' ] }
]
}
alligator.runFast() /* will work */
alligator.run() /* will fail unless run() is defined elsewhere */
You may want to temporarily change Merge Object dependencies as they are applied. For example, perhaps you want the dependency to point to an aliased merge function.
To add/remove/rename Merge Object dependencies, use the key overrideDepends
.
It should be structured as such:
{ overrideDepends: 'functionName:[newDependency1,newDependency2],functionName2[newDependency1,newDependency2]' }
Example:
static MergeSchema = {
merge: [
{ WaterTraits, use: [ 'swimType as stroke', 'breathingType'] }
{ WaterTraits,
use: [ 'swim' ],
overrideDepends: 'swim:[breathingType,stroke]'
}
]
}
alligator.swim('shore') /* this will work */
Instead of applying Merge Objects only to class constructors, you can apply them to prototypes. Each initialized class prototype would then have the merge attributes. To target a class's prototype, nest the relevant part of the JSON schema inside of onPrototype: { }
Example:
class Alligator {
static mergeMixins = {
onPrototype: {
merge: [
{ WaterTraits, use: [ 'swim' ] },
{ LandTraits, use: [ 'run' ] }
]
},
merge: [ { WaterTraits: use: [ 'huntFish' ] }]
}
}
mix(Alligator).register()
Alligator.huntFish()
// Eating Fish
const alligator = new Alligator()
alligator.swim('shore')
// Swims toward the shore
alligator.run('horizon')
// Runs toward the horizon
On rare occasionan, you may want to apply a merge method more than once, perhaps because there is a sensitive order in which Merge Object functions must be applied.
To do this, number the merge methods by adding an incrementing number to the end of their names.
Example:
const mergeSchema = {
merge: [
{ LandTraits, use: [ 'hasFeet' ] }
],
mergeOver: [
{ LandTraits, use: [ 'run', 'walk' ] },
],
merge2: [
{ WaterTraits, use: [ 'transitionToLand' ] }
]
}
Imagine that the overrides run/walk depend on new method hasFeet() existing. New method transitionToLand, in turn, depends on run/walk existing.
In examples so far, schemas have been built directly in class declarations so they apply to the constructor and all prototypes. Let's say, however, you want to apply a schema on a single class instance without applying it to the entire prototype (i.e. all instances).
To do that, use useSchema()
const mergeSchema = {
merge: [ { LandTraits, use: [ 'hasFeet' ] } ],
mergeOver: [ { LandTraits, use: [ 'run', 'walk' ] } ]
}
mix(instance).useSchema(mergeSchema)
instance
- the class instance to register a JSON Schema for.mergeSchema
- the JSON schema object to apply to the instance.
Example:
import { mix } from 'plugin-grind-mixins'
import { LandTraits } from 'Somewhere'
const MergeSchema = {
merge: [ { LandTraits, use: [ 'run' ] }
}
const alligator = new Alligator()
const alligator2 = new Alligator()
mix(alligator).useSchema(MergeSchema)
alligator.run('horizon')
// Runs toward the horizon
alligator2.run('horizon')
// TypeError: alligator2.run is not a function
The Merge API is an alternate way to construct a MergeSchema using methods.
The API methods correspond to the four main merge methods (plus awaitAppend
and awaitPrepend
) and the arguments passed to the methods look much like their JSON schema counterparts.
Example Usage of the Merge API:
import { mix } from 'plugin-grind-mixins'
import { LandTraits, WaterTraits } from 'Somewhere'
class Alligator { }
const alligator = new Alligator()
mix(alligator)
.mergeOver([
{ LandTraits, use: [ 'run' ] },
{ WaterTraits, use: [ 'swim' ] }
])
.merge({ LandTraits, use: [ 'catchAnimal', 'walk' ] })
.mergeOver({ WaterTraits, use: [ 'transitionToLand' ] })
.prepend([
{ LandTraits, use: [ 'environments' ] },
{ WaterTraits, use: [ 'environments' ] }
])
alligator.run('horizon') /* this will work */
Note: all API methods are chainable.
You should add use
clauses to specify which Merge Object functions to use. Apply this the same way as in the JSON schema (link).
Sometimes you want to add/remove/rename the dependencies for a function in the Mixin Object. You can do that with overrideDepends
. Apply this the same way as in the JSON schema (link).
The API is meant to be used directly on class instances. However, if you want to use the API on a class constructor in order to effect all prototypes use onPrototype()
.
Example:
import { mix } from 'plugin-grind-mixins'
class AlligatorClass extends AnimalClass { }
mix(AlligatorClass)
.onPrototype(builder => {
builder.mergeOver([
{ LandAnimalTraits, use: [ 'run' ] },
{ WaterAnimalTraits, use: [ 'swim' ] }
])
})
const alligator = new AlligatorClass()
alligator.run('horizon')
// 'Runs toward the shore'
alligator.swim('shore')
// 'Swims toward the shore'
Note that, for effecting a class constructor/all prototypes, using a JSON schema is usually easier and cleaner.
By default, every Mixin API method returns a Mixin object. If you want to, instead, return the altered class instance, add *AndDeclare()
to the final API method.
Example:
import { mix } from 'plugin-grind-mixins'
class Alligator { }
const alligator = mix(new Alligator())
.merge({ LandAnimalTraits, use: [ 'run' ] })
.mergeOverAndDeclare({ LandAnimalTraits, use: [ 'run' ] })
const alligator2 = mix(new Alligator())
.merge({ LandAnimalTraits, use: [ 'run' ] })
.mergeOver({ LandAnimalTraits, use: [ 'run' ] })
alligator.constructor.name /* instance of Alligator */
// Alligator
alligator2.constructor.name /* instance of Mixin */
// Mixin
Plugin Grind Mixins can be used with or without Grind. Using the Grind ecosystem, however, we can add useful syntactic sugar and reduce the number of imports necessary to use mixins.
First, you'll need to add MixinProvider
to your app/Bootstrap.js
.
// app/Bootstrap.js
import Grind from 'grind-framework'
import { MixinProvider } from 'plugin-grind-mixins'
const app = new Grind()
app.providers.push(MixinProvider)
Adding mixins inside of a class/function can now use the app.mixins.mix(classOrInstance)
function:
const alligator = new Alligator()
app.mixins.mix(alligator).merge({ LandAnimal, use: [ 'run' ] })
Note that app.mixins
will not work on a top level class declaration, as the provider will not yet have been run and the app
object isn't available.
Example that will fail:
// AlligatorModel.js
import { BaseAnimalModel, PredatorModel } from 'Somewhere'
class AlligatorModel extends this.app.mixins.mix(BaseAnimalModel)
.through(PredatorModel) {
//
}
// This will fail. this.app is not defined.
You can cache Inheritance classes and Merge Objects so that they no longer need to be imported when you use them.
Cahcing should happen in app/Providers/MixinBuilderProvder.js
, which you'll need to add to app/Bootstrap.js
// app/Bootstrap.js
import Grind from 'grind-framework'
import { MixinProvider } from 'plugin-grind-mixins'
import { MixinBuilderProvder } from './Providers/MixinBuilderProvder'
const app = new Grind()
app.providers.push(MixinProvider)
app.providers.push(MixinBuilderProvder)
// app/Providers/MixinBuilderProvider.js
import { LandTraits, WaterTraits } from 'Somewhere With Merge Objects'
import { Bird, Predator } from 'Somewhere with Mixin Classes'
export function MixinBuilderProvider(app) {
app.mixins.buildMerge('LandTraits', LandTraits)
app.mixins.buildMerge('WaterTraits', WaterTraits)
app.mixins.buildChain('Bird', Bird)
app.mixins.buildChain('Predator', Predator)
}
To cache Merge Objects, use buildMerge(name, MergeObject)
. For Inhertiance classes, use buildChain(name, InheritanceMixin)
.
name
- the name of the cached mixin, which is how it will be referenced.MergeObject
- the Merge Object to cache.InheritanceMixin
- the Inheritance mixin to cache.
Now when using the Mixin Object or Inheritance class, you can reference it using its string name.
For a Merge Object:
const alligator = new Alligator()
this.app.mixins.mix(alligator)
.mergeOver([ 'WaterAnimal(swim)', 'LandAnimal(run, walk)' ])
Or for an Inheritance Mixin (assuming it's inside of an already declared class):
const Heron = class Heron extends this.app.mixins.mix(BaseAnimal).through('Bird', 'Predator') { }
You can still use use
, alias
, and overrideDepends
in MergeSchemas that use cached Mixin Objects.
Example:
class AlligatorClass {
static mergeMixins = {
onPrototype: {
mergeOver: [
'LandAnimal(run)',
'WaterAnimal(swim)',
],
merge: [
'LandAnimal(hunt, walk as walkSlow)',
{
string: 'WaterAnimal(transitionToLand)',
overrideDepends: 'transitionToLand:[swim,walkSlow]'
}
}
]
}
}
Classes can be registered in the MixinBuilderProvider
so that as soon as the provider is run, the class's JSON Schema will be applied and can be used right away without further registering.
To register a class, use:
app.mixins.register(classObject, SchemaName)
classObject
- the class to register a MergeSchema for.SchemaName
- the name of the static variable for class function that returns the JSON schema. Defaults to 'mergeMixins'
Example:
// app/Providers/MixinBuilderProvider.js
import { LandTraits, WaterTraits } from 'Somewhere'
import { Alligator } from 'Somewhere'
export function MixinBuilderProvider(app) {
app.mixins.register(Alligator)
}
// Alligator.js
class Alligator {
static mergeMixins = {
onPrototype: {
merge: [
{ WaterTraits, use: [ 'swim', 'huntFish' ] },
{ LandTraits, use: [ 'run' ] }
]
}
}
hunt(location) {
return `${this.swim(location)} and looks for fish.`
}
}
// No need to register Alligator here. Go ahead and use the mixin capabilities!
const alligator = new Alligator()
alligator.hunt('shore')
// Swims toward the shore and looks for fish.
alligator.run('horizon')
// Runs toward the horizon
Contributions are always welcome! You are encouraged to open issues and merge requests.
Some of the dev environment is borrowed from Grind Framework, while the README borrows some language from the Grind Docs.
To run the tests, use npm run test
.
Inheritance
- Inheritance chains no longer require a base class.
Dev Environment
- Update travis for node 8.
- Update devDependencies.
MixinProvider
- Add a default priority to avoid errors when used in Grind 0.6.
- Re-cache mixins each time the provider is rerun to properly track changes to mixin code in development.