Building a Mini‑Program Framework with Vue 3.0: Custom Renderer, VNode and Template Generation
This article explains how to use Vue 3.0’s Composition API, TypeScript support, and custom render capabilities to create a mini‑program framework, covering file structure, VNode implementation, nodeOps adaptation, template compilation, and dynamic template selection with full code examples.
Because mini‑program development is traditionally primitive, complex, and far from mainstream web development, many frameworks such as mpvue, Taro, and uni‑app have emerged to bring modern development practices to mini‑programs, allowing developers to use React or Vue.
Basic Knowledge
Vue 3.0
Key new features of Vue 3.0 include:
Composition‑API
The Composition‑API provides a convenient way to extract logic functions, offering stronger code organization than the previous Options API. The same logic can be written in one place with clear boundaries.
Example:
<template>
<div>
<div>Add todo list</div>
<div class="field">
<input @input="handleInput" :value="todo" />
</div>
<button @click="handleAdd">Add +</button>
</div>
</template>
<script>
import { ref, reactive } from 'vue';
import { useMainStore } from '@/store';
export default {
setup() {
const todo = ref('');
const state = reactive({ a: 1, b: 'hello world' });
const store = useMainStore();
const handleInput = (e) => {
todo.value = e.detail.value;
state.a = 'dsdas';
};
const handleAdd = () => {
store.addTodo(todo.value);
};
return { handleInput, todo, handleAdd };
},
};
</script>Fragment, Teleport
Similar to React’s Fragment, Vue 3.0 allows multiple root nodes in a template.
<Fragment>
<component-1 />
<component-2 />
</Fragment>Teleport lets you declaratively mount a child component elsewhere in the DOM, similar to React’s Portal but more powerful.
<body>
<div id="app" class="demo">
<h3>Move the #content with the portal component</h3>
<div>
<teleport to="#endofbody">
<p id="content">This should be moved to #endofbody.</p>
</teleport>
<span>This content should be nested</span>
</div>
</div>
<div id="endofbody"></div>
</body>Better TypeScript Support
Vue 3.0 code is written in TypeScript; combined with the Composition‑API, business logic can seamlessly switch to TS.
Custom Render API
The Custom Render API enables easy construction of a custom rendering layer, which is the focus of the following sections.
import { createRenderer, CreateAppFunction } from '@vue/runtime-core';
export const { render, createApp: baseCreateApp } = createRenderer({
patchProp, // modify props
...nodeOps, // modify DOM nodes
});
render();Mini‑Program Files
A mini‑program page typically consists of four files:
index.js
This file contains the logic code.
Defines a Page function with an object configuration similar to Vue’s options, including a data property for initial state.
To modify data and update the view, you call setData , analogous to React’s state updates.
Page({
data: { text: 'hello word' },
onLoad() {
this.setData({ text: 'xxxxx' });
},
onReady() {},
onShow() {},
onHide() {},
onUnload() {},
handleClick() {
this.setData({ text: 'hello word' });
}
});index.ttml
This file holds the view template, similar to Vue’s template . The template must be defined beforehand because the logic layer (index.js) and the rendering layer (index.ttml) run in separate threads and communicate via setData .
<view>
<view bindtap="handleClick">{{text}}</view>
</view>index.json
Configuration file for the mini‑program page and components (parameters omitted for brevity).
index.ttss
Style file, analogous to CSS.
Template
Mini‑programs allow pre‑defining templates that can be imported where needed, similar to EJS or Pug imports.
// Pre‑define a template using the variable "text"
<template name="view">
<view>{{text}}</view>
</template>
// Use the template and pass the variable
<template is="view" data="{{text: text}}"/>Dynamic Template
Although DOM nodes cannot be modified dynamically, mini‑programs support dynamic template selection.
// Dynamically select a template
<template is="{{type}}" data="{{item: item}}"/>The type attribute is a variable that determines which template (e.g., view, input, button) to render.
Custom Rendering Layer (Very Important)
We now combine Vue 3.0’s custom rendering capabilities with the mini‑program’s dynamic template selection to build a framework.
import { createRenderer, CreateAppFunction } from '@vue/runtime-core';
export const { render, createApp: baseCreateApp } = createRenderer({
patchProp, // modify props
...nodeOps, // modify DOM nodes
});The createRenderer function requires two arguments: patchProp and nodeOps .
nodeOps
In a browser environment, nodeOps manipulates real DOM nodes:
import { RendererOptions } from '@vue/runtime-core';
export const svgNS = 'http://www.w3.org/2000/svg';
const doc = typeof document !== 'undefined' ? document : null as Document;
let tempContainer: HTMLElement;
let tempSVGContainer: SVGElement;
export const nodeOps: Omit
, 'patchProp'> = {
insert: (child, parent, anchor) => {
parent.insertBefore(child, anchor || null);
},
remove: child => {
const parent = child.parentNode;
if (parent) { parent.removeChild(child); }
},
createElement: (tag, isSVG, is) =>
isSVG ? doc.createElementNS(svgNS, tag) : doc.createElement(tag, is ? { is } : undefined),
createText: text => doc.createTextNode(text),
createComment: text => doc.createComment(text),
setText: (node, text) => { node.nodeValue = text; },
setElementText: (el, text) => { el.textContent = text; },
parentNode: node => node.parentNode as Element | null,
nextSibling: node => node.nextSibling,
querySelector: selector => doc.querySelector(selector),
setScopeId(el, id) { el.setAttribute(id, ''); },
cloneNode(el) { return el.cloneNode(true); },
};In the mini‑program environment, we cannot modify the real DOM, so we create a virtual DOM called VNode .
VNode
The VNode class mimics DOM nodes and provides methods for insertion, removal, text handling, etc., while internally calling the mini‑program’s setData to update the view.
class VNode {
id: number;
type: string;
props?: Record
;
text?: string;
children: VNode[] = [];
eventListeners?: Record
| null;
parentNode?: VNode | null;
nextSibling?: VNode | null;
constructor({ id, type, props = {}, text }: { id: number; type: string; props?: Record
; text?: string }) {
this.type = type;
this.props = props;
this.text = text;
this.id = id;
}
appendChild(newNode: VNode) {
if (this.children.find(child => child.id === newNode.id)) { this.removeChild(newNode); }
newNode.parentNode = this;
this.children.push(newNode);
setState({ node: newNode, data: newNode.toJSON() }); // calls mini‑program setData
}
insertBefore(newNode: VNode, anchor: VNode) {
newNode.parentNode = this;
newNode.nextSibling = anchor;
if (this.children.find(child => child.id === newNode.id)) { this.removeChild(newNode); }
const anchorIndex = this.children.indexOf(anchor);
this.children.splice(anchorIndex, 0, newNode);
setState({ node: this, key: '.children', data: this.children.map(c => c.toJSON()) });
}
removeChild(child: VNode) {
const index = this.children.findIndex(node => node.id === child.id);
if (index < 0) return;
if (index === 0) { this.children = []; }
else {
this.children[index - 1].nextSibling = this.children[index + 1];
this.children.splice(index, 1);
}
setState({ node: this, key: '.children', data: this.children.map(c => c.toJSON()) });
}
setText(text: string) {
if (this.type === TYPE.RAWTEXT) {
this.text = text;
setState({ node: this, key: '.text', data: text });
return;
}
if (!this.children.length) {
this.appendChild(new VNode({ type: TYPE.RAWTEXT, id: generate(), text }));
return;
}
this.children[0].text = text;
setState({ node: this, key: '.children[0].text', data: text });
}
path(): string {
if (!this.parentNode) return 'root';
const path = this.parentNode.path();
return [
...(path === 'root' ? ['root'] : path),
'.children[',
this.parentNode.children.indexOf(this) + ']',
].join('');
}
toJSON(): RawNode {
if (this.type === TYPE.RAWTEXT) {
return { type: this.type, text: this.text };
}
return {
id: this.id,
type: this.type,
props: this.props,
children: this.children && this.children.map(c => c.toJSON()),
text: this.text,
};
}
}Using nodeOps adapted for mini‑programs, we modify VNode objects instead of real DOM nodes.
export const nodeOps = {
insert: (child: VNode, parent: VNode, anchor?: VNode) => {
if (anchor != null) { parent.insertBefore(child, anchor); }
else { parent.appendChild(child); }
},
remove: (child: VNode) => {
const parent = child.parentNode;
if (parent != null) { parent.removeChild(child); }
},
createElement: (tag: string): VNode => new VNode({ type: tag, id: generate() }),
createText: (text: string): VNode => new VNode({ type: TYPE.RAWTEXT, text, id: generate() }),
createComment: (): VNode => new VNode({ type: TYPE.RAWTEXT, id: generate() }),
setText: (node: VNode, text: string) => { node.setText(text); },
setElementText: (el: VNode, text: string) => { el.setText(text); },
parentNode: (node: VNode) => node.parentNode ?? null,
nextSibling: (node: VNode) => node.nextSibling ?? null,
querySelector: () => getApp()._root,
setScopeId(el: VNode, id: string) {
if (el.props) {
const className = el.props.class;
el.props.class = className ? className + ' ' + id : id;
}
},
};toJSON()
Mini‑programs can only render data that is defined in the data property as plain objects. The toJSON method converts a VNode into such a plain object.
Page({
data: {
root: {
type: 'view',
props: { class: 'xxx' },
children: [/* ... */]
}
}
});Interface definition:
interface RawNode {
id?: number;
type: string; // view, input, button
props?: Record
;
children?: RawNode[];
text?: string; // text content
}path()
The path() method returns a string representing a node’s location in the tree, which can be used with setData to update a specific node.
const path = node.path(); // e.g., 'root.children[2].props.class'
this.setData({ [path]: 'newClass' });Combining Dynamic Template Selection
<template name="$_TPL">
<block tt:for="{{root.children}}" tt:key="{{id}}">
<template is="{{'$_' + item.type}}" data="{{item: item}}"/>
</block>
</template>
<template name="$_input">
<input class="{{item.props['class']}}" bindinput="{{item.props['bindinput']}}" value="{{item.props['value']}}">
<block tt:for="{{item.children}}" tt:key="{{id}}">
<template is="{{'$_' + item.type}}" data="{{item}}"/>
</block>
</input>
</template>
... (similar templates for button, view, rawText)Compilation Layer
The source code is written in Vue, not the generated template code. The compilation process parses Vue files and generates the mini‑program templates.
Template
For Vue template syntax, we use @vue/compiler-core to parse the template, traverse the AST, and collect tags and props.
import { parse } from '@vue/compiler-sfc';
import { baseCompile } from '@vue/compiler-core';
const { descriptor } = parse(source, { filename: this.resourcePath });
const { ast } = baseCompile(descriptor.template.content);JSX/TSX
If the business code is written in JSX/TSX, a Babel plugin can traverse the AST to collect tags and props.
Final Generated ttml
Given a .vue file:
<template>
<div class="container is-fluid">
<div class="subtitle is-3">Add todo list</div>
<div class="field">
<div class="control">
<input class="input is-info" @input="handleInput" :value="todo" />
</div>
</div>
<button class="button is-primary is-light" @click="handleAdd">Add +</button>
</div>
</template>
<script>
import { ref } from 'vue';
import { useMainStore } from '@/store';
export default {
setup() {
const todo = ref('');
const store = useMainStore();
const handleInput = e => { todo.value = e.detail.value; };
const handleAdd = () => { store.addTodo(todo.value); };
return { handleInput, todo, handleAdd };
},
};
</script>
<style>
.container { text-align: center; margin-top: 30px; }
</style>Will be compiled into the following mini‑program templates (abbreviated):
<template name="$_TPL">
<block tt:for="{{root.children}}" tt:key="{{id}}">
<template is="{{'$_' + item.type}}" data="{{item: item}}"/>
</block>
</template>
<template name="$_input">
// input has class, bindinput, value attributes
<input class="{{item.props['class']}}" bindinput="{{item.props['bindinput']}}" value="{{item.props['value']}}">
<block tt:for="{{item.children}}" tt:key="{{id}}">
<template is="{{'$_' + item.type}}" data="{{item}}"/>
</block>
</input>
</template>
... (similar templates for button, view, rawText)Starting from the root node of $_TPL , the appropriate template (input, button, view, etc.) is selected based on item.type , and each template contains loops, enabling infinite recursive rendering.
Join Us
We are the ByteDance Front‑End Game Team, covering game publishing business and have released multiple games. Our tech stack spans PC, H5, RN, and mini‑programs. We focus on high‑performance front‑end applications, performance optimization, and have extensive experience with mini‑program performance, custom frameworks, and open‑source initiatives.
Resume submission email: [email protected] , email subject: "Name - Years of Experience - Frontend Game".
Welcome to follow "ByteFE" and apply to join us!
Click to read the original article and join us!
ByteFE
Cutting‑edge tech, article sharing, and practical insights from the ByteDance frontend team.
How this landed with the community
Was this worth your time?
0 Comments
Thoughtful readers leave field notes, pushback, and hard-won operational detail here.