This compares my classical approach for dependencies, adapters, plugins etc. to using depject
The example is a theoretical API for working with DOM-type data regardless of the underlying implementation, so you can use the same API to do common DOM traversal, manipulation etc. regardless of whether you're using a DOM represented in some data format like JsonML, real DOM nodes in the browser, virtual DOM nodes provided by some library etc.
The core code for the classical approach is under /src
and using
depject
under /depject
.
You can run the example on the server with node index
, or in the browser by
running npm run build
and then navigating to
/browser/index.html
Note: the example code makes a lot of assumptions to make the code simpler,
omits optimisations etc. and doesn't do much that's useful, a normal DOM API
would have a lot more useful things in it ala jQuery
So, you have some representation of a DOM, which could be the real DOM, or it
could be some virtual DOM format, or it could be JsonML
format etc.:
JsonML
["section",
["h1","Test"],
["p","Hello ",["strong","World!"]]
]
You now need an API that lets you do stuff with your DOM regardless of what the implementation is.
Typical API examples are walk
, find
etc. which are all built on the
functions provided by the adapter, eg to walk you only need to be able to get
an array of child nodes, to find
you just need walk
etc. To see how many
useful traversal/manipulation functions there are, one need only look at the
jQuery API.
You write an adapter for each DOM node implementation, that handles just the stuff that's specific to the implementation:
JsonML adapter
const isElement = ( api, node ) => Array.isArray( node )
const isText = ( api, node ) => typeof node === 'string'
const text = ( api, node ) => node
const children = ( api, node ) => api.isElement( node ) ? node.slice( 1 ) : []
const nodeName = ( api, node ) => api.isElement( node ) ? node[ 0 ] : '#text'
module.exports = { isElement, isText, text, children, nodeName }
You write each function for the adapter as a "plugin" - that is, a function whose first argument is the API - later, when you have all your API functions, you remove this argument by wrapping it via partial application so that the consumer of the API doesn't have to manually pass in the API for every function:
// plugin function:
const foo = ( api, node ) => api.bar( node ) + 'foo'
// called on the final API:
api.foo( node )
Then you have your common API functions, that build on the adapter:
const walk = ( api, node, cb ) =>
cb( node ) ||
api.children( node ).some( child =>
api.walk( child, cb )
)
const find = ( api, node, predicate ) => {
//...
}
const parent = ( api, root, node ) =>
api.find( root, n => api.children( n ).includes( node ) )
const stringify = ( api, node ) => {
//...
}
module.exports = { walk, find, parent, stringify }
And then, you can build specialized plugins to extend the core functionality, which you just use when you need them, so they don't pollute the core common code. This example plugin finds nodes that contain a text node containing the string "Hello":
const helloParent = ( api, node ) => {
const { isText, text, find, parent } = api
const isHelloText = n =>
isText( n ) && text( n ).includes( 'Hello' )
const textNode = find( node, isHelloText )
if( textNode )
return parent( node, textNode )
}
Finally, for convenience you have an API factory that takes your adapter and any specialized plugins and generates the API:
const common = require( './common' )
const Plugins = require( '../plugins' )
const Api = ( adapter, plugins = {} ) => {
const api = {}
Plugins( api, common )
Plugins( api, adapter )
Plugins( api, plugins )
return api
}
This factory is what we would ship, and allows other consumers of the code to write their own adapters for different DOM formats and to add arbitrary plugins eg. that they've found on NPM, written themselves etc.
The adapters can have more optimized versions of the common functions, and because of the plugin order they'll override the common version:
DOM adapter
// ... normal required adapter functions go here
/*
override API common functions with more efficient versions provided by
implementation
*/
const parent = ( root, node ) => node.parentNode
const stringify = node => isText( node ) ? text( node ) : node.outerHTML
module.exports = {
isElement, isText, text, children, nodeName, parent, stringify
}
The function that handles partial application of the api argument is fairly straightforward though somewhat esoteric at first glance:
const Plugins = ( api, obj ) =>
Object.keys( obj ).forEach( fname =>
api[ fname ] = ( ...args ) => obj[ fname ]( api, ...args )
)
Every plugin function is described as a depject
module instead of being a raw
function expecting api
as its first argument.
depject
module functions with dependencies still take an api
argument, but
instead of automatically brute forcing every consumer facing function in the
api
with the Plugins
function described above, you curry it in with the create
property on the module, making it slightly more manual in a sense.
The api
argument provided by depject
has been resolved for you according to
the needs
property of the module rather than just being a bag of every
function in the api.
depject
enforces that all of your requirements are met rather than relying on the developer to pass the right things in and in the right order- If you know how
depject
works, you can explicitly see the dependencies of each function at a glance, rather than them being implicitly assumed to be in the generalapi
bag of functions - and even if you don't know howdepject
works, the module definitions are reasonably intuitive when looking at good examples. - Provides a standard implementation for dependencies, the classical approach I normally use is more of a pattern than an implementation
- Makes more sense to anyone already familiar with
depject
- People writing adapters, plugins etc. have to familiarise themselves with
depject
, it might be easier for them to understand the classical approach? - More verbose, though this is just due to making the dependencies explicit - meaning the explicit/implicit nature shows up in both the pros and cons!
- I can't figure out how once you've called
combine
you can then plug more modules into yoursockets
object post the fact - this is probably not so much a con as a lack of comprehension on my part! - Again, this is possibly due to a misunderstanding on my part, and it's easy to
work around, but the
sockets
that are returned bycombine
aren't an API in themselves out of the box, but an object mapping their names to arrays, so to get an API we end up doing something like this:
const api = Object.keys( sockets ).reduce( ( api, key ) => {
api[ key ] = sockets[ key ][ 0 ]
return api
}, {})
I really like depject
and will probably use it in future over my former approach, I like the explicitness it creates. It's a shame that I have so much existing code using the former approach though :/