| |

Vue.js 90 🟢 Virtual DOM-less Compilation: Vapor Mode Architecture

⏱️ Estimated read: 10 min | |

Vapor Mode is Vue’s second compilation target. The default Vue compiler produces code that creates VNodes, and the runtime diffs those VNodes to determine what changed. Vapor Mode skips both steps: the template compiler analyzes the template at build time and emits JavaScript that creates DOM nodes directly and binds reactive effects to the specific nodes that depend on them. There is no VNode tree, no patch function, and no diffing algorithm in the runtime. The mode is opt-in per component via the vapor attribute on <script setup>, and it is designed to coexist with the VDOM mode in the same application through an interop plugin.

This chapter covers the compilation pipeline that replaces VNodes with direct DOM operations, the two ways to adopt Vapor (pure and mixed), the interop boundary that determines the cost of coexistence, and the feature subset that Vapor supports by design.

Key point: Vapor Mode moves the work from runtime to compile time. The compiler knows the template structure, so it can generate direct DOM operations and fine-grained reactive bindings. The trade-off is that Vapor supports a subset of Vue’s features and has a separate runtime that must be opted into explicitly.


Why Vapor Mode exists

The VNode overhead problem. In VDOM mode, a page with 1,000 elements requires creating 1,000 VNode objects in addition to the 1,000 real DOM nodes. The VNodes are discarded after diffing, and new ones are created on the next render. The memory and allocation cost is paid on every update, even when the structure is unchanged.

The diffing problem. The runtime diff algorithm compares the new VNode tree to the old one to find what changed. For a large page with many dynamic nodes, the diff itself becomes a performance bottleneck. Vapor Mode eliminates the diff by having the compiler generate code that updates the exact nodes that can change, without comparing trees.

The runtime size problem. The VDOM runtime contains the VNode creation functions, the diff algorithm, the scheduler, and the patch functions. Vapor Mode’s runtime is significantly smaller because it does not need any of those. The reported reduction is around 53% in runtime size, with a Hello World bundle dropping from approximately 22.8 kB to 7.9 kB.


a. The Compilation Pipeline

The Vapor compiler transforms a Vue SFC into a block—a runtime structure that holds references to DOM nodes and reactive effects. The template is analyzed to identify which expressions are dynamic and which nodes they belong to. The compiler emits code that creates the static DOM structure once and sets up watchEffect calls that update only the dynamic parts.

// Conceptual output of the Vapor compiler
// Template: <div><h1>{{ title }}</h1><p>{{ body }}</p></div>

const block = createBlock(() => {
  const div = document.createElement('div')
  const h1 = document.createElement('h1')
  const p = document.createElement('p')
  div.append(h1, p)

  // Effect for {{ title }}
  watchEffect(() => { h1.textContent = title.value })
  // Effect for {{ body }}
  watchEffect(() => { p.textContent = body.value })

  return div
})

The effect is bound to the specific text node. When title changes, only h1.textContent is updated. There is no tree traversal and no comparison.


b. Opting In and Interop

A component opts into Vapor Mode by adding the vapor attribute to <script setup>. The component can then be used in two contexts.

Pure Vapor application. createVaporApp() creates an application instance that does not include the VDOM runtime. The bundle contains only the Vapor runtime and the compiled components.

import { createVaporApp } from 'vue'
import App from './App.vue'

createVaporApp(App).mount('#app')

Mixed application. An existing VDOM application can use Vapor components by installing the vaporInteropPlugin. The plugin allows Vapor and VDOM components to nest inside each other. The trade-off is that the VDOM runtime remains in the bundle, so the size reduction is not realized.

import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createApp(App)
  .use(vaporInteropPlugin)
  .mount('#app')

The Vue team recommends keeping Vapor and VDOM components in distinct regions of the application rather than mixing them deeply. Every interop crossing has overhead, and a list of nested VDOM components inside a Vapor loop multiplies the crossings. The boundary should be placed above the repeated region, not inside it.


c. Feature Subset and Limitations

Vapor Mode is feature-complete in Vue 3.6, but it supports a subset of Vue’s APIs by design. The features that depend on VNodes or the component public instance proxy are not available.

Unsupported FeatureReason
Options APIVapor only supports <script setup>
app.config.globalPropertiesDepends on the public instance proxy
getCurrentInstance()Returns null in Vapor components
@vue:xxx lifecycle eventsPer-element lifecycle depends on VNodes
v-memoVapor’s fine-grained updates make it unnecessary
Component template refsDo not expose $el, $props, $attrs, $slots, $refs

The getCurrentInstance() limitation has a practical consequence for libraries. A composable that calls getCurrentInstance() to detect component context will receive null inside a Vapor component. Libraries that need instance-aware behavior must use an alternative detection mechanism, such as reading Vue’s internal currentInstance export.

Custom directives also have a different interface in Vapor. The directive receives the element and a reactive getter for the binding value, and it can return a cleanup function. The directive author sets up watchEffect internally, and the effect is automatically released when the component unmounts.


Complete Example Session

// ============================================
// PART 1: OPTING IN
// ============================================
// <script setup vapor>
// import { ref } from 'vue'
// const count = ref(0)
// </script>
// <template><button @click="count++">{{ count }}</button></template>


// ============================================
// PART 2: PURE VAPOR APP
// ============================================
import { createVaporApp } from 'vue'
import App from './App.vue'

createVaporApp(App).mount('#app')
// No VDOM runtime in the bundle


// ============================================
// PART 3: MIXED APP
// ============================================
import { createApp, vaporInteropPlugin } from 'vue'
import App from './App.vue'

createApp(App)
  .use(vaporInteropPlugin)
  .mount('#app')
// VDOM runtime remains; Vapor components can be used


// ============================================
// PART 4: BOUNDARY PLACEMENT
// ============================================
// ❌ BAD: VDOM component inside a Vapor v-for
// <div v-for="item in items">
//   <VdomCheckbox :item="item" />
// </div>

// ✅ GOOD: Vapor loop inside a VDOM wrapper
// <VdomList :items="items" />


// ============================================
// PART 5: CUSTOM DIRECTIVE
// ============================================
const MyDirective = (el, source) => {
  watchEffect(() => {
    el.textContent = source()
  })
  return () => console.log('cleanup')
}
// source is a reactive getter, not a static value


// ============================================
// PART 6: UNSUPPORTED FEATURES
// ============================================
// ❌ Options API
// ❌ app.config.globalProperties
// ❌ getCurrentInstance() returns null
// ❌ @vue:xxx per-element lifecycle


// ============================================
// PART 7: EVENT DELEGATION CAVEAT
// ============================================
// Vapor delegates events to document
// stopPropagation() in an ancestor prevents delegation
// <button @[event]="onClick" /> bypasses delegation


// ============================================
// PART 8: SLOTS ARE NOT INSPECTABLE
// ============================================
// ❌ slots.default() executes the slot
// ✅ Let the template render the slot

The eight parts trace the opt-in, the two application modes, the boundary rule, and the notable limitations.


Quick Reference

Opting In

MethodSyntax
Script setup<script setup vapor>
Script shorthand<script vapor>
Template marker<template vapor>
Pure appcreateVaporApp(App)
Mixed appcreateApp(App).use(vaporInteropPlugin)

Vapor vs. VDOM

AspectVDOMVapor
VNode treeYesNo
Diff algorithmYesNo
Runtime sizeBaseline~53% smaller
Bundle (Hello World)~22.8 kB~7.9 kB
Fine-grained updatesComponent-levelNode-level
Options APISupportedNot supported

Unsupported Features

FeatureStatus
Options APINot supported
globalPropertiesNot supported
getCurrentInstance()Returns null
@vue:xxx lifecycleNot supported
v-memoNot supported
Async ComponentPending
Transition / KeepAlivePending
SuspenseNot in Vapor-only mode
SSR hydrationNot yet (Nuxt incompatible)

Best Practices

✅ Do This:

// Use createVaporApp for new, self-contained apps
createVaporApp(App).mount('#app')                                     // ✅
// Place the interop boundary above repeated regions
<VdomList :items="items" />  // one crossing, not N                     // ✅
// Use reactive getters in custom directives
const MyDirective = (el, source) => { watchEffect(() => { ... }) }    // ✅

❌ Don’t Do This:

// Nest VDOM components inside a Vapor v-for
<VdomComp v-for="i in items" />  // N crossings                        // ❌
// Call getCurrentInstance() in a Vapor component
getCurrentInstance()  // returns null                                 // ❌
// Call slots.default() to inspect slot output
slots.default?.()  // executes the slot, creates DOM                    // ❌

Common Pitfalls

PitfallWhy It HappensFix
Interop overhead in loopsVDOM component inside Vapor v-forMove boundary above loop
getCurrentInstance() is nullVapor does not expose instance proxyUse internal currentInstance detection
Slot inspection breaksslots.default() executes the slotLet template render the slot
Event handler not firingAncestor called stopPropagationUse direct binding form
Bundle size not reducedvaporInteropPlugin pulls in VDOM runtimeUse createVaporApp for pure apps

Real-World Examples

1. Opt-In

<script setup vapor>
const count = ref(0)
</script>

2. Pure Vapor App

createVaporApp(App).mount('#app')

3. Mixed App

createApp(App).use(vaporInteropPlugin).mount('#app')

4. Boundary Above Loop

<VdomList :items="items" />

5. Custom Directive

const MyDirective = (el, source) => { watchEffect(() => { el.textContent = source() }) }

6. Check Instance

getCurrentInstance() // null in Vapor

7. Slot Rendering

<template><slot /></template>

8. Event Delegation

<button @[event]="onClick" />

9. Bundle Size Check

vite build --mode production

10. Opt-Out for One Component

<script setup> <!-- no vapor attribute -->

Visual

VDOM vs. Vapor Compilation

┌──────────────────────────────────────────────────────────────┐
│  VDOM MODE                       VAPOR MODE                  │
│                                                              │
│  Template                        Template                     │
│     │                               │                        │
│     ▼                               ▼                        │
│  createVNode tree                createBlock + effects       │
│     │                               │                        │
│     ▼                               ▼                        │
│  patch(old, new)                 h1.textContent = ...        │
│     │                               │                        │
│     ▼                               ▼                        │
│  DOM update                      DOM update                  │
│                                                              │
│  VNode creation + diff           Direct DOM operation        │
│  Runtime overhead                Compile-time overhead       │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Interop Boundary

┌──────────────────────────────────────────────────────────────┐
│  VAPOR APP                                                   │
│  ┌─────────────────────────────────────────────────────────┐│
│  │ Vapor component                                         ││
│  │                                                         ││
│  │  ┌───────────────────────────────────────────────────┐ ││
│  │  │ VDOM wrapper (one crossing)                       │ ││
│  │  │  ┌─────────────────────────────────────────────┐  │ ││
│  │  │  │ VDOM list with 200 items                    │  │ ││
│  │  │  │ (no per-item crossing)                      │  │ ││
│  │  │  └─────────────────────────────────────────────┘  │ ││
│  │  └───────────────────────────────────────────────────┘ ││
│  └─────────────────────────────────────────────────────────┘│
│                                                              │
│  The boundary is above the repeated region.                  │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Feature Support

┌──────────────────────────────────────────────────────────────┐
│  SUPPORTED                       UNSUPPORTED                 │
│                                                              │
│  ┌─────────────────┐            ┌─────────────────┐         │
│  │ script setup    │            │ Options API     │         │
│  │ Composition API │            │ globalProperties│         │
│  │ Refs/computed   │            │ getCurrentInstance│       │
│  │ Props/emits     │            │ @vue:xxx events │         │
│  │ Slots           │            │ v-memo          │         │
│  │ Custom dirs*    │            │ Suspense (only) │         │
│  └─────────────────┘            └─────────────────┘         │
│                                                              │
│  * Custom directives use a different interface.              │
│                                                              │
└──────────────────────────────────────────────────────────────┘

Summary

ItemValue
Vapor ModeCompilation target without VDOM
Opt-in<script setup vapor>
Pure appcreateVaporApp()
Mixed appcreateApp().use(vaporInteropPlugin)
Runtime size reduction~53%
Bundle (Hello World)~7.9 kB vs ~22.8 kB
Options APINot supported
getCurrentInstance()Returns null
SSR hydrationNot yet supported
Recommended usePartial adoption, small new apps

Key takeaways:

  • Vapor Mode eliminates the VNode tree and the diff algorithm. The compiler generates code that creates DOM nodes directly and binds reactive effects to the specific nodes that depend on them. When a value changes, only the bound text node or attribute is updated, with no tree traversal and no comparison.
  • The mode is opt-in per component and can coexist with VDOM. A component opts in with the vapor attribute. A pure Vapor app uses createVaporApp and excludes the VDOM runtime. A mixed app installs vaporInteropPlugin and accepts the VDOM runtime cost in exchange for compatibility.
  • The interop boundary should be placed carefully. Every VDOM component instantiated from a Vapor template is a crossing, and compound components multiply crossings. A list of 200 VDOM checkboxes inside a Vapor loop is 400 crossings; wrapping the list in a single VDOM component is one.
  • Vapor supports a subset of Vue’s features. Options API, globalProperties, getCurrentInstance(), and per-element lifecycle events are not available. Custom directives use a different interface with a reactive getter and an optional cleanup function.
  • The recommended adoption path is partial. Use Vapor for performance-sensitive pages in existing apps, or build small new apps entirely in Vapor. Migrating a large existing component tree to Vapor is not recommended during the alpha and RC phases.

Remember: Vapor Mode is not a replacement for the VDOM mode; it is a second compilation target that produces a different runtime structure. The VDOM mode remains the default, the compatible mode, and the mode that supports the full feature set. Vapor Mode is the performance mode: it trades the flexibility of runtime diffing for the efficiency of compile-time binding. The two modes can coexist in one application, but the boundary between them has a cost, and the cost is paid per crossing. The correct adoption strategy is to keep the boundary above the repeated regions and to use Vapor where the fine-grained updates produce a measurable benefit.

Was this helpful?


Stop using slow, ad-bloated tool sites! 🤮

🔎 Search “KandZ Tools” on Google to use many professional utilities for free.

KandZ.me is the ultimate minimalist hub for:
✅ Finance (Mortgage, Interest, Inflation)
✅ Tech (Base64, JSON, Dev Suite, IP)
✅ Health (BMI, BMR, TDEE)
✅ Productivity (Timer, Workspace, QR)

⚡️ Fast & Private
🔒 No data leaves your device
💎 100% Free

🔗 Use it now: https://tools.kandz.me
🔖 Bookmark it—you’ll need it later!