The Power of Visitor Design Pattern in JavaScript

Christopher T.

June 19th, 2022

Share This Post

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):

  1. The elements that have an accept method (by convention we name the method "accept")
  2. The visitors that define the 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:

power-of-visitor-select-options-node-manipulation.png

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:

  1. Open/Closed Principle - As considered by Robert C. Martin, this principle is the “the most important principle of object-oriented design”. The visitor allows developers to introduce new behavior where they can work with difference objects of different classes without changing their implementation.
  2. Single Responsibility - You can move multiple versions of the same behavior into the same class. Since visitors implement their logic in a block that can't be accessed directly from the outside, it's easy to have them focus on one goal.
  3. When working with various objects, visitors can gather very useful information which can become very convenient when working with complex tree structures. The YAML JavaScript library takes this even further with async support.

Things to Watch Out For

  • 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).

Comparisons with other patterns

Command Design Pattern

command-design-pattern-in-javascript

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.

Composite Design Pattern

composite-design-pattern-in-javascript

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.

Iterator Design Pattern

iterator-design-pattern

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.

Conclusion

And that concludes the end of this point! I hope you found this to be valuable and look out for more in the future!


Top online courses in Web Development

Tags


Read every story from jsmanifest (and thousands of other writers on medium)

Your membership fee directly supports jsmanifest and other writers you read. You'll also get full access to every story on Medium.

Subscribe to the Newsletter

Get continuous updates

Mediumdev.toTwitterGitHubrss

© jsmanifest 2023