Aspect-Oriented Programming in JavaScript
An example
Let's think about an example. If we had an API for adding and removing users for a system, we may also be interested in keeping a log of every API call made. Here we have the API logic in orange and the logging logic in blue :
If the result of the main logic will never have any effect on the logging, why not split it up into different concerns? We can then have different files that only deal with one specific part of the system, leading to much better maintainability in large codebases:
In AOP, the logger
object is an aspect—a
feature linked to multiple parts of the system, but not part of the core functionality.
In JavaScript
First we can define a simple logging aspect:
const logger = {}
logger.addUser = function (...args) {
// Do logging
console.log('LOGGER: AddUser call ', args)
}
export default logger
Then we define the main API logic, using a special addAspects
function to initialise the API with the logging aspect:
import addAspects from './addAspects.js'
import logger from './logger.js'
const api = addAspects(logger)
api.addUser = function ({ name }) {
// Do API logic (simulating a delay here)
await new Promise(res => setTimeout(res, 1000))
console.log(name + ' added')
}
export default api
If we then use the api, we can see that the logger function (the advice in AOP terms) is called alongside the main function, even though we haven't explicitly called it:
import api from './api.js'
api.addUser({
name: 'Chris',
age: 28
})
LOGGER: AddUser call { name: 'Chris', age: 28 }
Chris added
How does that work?
We're making use of JavaScript Proxy and Reflect to
build addAspects
:
export default function addAspects (...aspects) {
const get = (target, prop, receiver) => {
if (typeof target[prop] !== 'function') {
return Reflect.get(target, prop, receiver)
}
return function (...args) {
aspects.forEach(aspect => aspect[prop](...args))
return Reflect.apply(target[prop], target, args)
}
}
return new Proxy({}, { get })
}
This simply watches for API function calls, and runs the correspondingly named advice when this happens.
So when addUser
is called, logger.addUser
will be called too. This function also allows us to add multiple aspects,
not just one.
Adding timing
We can further improve addAspects
by applying advice in different pointcuts (a location to apply advice). In other words, we can run aspect
functions during different stages of execution:
We can modify our logger aspect to return functions for different pointcuts:
logger.addUser = function (...args) {
return {
before: () => console.log('LOGGER: AddUser begin ', args)
after: () => console.log('LOGGER: AddUser end ', args)
}
}
We can also create a second aspect, this could be for tracking analytics:
const analytics = {}
analytics.addUser = function ({ requestIp }) {
return {
during: () => console.log(`ANALYTICS: ${requestIp} adding user`)
}
}
export default analytics
If we go back to our main API file, change to addAspect(logger, analytics)
, then run addUser
again:
import api from './api.js'
api.addUser({
name: 'Chris',
age: 28,
requestIp: '192.168.1.0'
})
LOGGER: AddUser begin { name: 'Chris', age: 28 }
ANALYTICS: 192.168.1.0 adding user
Chris added
LOGGER: AddUser end { name: 'Chris', age: 28 }
Final addAspects
Our addAspects
function now looks like this:
export default function addAspects (...aspects) {
const get = (target, prop, receiver) => {
if (typeof target[prop] !== 'function') {
return Reflect.get(target, prop, receiver)
}
return async function (...args) {
const run = pointcut => aspects.forEach(aspect => aspect[prop]?.(...args)[pointcut]?.())
run('before')
const result = Reflect.apply(target[prop], target, args)
run('during')
await result
run('after')
return result
}
}
return new Proxy({}, { get })
}
We're using the nullish coalescing operator (?.
) to check if the aspect has a function for the given advice and pointcut,
before running the advice at different stages of execution.
Should I try this?
I think there are specific circumstances where AOP could be quite helpful. Last year I put together Tauque,
a no-config bundler (it's 100x quicker than Webpack, thanks to esbuild!), and addAspects
would have been super helpful in separating out
bundle building and writing to the console.
Be careful
A major problem with aspect-oriented programming is that it's quite easy to obscure what's going on under the hood. Unless a programmer has knowledge of the implementation of the aspects, it could be difficult to find the source of problems and debug. Use with moderation!