Core Architecture and Minimal Vue 3 Implementation
Frontend Framework Architecture & Vue 3 Motivations
Modern frontend frameworks like Vue emphasize simplicity and a data-driven approach. By reducing direct DOM manipulation, frameworks leverage reactivity systems, declarative rendering, and virtual DOM diffing to efficiently synchronize the UI with the application state. Vue's progressive nature allows developers to adopt it incrementally, integrating state management, routing, and UI libraries as needed.
The shift to Vue 3 introduced several key architectural changes:
- Functional API over Classes: Unlike Engular or Vue 2 (which releid on class decorators), Vue 3 and React favor functions. Function signatures provide explicit inputs and outputs, drastically improving TypeScript type inference and overall code predictability.
- Composition API: This paradigm eliminates the ambiguous
thiscontext found in Options API. By explicitly declaring reactive variables and composing logic withinsetup(), code becomes more reusable, readable, and maintainable. - Improved Tree-shaking: By exposing instance methods and properties as standalone functions (e.g.,
import { ref } from 'vue'), bundlers can eliminate unused code more effectively. - API Simplification: Vue 3 unified and streamlined APIs. For instance,
v-modeland.syncwere merged into a singlev-modelsyntax. The render function arguments were also flattened. - Extensibility: Custom renderers (
createRenderer) allow Vue's core to operate independently of the browser DOM, enabling rendering to native mobile platforms, terminals, or canvas. - Performance via Proxy: Vue 3 replaced
Object.definePropertywithProxyfor reactivity. This solves recursive efficiency issues, removes the need for special array handling, natively supports dynamic property addition/deletion and collection types (Map,Set), and avoids the legacy API workarounds likeVue.set.
Constructing a Minimal Vue 3
1. Application Initialization & Mounting
The core of Vue initialization involves creating an application instance, locating the host element, processing the template or render function, and appending the resulting DOM.
const VueLib = {
buildApp(options) {
return {
attach(selector) {
const hostEl = document.querySelector(selector)
if (!options.render) {
options.render = this.transpile(hostEl.innerHTML)
}
const renderedEl = options.render.call(options.data ? options.data() : {})
hostEl.innerHTML = ''
hostEl.appendChild(renderedEl)
},
transpile(markup) {
return function render() {
const node = document.createElement('h3')
node.textContent = this.title
return node
}
}
}
}
}
When handling setup alongside data, Vue 3 prioritizes setup. A proxy can manage this access layer, routing property lookups and mutations to the correct source.
const contextProxy = new Proxy(instance, {
get(target, key) {
if (target.setupState && key in target.setupState) {
return Reflect.get(target.setupState, key)
}
return Reflect.get(target.dataState, key)
},
set(target, key, value) {
if (target.setupState && key in target.setupState) {
return Reflect.set(target.setupState, key, value)
}
return Reflect.set(target.dataState, key, value)
}
})
To decouple the framework from the browser, platform-specific operations should be abstracted into a custom renderer factory.
const VueLib = {
initRenderer({ getElement, appendNode }) {
return {
buildApp(options) {
return {
attach(selector) {
const hostEl = getElement(selector)
// ... render logic ...
appendNode(vnode, hostEl)
}
}
}
}
},
buildApp(options) {
const webRenderer = VueLib.initRenderer({
getElement(sel) { return document.querySelector(sel) },
appendNode(el, parent) { parent.appendChild(el) }
})
return webRenderer.buildApp(options)
}
}
2. Reactivity System
Vue 3 employs Proxy to intercept object access and mutations. To decouple state changes from the UI update mechanism, a dependency tracking system is required.
const dependencyGraph = new WeakMap()
let activeEffects = []
function watchEffect(fn) {
const execute = () => {
activeEffects.push(execute)
try {
fn()
} finally {
activeEffects.pop()
}
}
execute()
}
function recordDep(target, key) {
const currentEffect = activeEffects[activeEffects.length - 1]
if (!currentEffect) return
let depsMap = dependencyGraph.get(target)
if (!depsMap) {
depsMap = new Map()
dependencyGraph.set(target, depsMap)
}
let depSet = depsMap.get(key)
if (!depSet) {
depSet = new Set()
depsMap.set(key, depSet)
}
depSet.add(currentEffect)
}
function notifyDeps(target, key) {
const depsMap = dependencyGraph.get(target)
if (!depsMap) return
const depSet = depsMap.get(key)
if (depSet) {
depSet.forEach(effect => effect())
}
}
function makeReactive(obj) {
return new Proxy(obj, {
get(target, key) {
recordDep(target, key)
return Reflect.get(target, key)
},
set(target, key, value) {
const result = Reflect.set(target, key, value)
notifyDeps(target, key)
return result
}
})
}
The application's update function is wrapped in watchEffect, ensuring DOM updates occur automatically when reactive state mutates.
this.update = watchEffect(() => {
const element = options.render.call(contextProxy)
appendNode(element, hostEl)
})
3. Virtual DOM & Rendering
To avoid expensive full DOM replacements during updates, a Virtual DOM represents the UI as JavaScript objects.
function createVNode(tag, attrs, children) {
return { tag, attrs, children }
}
The compile step generates createVNode calls instead of real DOM elements. A mounting function recursively constructs the real DOM tree from these virtual nodes.
function constructElement(vnode) {
const el = document.createElement(vnode.tag)
if (typeof vnode.children === 'string') {
el.textContent = vnode.children
} else if (Array.isArray(vnode.children)) {
vnode.children.forEach(child => {
el.appendChild(constructElement(child))
})
}
vnode.el = el // Store real DOM reference for diffing
return el
}
4. Diffing & Patching
During an update, the new vnode tree is compared against the old one. If the component is already mounted, a patching process takes over.
function syncPatch(prevVNode, nextVNode) {
const el = nextVNode.el = prevVNode.el
if (prevVNode.tag === nextVNode.tag) {
const oldChildren = prevVNode.children
const newChildren = nextVNode.children
if (typeof oldChildren === 'string') {
if (typeof newChildren === 'string') {
if (oldChildren !== newChildren) el.textContent = newChildren
} else {
el.textContent = ''
newChildren.forEach(c => el.appendChild(constructElement(c)))
}
} else {
if (typeof newChildren === 'string') {
el.textContent = newChildren
} else {
reconcileChildren(el, oldChildren, newChildren)
}
}
} else {
// Node replacement logic
}
}
A simplified child reconciliation algorithm compares nodes by index, updates matching pairs, and handles leftover additions or deletions.
function reconcileChildren(parentEl, oldChildren, newChildren) {
const commonLength = Math.min(oldChildren.length, newChildren.length)
for (let i = 0; i < commonLength; i++) {
syncPatch(oldChildren[i], newChildren[i])
}
if (newChildren.length > oldChildren.length) {
newChildren.slice(commonLength).forEach(c => {
parentEl.appendChild(constructElement(c))
})
} else if (newChildren.length < oldChildren.length) {
oldChildren.slice(commonLength).forEach(c => {
parentEl.removeChild(c.el)
})
}
}