Vue.js 67 🟢 Cross-Platform Render Targets with Custom Renderers (createRenderer)
Vue’s core runtime is platform-agnostic. The DOM-specific parts—document.createElement, element.setAttribute, element.appendChild—live in a separate package, @vue/runtime-dom, which is itself built on the same createRenderer API that is exposed to application developers . This means the component model, the reactivity system, the virtual DOM diffing, and the lifecycle hooks are not tied to the browser. They are tied to an abstraction layer that the renderer implements. createRenderer is the function that takes a platform-specific implementation of that abstraction and returns a createApp function that uses it .
The abstraction is defined by RendererOptions, an interface with a set of required and optional methods. The required methods are the operations the runtime needs to perform on the host environment: create an element, create a text node, insert a node into a parent, remove a node, set the text content, patch a prop, and navigate the tree (parentNode, nextSibling). The optional methods—querySelector, setScopeId, cloneNode, insertStaticContent—are optimizations or DOM-specific features that a custom renderer can omit . Once these methods are implemented, the renderer can target any environment: a terminal cell buffer, a canvas, a PDF document, or a native window.
Key point: createRenderer takes a RendererOptions object with platform-specific implementations of createElement, insert, remove, patchProp, createText, createComment, setText, setElementText, parentNode, and nextSibling, and returns a createApp function that renders Vue components to that platform. The same component code that renders to the DOM can render to a terminal, a canvas, or a PDF, because the runtime only calls the operations the renderer provides.
Why the renderer is abstracted
The platform problem. A Vue component describes its output as a tree of virtual nodes. The runtime’s job is to turn that tree into something the user can see and interact with. In a browser, that something is a DOM element. In a terminal, it is a cell in a buffer. In a canvas, it is a draw call. The runtime does not know or care which; it only knows the operations that the renderer exposes . The abstraction is what makes the component portable.
The @vue/runtime-dom reference implementation. The DOM renderer is not a special case in the runtime. It is a package that calls createRenderer with a RendererOptions object whose methods call the DOM APIs . The Vue documentation points to @vue/runtime-dom as the reference implementation and to @vue/runtime-test, a private package used for Vue’s own unit testing, as a simpler example . A custom renderer is not a second-class citizen; it is the same mechanism the official renderer uses.
The ecosystem proof. The custom renderer API is used in production by several packages. vue3-pixi is a renderer that creates PixiJS objects instead of HTML elements, allowing Vue components to drive a 2D WebGL scene . @vui-rs/vue is a renderer for a terminal cell buffer, allowing Vue components to build terminal user interfaces . @i2d/pdf-frame-vue is a renderer that produces PDF and canvas graphics from Vue templates . Each of these is a different RendererOptions implementation, and each reuses the same component model.
The testing problem. A custom renderer that produces a plain object tree instead of DOM nodes is easier to assert on than a DOM tree. @vue/runtime-test exists for this reason: the test renderer’s createElement returns a simple object, and the test can inspect that object directly . A custom renderer for a design system can do the same thing, producing a serializable description of the output that is easier to snapshot than the DOM.
a. The RendererOptions interface and the required methods
The RendererOptions interface has ten required methods and four optional ones. The required methods are the minimum a platform must implement to be a valid render target.
// The createRenderer function from @vue/runtime-core
import { createRenderer } from '@vue/runtime-core'
// The RendererOptions interface, as documented
// The generic parameters are the node type and the element type
interface RendererOptions<HostNode, HostElement> {
// Patch a prop on an element
// prevValue is the old value, nextValue is the new value
patchProp(
el: HostElement,
key: string,
prevValue: any,
nextValue: any,
): void
// Insert a node into a parent, optionally before an anchor
insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void
// Remove a node from its parent
remove(el: HostNode): void
// Create an element of the given type
createElement(
type: string,
namespace?: ElementNamespace,
isCustomizedBuiltIn?: string,
vnodeProps?: (VNodeProps & { [key: string]: any }) | null,
): HostElement
// Create a text node
createText(text: string): HostNode
// Create a comment node (used for v-if placeholders)
createComment(text: string): HostNode
// Set the text content of a text node
setText(node: HostNode, text: string): void
// Set the text content of an element
setElementText(node: HostElement, text: string): void
// Get the parent of a node
parentNode(node: HostNode): HostElement | null
// Get the next sibling of a node
nextSibling(node: HostNode): HostNode | null
}
The patchProp method is the most complex. It receives the element, the prop key, the previous value, and the next value. For a DOM renderer, this method handles attributes, properties, classes, styles, and events. For a custom renderer, it can do whatever the platform requires: setting a property on a PixiJS object, writing a value to a terminal cell, or storing a prop in a plain object . The key is that the runtime does not know what a “prop” means on the platform; it only knows that when a prop changes, it calls patchProp.
The insert and remove methods are the tree manipulation operations. The runtime calls insert when a new node is mounted, and remove when an old node is unmounted. The anchor parameter in insert is used for inserting a node before a specific sibling, which is how the runtime maintains the correct order during diffing.
The createElement method creates a host element. The type parameter is the tag name from the template—div, button, or, in a custom renderer, box, text, or sprite. The vnodeProps parameter is optional and is used by the DOM renderer for optimized props handling; a custom renderer can ignore it.
b. The optional methods and the minimal renderer
The optional methods are not required for correctness, but they enable optimizations and DOM-specific features. A minimal custom renderer can omit all of them.
// A minimal renderer that targets a plain JavaScript object tree
// This is the pattern that @vue/runtime-test uses
interface TestNode {
type: string
props: Record<string, any>
children: TestNode[]
text?: string
}
// The options object
const options: RendererOptions<TestNode, TestNode> = {
// Create an element: a plain object
createElement(type) {
return { type, props: {}, children: [] }
},
// Create a text node: an object with a text field
createText(text) {
return { type: '#text', props: {}, children: [], text }
},
// Create a comment node: a distinct type
createComment(text) {
return { type: '#comment', props: {}, children: [], text }
},
// Set the text of a text node
setText(node, text) {
node.text = text
},
// Set the text of an element
setElementText(node, text) {
node.children = [{ type: '#text', props: {}, children: [], text }]
},
// Insert a child into a parent
insert(child, parent, anchor) {
if (anchor) {
const index = parent.children.indexOf(anchor)
parent.children.splice(index, 0, child)
} else {
parent.children.push(child)
}
},
// Remove a child from its parent
remove(child) {
// The parent reference is not available here
// A real renderer tracks the parent or uses the DOM's removeChild
},
// Patch a prop: store it on the object
patchProp(el, key, prev, next) {
el.props[key] = next
},
// Get the parent: not tracked in this minimal example
parentNode(node) {
return null
},
// Get the next sibling: not tracked in this minimal example
nextSibling(node) {
return null
},
}
// Create the renderer
const { createApp } = createRenderer(options)
// Use the createApp function with a Vue component
const App = {
template: '<div><p>Hello</p></div>',
}
// Mount the app to the root object
const root = { type: '#root', props: {}, children: [] }
createApp(App).mount(root)
The querySelector optional method is used by the DOM renderer for v-show and for template refs that use a selector string. A custom renderer that does not support selectors can omit it, and the runtime will not call it. The setScopeId method is used for scoped CSS in the DOM; a custom renderer without scoped CSS can omit it. The cloneNode method is used for v-once and static content caching; omitting it means those optimizations are not available. The insertStaticContent method is used for static hoisting, where the compiler emits a string of HTML that the renderer inserts directly. A custom renderer that cannot parse HTML can omit it.
The minimal renderer is not a toy. It is the foundation of the testing strategy for Vue itself, and it is the pattern that a design system’s renderer would use to produce a serializable tree for snapshot testing. The required methods are the contract; the optional methods are the performance features.
c. Building a terminal renderer: the @vui-rs/vue example
The @vui-rs/vue package is a production example of a custom renderer that targets a terminal cell buffer. It uses the same Vue component model and reactivity, but the host elements are boxes and text cells, not DOM nodes.
// The package's usage example, from its documentation
import { createApp, h, ref } from "@vui-rs/vue";
const App = {
setup() {
// A reactive counter
const n = ref(0);
// The render function returns a vui-rs element tree
return () => h("box", { border: "rounded", padding: 1 },
h("text", {}, `count: ${n.value}`)
);
},
};
// The createApp comes from the package's renderer
// It is the same createApp that createRenderer returns
createApp(App).mount();
The h function is Vue’s hyperscript, and the box and text elements are the host elements of the terminal platform. The renderer’s createElement returns a cell buffer structure, patchProp writes the border and padding to the buffer, and insert places the text cells in the correct position. The component’s reactivity drives the re-render, and the renderer’s patchProp updates the buffer when the count changes . The developer writes Vue components, and the renderer handles the terminal.
The vue3-pixi package follows the same pattern with PixiJS objects. The template uses <sprite>, <container>, and <graphics> instead of <div> and <span>. The renderer’s createElement creates a PixiJS object, patchProp sets the texture and position properties, and insert adds the object to the PixiJS scene graph . The events are wired through patchProp as well: a @click on a sprite becomes an event listener on the PixiJS object, and the component’s handler runs when the sprite is clicked .
The @i2d/pdf-frame-vue package uses the renderer to produce PDF documents. The template uses SVG-like tags, and the renderer’s methods write to the PDFKit document. The same reactivity and lifecycle hooks that drive a browser component drive the PDF generation, which means a component can be tested in the DOM and then rendered to a PDF without changing its logic .
Complete Example Session
The following session builds a minimal custom renderer for a plain object tree, mounts a component, and verifies the output structure.
// ============================================
// PART 1: THE RENDERER OPTIONS
// ============================================
import { createRenderer } from '@vue/runtime-core'
// A plain object tree node
// The renderer produces this instead of a DOM node
function createNode(type) {
return { type, props: {}, children: [] }
}
const options = {
// Create an element node
createElement(type) {
return createNode(type)
},
// Create a text node
createText(text) {
const node = createNode('#text')
node.text = text
return node
},
// Create a comment node
createComment(text) {
const node = createNode('#comment')
node.text = text
return node
},
// Set the text of a text node
setText(node, text) {
node.text = text
},
// Set the text of an element
setElementText(node, text) {
node.children = [{ type: '#text', text, props: {}, children: [] }]
},
// Insert a node into a parent
insert(child, parent, anchor) {
if (anchor) {
const index = parent.children.indexOf(anchor)
parent.children.splice(index, 0, child)
} else {
parent.children.push(child)
}
},
// Remove a node (simplified)
remove(child) {
// The parent is not tracked; a real renderer would remove from the parent
},
// Patch a prop
patchProp(el, key, prev, next) {
el.props[key] = next
},
// Parent node (not tracked)
parentNode(node) {
return null
},
// Next sibling (not tracked)
nextSibling(node) {
return null
},
}
// ============================================
// PART 2: THE RENDERER
// ============================================
const { createApp } = createRenderer(options)
// ============================================
// PART 3: THE COMPONENT
// ============================================
const App = {
// The template uses Vue's template syntax
template: `
<div class="app">
<h1>Hello</h1>
<p>Custom renderer</p>
</div>
`,
}
// ============================================
// PART 4: THE MOUNT
// ============================================
// The root is a plain object
const root = { type: '#root', props: {}, children: [] }
// Mount the app to the root
createApp(App).mount(root)
// The root now contains the object tree
console.log(JSON.stringify(root, null, 2))
// Output:
// {
// "type": "#root",
// "props": {},
// "children": [
// {
// "type": "div",
// "props": { "class": "app" },
// "children": [
// { "type": "h1", "props": {}, "children": [{ "type": "#text", "text": "Hello" }] },
// { "type": "p", "props": {}, "children": [{ "type": "#text", "text": "Custom renderer" }] }
// ]
// }
// ]
// }
The four parts cover the options object with the required methods, the renderer creation, the component with a template, and the mount with the resulting object tree. What the session demonstrates is that the runtime does not know what a div is; it only knows that it called createElement('div') and received a host element, and that it called insert to place the children.
Quick Reference
Required RendererOptions Methods
| Method | Purpose |
|---|---|
createElement | Create a host element |
createText | Create a text node |
createComment | Create a comment node |
setText | Set text on a text node |
setElementText | Set text on an element |
insert | Insert a node into a parent |
remove | Remove a node |
patchProp | Update a prop |
parentNode | Get the parent |
nextSibling | Get the next sibling |
Optional Methods
| Method | Purpose |
|---|---|
querySelector | Selector-based lookup (DOM) |
setScopeId | Scoped CSS (DOM) |
cloneNode | Static content caching |
insertStaticContent | Static hoisting |
Renderer Packages
| Package | Target |
|---|---|
@vue/runtime-dom | DOM (reference) |
@vue/runtime-test | Plain object (testing) |
vue3-pixi | PixiJS scene graph |
@vui-rs/vue | Terminal cell buffer |
@i2d/pdf-frame-vue | PDF / canvas |
Best Practices
✅ Do This:
// Implement all required methods
const options = {
createElement: (type) => createNode(type),
insert: (child, parent, anchor) => { /* ... */ },
patchProp: (el, key, prev, next) => { el.props[key] = next },
// ...
}
// Use the createApp from the returned renderer
const { createApp } = createRenderer(options)
createApp(App).mount(root)
❌ Don’t Do This:
// Use the DOM createApp in a non-DOM environment
import { createApp } from 'vue' // assumes document
// Omit a required method
const options = { createElement: ... } // runtime fails on mount
// Assume the renderer tracks the parent
// A minimal renderer must track it or use a DOM-like API
Common Pitfalls
| Pitfall | Why It Happens | Fix |
|---|---|---|
document is not defined | Used vue instead of @vue/runtime-core | Import from @vue/runtime-core |
| Mount fails | Missing required method | Implement all ten |
| Tree order wrong | insert ignores anchor | Handle the anchor in insert |
| Props not updated | patchProp ignores next | Store the new value |
| Text not set | setText not implemented | Set the text field |
Real-World Examples
1. Create a renderer
import { createRenderer } from '@vue/runtime-core'
const { createApp } = createRenderer(options)
2. Create an element
createElement(type) { return { type, props: {}, children: [] } }
3. Create a text node
createText(text) { return { type: '#text', text, children: [] } }
4. Insert a node
insert(child, parent, anchor) { parent.children.splice(anchor ? parent.children.indexOf(anchor) : parent.children.length, 0, child) }
5. Patch a prop
patchProp(el, key, prev, next) { el.props[key] = next }
6. Mount
createApp(App).mount(root)
7. PixiJS element
createElement(type) { return new PIXI[capitalize(type)]() }
8. Terminal element
createElement(type) { return { type, style: {}, children: [] } }
9. Testing renderer
// @vue/runtime-test produces plain objects for snapshot testing
10. Re-export core
export * from '@vue/runtime-core'
Visual
The Renderer Abstraction
┌──────────────────────────────────────────────┐
│ Vue Component │
│ (template, reactivity, lifecycle) │
│ │ │
│ ▼ │
│ Virtual DOM diff │
│ │ │
│ ▼ │
│ RendererOptions │
│ createElement, insert, patchProp, ... │
│ │ │
│ ┌────┴────┐ │
│ │ │ │
│ ▼ ▼ │
│ DOM Terminal │
│ node cell │
└──────────────────────────────────────────────┘
The RendererOptions Contract
┌──────────────────────────────────────────────┐
│ createElement(type) → HostElement │
│ createText(text) → HostNode │
│ insert(el, parent, anchor) │
│ remove(el) │
│ patchProp(el, key, prev, next) │
│ setText(node, text) │
│ setElementText(node, text) │
│ parentNode(node) → HostElement │
│ nextSibling(node) → HostNode │
└──────────────────────────────────────────────┘
Platform Implementations
┌──────────────────────────────────────────────┐
│ DOM: document.createElement, appendChild │
│ PixiJS: new Sprite(), addChild │
│ Terminal: cell buffer write │
│ Test: plain object │
└──────────────────────────────────────────────┘
Summary
| Item | Value |
|---|---|
| Function | createRenderer(options) |
| Package | @vue/runtime-core |
| Returns | { render, createApp } |
| Required methods | 10 |
| Optional methods | 4 |
| DOM implementation | @vue/runtime-dom |
| Test implementation | @vue/runtime-test |
| Custom examples | vue3-pixi, @vui-rs/vue |
Key takeaways:
- The runtime is platform-agnostic. The DOM-specific code lives in
@vue/runtime-dom, which is itself acreateRenderercall. The component model, reactivity, and diffing are shared across all platforms . createRenderertakes aRendererOptionsobject with ten required methods. The methods are the operations the runtime needs: create, insert, remove, patch, navigate. A platform that implements them can render Vue components .- The optional methods are optimizations.
querySelector,setScopeId,cloneNode, andinsertStaticContentare DOM-specific or performance-related. A minimal renderer can omit them . - The ecosystem uses the API in production.
vue3-pixirenders to PixiJS,@vui-rs/vuerenders to a terminal, and@i2d/pdf-frame-vuerenders to PDF. Each is a differentRendererOptionsimplementation . - A custom renderer is not a second-class citizen. The official DOM renderer uses the same API. The test renderer uses the same API. The abstraction is the mechanism, not a workaround .
Remember: createRenderer is the function that separates Vue’s component model from the browser. It takes the platform-specific operations—create an element, insert it, patch its props, remove it—and returns a createApp that uses them. The component code does not change; the renderer changes. A component that renders a <div> in the browser can render a <box> in a terminal or a <sprite> in a canvas, because the runtime only knows the operations the renderer provides. The abstraction is not a leaky one; it is the same abstraction the official DOM renderer uses, and the custom renderers in the ecosystem prove that it is complete enough to target any environment that can represent a tree of nodes.
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!