Diving into the Virtual DOM


This article is available as a screencast!

In this article, we do a dive into the virtual dom in Vue.js 3, and how we can traverse it with the goal of finding a specific component (or what we will call a vnode - more on this soon).

Most of the time, you don’t need to think about how Vue internally represents your components. Some libraries do make sure of this though - one such library is Vue Test Utils and it’s findComponent function. Another such use case is the Vue DevTools, which show you the component hierarchy for your application, seen on the left hand side of this screenshot.

Note: this is a technical article. While I will do my best to explain how everything works, the only real way to fully grasp this is to write your own code and console.log a lot, to see what’s really happening. This is often the nature of the type of recursive algorithm we will be writing.

An alternative would be to watch the accompanying screencast, will I will make free indefinitely. You can find the source code for this example here.

The Virtual DOM

For various reasons, one of which is performance, Vue keeps an internal representation of the component hierarchy. This is called a Virtual DOM (VDOM). When something changes (for example a prop) Vue will figure out if something needs to be updated, calculate the new representation, and finally, update the DOM. A trivial example might be:

<div>
  <span v-if="show">Visible</span>
</div>

It could be represented like this:

- div
  - span (show: true)
    - 'Visible'

So it would be HTMLDivElement -> HTMLSpanElement -> TextNode. If show becomes false, Vue would update it’s Virtual DOM:

- div
  - span (show: false)
    - 'Visible'

Then, finally, Vue would update the DOM, removing the <span>.

Our Goal - findComponent

Our goal will be to implement a subset of findComponent, part of the Vue Test Utils API. We will write something like this:

const { createApp } = require('vue')

const App = {
  template: `
    <C>
      <B>
        <A />
      </B>
    </C>
  `
}

const app = createApp(App).mount('#app')

const component = findComponent(A, { within: app })
// we found <A />! We can make some assertions, etc.

To have a working findComponent function, we need to traverse the Virtual DOM, a tree like structure of arbitrary depth. Let’s get started.

Inspecting the Component Internals

If you would like to follow along, you can grab the source code here. We will just use Node.js (v14, so we can use the ? or “optional chaining” operator). You will need Vue, jsdom and jsdom-global installed.

Start with setting up a simple app with some components:

// import jsdom-global. We need a global `document` for this to work.
require('jsdom-global')()
const { createApp, h } = require('vue')

// some components
const A = { 
  name: 'A',
  data() {
    return { msg: 'msg' }
  },
  render() {
    return h('div', 'A')
  }
}

const B = { 
  name: 'B',
  render() {
    return h('span', h(A))
  }
}

const C = { 
  name: 'C',
  data() {
    return { foo: 'bar' }
  },
  render() {
    return h('p', { id: 'a', foo: this.foo }, h(B))
  }
}

// mount the app!
const app = createApp(C).mount(document.createElement('div'))

We start of with some imports to ensure we have a globally available document to work with. This will let us have somewhere to mount our app. We set up some simple components - we are using render functions instead of template because it simplifies this example a little bit. We will discuss why that is later on. Finally, we create the app.

Some of the components have data and props - this will be useful as we investigate the Virtual DOM Vue creates for our app.

If you go ahead and do either console.log(app) or console.log(Object.keys(app)), you don’t see anything - just {}. Object.keys will only show enumerable properties - ones that show up in a for...of loop. There are some hidden non enumerable properties, though, which we can console.log. Try doing console.log(app.$). You get a whole bunch of information:

<ref *1> {
  uid: 0,
  vnode: {
    __v_isVNode: true,
    __v_skip: true,
    type: {
      name: 'C',
      data: [Function: data],
      render: [Function: render],
      __props: []
    },

    // hundreds of lines ...

You can do console.log(Object.keys(app.$)) to have a summary of what’s available:

Press ENTER or type command to continue
[
  'uid',         'vnode',       'type',
  'parent',      'appContext',  'root',
  'next',        'subTree',     'update',
  'render',      'proxy',       'withProxy',
  'effects',     'provides',    'accessCache',
  'renderCache', 'ctx',         'data',
  'props',       'attrs',       'slots',
  'refs',        'setupState',  'setupContext',
  'suspense',    'asyncDep',    'asyncResolved',
  'isMounted',   'isUnmounted', 'isDeactivated',
  'bc',          'c',           'bm',
  'm',           'bu',          'u',
  'um',          'bum',         'da',
  'a',           'rtg',         'rtc',
  'ec',          'emit',        'emitted'
]

It’s obvious what some of the properties do - slots and data for example. suspense is used for the new <Suspense> feature. emit is something every Vue dev knows, same as attrs. bc, c, bm etc are lifecycle hooks - bc is beforeCreate, c is created. There are some internal only lifecycle hooks, like rtg - it’s renderTriggered, used for updates after something changes and causes a re-render, like props or data changing.

We are interested in vnode, subTree, component, type and children.

Comparing Components

Let’s take a look at vnode. Again it has many properties, the two we will look at are type and component:

console.log(app.$.vnode.component)

<ref *1> {
  uid: 0,
  vnode: {
    __v_isVNode: true,
    __v_skip: true,
    type: {
      name: 'C',
      data: [Function: data],
      render: [Function: render],
      __props: []
    },

    // ... many more things ...
  }
}

type is of interest! It matches the C component we defined earlier. You can see it has a data function (we defined one with a msg variable). In fact, if we compare this to C:

console.log(C === app.$.vnode.component.type) //=> true

It matches! We can also do the same comparison with vnode.type: console.log(C === app.$.vnode.type) //=> true. I am not entirely clear on why there is two properties pointing to the same object. I am still learning. Anyway, we identified a way to match components.

Diving Deeper into the Virtual DOM

After a little trial and error, you can eventually find A like this:

console.log(
  app.$
  .subTree.children[0].component
  .subTree.children[0].component
  .type === A) //=> true

There is a pattern emerging - subTree -> children -> component. children can be an array. It is going to be an array of vnode. Consider this structure:

<div>
  <span />
  <span />
</div>

In this case, the <div> node would have a subTree.children array with a length of 2.

Now we know the recursive nature of the Virtual DOM, we can write a recursive solution!

Writing findComponent

I am using Node.js v14, which supports optional chaining: subTree?.children for example. Before we write a recursive find function, we need some way to know if we have found the component: matches:

function matches(vnode, target) {
  if (!vnode) {
    return false
  }

  return vnode.type === target
}

You could write vnode?.type === target but I like the verbose one a little more.

We will write two functions. findComponent, which will be the public API that users call, and find, an internal, recursive function.

Let’s start with the public API:

function findComponent(comp, { within }) {
  const result = find([within.$], comp)
  if (result) {
    return result
  }
}

Since we know children can be an array, we will make the first argument to the find function an array of vnodes. The second will be the component we are looking for. Because the initial starting vnode, app.$, is a single vnode, we just put it in an array to kick things off.

The third argument is an empty array - because are writing a recursive function, we need some place to keep the components we have found that match the target. We will store them in this array, passing it to each recursive call of find. This way we avoid mutating an array - I find less mutation leads to less bugs (your mileage may vary).

Recursive find

When writing a recursive function, you need to have some way to exit, or you will get stuck in an endless loop. Let’s start with that:

function find(vnodes, target) {
  if (!Array.isArray(vnodes)) {
    return 
  }
}

If we have recursed all the way to the bottom of the Virtual DOM (and checked all vnodes in the process) we just return. This will ensure we do not get stuck in a loop. If we run this now:

const result = findComponent(A, { within: app }) //=> undefined

Of course we find nothing. Let’s dive into subTree. Because we are working with an array of vnodes (if children is an array, which it always will be in this small example), we can use reduce to iterate over them. If you don’t understand reduce well, you will need to look it up and understand it to get what is happening here.

While traversing the vnodes, if we find a matching component, we will just return it. If we did not find a matching component, we may need to dive deeper by checking if vnode.subTree.children is defined. Finally, if it’s not, we return the accumulator.

function find(vnodes, target) {
  if (!Array.isArray(vnodes)) {
    return 
  }

  return vnodes.reduce((acc, vnode) => {
    if (matches(vnode, target)) {
      return vnode
    }

    if (vnode?.subTree?.children) {
      return find(vnode.subTree.children, target)
    }

    return acc
  }, {})
}

If you do a console.log inside of the if (vnode?.subTree?.children) { block, you will see we are now at the B component subTree. Remember, the path to A is as follows:

app.$
  .subTree.children[0].component
  .subTree.children[0].component
  .type === A) //=> true

By calling find again: find(vnode.subTree.children, target), the first argument to find on the next iteration will be app.$.subTree.children, which is an array of vnodes. That means we need to do component.subTree.children on the next iteration of find - but we are only checking vnode.subTree.children. We need a check for vnode.component.subTree as well:

function find(vnodes, target) {
  if (!Array.isArray(vnodes)) {
    return 
  }

  return vnodes.reduce((acc, vnode) => {
    if (matches(vnode, target)) {
      return vnode
    }

    if (vnode?.subTree?.children) {
      return find(vnode.subTree.children, target)
    }

    if (vnode?.component?.subTree) {
      return find(vnode.component.subTree.children, target)
    }

    return acc
  }, {})
}

And somewhat surprisingly, that’s it. const result = findComponent(A, { within: app }) now returns a reference to A. You can see it working like this:

console.log(
  result.component.proxy.msg
) // => 'msg'

If you have used Vue Test Utils before, you may recognise this in a slightly different syntax: wrapper.vm.msg, which is actually accessing the proxy internally (for Vue 3) and the vm for Vue 2. If you are using TypeScript, you may notice proxy does not show up as a valid type - that’s because it is internal, not generally intended for use in regular applications, although some internal tools still use it, like Vue Test Utils and Vue DevTools.

A More Complete Example

This implementation is far from perfect - there are more checks that need to be implemented. For example, this does not work with components using template instead of render, or <Suspense>. A more complete implementation can be found here in the Vue Test Utils source code.

At the time of this article, the implementation there mutates an array instead of passing a new copy to each recursive call. You can see it here - the functions you want to look at are findAllVNodes and aggregateChildren.

You can find the source code for this example here.


Get occasional emails about new content and blog posts.
Absolutely no unsolicted spam. Unsubscribe anytime.
Thanks for registering!