Rendering, Render Functions, and Web Components
|
This section documents the current Vue 3.x release line as published at
the official Vue.js documentation, which is the reference these pages are written and
verified against. No specific patch version is pinned. The Composition API with This content was generated with the assistance of AI and should be verified against the official documentation before being relied on in production, since Vue and its ecosystem iterate quickly. This section’s bibliography lists the reference material consulted while preparing these pages. |
Templates are the recommended way to author Vue components, but they are a convenience: the compiler turns each one into a JavaScript render function that returns a tree of virtual DOM nodes. This page explains that pipeline, when to write a render function by hand instead, and how Vue interoperates with native Web Components in both directions.
The virtual DOM and the render pipeline
A component’s render function returns VNodes — plain objects describing what the DOM should look like. On the first render Vue mounts that tree, creating real DOM nodes. On every update it produces a new VNode tree and patches: it walks old and new trees together and touches only the DOM that actually changed. See Rendering Mechanism.
or runtime)"| render["render() function"] render -->|"call on each render"| vtree["VNode tree"] vtree -->|"first render"| mount["mount → create real DOM"] vtree -->|"subsequent renders"| patch["patch: diff old vs. new"] patch --> dom["Real DOM (minimal writes)"] mount --> dom hints["Compiler hints:
static hoisting • patch flags • tree flattening"] -.->|"attached to VNodes,
read during patch"| patch
Compiler-informed virtual DOM
Because Vue’s compiler sees the template, it annotates the render function with hints that a framework-agnostic VDOM (React’s, for example) cannot derive. This is why Vue rarely needs manual memoisation. The Compiler-Informed Virtual DOM section covers all three:
-
Static hoisting — elements with no dynamic bindings are created once, outside the render function, and reused by reference on every render. Whole static subtrees are hoisted as one.
-
Patch flags — a dynamic element is tagged with a bitmask of what can change (
CLASS,STYLE,PROPS,TEXT, …). During patch Vue checks only the flagged aspects instead of comparing every attribute. -
Tree flattening — each block (a template, or a
v-if/v-forbranch) keeps a flat array of only its dynamic descendants, so patching skips the stable structure entirely and iterates a short list.
You can inspect the output on the Template Explorer.
v-once and v-memo
These push the same idea further when profiling shows a hot spot:
<template>
<header v-once>{{ appName }} -- built {{ buildDate }}</header>
<div v-for="item in list" :key="item.id" v-memo="[item.id === selectedId]">
<ExpensiveRow :item="item" :selected="item.id === selectedId" />
</div>
</template>
Render functions
h() (hyperscript) creates a VNode: h(type, props, children). Return it from a render option or from
setup(). See Render Functions & JSX.
import { h, ref } from 'vue'
export default {
setup() {
const count = ref(0)
// setup() may return a render function directly
return () =>
h('button', { class: 'counter', onClick: () => count.value++ }, `count is ${count.value}`)
},
}
VNode shape and the arguments
-
type — a tag string (
'div'), a component object/definition, orFragment/Text/Comment. -
props — attributes, DOM properties,
class/style(arrays and objects allowed), andonXxxevent listeners.h('input', \{ onInput: e ⇒ … }). -
children — a string, an array of VNodes, or an object of slot functions:
h(Comp, null, \{ default: () ⇒ h('span', 'hi'), header: () ⇒ 'Title' }).
VNodes must be unique — render the same node twice by wrapping it in a factory (() ⇒ h(…)) or by
cloning.
Template features as plain JavaScript
There are no directives in a render function; use language constructs:
import { h } from 'vue'
function render() {
return h('ul', [
// v-if / v-else -> ternary or if/else
this.loading
? h('li', 'Loading...')
// v-for -> .map()
: this.items.map(item => h('li', { key: item.id }, item.label)),
])
}
v-model is sugar for a prop plus an update listener, so pass them explicitly:
h(TextField, \{ modelValue: text.value, 'onUpdate:modelValue': v ⇒ (text.value = v) }). Built-in
directives that have no plain-JS form (v-show, custom directives) are applied with
withDirectives.
JSX
With @vue/babel-plugin-jsx (bundled by `create-vue’s JSX option) you can write the same tree as JSX,
which many find more readable for markup-heavy output:
import { ref } from 'vue'
export default function Counter() {
const count = ref(0)
return () => (
<button class="counter" onClick={() => count.value++}>
count is {count.value}
</button>
)
}
Vue’s JSX differs from React’s: use class (not className), onClick maps to a native listener, and
v-model is available as v-model=\{[text.value, 'modelValue']}.
Functional components
A plain function (props, context) ⇒ VNode is a functional component — no instance, no reactive state
of its own, just props in and VNodes out. Declare props and emits as properties on the function for
runtime validation:
function Divider(props) {
return h('hr', { class: ['divider', props.vertical && 'divider--vertical'] })
}
Divider.props = ['vertical']
When a render function beats a template
Reach for h() / JSX when the output structure is computed rather than declared: a component that renders
a different element based on a level prop, a recursive tree renderer, a library component that forwards
arbitrary slots, or a higher-order wrapper. For ordinary UI, templates compile to faster code thanks to the
hints above.
Vue and Web Components
Custom elements (<my-widget>) and Vue components are different technologies that can be used together.
See Vue and Web Components.
Using custom elements in Vue
Tell the compiler which tags are not Vue components so it renders them as elements and does not warn.
With create-vue/Vite this goes in vite.config.js:
import vue from '@vitejs/plugin-vue'
vue({
template: {
compilerOptions: {
// treat every tag containing a dash (except your own) as a custom element
isCustomElement: tag => tag.includes('-'),
},
},
})
Then bind to it. Vue sets a value as a DOM property when the element already has a property of that name,
otherwise as an attribute; force one with the .prop / .attr modifiers. Listen with @:
<template>
<video-player
:src="url"
:.currentTime="seek"
muted
@timeupdate="onTime"
/>
</template>
Because attributes are strings, pass objects and arrays via a property binding (:.config="cfg"), not an
attribute.
Building custom elements with defineCustomElement
defineCustomElement takes the same options as a normal Vue
component (or an imported SFC) and returns a constructor for customElements.define:
import { defineCustomElement } from 'vue'
import MyWidget from './MyWidget.ce.vue'
const MyWidgetElement = defineCustomElement(MyWidget)
customElements.define('my-widget', MyWidgetElement)
Inside such a component:
-
Props declared on the component become observed attributes; number/boolean props are cast from the string attribute automatically.
-
emit('change', payload)dispatches a realCustomEventnamedchange. -
useHost()returns the host element;useShadowRoot()returns its shadow root (for focus management, measuring, or slot inspection). -
Styles from
<style>are inlined into the shadow root. Use.ce.vue(or the*.ce.vueconvention) so the build injects styles as a string rather than a<link>. Shared design tokens must be CSS custom properties, since the shadow boundary blocks outside stylesheets.
import { defineCustomElement, useHost, h, ref, onMounted } from 'vue'
const Stepper = defineCustomElement({
props: { min: Number, max: Number },
setup(props, { emit }) {
const host = useHost()
const value = ref(props.min ?? 0)
onMounted(() => host.setAttribute('role', 'spinbutton'))
function bump(delta) {
value.value = Math.min(props.max, Math.max(props.min, value.value + delta))
emit('change', value.value)
}
return () => h('div', [
h('button', { onClick: () => bump(-1) }, '-'),
h('span', value.value),
h('button', { onClick: () => bump(1) }, '+'),
])
},
})
customElements.define('x-stepper', Stepper)
Vue components vs. custom elements
Custom elements are the right unit when the consumer is framework-agnostic — a design-system library used by
teams on different stacks, or widgets embedded in server-rendered HTML. They pay a cost: no build-time
template optimisation across the boundary, string-only attributes, styling isolated by the shadow DOM, and
extra care for SSR (the element upgrades only after its script runs on the client). For an app that is Vue
end to end, plain .vue components are simpler, faster, and fully typed.
See also
-
Performance and Deployment — profiling renders and the update-time optimisations.
-
React Reference — a virtual DOM without the compiler hints, for contrast.