I saw a very impressive project on GitHub, claiming to be the smallest responsive UI framework in the world.
Github Repo not found
The embedded github repo could not be found…
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 relatively poor readability (the author also points out), it is still relatively easy to understand with some organization, as the entire body 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 due to the lightweight nature of this library, I felt it could be simply parsed.
Function-based DOM Construction#
Some might ask what is meant by function-based. Isn't the essence of JSX createElement
a function?
This function-based approach constructs tags purely through a series of functions rather than using vdom (but the experience is quite similar to JSX). The author defines this library as a bash script for the frontend (even requiring you to download the source code for use 😄 though publishing to npm is planned), so using vdom or JSX does not align with its design philosophy.
The library provides van.tags
, where destructured properties can be used to directly create DOM tags as functions. Essentially, it is document.createElement
.
However, the implementation of this part is quite clever; it does not require declaring all tags as properties but instead wraps a function with Proxy to handle property retrieval.
// The name parameter is actually the destructured properties name, ...args are the final function parameters used
let tags = new Proxy((name, ...args) => {
// Since props/attrs can be omitted, handle to ensure props have 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; the first parameter of the target becomes the property name used
tag.bind(_undefined, name)})
PS: To be honest, the internal types of this tags are really impossible to 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 flaws. If you want to see the extended and annotated code, it will be linked at the end of the gist.
Reactive Basics#
The foundation of current reactive UI frameworks is still the same old story, fundamentally a publish-subscribe model or observer pattern.
However, vanjs takes an extreme approach to compress size, resulting in the code for this part, aside from the implementation logic, being quite robust, 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 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 directly uses an object literal and points its __proto__
to this stateProto
, significantly reducing code size.
PS: If you have ever manually written a prototype chain, you should be familiar with this kind of writing, but it deviates from the constructor and is instead directly an object literal with the __proto__
property though 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. This function is also used in the previous tags to handle props/attrs updates.
PS: What I contributed to vanjs is the signature type of this function, 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 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. Back in the stateProto
, we can see that the logic in the setter function of val
is that when the value passed to the setter is different 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 during state initialization),
When the state changes 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 we inserted an updateDoms
into the macro task queue in the previous branch, if during this event loop, 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<State>
, preventing it from executing the current State
change twice and reverting back 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.
PS: However, there are still issues with reference types, causing the shallowRef effect, where 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, consider it as a DOM change; vanjs actually recommends directly modifying a prop/attrs of an element 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 complex. First, it destructures the changedStates
Set<State>
into a State[]
variable and then sets the original changedStates
to undefined.
Next, it retrieves the bindings
from the obtained changedStatesArray
and flattens them to get 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 whether to delete old DOM nodes.
Finally, it assigns the current _val
value to the oldVal
of all states in changedStateArray
, so that if this State
changes again, it will still trigger the corresponding side effects.
Conclusion#
There are actually some GC control elements in there, but I am not too familiar with this part, so I won't delve into it.
Ultimately, the core difficulty in building a reactive frontend framework is not high; the real challenge lies in constructing the ecosystem and developing surrounding facilities, especially when you directly use existing browser APIs rather than any virtual DOM. The difficulty is very low. However, the characteristic of this library is its extremely lightweight size, which, although leads to some things looking obviously problematicXD
However, the web pages built with such tools (vanjs's official website) are not inferior to those built with Vue/React. I also casually created a todomvc and feel quite good about it. I will continue to pay attention to this project and may submit a PR when I have the opportunity.
Finally, here is a gist: Extended + Type Annotation Version, imported type reference to the official repository for learning.
I hope you learn something after reading this article❤️