Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active October 2, 2023 13:48
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
Star You must be signed in to star a gist
Save loilo/a3ec6dbb78d42594b45ec7ebd00139c7 to your computer and use it in GitHub Desktop.
Keep pre-existing DOM nodes alive inside Vue components

Keep pre-existing DOM alive in Vue

For my use cases, Vue has one critical pitfall: I frequently have/want to use Vue components with <slot>s as wrappers for content from a CMS which I don't have control over. That is, the content comes over the wire via HTML, and I have to activate Vue for some of it.

<interactive-element>
  <p>Slot content I don't have control over</p>
</interactive-element>

I need to activate the Vue component <interactive-element>.

In particular, "slot content I don't have control over" may include interactive elements with attached event listeners etc. This content will be re-rendered when activating Vue and subsequently, all previous modifications to its DOM — including any DOM references and event listeners — will be lost:

document.querySelector('p').onclick = function () {
  alert('Hello world!')
}

// Clicking the <p> will greet the world

new Vue({ el: 'interactive-element' })

// Clicking the <p> will no longer do anything

That's where this script comes in. It exposes a DomKeepAlive class and an according <dom-keep-alive> Vue component. Unfortunately, just a Vue component won't do the trick, so the activation routine is a little more tricky:

  1. Add <dom-keep-alive> wrappers to the HTML we have control over (the wrapper):

    <interactive-element>
      <dom-keep-alive>
        <p>Slot content I don't have control over</p>
      </dom-keep-alive>
    </interactive-element>
  2. Add some action before and after throwing Vue onto the <interactive-element>:

// Somewhere in your script
const dka = new DomKeepAlive(Vue)

// For each of the Vue instances to activate:

// Pick the element that holds the future Vue instance
const el = document.querySelector('interactive-element')

// Get an initializer for the <dom-keep-alive> nodes *before* activating Vue
const init = dka.prepareRenderTarget(el)

// Activate Vue
const vm = new Vue({
  el,
  // ...
})

// Run the initializer on the Vue root node
init(vm.$el)

This will prevent the content inside the <dom-keep-alive> from ever being re-rendered by Vue.

Some more things to note:

  • The script uses MutationObservers to detect entrance or removal of the contained nodes, so it works even when a <dom-keep-alive> lives inside a v-if branch of the surrounding component.
  • You may have multiple <dom-keep-alive> wrappers inside the same Vue component. However, don't nest them.
  • <dom-keep-alive> content is pretty much handled like v-pre — you cannot use interactive Vue syntax inside it.
  • An actual DOM element needs to be rendered in place of a <dom-keep-alive> placeholder. By default, this will be a <div>, but you can easily change that with the familiar Vue syntax: <dom-keep-alive is="span">.
/**
* Handles DOM nodes to keep alive inside a Vue component
*/
class DomKeepAlive {
/**
* Holds a mapping of all generated IDs to their according child node fragments
*/
protected fragments = new Map<string, DocumentFragment>()
/**
* Registers the <dom-keep-alive /> component in Vue
* @param Vue The Vue constructor to use
*/
public constructor (Vue: any) {
if (!('dom-keep-alive' in Vue.options.components)) {
Vue.component('dom-keep-alive', {
render: createElement => createElement('div')
})
}
}
/**
* Generates a random ID
* @return The generated ID
*/
protected generateId (): string {
// @ts-ignore
return `dom-keep-alive--${([1e7] + -1e3 + -4e3 + -8e3 + -1e11).replace(/[018]/g, c => (c ^ (crypto.getRandomValues(new Uint8Array(1))[0] & (15 >> (c / 4)))).toString(16))}`
}
/**
* Takes the child nodes of an element and transfers them to a DocumentFragment
* @param target The element to take child nodes from
* @return The DocumentFragment containing the target's child nodes
*/
protected childNodesToFragment (target: Element) {
const frag = document.createDocumentFragment()
for (const node of Array.from(target.childNodes)) {
frag.appendChild(node)
}
return frag
}
/**
* Checks if the given element contains an element with the given ID (or has the ID itself)
* @param target The target for looking up the ID
* @param id The ID to check for
* @return The element with the present ID, null otherwise
*/
protected containsId (target: Element, id: string) {
return target.id === id
? target
: target.querySelector('#' + id)
}
/**
* Waits (via MutationObserver) for a certain element ID to be present/gone from the subtree of a target. Resolves immediately if the ID is present/gone from the beginning.
* @param target The element that will be observed for the given ID to be present/gone
* @param id The element ID to look out for
* @param present If to look for an added (true) or removed (false) ID
* @return Resolves when the ID is present/gone
*/
protected waitForId (target: Element, id: string, present: boolean) {
return new Promise<Element|null>((resolve, reject) => {
const el = this.containsId(target, id)
if (present ? el : !el) {
resolve(present ? el : null)
} else {
const observer = new MutationObserver(mutations => {
let foundElement = null
beforeMutations: {
for (const mutation of mutations) {
for (const node of mutation[`${present ? 'added' : 'removed'}Nodes`]) {
if (node instanceof Element) {
const el = this.containsId(node, id)
if (el) {
foundElement = el
break beforeMutations
}
}
}
}
}
if (foundElement) {
observer.disconnect()
resolve(foundElement)
}
})
observer.observe(target, { childList: true, subtree: true })
}
})
}
/**
* Waits for a certain element ID to be present from the subtree of a target. Resolves immediately if the ID is present from the beginning.
* @param target The element that will be observed for the given ID to be present
* @param id The element ID to look out for
* @return Resolves when the ID is present
*/
protected waitForIdPresent (target: Element, id: string) {
return this.waitForId(target, id, true) as Promise<Element>
}
/**
* Waits for a certain element ID to be gone from the subtree of a target. Resolves immediately if the ID is not present from the beginning.
* @param target The element that will be observed for the given ID to be gone
* @param id The element ID to look out for
* @return Resolves when the ID is gone
*/
protected waitForIdGone (target: Element, id: string) {
return this.waitForId(target, id, false)
}
/**
* Prepare a render target element with all its contained keep-alive nodes
* @param renderTarget The render root node
* @return An initializer function that can be passed the Vue root node and handles contained keep-alive nodes
*/
public prepareRenderTarget (renderTarget: Element) {
const ids = []
for (const element of Array.from(renderTarget.querySelectorAll('dom-keep-alive'))) {
ids.push(this.prepareKeepAlive(element))
}
return (vueRoot: Element) => this.initKeepAliveLifecycles(vueRoot, ids)
}
/**
* Prepare a single keep-alive element
* @param keepAliveElement The keep-alive node to prepare
* @return The ID generated for the keep-alive node
*/
public prepareKeepAlive (keepAliveElement: Element) {
const id = this.generateId()
keepAliveElement.setAttribute('id', id)
this.fragments.set(id, this.childNodesToFragment(keepAliveElement))
return id
}
/**
* Initialize the lifecycle of a keep-alive node inside a render target
* @param renderTarget The render target to check for the keep-alive node
* @param keepAliveId The ID the surveilled keep-alive node will have
*/
public async initKeepAliveLifecycle (renderTarget, keepAliveId) {
const appearedEl = await this.waitForIdPresent(renderTarget, keepAliveId)
if (this.fragments.has(keepAliveId)) {
const frag = this.fragments.get(keepAliveId)
appearedEl.appendChild(frag)
}
const removedEl = await this.waitForIdGone(renderTarget, keepAliveId)
this.fragments.set(keepAliveId, this.childNodesToFragment(removedEl))
this.initKeepAliveLifecycle(renderTarget, keepAliveId)
}
/**
* Initialize the lifecycle of multiple keep-alive nodes inside a render target
* @param renderTarget The render target to check for keep-alive nodes
* @param keepAliveIds The IDs the surveilled keep-alive nodes
*/
public initKeepAliveLifecycles (target: Element, keepAliveIds: string) {
for (const id of keepAliveIds) {
this.initKeepAliveLifecycle(target, id)
}
}
}
@johannes-z
Copy link

Thanks for this snippet, it works really well! What I've noticed though is, that the initKeepAliveLifecycles is called sometime after the root instance has rendered, i.e. you cannot reference dom-keep-alive-elements in the mounted hook of your component. A workaround for this I found is referencing these elements before calling dka.prepareRenderTarget(el). Is there a more reliable way to reference those elements, preferably in the mounted hook?

@loilo
Copy link
Author

loilo commented Feb 23, 2021

The whole point of this snippet is to keep nodes in their pre-Vue state, so accessing them before Vue is initialized is probably the best option.

Having elements available in the mounted hook is not possible, because at that point in time Vue has freshly rendered its template, meaning at that point the original nodes are most definitly gone. The MutationObserver only kicks in sometime after that which will lead to the nodes being restored.

However, since the MutationObserver is inherently and deliberately asynchronous, it's impossible to reliably predict when it's going to trigger.

What you could theoretically do is emitting an event on the hosting Vue component whenever the script re-inserts the removed nodes:

// After line 154 of this Gist, insert:
renderTarget.$emit('dom-keep-alive:insert', appearedEl)

Usage:

// ...
mounted() {
  // Use `this.$once()` instead of `this.$on()` because this event may be
  // emitted multiple times (i.e. whenever the relevant part of the
  // template is re-rendered) and we probably only want to handle it once:

  this.$once('dom-keep-alive:insert', element => {
    // The re-inserted nodes are the children of `element`
  })
}

@johannes-z
Copy link

johannes-z commented Feb 23, 2021

Ok thanks. The reason I want to use in the mounted hook is because I'm using Vue for some controls and managing component states, and want to use the state to change the kept alive nodes. In your code renderTarget is of type Element, i.e. $emit doesnt' work. I don't think the current implementation has a reference to the DomKeepAlive-instance nor the root component, right?

@loilo
Copy link
Author

loilo commented Feb 23, 2021

You're absolutely right — sorry, I haven't used this code in quite some time. 😁
An alternative would be to use native DOM events instead of Vue events in this place:

// After line 154 of the Gist, insert:
appearedEl.dispatchEvent(new CustomEvent('insert'))

Then you should be able (haven't tested, so no guarantees) to do this in your template:

<dom-keep-alive v-on:insert.native="onInsert">
  <!-- ... -->
</dom-keep-alive>

And then implement an onInsert method which handles the event:

// ...
methods: {
  onInsert(event) {
    // The <dom-keep-alive> element is available as `event.target`
  }
}

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment