Vue Deep Dive — 02 · Compiler, Virtual DOM & Vapor Mode
Vue's answer to "virtual DOM is slow" has always been the compiler. A template is not interpreted at run time: it is compiled into a render function that tells the runtime, node by node, which parts can ever change and how. That information — patch flags, cached static subtrees, blocks with flat lists of dynamic nodes — is why a Vue re-render usually skips most of the tree without comparing it. Vapor mode takes the idea to its end: compile the template straight to DOM operations and drop the virtual DOM entirely. This post reads real compiler output. It walks the SFC pipeline, decodes patch flags, explains static caching and the block tree, works through the keyed diff and its longest-increasing-subsequence step, shows what v-once and v-memo compile to, and compares a component compiled both ways. Every code sample marked as output was produced by @vue/compiler-dom / @vue/compiler-sfc 3.5, or 3.6.0-rc.8 for Vapor.
Series map: 00 Reactivity Internals → 01 Composition API at Scale → 02 Compiler, Virtual DOM & Vapor Mode (this post) → 03 Ecosystem & Production (Pinia, Router, Nuxt). Future: see the extensions table in 03.
Learning Outcome
- Trace an
.vuefile through@vue/compiler-sfc: descriptor, script, template, styles, and what the Vite plugin does with each. - Read a compiled render function:
openBlock,createElementBlock,_ctx,_cache, hoisted props objects. - Decode any patch flag number and say what the runtime will and will not compare.
- Explain the 3.5 switch from static hoisting to static caching, and when the compiler stringifies static HTML.
- Explain the block tree and why structural directives start new blocks.
- Execute the keyed diff by hand, including the longest increasing subsequence that minimises DOM moves.
- Choose between
v-once,v-memoand plain reactivity fixes. - Explain what Vapor mode compiles to, how to opt in, what it does not support, and its status in 3.6.
- Apply the compiler-aware performance checklist.
Chapter 1 — The SFC pipeline
Terminology
- Descriptor — the parsed
.vuefile:template,script,scriptSetup,styles[],customBlocks[], each with its source and location. - AST — the tree the template parser produces; transforms rewrite it before code generation.
- Transform — a compiler pass for one feature:
v-if,v-for,v-model, slots, static analysis, patch flags. - Codegen — turning the transformed AST into JavaScript source.
- Binding metadata — what
<script setup>declares (setup-ref,setup-const,props, …). The template compiler uses it to emitcount.valueor__props.xinstead of a generic_ctx.countlookup. - Inline template — the template compiled into the
setup()function as a closure, instead of as a separaterenderfunction.
The stages
parse()splits the file into a descriptor. Nothing is compiled yet.compileScript()processes<script setup>: removes macros (Post 01), resolves prop types, analyses bindings, and — in production — inlines the compiled template.compileTemplate()calls@vue/compiler-dom: parse → transforms → codegen, producing a render function.compileStyle()runs PostCSS forscopedstyles (addsdata-v-<hash>attribute selectors,:deep(),:slotted()), CSS modules, andv-bind()in CSS (compiled to CSS custom properties set from the component).
@vitejs/plugin-vue drives these stages. It uses the inline-template mode only when all of these hold: not the dev server, devtools support not enabled for production, the component has <script setup>, and the template is not loaded from src. Inline mode lets the render closure read setup bindings directly (count.value) without going through the _ctx proxy; the dev server keeps a separate render function so hot reload can swap the template alone.
Watch out: compiling templates at run time needs the full build (
vue/dist/vue.esm-bundler.jswith the compiler). The defaultvueimport in a Vite app is the runtime-only build. A stringtemplate:option in a component object silently renders nothing and logs a warning in that setup.
Chapter 2 — From template to render function
This template:
1
2
3
4
5
6
7
8
9
<div class="card">
<h2>Title</h2>
<p class="static">Some static text</p>
<span :class="cls">{{ msg }}</span>
<button @click="inc">+</button>
<ul><li v-for="i in items" :key="i.id">{{ i.name }}</li></ul>
<p v-if="show">yes</p>
</div>
compiles with @vue/compiler-dom 3.5 (module mode, hoistStatic and cacheHandlers on, as in an SFC build) to:
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
const _hoisted_1 = { class: "card" }
const _hoisted_2 = { key: 0 }
export function render(_ctx, _cache) {
return (_openBlock(), _createElementBlock("div", _hoisted_1, [
_cache[1] || (_cache[1] = _createElementVNode("h2", null, "Title", -1 /* CACHED */)),
_cache[2] || (_cache[2] = _createElementVNode("p", { class: "static" }, "Some static text", -1 /* CACHED */)),
_createElementVNode("span", {
class: _normalizeClass(_ctx.cls)
}, _toDisplayString(_ctx.msg), 3 /* TEXT, CLASS */),
_createElementVNode("button", {
onClick: _cache[0] || (_cache[0] = (...args) => (_ctx.inc && _ctx.inc(...args)))
}, "+"),
_createElementVNode("ul", null, [
(_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.items, (i) => {
return (_openBlock(), _createElementBlock("li", {
key: i.id
}, _toDisplayString(i.name), 1 /* TEXT */))
}), 128 /* KEYED_FRAGMENT */))
]),
(_ctx.show)
? (_openBlock(), _createElementBlock("p", _hoisted_2, "yes"))
: _createCommentVNode("v-if", true)
]))
}
Each line carries a decision the runtime would otherwise make by comparing:
| In the output | Meaning |
|---|---|
_hoisted_1 = { class: "card" } |
A static props object, created once per module and shared by every instance |
_cache[1] || (_cache[1] = …, -1 /* CACHED */) |
A fully static element, created once per component instance and reused on every render |
3 /* TEXT, CLASS */ |
Only the text and the class of this <span> can change |
onClick: _cache[0] || (…) |
The handler is cached, so the <button> has no patch flag: its props never change identity |
_openBlock(true) … 128 /* KEYED_FRAGMENT */ |
The v-for list is a fragment of keyed children, diffed by key |
(_ctx.show) ? (_openBlock(), …) : _createCommentVNode("v-if", true) |
v-if produces a new block, or a placeholder comment |
Trick: paste any template into the Vue SFC Playground (play.vuejs.org) and open the "JS" tab to see this output for your own code. It is the fastest way to answer "will this re-render?" questions.
Chapter 3 — Patch flags
A patch flag is a bitmask on a vnode that lists the only things that can differ between two renders of it. The runtime checks the flag and skips everything else. The values in 3.5 (@vue/shared):
| Flag | Value | What the runtime patches |
|---|---|---|
TEXT |
1 | Text children only |
CLASS |
2 | class only |
STYLE |
4 | style only |
PROPS |
8 | Only the props named in the dynamicProps array passed after the flag |
FULL_PROPS |
16 | All props, full diff (dynamic keys such as v-bind="obj" or :[name]) |
NEED_HYDRATION |
32 | Needs props during hydration (e.g. event listeners) |
STABLE_FRAGMENT |
64 | Fragment whose children order never changes |
KEYED_FRAGMENT |
128 | Fragment of keyed children (v-for with :key) |
UNKEYED_FRAGMENT |
256 | Fragment of unkeyed children |
NEED_PATCH |
512 | Non-prop work: template ref, custom directives, vnode hooks |
DYNAMIC_SLOTS |
1024 | Slots that must be updated on every render |
DEV_ROOT_FRAGMENT |
2048 | Development only: root fragment created by comments |
CACHED |
−1 | Cached static vnode; hydration can skip the whole subtree |
BAIL |
−2 | Leave optimised mode and do a full diff |
Positive flags combine with bitwise OR: 9 /* TEXT, PROPS */ means text plus the listed props. Negative flags are special values and never combined. Two real examples from the 3.5 compiler:
1
2
3
_createElementVNode("p", { id: _ctx.pid, class: "x" }, _toDisplayString(_ctx.msg), 9 /* TEXT, PROPS */, ["id"])
_createVNode(_component_Comp, { a: 1, b: _ctx.obj }, null, 8 /* PROPS */, ["b"])
In the second line, the child component Comp receives two props, but only b is listed as dynamic. When the parent re-renders, the runtime compares only b to decide whether Comp must update. If obj is the same object, the child does not re-render at all.
Common pitfall:
<Comp :options="{ dense: true }" />creates a new object on every parent render.optionsis dynamic (it is an expression), its identity always changes, andCompre-renders every time its parent does. Hoist the literal into a constant in<script setup>or acomputed.
Hand-written render functions with h() get no patch flags: the runtime must diff them fully. That is one reason templates are the recommended default in Vue, not only a style preference.
Chapter 4 — Static caching and stringification
Hoisting became caching in 3.5
The same <h2>Title</h2> compiled by 3.4 and by 3.5:
1
2
3
4
5
6
7
// 3.4
const _hoisted_2 = /*#__PURE__*/_createElementVNode("h2", null, "Title", -1 /* HOISTED */)
// … in render: _hoisted_2,
// 3.5
_cache[0] || (_cache[0] = _createElementVNode("h2", null, "Title", -1 /* CACHED */))
In 3.4 a static vnode was hoisted to module scope and shared by every instance of the component. In 3.5 it is created on first render and kept in the instance's _cache array. The flag name changed from HOISTED to CACHED with the same value (−1). Static props objects (_hoisted_1 = { class: "card" }) are still hoisted to module scope, because a plain object with no DOM reference is safe to share.
The practical effect is the same — static nodes are created once and skipped by the diff — but nothing is shared between instances any more. A hoisted vnode kept a reference (el) to the DOM element it was last mounted to, which was a known way to retain detached DOM trees (vuejs/core issue #5256). A per-instance cache avoids that shared state.
Stringification
When enough static content sits together, the compiler goes further and emits it as an HTML string that the runtime inserts with innerHTML:
1
2
3
_cache[0] || (_cache[0] = _createStaticVNode(
"<p class=\"a\">1</p><p class=\"a\">2</p><p class=\"a\">3</p><p class=\"a\">4</p><p class=\"a\">5</p>", 5))
The threshold in @vue/compiler-dom 3.5 is 20 or more consecutive static nodes, or 5 or more static elements with attributes. The same template with four <p class="a"> elements instead of five produced four separate CACHED vnodes. Stringified chunks create no vnodes per element at all, which matters for large static sections such as documentation pages and legal text.
What is never static
Anything with a binding, a directive, a component, a ref, or a child that is dynamic. A single {{ }} inside a large block makes that element dynamic, but its static siblings and static descendants are still cached separately. Keep dynamic bits in small leaf elements and the static structure around them stays free.
Chapter 5 — The block tree
Blocks collect dynamic descendants
openBlock() starts collecting; every vnode created with a patch flag while the block is open is pushed into the block's dynamicChildren array; createElementBlock() closes it. On update, the runtime calls patchBlockChildren, which walks this flat array and patches each dynamic node against its counterpart. The static structure between them is never visited.
For the Chapter 2 template, the root block's dynamicChildren holds the <span> (flag 3), the v-for fragment, and the v-if branch. The cached <h2> and <p>, the <button> with its cached handler, and the <ul> itself are not in it. A re-render after msg changes touches one text node's comparison, not the whole tree.
Why structural directives start new blocks
A flat array works only if the set of dynamic nodes has a fixed shape. v-if and v-for change the shape: a branch may exist or not, a list may have 3 or 300 items. So each v-if branch is its own block (with a key so the runtime knows when the branch switched), and each v-for produces a fragment whose children are diffed as a list (Chapter 6), each child being a block of its own. Inside each block, the flat fast path applies again.
openBlock(true) on the v-for fragment disables collection for that fragment: its children are diffed through the keyed algorithm, not through dynamicChildren.
Chapter 6 — The keyed diff and the longest increasing subsequence
The five steps of patchKeyedChildren
Given old children c1 and new children c2:
- Sync from the start. While the heads have the same type and key, patch them and advance.
- Sync from the end. Same from the tails.
- Only new nodes left (old range exhausted): mount the remaining new nodes.
- Only old nodes left (new range exhausted): unmount the remaining old nodes.
- Unknown middle sequence:
- Build
keyToNewIndexMapfrom the new middle. - Walk the old middle. For each old node, find its new index by key. Not found: unmount it. Found: patch it, and record
newIndexToOldIndexMap[newIndex - start] = oldIndex + 1(0 is reserved for "new node"). If a found new index is smaller than the largest seen so far, setmoved = true. - If anything moved, compute the longest increasing subsequence (LIS) of
newIndexToOldIndexMap. Nodes in the LIS are already in the right relative order and stay where they are. Walk the new middle backwards: a 0 entry is mounted, a node outside the LIS is moved, each before the node after it.
- Build
A worked example
Old: a b c d e f g. New: a b e c d h f g.
- Step 1 patches
a,b. Step 2 patchesg,f. - Middle: old
c d e(old indices 2, 3, 4), newe c d h(new indices 2–5). - Step 5.2:
c→ new index 3, map slot 1 = 2 + 1 = 3.d→ new index 4, slot 2 = 4.e→ new index 2, which is smaller than 4, somoved = true; slot 0 = 4 + 1 = 5.his never found in the old list, so slot 3 stays 0. - Map:
[5, 3, 4, 0]. Its LIS, as positions, is[1, 2]— the values 3 and 4, i.e.candd. (Checked by running Vue's owngetSequence([5, 3, 4, 0]), which returns[1, 2].) - Step 5.3, backwards:
h(0) is mounted beforef;dandcare in the LIS and stay;eis moved beforec.
Result: one insertion, one move, and no work at all for the six other nodes beyond patching them. The LIS is what makes the number of moves minimal: the nodes that can stay are exactly the longest run that is already in order. getSequence computes it in O(n log n) with patience sorting and binary search.
Without keys
A v-for without :key compiles to UNKEYED_FRAGMENT (256) and uses patchUnkeyedChildren: patch old and new by index for the common length, then mount or unmount the tail. Inserting at the front of an unkeyed list re-patches every item with its neighbour's data and moves no DOM, so component state and DOM state (focus, input values, animations) stay at their old positions while the data shifts.
Common pitfall:
:key="index"is the same as no key for this purpose. Keys must be stable identities from the data (item.id). Random keys (Math.random()) are worse: every render remounts every item.
Chapter 7 — v-once and v-memo
v-once
1
2
<p v-once>{{ a }}</p>
compiles to:
1
2
3
4
5
6
7
8
9
_cache[0] || (
_setBlockTracking(-1, true),
(_cache[0] = _createElementVNode("p", null, [
_createTextVNode(_toDisplayString(_ctx.a), 1 /* TEXT */)
])).cacheIndex = 0,
_setBlockTracking(1),
_cache[0]
)
The subtree is rendered once, stored in _cache, and setBlockTracking(-1) keeps it out of the parent's dynamicChildren. Later changes to a are ignored on purpose. Use it for content that depends on data but must never update, such as a value captured at mount.
v-memo
1
2
3
4
<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
<span>{{ item.label }}</span>
</div>
compiles to (inside the renderList callback):
1
2
3
4
5
6
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.el && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", { key: item.id }, [ /* … */ ]))
_item.memo = _memo
return _item
If the memo array is shallow-equal to last time, the previous vnode is returned and the whole subtree is skipped — no render, no diff. For a 1,000-row list where only the selected row changes, changing selected re-renders two rows (the old and the new selection) instead of all of them.
Rules and traps:
- Inside
v-for,v-memomust be on the same element as thev-for, and thev-forneeds akey. - The memo array must list everything the subtree reads that can change. In the example above, if
item.labelchanges, the row does not update:labelis not in the memo. That is the classicv-memobug. - An empty array,
v-memo="[]", is equivalent tov-once. v-memois not supported in Vapor mode (Chapter 8).
Trick: before reaching for
v-memo, check whether the list re-renders for a reactivity reason — a computed that returns a new array every time, an inline object prop (Chapter 3), a deep watcher. Fixing the cause is cheaper and cannot go stale.
Chapter 8 — Vapor mode
What it is
Vapor mode is an alternative compilation target for SFCs. Instead of a render function that creates vnodes, the component compiles to code that builds the DOM once from an HTML template and then binds the dynamic parts to small reactive effects. There is no virtual DOM, no diff and no patch flags. The 3.6 release notes describe it as "100% opt-in", supporting "a subset of existing Vue APIs with mostly identical behavior", and report the same level of performance as Solid and Svelte 5 in third-party benchmarks.
The same component, compiled by Vapor
1
2
3
4
5
6
7
8
9
10
11
12
13
14
<script setup vapor>
import { ref } from 'vue'
const count = ref(0)
const items = ref([{ id: 1, name: 'a' }])
</script>
<template>
<div class="card">
<h2>Title</h2>
<button @click="count++">{{ count }}</button>
<ul><li v-for="i in items" :key="i.id">{{ i.name }}</li></ul>
</div>
</template>
@vue/compiler-sfc 3.6.0-rc.8 output (setup body):
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
const t0 = _template("<li> ")
const t1 = _template("<div class=card><h2>Title</h2><button> </button><ul>", 1)
const n5 = t1()
const n0 = _next(_child(n5))
const n4 = _next(n0)
const x0 = _txt(n0)
_on(n0, "click", () => (count.value++))
_renderEffect(() => _setText(x0, _toDisplayString(count.value)))
_setInsertionState(n4)
const n1 = _createFor(() => (items.value), (_for_item0) => {
const n3 = t0()
const x3 = _txt(n3)
_renderEffect(() => _setText(x3, _toDisplayString(_for_item0.value.name)))
return n3
}, (i) => (i.id), 9 /* FAST_REMOVE, IS_SINGLE_NODE */)
return n5
Read it top to bottom:
_template()holds static HTML as a string; calling it clones a prepared DOM fragment. All static structure is created in one browser-native operation._child/_next/_txtwalk to the nodes that have bindings, once.- Dynamic bindings are wrapped in small
_renderEffects. Bindings on the same element share one:<span :class="cls">{{ msg }}</span>compiles (rc.8) to a single effect that calls_setClass(n0, cls.value)and_setText(x0, …). Whencountchanges, only the button's effect runs and sets one text node. No component re-render happens. v-forbecomes_createForwith a source getter, a per-item render function and a key function. List reconciliation still exists, but at the level of DOM blocks, not vnodes.- The component definition carries
__vapor: true.
This is the reactivity graph from Post 00 wired directly to the DOM, which is what signal-based frameworks such as Solid do. It also leans much harder on the reactivity core — many small effects instead of one per component — which is why the 3.6 work on that core (alien-signals) matters for Vapor.
Opting in
| Where | How |
|---|---|
| One component | <script setup vapor> (or <template vapor>) |
| A whole app, no virtual DOM runtime | createVaporApp(App).mount('#app') — the VDOM runtime is not pulled into the bundle |
| Vapor components inside an existing app | createApp(App).use(vaporInteropPlugin) — Vapor and VDOM components can nest |
What it does not support
The rc notes list: the Options API, getCurrentInstance(), v-memo, component template refs that expose $el, $props, $attrs and similar instance properties, and @vue:xxx per-element lifecycle events. Custom directives have a different interface in Vapor (the binding value arrives as a reactive getter). Libraries that reach into component instances are the most likely to break.
Status (September 2026)
- 3.6.0-alpha.1 (July 2025) introduced Vapor mode and the alien-signals reactivity refactor; at that point SSR hydration, async components, Transition, KeepAlive and Suspense were not yet supported.
- 3.6.0-rc.1 (July 2026) declared the intended Vapor feature set complete. The rc changelogs since then are mostly Vapor fixes, including hydration, KeepAlive, Transition and Suspense.
- At the time of writing the latest is 3.6.0-rc.8, and
lateston npm is still 3.5.x.
The rc notes recommend two uses for now: performance-sensitive parts of existing apps, and small new apps built entirely in Vapor mode. For an existing app, the interop plugin allows adopting it one hot component at a time and measuring.
Chapter 9 — A compiler-aware performance checklist
- Keep props stable. No inline object or array literals as component props. Stable props let the
PROPScheck skip child updates (Chapter 3). - Keep computed values stable. A computed that returns an equal primitive stops propagation; one that returns a fresh array does not (Post 00, Chapter 4).
- Use
shallowReffor large lists you replace, and virtualise long lists (only render visible rows). No compiler optimisation beats not rendering. - Always key
v-forwith a data identity. - Split big components where a small part updates often. A component is the unit of re-render in the VDOM model; a ticking clock inside a 2,000-node page component re-renders the page's block.
v-showfor frequent toggles,v-iffor rare ones.v-showkeeps the subtree mounted and flipsdisplay;v-ifunmounts and remounts.v-memoonly for measured hot lists, with a complete memo array.- Build flags. Set
__VUE_OPTIONS_API__: falseif no component uses the Options API (smaller bundle). Keep__VUE_PROD_DEVTOOLS__and__VUE_PROD_HYDRATION_MISMATCH_DETAILS__false in production unless you need them. - Measure. Vue DevTools' performance timeline and
app.config.performance = true(development only: component init, compile, render and patch timings in the browser's performance panel) come before any of the above. - Consider Vapor for a hot subtree once it is stable enough for your risk appetite: a live dashboard or a data grid is the best-case workload.
Practice Exercises
Exercise 1 — Decode the flags
What do 9 /* TEXT, PROPS */, ["id"], 16, 512 and -1 tell the runtime?
Solution
9 = TEXT (1) | PROPS (8): compare the text child and only the id prop. 16 = FULL_PROPS: dynamic keys, diff all props. 512 = NEED_PATCH: nothing prop-related changes, but the vnode has a ref, directive or vnode hook that must run. -1 = CACHED: a static vnode from _cache, never diffed, skippable during hydration.
Exercise 2 — Why does the child re-render?
<UserCard :user="user" :style-opts="{ compact: true }" @select="onSelect" /> re-renders every time the parent does, though user did not change. Why, and what is the fix?
Solution
style-opts is an expression that builds a new object on every parent render, so the PROPS comparison always finds a change. Move { compact: true } into a constant in <script setup>. @select="onSelect" is not the problem: the compiler caches handlers.
Exercise 3 — Run the diff
Old [a, b, c, d], new [d, a, b, c], all keyed. Which nodes move?
Solution
No common prefix (a vs d) and no common suffix (d vs c), so the whole list is the middle. Walking the old list: a → new index 1, b → 2, c → 3, then d → 0, which is smaller than the largest index seen (3), so moved = true. The map from new position to old index + 1 is [4, 1, 2, 3]; getSequence returns positions [1, 2, 3] (a b c). Only d is outside the LIS: one DOM move, to the front.
Exercise 4 — The stale memo
A list uses v-memo="[item.id === selectedId]" and renders {{ item.label }} and {{ item.price }}. After a price update over WebSocket, prices on screen stay old. Fix it two ways.
Solution
The memo does not include price, so memoised rows are reused. Either add it: v-memo="[item.id === selectedId, item.price, item.label]", or remove v-memo and fix the real re-render cause (for example a shallowRef list replaced immutably, so unchanged rows keep their identity).
Exercise 5 — Hoisting vs caching
Your library generates thousands of short-lived component instances. Explain what 3.5's change from hoisted to cached static vnodes costs and what it buys.
Solution
Cost: each instance creates its static vnodes once on its first render (in 3.4 they were created once per module), so first render does a little more work and each instance holds its own _cache entries. Buys: no vnode, and therefore no el reference, is shared across instances, which removes a known source of retained detached DOM. Static props objects are still hoisted and the diff still skips cached nodes, so update performance is unchanged.
Exercise 6 — Vapor adoption plan
A large Options API app has one slow real-time table. Outline how to try Vapor mode on it and what could block you.
Solution
Rewrite the table (and its row component) with <script setup vapor>, install vaporInteropPlugin on the existing createApp(), and measure. Blockers from the rc notes: any Options API inside the Vapor components, getCurrentInstance(), v-memo, parent code that reads $el or $props through a template ref to the table, @vue:mounted-style vnode events, and custom directives written for the VDOM interface. Keep everything outside the table on the VDOM runtime.
Cheat-sheet
| Question | Answer |
|---|---|
| See compiled output | play.vuejs.org → JS tab |
| Text only changes | 1 /* TEXT */ |
| Specific props change | 8 /* PROPS */, ["name"] |
| Dynamic prop keys | 16 /* FULL_PROPS */ |
| Keyed list | 128 /* KEYED_FRAGMENT */ |
| Static vnode | -1 /* CACHED */ in _cache (3.5; HOISTED in 3.4) |
| Big static HTML | createStaticVNode("…") at ≥ 20 nodes or ≥ 5 elements with attributes |
| Flat dynamic list | dynamicChildren of a block |
| New block | root, each v-if branch, each v-for item |
| Minimal moves | LIS of newIndexToOldIndexMap |
| Render once | v-once |
| Skip a subtree conditionally | v-memo="[deps]" (not in Vapor) |
| Opt a component into Vapor | <script setup vapor> |
| Vapor-only app | createVaporApp(App) |
| Mix Vapor and VDOM | app.use(vaporInteropPlugin) |
What's next
Post 03 moves from the engine to the production stack: Pinia setup stores and SSR state, Vue Router's guards, lazy routes and typed routes, Nuxt 4's structure, rendering modes, hydration mismatches and data fetching, testing with Vitest, Vue Test Utils and Playwright, and deployment. For the React side of the same trade-offs — a compiler that memoises instead of a compiler that flags — see the React Deep Dive series; the JavaScript Deep Dive series covers the DOM and event-loop costs that all of these optimisations are trying to avoid.
References
- Rendering Mechanism — compiler-informed virtual DOM, patch flags, tree flattening.
- Performance — props stability,
v-once,v-memo, large lists. - Built-in Directives: v-memo — rules and the
v-forrequirement. @vue/sharedpatchFlags source — the flag definitions.@vue/runtime-corerenderer source —patchKeyedChildren,getSequence.- Vue SFC Playground — compile any template and read the output.
- vuejs/core v3.6.0-alpha.1 and v3.6.0-rc.1 release notes — Vapor mode scope, limits and recommendations.
- Compile-time flags —
__VUE_OPTIONS_API__and friends.










