Like my content? Sign up to get occasional emails about new blog posts and other content.
Unsubscribe anytime here.Writing a Custom Renderer - Vue.js 3
Among many other cool features, Vue.js 3 is much more modular than Vue.js 3. The project is consists of many different packages, making it even more flexible and customizable.
One of the more interesting architectural changes is the decoupled renderer and runtime. This makes it much easier to build custom renderers.
What is a Custom Renderer?
Vue consists of several "systems". There is the reactivity system, it's custom component system, a virtual DOM, and several others. A renderer is what takes the output of the virtual DOM and renders it using some UI layer. The DOM renderer (the only one that ships with Vue) could be considered only official renderer, and as such, the reference renderer.
So, a custom renderer is renderer that targets anything other than the DOM.
The official DOM renderer can also be considered the best resource to learn to build a custom renderer - if you want to write one, you will become very well acquainted with it, since there are not many other resources on building a Vue 3 renderer.
Existing Literature
The main resources I used when preparing this post were:
- Vuminal. A terminal renderer. It's source code is overly modular and kind of difficult to navigate, and I couldn't get it to do anything much more than the basic counter example in the README.
- Vugel, a WebGL renderer.
- Vue 3 DOM Renderer source. This was the most useful resource by far.
- React PDF. This is a custom PDF renderer for React. Not Vue, but the ideas apply, and the inspiration for this project.
What Are We Building?
I decided to go with a PDF renderer. The goal is to take something like this:
<template>
<View>
<View :styles="{color: 'red'}">
<Text v-for="color in colors" :styles="{color}">
{{ color }}
</Text>
</View>
<Text>Default</Text>
<Text :styles="{color: 'yellow'}">Yellow</Text>
</View>
<template>
<script>
export default {
data() {
return {
colors: ['red', 'blue', 'green']
}
}
}
</script>
And get this:
I will be using PDFKit to produce the PDF. This is just an example - a fully featured PDF Renderer would be much more complex.
Let's get started!
Anatomy of a Custom Renderer
The runtime-core gives us a hint and how to create a custom renderer:
import { createRenderer } from '@vue/runtime-core'
const { render, createApp } = createRenderer({
patchProp,
insert,
remove,
createElement,
// ...
})
createRenderer
takes one argument, an object of options. These are called node ops, short for node operations. Operations Vue can perform on nodes. This basically means CRUD actions (create, read, update, delete) and a few more. A full list including types can be found in the Vue source code. It's pretty important to understand what they all do. Here a list of all the node ops.
const nodeOps = {
patchProp(...args): void;
forcePatchProp?(...args): boolean;
insert(...args): void;
remove(...args): void;
createElement(...args): HostElement;
createText(...args): HostNode;
createComment(...args): HostNode;
setText(...args): void;
setElementText(...args): void;
parentNode(...args): HostElement | null;
nextSibling(...args): HostNode | null;
querySelector?(...args): HostElement | null;
setScopeId?(...args): void;
cloneNode?(...args): HostNode;
insertStaticContent?(...args): HostElement[];
}
We are only interested in a subset of node ops. The reason is our PDF renderer will be static - no dynamic, real time updates. For this reason we have little need for things like querySelector
or remove
- since nodes are not moving around or otherwise dynamically changing, we won't be needing these. We also don't need things like createComment
- PDFs don't have comments.
To figure out which node ops we need to implement, I'll just start writing the renderer, and filling them out as they get called.
Creating the Renderer
Time to write some code. We start by calling createRenderer
, and passing in the node ops. For now I am just going to console.log
the relevant values to illustrate how and when the different operations are called.
import { RendererOptions } from 'vue'
class PDFNode {
id: string = (Math.random() * 10000).toFixed(0)
}
function noop(fn: string): any {
throw Error(`no-op: ${fn}`)
}
export const nodeOps: RendererOptions<any, any> = {
patchProp: (el, key, prevVal, nextVal) => {
console.log('patchProp', { el, key, prevVal, nextVal })
},
insert: (child, parent, anchor) => {
console.log('insert', { parent, child })
},
createElement: (tag): any => {
console.log(`createElement: ${tag}`)
},
createText: text => {
console.log(`createText: ${text}`)
return text
},
parentNode: node => {
console.log('parentNode')
return null
},
createComment: (text) => {
console.log(`createComment ${text}`)
return text
},
setText: () => noop('setText'),
setElementText: () => noop('setElementText'),
nextSibling: () => noop('nextSibling'),
querySelector: () => noop('querySelector'),
setScopeId: () => noop('setScopeId'),
cloneNode: () => noop('cloneNode'),
insertStaticContent: () => noop('insertStaticContent'),
forcePatchProp: () => noop('forcePatchProp'),
remove: () => noop('remove'),
}
I declared the "host node" and "host element" by passing <any, any>
as the first second generic parameters to the createRenderer
function. In a DOM renderer, the host node is Node
and the host element is Element
. I am not differentiating between the two here, yet, but I will later on. I will also improve the type defintions later on.
I made all the node ops that are not be used for this simple example throw an error. I figured out which node ops I would need by experimentation.
Let's try it out!
import {
RendererOptions,
createRenderer,
defineComponent,
compile
} from 'vue'
// ...
const App = defineComponent({
render: compile(`
<Text>This is some text</Text>
`)
})
const root = {}
const { createApp } = createRenderer(nodeOps)
const app = createApp(App).mount(root)
This is the output:
[Vue warn]: Failed to resolve component: Text
at <App>
createElement: Text
createText: This is some text
insert { parent: undefined, child: 'This is some text' }
/Users/lachlan/code/dump/term-renderer/node_modules/@vue/runtime-core/dist/runtime-core.cjs.js:3805
Object.defineProperty(el, '__vnode', {
^
TypeError: Object.defineProperty called on non-object
We have not created a <Text>
element yet, so we get a warning. Then three node ops are executed:
createElement: Text
createText: This is some text
insert { parent: undefined, child: 'This is some text' }
Makes sense. Vue creates the <Text>
element, then the inner text, then calls the insert
node op to try and insert it. We then get an error that a __vnode
property cannot be defined on an el
.
Defining Node Types
The problem is our createElement
and createText
node ops are not returning any nodes - they should be creating and returning new nodes, as the names suggest.
We should make those node ops return the correct elements. I'll also improve the types.
class PDFNode {
id: string = (Math.random() * 10000).toFixed(0)
}
class PDFTextNode extends PDFNode {
parent?: string
value: string
constructor(value: string) {
super()
this.value = value
}
}
class PDFElement extends PDFNode {}
class PDFDocumentElement extends PDFElement {
id = 'root'
children: string[] = []
}
class PDFTextElement extends PDFElement {
parent?: string
children: string[] = []
}
class PDFViewElement extends PDFElement {
parent?: string
children: string[] = []
}
type PDFRenderable = PDFTextNode | PDFTextElement | PDFDocumentElement
type PDFNodes = PDFTextNode
type PDFElements = PDFTextElement | PDFDocumentElement | PDFViewElement
export const nodeOps: RendererOptions<PDFNodes, PDFElements> = {
// ...
createElement: (tag: 'Text' | 'Document' | 'View') => {
console.log(`createElement: ${tag}`)
if (tag === 'Text') {
return new PDFTextElement()
}
throw Error(`Unsupported tag ${tag}`)
},
createText: (text: string) => {
return new PDFTextNode(text)
},
// ...
}
const { createApp } = createRenderer<PDFTextNode, PDFElements>(nodeOps)
const App = defineComponent({
render: compile(`
<Text>This is some text</Text>
`)
})
const root = new PDFDocumentElement()
const { createApp } = createRenderer<PDFNode, PDFElements>(nodeOps)
const app = createApp(App).mount(root)
Unlike a DOM renderer, where all the build in node types have already been defined by a the DOM specification, there is no such thing for PDFs. I decided to define my own node types to model a PDF.
I also added parent
and children
keys to some of the nodes and elements. You will see why soon.
Running this now works a whole lot better:
[Vue warn]: Failed to resolve component: Text
at <App>
createElement: Text
insert {
parent: PDFTextElement { id: '831', children: [] },
child: PDFTextNode { id: '9528', value: 'This is some text' }
}
insert {
parent: PDFDocumentElement { id: '9992', children: [] },
child: PDFTextElement { id: '831', children: [] }
}
This looks promising. The nodes are created and inserted (well, the insert
node op is called) on the correct pair of nodes.
Handling Insert
We don't have any way to track the parent-child relationship between the nodes. Looking at the Vue DOM Renderer we can see this is handled using node.insertBefore
, which comes for free from the DOM. We will need something similar. This is because when it comes time to integrate PDFKit, we want to support styles in a cascading fashion. For example:
<View :styles="{color: 'red'}">
<View>
<Text>Text</Text>
</View>
</View>
In this case, Text
should be red - we need to know the parent, so we can recursively climb the tree to find the nearest parent node with a color attribute set. It will also be useful if we want to support something like the flex box model, where the child's layout depends on the parent.
Update insert
:
const nodeMap: Record<string, PDFNodes | PDFElements> = {}
export const nodeOps: RendererOptions<PDFNodes, PDFElements> = {
// ...
insert: (child, parent, anchor) => {
if (parent instanceof PDFDocumentElement) {
nodeMap[parent.id] = parent
}
if (! (child.id in nodeMap)) {
nodeMap[child.id] = child
}
parent.children.push(child.id)
child.parent = parent.id
},
// ...
}
I added a few things:
const nodeMap: Record<string, PDFTextNode | PDFTextElement> = {}
. This is to keep track of the nodes - it turns out it'll be useful to have our own cache of the nodes for later.nodeMap['root']
to easily access the top levelPDFDocumentElement
node.- Assigning values to
parent
andchildren
. I am keeping track of these by theid
, not a reference to the actual node. We can easily get the node from thenodeMap
when we need it.
nodeMap
looks like this:
{
'325': PDFTextNode {
id: '325',
value: 'This is some text'
},
'6805': PDFTextElement {
id: '6805',
children: [ '325' ]
},
root: PDFDocumentElement {
id: '8306',
children: [ '6805' ]
}
}
Creating a Custom Tree Structure
You may have noticed we are more or less extracting our own tree structure from the node ops as they are executed. An alternative would be to use Vue's own internal virtual DOM, which can be accessed like this:
const { createApp } = createRenderer(nodeOps)
const app = createApp(App)
app.mount(root).$.subTree //=> access virtual DOM
It turns out this isn't too practical, primarily because Vue's virtual DOM is much more noisy and complex than what we need for this simple example. You could consider using that, though, and it would be required if you were building any kind of real-time renderer than relies on reactivity. In this example I am building a static renderer, so we don't have any need for reactivity or any of the other features Vue's virtual DOM supports.
For this reason I decided to create my own simple node cache (the nodeMap
object) which is seeded by the initial virtual DOM render. We still get the power of Vue's directives, like v-for
and v-if`, as well as the ability tocreate a PDF using Vue's declarative template system, as we will see soon!
Custom Components
The console still has the warnings about <Text>
and <View>
components not existing. Let's make those.
import { h } from 'vue'
// ...
const createPDFComponent = (tag: string) =>
defineComponent({
inheritAttrs: false,
name: tag,
render() {
return h(tag, this.$attrs, this.$slots?.default?.() || [])
}
})
const View = createPDFComponent('View')
const Text = createPDFComponent('Text')
const Document = createPDFComponent('Document')
const App = defineComponent({
components: { Text, View },
render: compile(`
<Text>This is some text</Text>
`)
})
We are not really using Vue's component system heavily in this example, nor the the virtual DOM, so the components don't do a whole lot - basically just render their children and forward the attributes, like styles
, which we will implement soon. I might like to add more complex, featureful components in the future though, so we should keep this in mind as we build.
Rendering a PDF
I am using PDFKit to produce the PDF. It has an imperative API. To draw some red text, you would write:
import { PDFDocument } from 'pdfkit'
const pdf = new PDFDocument()
pdf
.fill('red')
.text('This is some text')
This means we need to somehow go from the nodeMap
to this. The first step will be to traverse the nodes, and the second step will be drawing and text or styles.
Let's start with a traverse
function:
const draw = (node: PDFRenderable) => {
// ...
}
const traverse = (node: PDFRenderable) => {
if (node instanceof PDFElement) {
for (const child of node.children) {
draw(nodeMap[child])
traverse(nodeMap[child])
}
}
}
PDFRenderable
represents any node in our tree - both PDFNodes
and the more complex PDFElements
, which includes PDFDocumentElement
and PDFViewElement
at the moment.
PDFTextNode
never has children - but PDFElement
does. If we are traversing a PDFElement
, we want to traverse all of the children, too. draw
will handle interfacing with PDFKit.
Now add draw
:
const draw = (node: PDFRenderable) => {
if (node instanceof PDFTextNode) {
pdf.text(node.value)
}
}
Finally, write the PDF to the filesystem using fs
import PDFDocument from 'pdfkit'
import fs from 'fs'
const pdf = new PDFDocument()
const stream = pdf.pipe(fs.createWriteStream('./goal.pdf'))
// ...
const rootNode = nodeMap['root']
traverse(rootNode)
pdf.end()
stream.on('finish', () => {
console.log('Wrote to file.pdf.')
})
Finally, we have a PDF!
Now we get to have some fun and add styles.
Adding Styles
I have decided all styles should be defined in a styles
attribute. I have decided to only support a limited subset of CSS, much like react-native and react-pdf
.
Update the example:
const App = defineComponent({
components: { Text, View },
render: compile(`
<View :styles="{color: 'red'}"> <Text>This is some text</Text>
</View> `)
})
Also, update createElement
to support <View>
:
export const nodeOps: RendererOptions<PDFNodes, PDFElements> = {
// ...
createElement: (tag: 'Text' | 'Document' | 'View') => { console.log(`createElement: ${tag}`)
if (tag === 'Text') {
return new PDFTextElement()
}
if (tag === 'View') { return new PDFViewElement() }
throw Error(`Unknown tag ${tag}`)
},
}
Running this shows the patchProp
node op is now called!
createElement: View
createElement: Text
insert {
parent: PDFTextElement { id: '2358', children: [] },
child: PDFTextNode { id: '9202', value: 'This is some text' }
}
insert {
parent: PDFViewElement { id: '5973', children: [] },
child: PDFTextElement { id: '2358', children: [ '9202' ] }
}
patchProp { el: PDFViewElement { id: '5973', children: [ '2358' ] }, key: 'styles', prevVal: null, nextVal: { color: 'red' }}insert {
parent: PDFDocumentElement { id: '7005', children: [] },
child: PDFViewElement { id: '5973', children: [ '2358' ] }
}
patchProp
applies updates to attributes - this can include class
, style
, or any other attribute, including custom attributes. We need to grab styles
and store it somewhere. We want key
and nextVal
in this case. We also need to update PDFElement
to have a styles
property.
class PDFElement extends PDFNode {
styles: Record<string, string> = {}}
// ...
export const nodeOps: RendererOptions<PDFNodes, PDFElements> = {
// ...
patchProp: (el, key, prevVal, nextVal: Record<string, any>) => {
if (nextVal && key === 'styles') { for (const [attr, value] of Object.entries(nextVal)) { el.styles[attr] = value } } },
// ...
}
Now update draw
to apply the style.
const draw = (node: PDFRenderable) => {
if (node instanceof PDFElement) { if (node.styles.color) { pdf.fill(node.styles.color) } }
if (node instanceof PDFTextNode) {
pdf.text(node.value)
}
}
Now we have red text:
Supporting Default Styles
We have cascading styles - anything nested under a <View>
with {color: 'red'}
will be red. The way PDFKit works is not exactly what we want, though - once you do pdf.fill('red')
, everything will be red until you change the color to something else. What we want to do is mimic a browser - to figure out the correct color, we should recurse up the tree until we find a parent with :styles="{color: '...'}"
. If we don't, we should apply some default color. Black seems like the obvious choice.
This can be implememnted using a recursive getParentStyle
function, and by setting some defaults:
const defaults: Record<string, any> = {
color: 'black'
}
const getParentStyle = (attr: string, parent: PDFRenderable): string => {
// we are at the root <Document> element.
if (parent instanceof PDFDocumentElement) {
return defaults[attr]
}
// check parent for style.
if (parent instanceof PDFElement) {
if (parent.styles[attr]) {
return parent.styles[attr]
}
}
// recurse up the tree.
return getParentStyle(attr, nodeMap[parent.parent!])
}
const draw = (node: PDFRenderable) => {
if (node instanceof PDFElement) {
if (node.styles.color) {
pdf.fill(node.styles.color)
} else {
// @ts-ignore
pdf.fill(getParentStyle('color', nodeMap[node.parent]))
}
}
if (node instanceof PDFTextNode) {
pdf.text(node.value)
}
}
Let's make the example a bit more interesting. I will use v-for
, to make sure everything works as it should:
const App = defineComponent({
components: { Text, View },
data() { return { colors: ['red', 'blue', 'green'] } }, render: compile(`
<View> <View :styles="{color: 'red'}"> <Text v-for="color in colors" :styles="{color}"> {{ color }} </Text> </View> <Text>Default</Text> <Text :styles="{color: 'yellow'}">Yellow</Text> </View> `)
})
It works:
Conclusion
We explored creating a simple custom renderer. The main topics covered were:
- Typing
createRenderer
-HostNode
andHostElement
types - Implementing
nodeOps
for your renderer
You can grab the source code here to try it out.
Like my content? Sign up to get occasional emails about new blog posts and other content.
Unsubscribe anytime here.