Skip to the solution
HardFrontend11 min read

What is a custom renderer in Vue (createRenderer)?

250 views

The problem

Question presented to candidate: "Vue is usually described as a DOM framework, yet there are libraries that render Vue components into a WebGL scene or a terminal. How is that possible? Explain what createRenderer is and what you have to provide to build a custom renderer."

What a strong answer should cover:

  • Vue's core (@vue/runtime-core: components, reactivity, scheduler, VNode diffing) is platform-agnostic; the browser DOM support in @vue/runtime-dom is itself built with createRenderer.
  • createRenderer(options) takes node operations: createElement, createText, createComment, insert, remove, setText, setElementText, patchProp, parentNode, nextSibling, plus optional ones such as insertStaticContent.
  • It returns { render, createApp }; createApp(Root).mount(container) mounts into your container type.
  • Updates are minimal: after a state change the renderer receives only the calls for what changed (for example one patchProp and one setElementText).
  • Real uses: TresJS (three.js), canvas or PDF targets, terminals, testing.
  • Caveats: templates compiled for the DOM may emit static HTML strings, Transition/v-show/v-model live in runtime-dom, and Vapor mode does not apply.

Clarifying questions expected:

  • "What is the target: a canvas, a 3D scene, native widgets, or something like a terminal?"

Code / implementation expected: Optional โ€” a small renderer with a handful of node operations and a component rendered into it.

  • vue
  • custom-renderer
  • internals

Try it yourself

Sketch your approach before you read the solution. That is what the interview tests.

Stuck? Get a hint.

A short nudge toward the approach. It will not give the answer away.

Solution

The solution is hidden so you can try first

11 min read ยท 11 sections

Read the code

VueA custom renderer inside one SFC: Vue renders a scene graph of plain objects
<script setup>
import { createRenderer, defineComponent, h, ref, shallowRef } from "vue";

// 1. Node operations for a tiny "scene graph": plain objects, no DOM at all.
const log = shallowRef([]);
const record = (msg) => { log.value = [...log.value, msg]; };

const { createApp: createSceneApp } = createRenderer({
  createElement: (type) => { record(`createElement ${type}`); return { type, props: {}, children: [], parent: null }; },
  createText: (text) => ({ type: "#text", text, parent: null }),
  createComment: (text) => ({ type: "#comment", text, parent: null }),
  setText: (node, text) => { record(`setText "${text}"`); node.text = text; },
  setElementText: (node, text) => { record(`setElementText "${text}"`); node.children = [{ type: "#text", text, parent: node }]; },
  insert: (child, parent, anchor) => {
    const i = anchor ? parent.children.indexOf(anchor) : -1;
    if (child.parent) child.parent.children.splice(child.parent.children.indexOf(child), 1);
    i === -1 ? parent.children.push(child) : parent.children.splice(i, 0, child);
    child.parent = parent;
    record(`insert ${child.type} into ${parent.type}`);
  },
  remove: (child) => { const p = child.parent; if (p) p.children.splice(p.children.indexOf(child), 1); record(`remove ${child.type}`); },
  patchProp: (node, key, prev, next) => { record(`patchProp ${node.type}.${key} = ${JSON.stringify(next)}`); node.props[key] = next; },
  parentNode: (node) => node.parent,
  nextSibling: (node) => { const s = node.parent?.children; return s ? s[s.indexOf(node) + 1] ?? null : null; },
});

// 2. A component written against that renderer: "circle" and "label" are not HTML tags.
const radius = ref(10);
const Scene = defineComponent({
  setup: () => () => h("group", { name: "root" }, [
    h("circle", { r: radius.value, fill: radius.value > 20 ? "red" : "blue" }),
    h("label", null, `r = ${radius.value}`),
  ]),
});

// 3. Mount into a plain object instead of a DOM element.
const container = { type: "scene", props: {}, children: [], parent: null };
createSceneApp(Scene).mount(container);

const strip = (n) => (n.type === "#text" ? n.text : { type: n.type, ...(Object.keys(n.props).length ? { props: n.props } : {}), children: n.children.filter((c) => c.type !== "#comment").map(strip) });
const tree = ref(JSON.stringify(strip(container.children[0]), null, 1));
const grow = async () => {
  log.value = [];
  radius.value += 15;
  await Promise.resolve(); // the scheduler flushes in a microtask
  await Promise.resolve();
  tree.value = JSON.stringify(strip(container.children[0]), null, 1);
};
</script>

<template>
  <button @click="grow">grow the circle</button>
  <h4>Operations the renderer was asked to do</h4>
  <ol><li v-for="(op, i) in log" :key="i">{{ op }}</li></ol>
  <h4>The custom tree (not DOM)</h4>
  <pre>{{ tree }}</pre>
</template>
A terminal renderer with @vue/runtime-core (run with Node)
// A custom renderer for the terminal, using @vue/runtime-core directly (no DOM, no runtime-dom).
// Elements are plain objects; after each flush we print the tree as text.
import { createRenderer, h, ref, nextTick } from "@vue/runtime-core";

const el = (type) => ({ type, props: {}, children: [], parent: null });
const { createApp } = createRenderer({
  createElement: el,
  createText: (text) => ({ type: "text", text, parent: null }),
  createComment: () => ({ type: "comment", parent: null }),
  setText: (node, text) => { node.text = text; },
  setElementText: (node, text) => { node.children = [{ type: "text", text, parent: node }]; },
  insert(child, parent, anchor) {
    if (child.parent) child.parent.children.splice(child.parent.children.indexOf(child), 1);
    const i = anchor ? parent.children.indexOf(anchor) : -1;
    i < 0 ? parent.children.push(child) : parent.children.splice(i, 0, child);
    child.parent = parent;
  },
  remove(child) { const s = child.parent?.children; if (s) s.splice(s.indexOf(child), 1); },
  patchProp(node, key, prev, next) { node.props[key] = next; },
  parentNode: (node) => node.parent,
  nextSibling(node) { const s = node.parent?.children; return s ? s[s.indexOf(node) + 1] ?? null : null; },
});

// Paint: "box" draws a border, "bar" draws a progress bar, text nodes print as is.
const paint = (n, depth = 0) => {
  const pad = "  ".repeat(depth);
  if (n.type === "text") return pad + n.text;
  if (n.type === "comment") return null;
  if (n.type === "bar") return pad + "[" + "#".repeat(n.props.value) + ".".repeat(10 - n.props.value) + "] " + n.props.value * 10 + "%";
  const inner = (d) => n.children.map((c) => paint(c, d)).filter((x) => x !== null);
  return n.type === "box" ? [pad + "+-- " + (n.props.title ?? ""), ...inner(depth + 1), pad + "+--"].join("\n") : inner(depth).join("\n");
};

const progress = ref(3);
const App = { setup: () => () => h("box", { title: "Deploy" }, [h("label", null, `step ${progress.value} of 10`), h("bar", { value: progress.value })]) };

const screen = el("screen");
createApp(App).mount(screen);
console.log(paint(screen.children[0]));
progress.value = 7;          // reactive update: Vue patches the object tree
await nextTick();
console.log(paint(screen.children[0]));

Discussion

Other approaches, follow-ups and how it went in real interviews.

Sign in to join the discussion.

No comments yet. Share how you would answer it.

Question 89 of 92 in Vue.js

Keep your momentum going.

Mark this question solved to track your progress, then take the next one while it is fresh.

All Vue.js questions
Up nextHard

What is effectScope() and why do composables use it for cleanup?

Continue