Cross-Platform Renderer: Design Philosophy and Implementation of Custom Renderers
Chapter 18: Cross-Platform Renderer โ The Design Philosophy and Implementation of Custom Renderers
There is not a single line of code in
@vue/runtime-corethat callsdocument.createElementโ it doesn't even reference thedocumentglobal variable. The entire component system, reactive bindings, lifecycle hooks, and directive processing all operate in complete ignorance of "what the DOM is."
Core question of this chapter: How does Vue fully abstract the act of "rendering" so that the same component logic can drive DOM, Canvas, mini-programs, and server-side HTML string output on completely different target platforms?
After reading this chapter you will understand:
- The complete definition of the
RendererOptionsinterface and the responsibility of each method - How
createRenderer(options)injects platform operations into the renderer - The complete steps to build a minimal Canvas renderer
- The fundamental difference between
@vue/server-renderer's output mechanism and a regular renderer - The practical relationship between custom renderers and testing tools and mini-program platforms
Level 1 ยท What You Need to Know (1โ3 Years Experience)
The Layered Architecture of the Renderer
Vue 3 splits rendering-related code into three layers:
Layer structure:
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
โ @vue/runtime-dom (DOM platform layer) โ
โ โโโ patchDOMProp / patchAttr / patchClass / patchStyle โ
โ โโโ patchEvent (addEventListener / removeEventListener) โ
โ โโโ Implements RendererOptions, calls document.* โ
โ โ
โ Calls createRenderer(domOptions) โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ @vue/runtime-core (platform-agnostic core layer) โ
โ โโโ Component mount/update/unmount logic โ
โ โโโ diff algorithm โ
โ โโโ directive processing โ
โ โโโ lifecycle hook calls โ
โ โโโ Calls platform methods via RendererOptions interface โ
โ โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโค
โ @vue/reactivity (reactivity system, fully independent) โ
โ โโโ ref / reactive / computed / watch โ
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
@vue/runtime-dom is Vue's browser entry point. If you want Canvas rendering, you don't use @vue/runtime-dom at all โ you use createRenderer from @vue/runtime-core directly, passing in your own Canvas operation methods.
RendererOptions: The Renderer's "Driver Contract"
// packages/runtime-core/src/renderer.ts
export interface RendererOptions<HostNode = RendererNode, HostElement = RendererElement> {
// Core node operations
createElement(type: string, isSVG?: boolean, isCustomizedBuiltIn?: string): HostElement
createText(text: string): HostNode
createComment(text: string): HostNode
setText(node: HostNode, text: string): void
setElementText(el: HostElement, text: string): void
// Tree operations
insert(el: HostNode, parent: HostElement, anchor?: HostNode | null): void
remove(el: HostNode): void
parentNode(node: HostNode): HostElement | null
nextSibling(node: HostNode): HostNode | null
// Prop/event handling
patchProp(
el: HostElement,
key: string,
prevValue: any,
nextValue: any,
isSVG?: boolean,
prevChildren?: VNode[],
parentComponent?: ComponentInternalInstance | null,
unmountChildren?: UnmountChildrenFn
): void
// Optional: special operations for SSR
querySelector?: (selector: string) => HostElement | null
setScopeId?(el: HostElement, id: string): void
cloneNode?(node: HostNode): HostNode
insertStaticContent?(
content: string,
parent: HostElement,
anchor: HostNode | null,
isSVG: boolean
): [HostNode, HostNode]
}
Implement these 10โ12 methods, and all features of @vue/runtime-core (components, Suspense, KeepAlive, directives...) will work on your platform.
createRenderer: The Renderer Factory
import { createRenderer } from '@vue/runtime-core'
// Create a "does nothing" test renderer
const { render, createApp } = createRenderer({
createElement: (type) => ({ type, children: [], props: {} }),
createText: (text) => ({ type: '#text', text }),
createComment: (text) => ({ type: '#comment', text }),
setText: (node, text) => { node.text = text },
setElementText: (el, text) => { el.text = text },
insert: (el, parent, anchor) => {
const index = anchor ? parent.children.indexOf(anchor) : parent.children.length
parent.children.splice(index, 0, el)
},
remove: (el) => {
const parent = el.parentNode
if (parent) {
const index = parent.children.indexOf(el)
parent.children.splice(index, 1)
}
},
patchProp: (el, key, prevValue, nextValue) => {
el.props[key] = nextValue
},
parentNode: (node) => node.parentNode || null,
nextSibling: (node) => {
const parent = node.parentNode
if (!parent) return null
const index = parent.children.indexOf(node)
return parent.children[index + 1] || null
},
})
Using @vue/server-renderer
// Server-side (Node.js)
import { createSSRApp } from 'vue'
import { renderToString } from '@vue/server-renderer'
const app = createSSRApp({
data() { return { msg: 'Hello SSR' } },
template: '<div>{{ msg }}</div>'
})
// renderToString doesn't touch the DOM โ it produces an HTML string
const html = await renderToString(app)
console.log(html) // '<div>Hello SSR</div>'
The SSR renderer's core difference: it doesn't need to implement insert/remove/parentNode DOM operations, because it just concatenates strings.
Level 2 ยท How It Actually Works (3โ5 Years Experience)
Internal Structure of createRenderer
// packages/runtime-core/src/renderer.ts (simplified)
export function createRenderer<
HostNode = RendererNode,
HostElement = RendererElement
>(options: RendererOptions<HostNode, HostElement>) {
return baseCreateRenderer<HostNode, HostElement>(options)
}
function baseCreateRenderer(options, createHydrationFns?) {
// Destructure RendererOptions; all subsequent operations use these local variables
const {
insert: hostInsert,
remove: hostRemove,
patchProp: hostPatchProp,
createElement: hostCreateElement,
createText: hostCreateText,
createComment: hostCreateComment,
setText: hostSetText,
setElementText: hostSetElementText,
parentNode: hostParentNode,
nextSibling: hostNextSibling,
setScopeId: hostSetScopeId,
insertStaticContent: hostInsertStaticContent,
} = options
// All patch / mount / unmount functions are defined inside this closure
// They call platform methods through the host* variables above
const patch = (n1, n2, container, anchor, ...) => {
// ... uses hostCreateElement etc., never uses document directly
}
const mountElement = (vnode, container, anchor, ...) => {
const el = (vnode.el = hostCreateElement(
vnode.type,
isSVG,
vnode.props && vnode.props.is
))
hostInsert(el, container, anchor)
}
return {
render,
hydrate,
createApp: createAppAPI(render, hydrate),
}
}
Key insight: All rendering logic lives inside the closure of baseCreateRenderer. Variables like hostCreateElement are shared by all internal functions through this closure. Swap in a different set of options, and you swap the entire "platform driver" without changing any of the upper-layer logic.
The DOM Renderer's patchProp Implementation
The DOM platform's patchProp is the most complex single implementation, handling many different attribute types:
// packages/runtime-dom/src/patchProp.ts (simplified)
export const patchProp: DOMRendererOptions['patchProp'] = (
el, key, prevValue, nextValue, isSVG, ...
) => {
if (key === 'class') {
patchClass(el, nextValue, isSVG)
} else if (key === 'style') {
patchStyle(el, prevValue, nextValue)
} else if (isOn(key)) {
// Keys starting with 'on' (onClick โ click event)
if (!isModelListener(key)) {
patchEvent(el, key, prevValue, nextValue, ...)
}
} else if (
key[0] === '.'
? ((key = key.slice(1)), true)
: !isSVG && nativeOnlyTags.has(el.tagName) && domAttrConfig[key]
? (el[key] = nextValue) && false
: false
) {
// DOM property (.value, .checked, etc.)
patchDOMProp(el, key, nextValue, ...)
} else {
// HTML attribute (aria-*, data-*, custom attributes)
patchAttr(el, key, nextValue, isSVG)
}
}
Practice: Building a Minimal Canvas Renderer
// Node type definition
class CanvasNode {
constructor(type, props = {}) {
this.type = type // 'rect' | 'circle' | 'text' | '#text' | '#fragment'
this.props = props
this.children = []
this.parent = null
}
}
import { createRenderer } from '@vue/runtime-core'
const { createApp } = createRenderer({
createElement(type) {
return new CanvasNode(type)
},
createText(text) {
return new CanvasNode('#text', { text })
},
createComment(text) {
return new CanvasNode('#comment', { text })
},
setText(node, text) {
node.props.text = text
scheduleRedraw()
},
setElementText(el, text) {
el.props.text = text
scheduleRedraw()
},
insert(el, parent, anchor) {
el.parent = parent
if (anchor) {
const index = parent.children.indexOf(anchor)
parent.children.splice(index, 0, el)
} else {
parent.children.push(el)
}
scheduleRedraw()
},
remove(el) {
if (el.parent) {
const index = el.parent.children.indexOf(el)
el.parent.children.splice(index, 1)
el.parent = null
}
scheduleRedraw()
},
parentNode(node) {
return node.parent || null
},
nextSibling(node) {
if (!node.parent) return null
const index = node.parent.children.indexOf(node)
return node.parent.children[index + 1] || null
},
patchProp(el, key, prevValue, nextValue) {
if (nextValue === null || nextValue === undefined) {
delete el.props[key]
} else {
el.props[key] = nextValue
}
scheduleRedraw()
},
})
// Canvas drawing engine
const canvas = document.getElementById('canvas')
const ctx = canvas.getContext('2d')
let rafId = null
function scheduleRedraw() {
if (rafId !== null) return
rafId = requestAnimationFrame(() => {
rafId = null
ctx.clearRect(0, 0, canvas.width, canvas.height)
renderNode(rootNode, 0, 0)
})
}
function renderNode(node, x, y) {
if (!node) return
const { type, props, children } = node
const nodeX = x + (props.x || 0)
const nodeY = y + (props.y || 0)
switch (type) {
case 'rect':
ctx.fillStyle = props.fill || '#000'
ctx.fillRect(nodeX, nodeY, props.width || 100, props.height || 100)
break
case 'circle':
ctx.fillStyle = props.fill || '#000'
ctx.beginPath()
ctx.arc(nodeX, nodeY, props.radius || 50, 0, Math.PI * 2)
ctx.fill()
break
case 'text':
ctx.fillStyle = props.color || '#000'
ctx.font = props.font || '16px sans-serif'
ctx.fillText(props.text || '', nodeX, nodeY)
break
}
children.forEach(child => renderNode(child, nodeX, nodeY))
}
// Usage
const app = createApp({
setup() {
const color = ref('red')
const x = ref(50)
setTimeout(() => {
color.value = 'blue' // reactive update โ Canvas redraw
x.value = 100
}, 1000)
return () => h('rect', { fill: color.value, x: x.value, y: 50, width: 80, height: 80 })
}
})
const rootNode = new CanvasNode('#fragment')
app.mount(rootNode)
Rendering Path Comparison
DOM renderer:
VNode tree
โ
โผ patch()
DOM operation call sequence
โโโ document.createElement('div')
โโโ el.setAttribute('class', 'box')
โโโ el.addEventListener('click', handler)
โโโ document.body.appendChild(el)
Real DOM tree
โ
โผ Browser rendering engine
Pixels on screen
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Server-side renderer (@vue/server-renderer):
VNode tree
โ
โผ renderToString()
String concatenation sequence
โโโ '<div'
โโโ ' class="box"'
โโโ '>'
โโโ '</div>'
HTML string
โ
โผ HTTP response
Client receives and hydrates
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
Canvas renderer (custom):
VNode tree
โ
โผ patch() (using custom RendererOptions)
CanvasNode tree operations
โโโ new CanvasNode('rect')
โโโ node.props.fill = 'red'
โโโ parent.children.push(node)
CanvasNode tree
โ
โผ redraw() (requestAnimationFrame)
Canvas 2D draw calls
โโโ ctx.fillRect(...)
โโโ ctx.arc(...)
Canvas pixels
How Mini-Program Renderers Work (uni-app)
uni-app (Vue 3 version) uses a custom renderer to render Vue components to native nodes on each platform:
// uni-app renderer options (conceptual, not actual code)
const uniRenderer = createRenderer({
createElement(type) {
// On mini-programs, "creating a node" means creating a descriptor object
// which is eventually passed to the mini-program framework via setData()
return { type, props: {}, children: [], nodeId: generateId() }
},
insert(el, parent, anchor) {
// Modify the virtual DOM tree
// Then calculate the minimal setData update via diff
parent.children.splice(anchor ? ... : parent.children.length, 0, el)
batchSetData({ [el.nodeId]: el }) // notify mini-program framework
},
patchProp(el, key, prevValue, nextValue) {
el.props[key] = nextValue
// Only setData the changed attribute, not the entire tree
setData({ [`${el.nodeId}.${key}`]: nextValue })
},
})
Key principle: A mini-program's render layer and logic layer are separated (two independent JS runtimes). All node operations are passed to the render layer via setData messages. The custom renderer translates Vue's VNode operations into setData calls.
Level 3 ยท Design Docs and Source Code (Senior Developers)
How @vue/server-renderer Works Internally
The fundamental difference between SSR and client-side renderers:
// packages/server-renderer/src/renderToString.ts (simplified)
export async function renderToString(
input: App | VNode,
context: SSRContext = {}
): Promise<string> {
const vnode = createVNode(input._component, input._props)
vnode.appContext = input._context
const ctx: SSRContext = context
const buffer: SSRBuffer = []
await renderComponentVNode(vnode, null, ctx, buffer)
return unrollBuffer(await Promise.all(buffer))
}
// Server-side component rendering: recursively generates strings, no real DOM created
async function renderComponentVNode(vnode, parentComponent, ctx, buffer) {
const instance = createComponentInstance(vnode, parentComponent, null)
await setupServerComponent(instance) // run setup, await async setup
const subTree = renderComponentRoot(instance)
await renderVNode(subTree, instance, ctx, buffer)
}
async function renderVNode(vnode, parentComponent, ctx, buffer) {
const { type, shapeFlag } = vnode
if (type === Text) {
buffer.push(escapeHtml(vnode.children))
} else if (type === Comment) {
buffer.push(`<!--${vnode.children}-->`)
} else if (type === Fragment) {
await renderVNodeChildren(vnode.children, parentComponent, ctx, buffer)
} else if (shapeFlag & ShapeFlags.ELEMENT) {
await renderElementVNode(vnode, parentComponent, ctx, buffer)
} else if (shapeFlag & ShapeFlags.COMPONENT) {
await renderComponentVNode(vnode, parentComponent, ctx, buffer)
}
}
async function renderElementVNode(vnode, parentComponent, ctx, buffer) {
const { type: tag, props, children, shapeFlag } = vnode
buffer.push(`<${tag}`)
if (props) {
for (const key in props) {
buffer.push(` ${key}="${escapeHtmlAttr(props[key])}"`)
}
}
if (isSelfClosingTag(tag)) {
buffer.push(' />')
return
}
buffer.push('>')
if (shapeFlag & ShapeFlags.TEXT_CHILDREN) {
buffer.push(escapeHtml(children))
} else if (shapeFlag & ShapeFlags.ARRAY_CHILDREN) {
await renderVNodeChildren(children, parentComponent, ctx, buffer)
}
buffer.push(`</${tag}>`)
}
The SSR renderer doesn't need RendererOptions, because it doesn't go through createRenderer โ it operates directly on VNode trees, recursively generating strings.
Hydration: The SSR to CSR Transition
// packages/runtime-dom/src/index.ts
export const { createApp: createSSRApp } = createHydrationRenderer({
...domRendererOptions,
// Extra method: to "claim" existing nodes during hydration
cloneNode: (el) => el.cloneNode(true),
insertStaticContent: (content, parent, anchor) => {
// SSR-generated static HTML is inserted directly, no per-node creation needed
const temp = document.createElement('template')
temp.innerHTML = content
const first = temp.content.firstChild as Element
const last = temp.content.lastChild as Element
parent.insertBefore(temp.content, anchor)
return [first, last]
}
})
// Hydration process:
// 1. Client receives SSR HTML
// 2. createSSRApp().mount('#app') โ performs hydration (doesn't create new DOM, "claims" existing DOM)
// 3. patch(existingDOMNode, newVNode) โ reuses DOM elements, only attaches event listeners
// 4. After hydration, the app becomes interactive
@vue/test-utils and Custom Renderers
// @vue/test-utils internally uses jsdom (a Node.js DOM implementation)
// It is NOT a custom renderer โ it runs the real DOM renderer in a jsdom environment
// This means:
// 1. Test speed: jsdom is slower than a real browser, and slower than a custom renderer
// 2. Accuracy: jsdom covers ~90% of DOM API surface; some CSS/layout features unavailable
// 3. Use case: testing component behavior (not visual appearance)
// For your own custom renderer, you can create a dedicated test renderer:
const { render, createApp } = createRenderer(myCanvasRendererOptions)
function mountCanvas(component, props?) {
const root = new CanvasNode('#fragment')
const app = createApp(component, props)
app.mount(root)
return { root, app }
}
// Then assert directly on the CanvasNode tree, no jsdom needed
test('renders a rect', () => {
const { root } = mountCanvas({ setup: () => () => h('rect', { fill: 'red' }) })
expect(root.children[0].type).toBe('rect')
expect(root.children[0].props.fill).toBe('red')
})
Vue Renderer vs React Reconciler: Cross-Platform Abstraction Comparison
Vue 3's cross-platform abstraction:
@vue/runtime-core defines the RendererOptions interface (~12 methods)
Platform implementers implement this interface
createRenderer(options) injects the interface into the renderer closure
Characteristics:
- Simple interface (12 methods)
- Synchronous calls: insert, remove are synchronous
- No time-slicing (no Concurrent Mode equivalent)
- diff is synchronous; long list diffs can block the main thread
โโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโโ
React's cross-platform abstraction (Reconciler):
react-reconciler defines the HostConfig interface (~35 methods)
Platform implementers implement HostConfig
Created with: ReactReconciler(config)
Characteristics:
- Complex interface (35 methods, including prepareUpdate, commitMount, etc.)
- Async scheduling: supports interruptible rendering via Scheduler (Concurrent Mode)
- More control points: render phase (interruptible) and commit phase (synchronous)
- Cost: high complexity, steep learning curve
Vue 3 strikes a balance between simplicity and flexibility:
12 methods are sufficient for 95% of cross-platform scenarios
while keeping rendering predictable and easy to debug.
Level 4 ยท Edge Cases and Traps (Everyone)
Trap 1: Forgetting to Handle null Values in patchProp
// When a prop is removed, nextValue is null
patchProp(el, 'fill', 'red', null)
// Wrong: direct assignment
patchProp(el, key, prevValue, nextValue) {
el.props[key] = nextValue // el.props.fill = null โ causes issues in next render
}
// Correct: handle null (remove the property)
patchProp(el, key, prevValue, nextValue) {
if (nextValue === null || nextValue === undefined) {
delete el.props[key]
} else {
el.props[key] = nextValue
}
scheduleRedraw()
}
Trap 2: scheduleRedraw Without Reentrancy Guard Causes Performance Problems
// Wrong: redraw immediately on every insert/remove/patchProp
patchProp(el, key, prevValue, nextValue) {
el.props[key] = nextValue
redraw() // if one update touches 50 props, redraw runs 50 times!
}
// Correct: use requestAnimationFrame to batch-merge redraws
let rafId = null
function scheduleRedraw() {
if (rafId !== null) return // already a pending redraw scheduled, skip
rafId = requestAnimationFrame(() => {
rafId = null
redraw()
})
}
Trap 3: SSR Context Object Leaks in Concurrent Requests
// The SSR renderToString accepts a context object
// Components can access it via useSSRContext()
// Problem: in concurrent request scenarios, the same context is shared across requests
// Wrong: using a global context
const globalContext = { state: {} }
// Multiple concurrent requests all modify the same state in renderToString!
// Correct: create a fresh context for each request
app.get('/page', async (req, res) => {
const context = { req } // independent context per request
const html = await renderToString(createApp(App), context)
res.send(html)
})
Trap 4: Not Implementing parentNode Causes Teleport to Silently Fail
// Teleport internally needs parentNode to determine the parent of the anchor node
// If not correctly implemented, Teleport fails silently
// Wrong: may return undefined
parentNode(node) {
return node.parent // if parent was never assigned, returns undefined
}
// Correct: always return null (not undefined) for "no parent"
parentNode(node) {
return node.parent || null // null is the "no parent" return value per RendererOptions spec
}
Trap 5: Not Removing Old Event Listeners in Custom Renderers
// Wrong: append a new listener on every patchProp call
patchProp(el, key, prevValue, nextValue) {
if (key.startsWith('on')) {
const eventName = key.slice(2).toLowerCase()
canvas.addEventListener(eventName, nextValue) // old one never removed!
// After many updates, dozens of duplicate listeners accumulate
}
}
// Correct: remove old listener before adding new one
patchProp(el, key, prevValue, nextValue) {
if (key.startsWith('on')) {
const eventName = key.slice(2).toLowerCase()
if (prevValue) {
canvas.removeEventListener(eventName, prevValue)
}
if (nextValue) {
canvas.addEventListener(eventName, nextValue)
}
}
}
Trap 6: Data Fetching Timing in SSR
// Problem: components fetch data in onMounted, but SSR never runs onMounted
const MyComp = defineComponent({
setup() {
const data = ref(null)
onMounted(async () => {
data.value = await fetch('/api/data').then(r => r.json())
})
// During SSR, data.value is always null
return { data }
}
})
// Correct: use async setup or onServerPrefetch
const MyComp = defineComponent({
async setup() {
// async setup runs both in SSR and client
const data = await fetch('/api/data').then(r => r.json())
return { data }
}
// Alternative:
// setup() {
// const data = ref(null)
// onServerPrefetch(async () => {
// data.value = await fetch('/api/data').then(r => r.json())
// })
// return { data }
// }
})
Chapter Summary
-
Layered design is the foundation of cross-platform support:
@vue/runtime-coreabstracts all platform-specific operations into a replaceable driver via theRendererOptionsinterface (12 methods), without referencing any browser API itself.createRenderer(options)injects these 12 methods into all renderer internal functions through a closure. -
The core work of a custom renderer is mapping tree operations:
insert/remove/parentNode/nextSiblingdefine how your "nodes" form a tree;createElement/createTextdefine how nodes are created;patchPropdefines how props are applied. Implement these 10 methods and the full Vue component system runs on your platform. -
The SSR renderer is not created via createRenderer:
@vue/server-rendereroperates directly on VNode trees, recursively generating HTML strings. It doesn't needinsert/removeor other DOM tree operations, because its "DOM" is simply a string buffer. -
Hydration is the essential step after SSR: The hydration renderer "claims" existing DOM nodes (reusing DOM elements) and only attaches event listeners, skipping node creation.
insertStaticContentandcloneNodeare two additionalRendererOptionsmethods specific to hydration. -
Mini-program renderers are fundamentally setData translators: Mini-programs separate their render layer and logic layer; the custom renderer translates Vue's VNode operations (insert/remove/patchProp) into
setDatacalls, which are propagated to the render layer via the framework's message-passing mechanism โ this is the core technology behind cross-platform frameworks like uni-app.