How we used JSDoc & Webpack to write some custom JavaScript decorators & annotations
Thomas Beverley
Posted on May 19, 2022
This article was originally posted on the Wavebox blog
At Wavebox, we use JavaScript for some of our code and we came across an interesting problem (and solution) this week when trying to export some data.
We encapsulate a lot of our data in JavaScript classes/models, this means we can store sparse data and access it through the models, with the models automatically substituting defaults and creating more complex getters for us. As part of a new feature, we want to be able to share of some this data, but not all of it... and this is where we came up with an interesting solution that involves JSDoc decorators & annotations...
The models
We store most of our data structures in classes that wrap the raw data, a simple model looks something like this...
class App {
constructor (data) {
this.__data__ = data
}
get id () { return this.__data__.id }
get name () { return this.__data__.name || 'Untitled' }
get nameIsCustom () { return Boolean(this.__data__.name) }
get lastAccessed () { return this.__data__.lastAccessed || 0 }
}
const app = new App({ id: 123, name: 'test', lastAccessed: 1000 })
The __data__
variable holds the raw JavaScript object and when accessing something in the model, we normally use a getter that provides the value.
In the above example, we've got some basic getters that just return some data like id
. We've also got some getters that return a default if the value doesn't exist like name
and lastAccessed
.
These models form the a core part of how we manage data and ensure that we don't need to check for undefined's throughout the code, substitute default values and so forth.
Exporting some of the data
We've been working on a new feature that will allow you to share some of your models, but there's a problem. We only want to share some of the data. In our simple App example above, there are some fields we want to share and some we don't...
-
id
&name
these are good to share 👍 -
nameIsCustom
this just works by reading the name field, don't share 🚫 -
lastAccessed
we don't want to share this 🙅♂️
So lets looks at the most basic example, we can drop nameIsCustom by just reading the raw __data__
object...
console.log(app.__data__)
// { id: 123, name: 'test', lastAccessed: 1000 }
...but this still gives us the lastAccessed
field that we don't want. So we went around writing an export function that looks more like this...
class App {
...
getExportData () {
const { lastAccessed, ...exportData } = this.__data__
return exportData
}
}
...looks great. It works! But I predict a problem...
Keeping the code maintainable
The getExportData()
function works great, but there's a problem. Some of our models are quite large, and these models will have new fields added in the future. Future me, or future anyone else working on the code is guaranteed to forget to add another exclude to that function and we're going to get a bug. Not so great. So I started thinking about ways we could make this a little bit more maintainable.
Big changes to the models were out of the question, we started with this pattern quite some ago and there are tens of thousands of uses of the models through the code, so whatever we come up with needs to have minimal impact everywhere.
This got me thinking about decorators. I was thinking about a way that I could generate a list of properties to export in the same place that they're defined. This would improve maintainability going forwards.
I came up with some pseudo code in my head that looked something like this...
const exportProps = new Set()
function exportProp () {
return (fn, descriptor) => {
exportProps.add(descriptor.name)
}
}
class App {
@exportProp()
get id () { return this.__data__.id }
@exportProp()
get name () { return this.__data__.name || 'Untitled' }
get nameIsCustom () { return Boolean(this.__data__.name) }
get lastAccessed () { return this.__data__.lastAccessed || 0 }
}
const app = new App({})
Object.keys(app).forEach((key) => { app[key })
console.log(Array.from(exportProps))
// [id, name]
...you can decorate each getter with @exportProp
which is nice, but the implementation is far from ideal. In fact it's the sort of code that gives me nauseous 🤢. To begin with, the exported properties now need to run through a decorator before being accessed, there's going to be a performance hit for this. Also to generate the list, you need to create an empty object and iterate over it, although there's nothing wrong with this, it didn't feel particularly nice.
So I started to think about how else we could achieve a similar pattern...
Using JSDoc
This is when I started to think, could we use JSDoc to write some annotations at build time? Doing this would remove the need to generate anything at runtime, keeping the getters performant and allowing us to add an annotation to each property in-situ as required.
I started to play around and came up with this...
class App {
/**
* @export_prop
*/
get id () { return this.__data__.id }
/**
* @export_prop
*/
get name () { return this.__data__.name || 'Untitled' }
get nameIsCustom () { return Boolean(this.__data__.name) }
get lastAccessed () { return this.__data__.lastAccessed || 0 }
}
Okay, the comments now span a few more lines, but if it satisfies all the other requirements I can live with that. If we run JSDoc over the file, we get something like this...
[{
"comment": "/**\n * @export_prop\n */",
"meta": {
"filename": "App.js",
"lineno": 61,
"columnno": 2,
"path": "/src/demo",
"code": {
"id": "astnode100000128",
"name": "App#id",
"type": "MethodDefinition",
"paramnames": []
},
"vars": { "": null }
},
"tags": [{
"originalTitle": "export_prop",
"title": "export_prop",
"text": ""
}],
"name": "id",
"longname": "App#id",
"kind": "member",
"memberof": "App",
"scope": "instance",
"params": []
}, ...]
...and hey presto! We get the getter name, and in the list of tags is the export_prop annotation we added. A little bit of looping around on this and we can generate a nice list of property names to export.
Mixing JSDoc & Webpack
You could write a pre-build script to write the docs into a file and then read that in at compile time, but where's the fun in that? We use Webpack for our bundling needs, which means we can write a custom loader. This will run JSDoc over the file for us, play around with the data a little bit and give us a nice output. We can use this output to configure which data comes out the model.
So our Webpack loader can look something a little bit like this, it just runs JSDoc over the input file, strips out everything we don't need and writes the output as a JSON object...
const path = require('path')
const jsdoc = require('jsdoc-api')
module.exports = async function () {
const callback = this.async()
try {
const exportProps = new Set()
const docs = await jsdoc.explain({ files: this.resourcePath })
for (const entry of docs) {
if (entry.kind === 'member' && entry.scope === 'instance' && entry.params && entry.tags) {
for (const tag of tags) {
if (tag.title === 'export_prop') {
exportProps.add(entry.name)
break
}
}
}
}
callback(null, 'export default ' + JSON.stringify(Array.from(exportProps)))
} catch (ex) {
callback(ex)
}
}
...and we just need to update our webpack config to use the loader...
config.resolveLoader.alias['export-props'] = 'export-props-loader.js'
config.module.rules.push({
test: /\*/,
use: {
loader: 'export-props'
}
})
...great! That's all the hard work done. Now we can add this to our App model and see what we get out!
import exportProps from 'export-props!./App.js'
class App {
/**
* @export_prop
*/
get id () { return this.__data__.id }
/**
* @export_prop
*/
get name () { return this.__data__.name || 'Untitled' }
get nameIsCustom () { return Boolean(this.__data__.name) }
get lastAccessed () { return this.__data__.lastAccessed || 0 }
getExportData () {
return exportProps.reduce((acc, key) => {
if (this.__data__[key] !== undefined) {
acc[key] = this.__data__[key]
}
return acc
}, {})
}
}
const app = new App({ id: 123, name: 'test', lastAccessed: 1000 })
console.log(app.getExportData())
// { id: 123, name: 'test' }
Hey presto! There it is! Using JSDoc we can generate the list of properties to export at compile time, serialise those into an array and read that out at runtime. We can then use that list to only include what we want in the exported data 👍.
The really great thing is, that we can define which properties are exported next to where they are declared in the hope a future dev will be able to continue along with with pattern.
Taking it one step further
Maybe you've got some properties that need more configuration, or some special behaviours... You can change some of the annotations to look something like this...
class App {
/**
* @export_prop isSpecial=true
*/
get id () { return this.__data__.id }
}
...and then in your loader use...
if (tag.title === 'export_prop') {
if (tag.value === 'isSpecial=true') {
// Do something special
} else {
exportProps.add(entry.name)
}
break
}
If you need it, this gives a way to configure what each one does.
Wrapping up
I thought I'd share this neat little trick, because once you've got the pattern setup it's trivially easy to use. I mean sure, it's a complete mis-use of JSDoc, comments and Webpack loaders, but it works flawlessly, runs at compile time and helps keep our code maintainable. It's a win win!
Posted on May 19, 2022
Join Our Newsletter. No Spam, Only the good stuff.
Sign up to receive the latest update from our blog.
Related
May 19, 2022