Skip to content

Instantly share code, notes, and snippets.

@loilo
Last active October 2, 2023 13:48
Show Gist options
  • Star 6 You must be signed in to star a gist
  • Fork 1 You must be signed in to fork a gist
  • Save loilo/a3ec6dbb78d42594b45ec7ebd00139c7 to your computer and use it in GitHub Desktop.
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)
}
}
}
@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