Vue.js 90 🟢 Virtual DOM-less Compilation: Vapor Mode Architecture
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 Feature | Reason |
|---|---|
| Options API | Vapor only supports <script setup> |
app.config.globalProperties | Depends on the public instance proxy |
getCurrentInstance() | Returns null in Vapor components |
@vue:xxx lifecycle events | Per-element lifecycle depends on VNodes |
v-memo | Vapor’s fine-grained updates make it unnecessary |
| Component template refs | Do 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
| Method | Syntax |
|---|---|
| Script setup | <script setup vapor> |
| Script shorthand | <script vapor> |
| Template marker | <template vapor> |
| Pure app | createVaporApp(App) |
| Mixed app | createApp(App).use(vaporInteropPlugin) |
Vapor vs. VDOM
| Aspect | VDOM | Vapor |
|---|---|---|
| VNode tree | Yes | No |
| Diff algorithm | Yes | No |
| Runtime size | Baseline | ~53% smaller |
| Bundle (Hello World) | ~22.8 kB | ~7.9 kB |
| Fine-grained updates | Component-level | Node-level |
| Options API | Supported | Not supported |
Unsupported Features
| Feature | Status |
|---|---|
| Options API | Not supported |
globalProperties | Not supported |
getCurrentInstance() | Returns null |
@vue:xxx lifecycle | Not supported |
v-memo | Not supported |
| Async Component | Pending |
| Transition / KeepAlive | Pending |
| Suspense | Not in Vapor-only mode |
| SSR hydration | Not 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
| Pitfall | Why It Happens | Fix |
|---|---|---|
| Interop overhead in loops | VDOM component inside Vapor v-for | Move boundary above loop |
getCurrentInstance() is null | Vapor does not expose instance proxy | Use internal currentInstance detection |
| Slot inspection breaks | slots.default() executes the slot | Let template render the slot |
| Event handler not firing | Ancestor called stopPropagation | Use direct binding form |
| Bundle size not reduced | vaporInteropPlugin pulls in VDOM runtime | Use 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
| Item | Value |
|---|---|
| Vapor Mode | Compilation target without VDOM |
| Opt-in | <script setup vapor> |
| Pure app | createVaporApp() |
| Mixed app | createApp().use(vaporInteropPlugin) |
| Runtime size reduction | ~53% |
| Bundle (Hello World) | ~7.9 kB vs ~22.8 kB |
| Options API | Not supported |
getCurrentInstance() | Returns null |
| SSR hydration | Not yet supported |
| Recommended use | Partial 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
vaporattribute. A pure Vapor app usescreateVaporAppand excludes the VDOM runtime. A mixed app installsvaporInteropPluginand 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.
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!