June 19th, 2022
When developing web applications one powerful strategy that is very rewarding in value is the Visitor Design Pattern. This post will go over the Visitor Pattern in JavaScript and knock away some important concepts and techniques that every JavaScript developer must know when using the Visitor.
In my experience the Visitor is one of the most complex patterns to understand both in code and in a visual perspective when starting out but is actually not that bad once you get the hang of it.
We mostly find visitors implemented in libraries or frameworks so if you haven't used many libraries or frameworks you might have not worked with visitors yet. They're most often useful when library authors seek extensibility.
There are two main participants required to complete the visitor pattern (not including the client code):
accept
method (by convention we name the method "accept"
)visit
method. This is where they run their logic on elements that they are interested in.Objects that implement the visit
method take the element
(or more formally the node) in question as an argument. It is at this time where the visitor can perform their desired logic to objects they are interested in.
If we were authoring a library and we provide a Visitor that will have its visit
method called during a traversal (or some looping operation) then we can easily implement some form of extensibility to clients through this call.
For example, lets say we have this collection of elements (which represent DOM nodes--but are not actual DOM nodes):
[
{ "tagName": "div", "style": { "width": "28.5px", "height": "20px" } },
{ "tagName": "label", "style": { "width": "28.5px", "height": "20px" } },
{
"tagName": "div",
"style": { "width": "28.5px", "height": "20px" },
"children": [
{
"tagName": "div",
"style": { "width": "28.5px", "height": "20px" },
"children": [
{
"tagName": "input",
"style": { "width": "28.5px", "height": "20px" }
},
{
"tagName": "input",
"style": { "width": "28.5px", "height": "20px" }
},
{
"tagName": "select",
"style": { "width": "28.5px", "height": "20px" }
},
{
"tagName": "div",
"style": { "width": "28.5px", "height": "20px" },
"children": [
{
"tagName": "input",
"style": { "width": "28.5px", "height": "20px" }
},
{
"tagName": "div",
"style": { "width": "28.5px", "height": "20px" },
"children": [
{
"tagName": "a",
"href": "https://google.com",
"target": "_blank"
}
]
},
{
"tagName": "select",
"style": { "width": "28.5px", "height": "20px" }
}
]
},
{
"tagName": "input",
"style": { "width": "28.5px", "height": "20px" }
}
]
}
]
}
]
Lets say we were building a tiny, simple JavaScript library that lets consumers of our code provide any collection of elements and give them the ability to easily provide their own functions that can transform their keys/values as they wish.
We can provide an API that iterates through every element in the DOM tree and allow them to pass in their functions as transformers to manipulate nodes they are interested in.
First we will define our base Node
class that takes in the element
and stores it internally:
class Node {
constructor(value) {
this.value = value
}
accept(visitor) {
visitor.visit(this)
}
}
Our Node
class defines the accept
method that calls visitors via their visit
method. Additionally it passes itself in via this
as arguments so visitors are able to freely manipulate the original element.
Next we will have a base Visitor
class that all future visitors will derive from:
class Visitor {
visit(node) {}
}
With this in place we are now in the position to start creating visitors.
Lets start with a SelectOptionsVisitor
visitor. This visitor will be interested in select elements and let clients set custom options:
class SelectOptionsVisitor extends Visitor {
constructor(options) {
super()
this.options = options
}
visit(node) {
if (node.value.tagName === 'select') {
node.value.options = this.options.map((option) => ({
tagName: 'option',
value: option,
text: option[0].toUpperCase() + option.slice(1),
}))
}
}
}
Lets be the client and create an instance of it. We will make our visitor set all select options to "Monday"
, "Tuesday"
, "Wednesday"
, "Thursday"
, "Friday"
, "Saturday"
and "Sunday"
:
const selectOptionsVisitor = new SelectOptionsVisitor([
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
])
Now we need some way for callbacks to land on each element in some loop operation. Libraries such as babel provide a utility named something like traverse to do this.
Let's demonstrate with our own simple traverser that will iterate each node and call their accept
method:
const traverse = function traverseNodes(elements, visitorsProp) {
const visitors = []
if (visitorsProp) {
;(Array.isArray(visitorsProp) ? visitorsProp : [visitorsProp]).forEach(
(visitor) => visitors.push(visitor),
)
} else {
throw new Error(`No visitors to run`)
}
const nodes = elements.map((element) => {
const node = new Node(element)
for (const visitor of visitors) {
node.accept(visitor)
if (node.value.children) {
traverse(node.value.children, visitors)
}
}
return node
})
return {
[Symbol.for('nodejs.util.inspect.custom')]() {
return this.toJSON()
},
toJSON() {
return nodes.map((node) => node.value)
},
toString() {
return JSON.stringify(this.toJSON())
},
}
}
Now lets use our new traverse
function and pass it our list of elements we had in our first snippet:
const selectOptionsVisitor = new SelectOptionsVisitor([
'Monday',
'Tuesday',
'Wednesday',
'Thursday',
'Friday',
'Saturday',
'Sunday',
])
const traversed = traverse(elems, [selectOptionsVisitor])
console.log(traversed.toJSON())
Now if we look at our select element, we will notice that it was transformed to include the seven days of the week as options
:
If you're like me you might have weird habit of associating different patterns to different fruits. Yes, fruits like apples and bananas. I associate the Visitor pattern with an Apple because just like apples they provide many benefits such as:
Whenever visitors remove or add new nodes to the tree they must update all visitors otherwise they will cause errors.
If the classes that work with the tree don't implement the accept
operations then the visitor will no longer work for them.
Most libraries don't implement these nodes as immutable objects so be aware of making any side effects!
The visitor should never be aware of the tree structure of the nodes. Elements should be allowed to call visitors on any of its underlying elements (children for example).
Since visitors can run operations on certain objects they are interested in they can be seen as dispatching "commands" that trigger depending on the object or class.
When working with tree structures (like an Abstract Syntax Tree for example) they're usually implemented as composite structures so that the client code can work with all objects. The visitor pattern shares a similar goal so it is a powerful practice to combine the visitor and composite pattern together.
Another powerful combination is the iterator and visitor pattern together. With the iterator pattern there is a usually an interface exposed to the client to inject their own algorithms to iterate each object. There are some limitations to this approach like being able to deeply access children of any subtrees.
The visitor pattern can help fill that void with recursion so it is very powerful. Click here to see an example.
And that concludes the end of this point! I hope you found this to be valuable and look out for more in the future!
Tags
© jsmanifest 2023