How does Viget JavaScript?

By Leo

12 March, 2021 @ 10:00

The data-module pattern explainer no one really asked for

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 and bindEvents 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. 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

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.