enpitsulin

enpitsulin

这个人很懒,没有留下什么🤡
twitter
github
bilibili
nintendo switch
mastodon

Build a responsive UI framework in less than 100 lines of code

I saw a very impressive project on GitHub, claiming to be the smallest responsive UI framework in the world.

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).

image

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❤️

Loading...
Ownership of this post data is guaranteed by blockchain and smart contracts to the creator alone.