Ref Element of Component in Vue.js

The Problem

In Vue.js we use ref to get the real DOM element of a node, like:

1
2
3
4
5
const dom = ref()
// Output: a <div> element
watch(dom, (v) => console.log(v))

<div ref="dom"></div>

However when the refed node is a custom component, we’ll get the component instance instead:

1
2
3
4
5
const dom = ref()
// Output: a component instance
watch(dom, (v) => console.log(v))

<Comp ref="dom"></Comp>

And we can use the $el property of the instance to get the underlying DOM element:

1
2
3
4
5
const dom = ref()
// Output: the root element of the component
watch(dom, (v) => console.log(v.$el))

<Comp ref="dom"></Comp>

So far so good, unless your component has a v-if directive on the root:

1
2
3
4
// Comp.vue
<template>
<div v-if="flag">...</div>
</template>

If flag is not truthy when the component is mounted, we will get a comment node by accessing the $el property:

1
2
// Output: a comment node like <!-- -->
watch(dom, (v) => console.log(v.$el))

Of course when flag turns truthy the $el property will be the rendered <div> element. It’s all reasonable, except that the $el property of a component instance is not reactive and it won’t trigger any watch or computed expressions!

So if you want to attach event listeners to the root element of a component (I know it sounds like bad-smell code but sometimes you have to do that) using pre-defined composables like useEventListener, it may not work because it uses watch under the hood. Basically we have no idea on the parent side when will the root element (with a v-if) of a child component be rendered, unless you manually watch the flag and emit a event from the child component.

The Solution

The solution is using custom directives. As mentioned in the doc, custom directive hooks will be passed the element the directive is bound to as an argument. And in fact, the directive hooks will only be triggered when there is a real HTML element, i.e. when the root element is actually rendered. So we can write a custom directive like this:

1
2
3
4
// binding.value should be a ref
export const vRefElement = (el, binding) => {
binding.value.value = el;
};

And use it like:

1
2
const dom = ref()
<Comp v-ref-element="dom" />

Oops! An error:

1
Uncaught (in promise) TypeError: Cannot set properties of undefined (setting 'value')

The problem here is that Vue unwraps refs in template so the directive receives not the dom ref itself but its inside value, which is undefined! So to avoid this kind of unwrapping, we can wrap it in a plain object:

1
2
3
4
5
const dom = ref()
const domWrap = { dom }
// Output(after flag becomes truthy): <div>
watch(dom, (v) => console.log(v))
<Comp v-ref-element="domWrap.dom" />

Now it works! The dom ref will be filled with a <div> when flag becomes truthy and will trigger all relevant reactive computations.

See a demo here:

https://stackblitz.com/edit/vitejs-vite-3sqrri?file=src/App.vue

评论区