A version of this first appeared in the Viget blog.
At Viget we have many ways of writing JavaScript, but when it comes to our front-end team the default is the data-module
pattern. Over the years this pattern has evolved and changed.
A long time ago there was the "Garber-Irish" implementation. Then, there was Blendid. Blendid combined build tools into a single package, but also set some standards on how we wrote JavaScript. It looked like this.
We call this the data-module
pattern. data-module
attributes drive the process. Tooling is set up so that every data-module
attribute value maps to a corresponding moduleโa file that exports a JavaScript class
โto be instantiated.
/*
Usage:
======
html
----
<button data-module="disappear">disappear!</button>
js
--
// modules/disappear.js
export default class Disappear {
constructor(el) {
el.style.display = 'none'
}
}
*/
Today, the pattern is mostly the same. We still start with a data-module
attribute on an HTML element:
<!-- index.html -->
<button data-module="sample">Button</button>
Our app.js
(the file our build tool uses as the main entry point) is set up to asynchronously load all modules being referenced in HTML via those data-module
attributes:
// ./src/app.js
// select every "data-module" and convert the NodeList to an Array
const dataModules = [...document.querySelectorAll('[data-module]')]
// store all instances to clean up during HMR (hot module replacement)
const storage = {}
dataModules.forEach((element) => {
element.dataset.module.split(' ').forEach(function (moduleName) {
// dynamic imports help with code splitting
import(
// assumes modules are in directory `.src/modules/<module-name>.js`
// and your entry point lives in `.src/<entry-point-file>.js
`./modules/${moduleName}`
).then((Module) => {
// create a new instance of our module passing the element and store it
storage[moduleName] = new Module.default(element)
})
})
})
// enable HMR
if (module.hot) {
module.hot.accept()
module.hot.dispose(() => {
dataModules.forEach((element) => {
element.dataset.module.split(' ').forEach(function (moduleName) {
// every module must have a `cleanUp` method
storage[moduleName].cleanUp()
})
})
})
}
Dynamic imports are useful for webpack to do code splitting. This way we only load the JS we actually need. During development we make use of hot module replacement for a better developer experience.
At a minimum a module looks like this:
// ./src/modules/sample.js
export default class Sample {
constructor(el) {
this.el = el
this.setVars()
this.bindEvents()
}
setVars() {
/**
* - select elements
* - initialize state
* - etc
*/
}
bindEvents() {
// add event listeners
}
// all your other methods
/**
* IMPORTANT:
* Clean up anything HMR will need to reload
* This is required for HMR to work correctly
*/
cleanUp() {
// remove event listeners and subscriptions
}
}
The constructor is called by passing the element the data-module
attribute is placed on. We then set this.el
to that element. This is useful for scoping our selectors (eg. this.el.querySelector('some-selector')
).
Within the setVars()
method we usual query elements. Within bindEvents()
we attach any event listeners our module may need. Finally, in cleanUp()
we remove listeners and subscriptions so that HMR (hot module reloading) works correctly.
Quick note: everyone comes up with their own method names for
setVars
andbindEvents
these are mine. They all do the same thing though, no matter what they are called.
Jump to heading A sample module
First lets write some HTML:
<div data-module="incrementer">
<div data-target>0</div>
<button data-trigger>Add 1</button>
</div>
data-module="incrementer"
loads the./modules/incrementer.js
module. Thedata-target
anddata-trigger
attributes will be used to query those elements.
The "Incrementer" module would look like this:
// modules/incrementer.js
export default class Incrementer {
constructor(el) {
this.el = el
this.setVars()
this.bindEvents()
}
setVars() {
this.counter = this.el.querySelector('[data-target]')
this.button = this.el.querySelector('[data-trigger]')
}
bindEvents() {
this.button.addEventListener('click', this.add)
}
cleanUp() {
this.button.removeEventListener('click', this.add)
}
add = () => {
const content = this.counter.innerHTML
this.counter.innerHTML = parseInt(content) + 1
}
}
The counter will increment by 1 each time the button is clicked. If you would like to try it out for yourself check out this quick demo on GitHub
Jump to heading Wrapping up
That is the data-module
pattern in a nutshell.
First: a loader checks for data-module
attributes on elements and instantiates the corresponding module.
Then: Modules set variables, bind events, and clean up after themselves.