I saw a very impressive project on GitHub, claiming to be the smallest responsive UI framework in the world.
🍦 VanJS: World's smallest reactive UI framework. Incredibly Powerful, Insanely Small - Everyone can build a useful UI app in an hour.
After a brief preview, I found it indeed very small and quite suitable for studying its underlying implementation. Although the code style is overly minimalist, leading to poor readability (the author also points out), it is still relatively easy to understand with some organization, as the entire codebase is less than 100 lines (in fact, only 94 lines).
I did a simple study and helped refine the type definitions. I personally dislike the trend of researching various source codes, but given the lightweight nature of this library, I felt it was worth a simple analysis.
Function-based DOM Construction#
Some may ask what is meant by function-based. Isn't the essence of JSX's createElement
a function?
This function-based approach purely constructs tags through a series of functions rather than using vdom (though the experience is quite similar to JSX). The author defines this library as a frontend bash script (even requiring you to download the source code for usage 😄 although publishing to npm is planned), so using vdom or JSX doesn't align with its design philosophy.
The library provides van.tags
, which allows destructured properties to directly create DOM tags as functions, essentially equivalent to document.createElement
.
However, the implementation of this part is quite clever; it doesn't require declaring all tags as properties but wraps a function with Proxy to handle property access.
// The name parameter is actually the destructured properties name, ...args are the actual function parameters used
let tags = new Proxy((name, ...args) => {
// Since props/attrs can be omitted, handle to ensure props has a value
let [props, ...children] = protoOf(args[0] ?? 0) === objProto ? args : [{}, ...args]
let dom = document.createElement(name)
Obj.entries(props).forEach(([k, v]) => {
// Set dom properties or attributes; the method of checking undefined here has a clear bug, always falling into the falsy case
let setter = dom[k] !== _undefined ? v => dom[k] = v : v => dom.setAttribute(k, v)
// Handle vanjs's reactive state
if (protoOf(v) === stateProto) bind(v, v => (setter(v), dom))
else if (protoOf(v) === objProto) bind(...v["deps"], (...deps) => (setter(v["f"](...deps)), dom))
else setter(v)
})
return add(dom, ...children)
}, {get: (tag, name) =>
// bind handles the name parameter, effectively making the first parameter of the target the accessed property name
tag.bind(_undefined, name)})
To be honest, the internal types of this tags are really impossible to directly output correct types. When reading the extended and annotated code, encountering such situations made me feel for the first time that TypeScript has its flaws. I will place the extended and annotated code at the end of the gist link.
Reactive Basics#
The foundation of today's reactive UI frameworks is still the same old thing, fundamentally a publish-subscribe pattern or observer pattern.
However, vanjs takes the extreme route of size compression, resulting in a very robust implementation logic for this part, giving me a feeling of returning to ES5 to manually implement class prototypes.
State Management#
vanjs provides a state function to provide state, which essentially implements a reactive variable as a publisher.
However, its implementation is not like vue2/vue3, which uses defineProperties or Proxy wrapping, but simply completes this step through getter/setter.
So there is a flaw here: the reactivity of the state is only shallow, similar to vue3's shallowRef
, and must be triggered by modifying State.val
.
First, stateProto
is defined as the prototype of the state.
let stateProto = {
get "val"() { return this._val },
set "val"(value) {
// Aliasing `this` to reduce the bundle size.
let self = this, currentVal = self._val
if (value !== currentVal) {
if (self.oldVal === currentVal)
changedStates = addAndScheduleOnFirst(changedStates, self, updateDoms)
else if (value === self.oldVal)
changedStates.delete(self)
self._val = value
self.listeners.forEach(l => l(value, currentVal))
}
},
"onnew"(listener) { this.listeners.push(listener) },
}
It essentially implements
interface State<T = any> {
val: T
onnew(l: (val: T, oldVal: T) => void): void
}
If written using class, most people would directly use class StateImpl implements State
, but vanjs chose not to use class for the sake of extreme size (in fact, it was class a few minor versions ago :satisfied:).
How does vanjs do it? It's actually quite simple; it directly uses an object literal and points its __proto__
to this stateProto
, significantly reducing the code size.
Friends who have manually written prototypes should be familiar with this kind of writing, but it deviates from the constructor and uses an object literal and the __proto__
property. However, using Object.create
w/ Object.assign
might yield a slight performance boost XD
Binding State#
vanjs provides the bind
function to bind state with some side-effect scheduling tasks. The internal handling of props/attrs updates in the previous tags also uses this function.
I contributed the signature type of this function to vanjs, simply performing a type gymnastics to solve the original handwritten 10 function overloads that were still insufficient😁
let bind = (...deps) => {
let [func] = deps.splice(-1, 1)
let result = func(...deps.map(d => d._val))
if (result == _undefined) return []
let binding = {_deps: deps, dom: toDom(result), func}
deps.forEach(s => {
statesToGc = addAndScheduleOnFirst(statesToGc, s,
() => (statesToGc.forEach(filterBindings), statesToGc = _undefined),
bindingGcCycleInMs)
s.bindings.push(binding)
})
return binding.dom
}
In this function, statesToGc
is actually managing GC and has little to do with reactivity.
This function primarily produces a binding
and adds this binding
to the state's bindings
list.
Then, when modifying State.val
, it triggers scheduling tasks to execute side effects through the updateDoms
function. Returning to the stateProto
prototype object, we can see that the logic in the setter function for val
is that when the value passed to the setter differs from the current value _val
, a series of logic will be executed.
set "val"(value) {
// Aliasing `this` to reduce the bundle size.
let self = this, currentVal = self._val
if (value !== currentVal) {
if (self.oldVal === currentVal)
changedStates = addAndScheduleOnFirst(changedStates, self, updateDoms)
else if (value === self.oldVal)
changedStates.delete(self)
self._val = value
self.listeners.forEach(l => l(value, currentVal))
}
}
First, if the saved old value oldVal
is the same as the current value _val
(here, these two values are the same when the state is initialized), when the state is changed for the first time, vanjs will add the current state instance to the changedStates
Set<State>
, and execute the updateDoms
task through a setTimeout
without a delay (macro task queue, executed directly in the next loop).
The actual execution logic of the side effects is in updateDoms
.
But what is the purpose of the else if
branch?
Because in the previous branch, we inserted an updateDoms
into the macro task queue, but if this State.val
is modified again in this event loop, and the target value being modified is the same as oldVal
(the value has been restored), we can directly delete the current State
that was previously added to the changedStates
Set<State>
, preventing the current State
from executing twice in updateDoms
and reverting to the original oldVal
, thus generating erroneous side effects.
Of course, if the target value differs from oldVal
, the expected side effects will still be executed.
However, there are still issues with reference types, causing a shallowRef effect; changing deep values may not trigger the expected side effects, and even modifying the state may not either 🐷
Executing Side Effects#
let updateDoms = () => {
let changedStatesArray = [...changedStates]
changedStates = _undefined
new Set(changedStatesArray.flatMap(filterBindings)).forEach(b => {
let {_deps, dom, func} = b
let newDom = func(..._deps.map(d => d._val), dom, ..._deps.map(d => d.oldVal))
// If the element references are different, treat it as a DOM change; vanjs actually recommends directly modifying a prop/attrs of an element reference, as there is no vdom
if (newDom !== dom)
if (newDom != _undefined)
dom.replaceWith(b.dom = toDom(newDom)); else dom.remove(), b.dom = _undefined
})
changedStatesArray.forEach(s => s.oldVal = s._val)
}
The logic here isn't too complex; it first destructures the changedStates
Set<State>
into a State[]
variable and then empties the original changedStates
.
Next, it retrieves the bindings
from the obtained changedStatesArray
, flattens them into an array of bindings
.
Using new Set
to deduplicate, it iterates through the binding
to obtain the state dependencies, DOM elements, and side effect functions to get new elements, then checks whether the references of the new and old elements are the same to update DOM nodes and determine whether to remove old DOM nodes.
Finally, it assigns the current _val
value to the oldVal
of all states in changedStateArray
, ensuring that if this State
is changed again, the corresponding side effects will still be triggered.
Conclusion#
There are actually some GC control mechanisms in there, but I'm not very familiar with that part, so I won't delve into it.
Ultimately, the core difficulty of building a reactive frontend framework isn't high; the real challenge lies in constructing the ecosystem and surrounding facilities, especially when you directly use existing browser APIs instead of some virtual DOM. The difficulty is very low. However, the characteristic of this library is its extremely lightweight size, which leads to some aspects appearing problematicXD
Nonetheless, the web pages built with this tool website (vanjs's official site) are actually on par with those built with Vue/React. I also casually created a todomvc and found it quite good. I will continue to follow this project and may submit a PR when I have the opportunity.
Finally, here’s a gist: extended + type annotation version, imported type reference to the official repository for learning.
I hope you learn something from this article❤️