I saw a very impressive project on GitHub, claiming to be the world's smallest responsive UI framework.
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 himself also pointed 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 wonder 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 or similar (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 use 😄 although publishing to npm is planned), so using vdom or JSX does not align with its design philosophy.
The library provides van.tags
which allows destructured properties to be used as functions to directly create DOM tags, essentially equivalent to document.createElement
.
However, the implementation of this part is quite clever; it does not 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 arguments
let tags = new Proxy((name, ...args) => {
// Since props/attrs can be omitted, 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 turning the first parameter of target into 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 source code with extensions and type annotations, 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 essentially the same old story, fundamentally still a publish-subscribe model or observer pattern.
However, vanjs takes an extreme approach to compress size, 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 offer 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 rather directly completes this step through getter/setter.
So there is a flaw here: the reactivity of 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 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 with 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 uses a literal object and sets its __proto__
to this stateProto
, significantly reducing code size.
Friends who have manually written prototypes should be familiar with this writing style, but it deviates from constructors and uses a literal object and __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. Internally, the same function is used in the previous tags for updating props/attrs.
I contributed the signature type for this function to vanjs, simply doing 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 is not closely related to reactivity.
This function primarily generates a binding
and adds this binding
to the state's bindings
list.
Then, when State.val
is modified, it triggers the scheduling task to execute side effects through the updateDoms
function. Returning to the stateProto
prototype object, we can see that the logic in the setter function of val
is that when the setter receives a value different from the current value _val
, it will perform a series of logic.
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 during state initialization), when the state is changed for the first time, vanjs will add the current state instance to the changedStates
Set 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 side effects is in updateDoms
.
But what is the purpose of the else if
branch?
Because we inserted an updateDoms
into the macro task queue in the previous branch, if during this event loop the State.val
is modified again, and the target value being modified is the same as oldVal
(the value has been restored), then we can directly delete the current State
that was previously added to the changedStates
Set, preventing the current State
from executing the change twice and reverting to the original oldVal
, thus generating erroneous side effects.
Of course, if the target value is different from oldVal
, the expected side effects will still be executed.
However, there are still issues with reference types, which can still cause shallowRef effects; 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))
// Different element references are considered as DOM changes; vanjs actually recommends directly modifying a prop/attrs of an element reference since 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 is not too complicated; it first destructures the changedStates
Set 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
.
By using new Set
to deduplicate and iterating through the binding
, it obtains new elements based on the state dependencies, DOM elements, and side-effect functions, then checks whether the new and old element references are consistent to update DOM nodes and whether to delete old DOM nodes.
Finally, it assigns the current _val
value to all states' oldVal
in the changedStateArray
, so that if this State
is changed again, it will still trigger the corresponding side effects.
Conclusion#
There are actually some GC control aspects in there, but I am not very familiar with this part, so I will leave it at that.
In essence, the core difficulty of building a reactive frontend framework is not high; the real challenge lies in the development of its ecosystem and surrounding supporting facilities, especially when you directly use existing browser APIs rather than any virtual DOM. The difficulty is very low, but the characteristic of this library is its extremely lightweight size, which, however, leads to some things looking obviously problematicXD
However, the web pages built with such tools (website of vanjs) are actually not inferior to those built with Vue/React. I also casually created a todomvc and found it quite good. I will continue to pay attention to this project and may submit a PR if I have the opportunity.
Finally, here is a gist: extended + type annotated version, imported type reference to the official repository for learning.
I hope you learn something after reading this article. ❤️