Chapter 18

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-core that calls document.createElement — it doesn't even reference the document global 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:

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

  1. Layered design is the foundation of cross-platform support: @vue/runtime-core abstracts all platform-specific operations into a replaceable driver via the RendererOptions interface (12 methods), without referencing any browser API itself. createRenderer(options) injects these 12 methods into all renderer internal functions through a closure.

  2. The core work of a custom renderer is mapping tree operations: insert/remove/parentNode/nextSibling define how your "nodes" form a tree; createElement/createText define how nodes are created; patchProp defines how props are applied. Implement these 10 methods and the full Vue component system runs on your platform.

  3. The SSR renderer is not created via createRenderer: @vue/server-renderer operates directly on VNode trees, recursively generating HTML strings. It doesn't need insert/remove or other DOM tree operations, because its "DOM" is simply a string buffer.

  4. 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. insertStaticContent and cloneNode are two additional RendererOptions methods specific to hydration.

  5. 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 setData calls, 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.

Rate this chapter
4.7  / 5  (12 ratings)

💬 Comments