How does Viget JavaScript?
Leo Bauza, Former Front-End Development Technical Director
Article Categories:
Posted on
The data-module pattern explainer no one really asked for.
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 listnerers 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
and bindEvents
these are mine. They all do the same thing though, no matter what they are called.
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. The data-target
and data-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
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.