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 the timeline of GitHub, claiming to be the smallest responsive UI framework in the world.

After a brief preview, I found that it is indeed very small and quite suitable for studying its underlying implementation. Although the minimalist coding style leads 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).

image

I did a simple study and helped refine the type definitions. Personally, I dislike the trend of researching various source codes, but given the lightweight nature of this library, I feel it can be simply parsed.

Function-based DOM Construction#

Some may ask what is meant by function-based. Isn't the essence of JSX createElement a function?

This function-based approach purely constructs tags through a series of functions rather than using vdom or similar (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 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 as the target and sets a handler to manage 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, 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 argument of the target into the accessed property name.
  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 its flaws. I will place the extended and annotated code at the end of the gist link.

Reactive Basics#

The foundation of current reactive UI frameworks is still the same old story; it fundamentally remains a publish-subscribe model or observer pattern.

However, vanjs takes an extreme approach to compress size, making this part, aside from the implementation logic, quite robust, 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 Vue 2/Vue 3, 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 Vue 3'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); },
}

This 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, for the sake of extreme size, did not choose class (in fact, a few minor versions ago it was still class :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 writing style, but it departs from the constructor and uses an object literal and 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 states with some side-effect scheduling tasks. This function is also used in the previous tags for updating props/attrs.

PS: What I contributed to vanjs is the signature type of this function, simply doing a type gymnastics to solve the original handwritten 10 function overloads that were still not enough😁

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 part of the function, statesToGc is actually managing GC and is not closely related to reactivity.

This function primarily produces a binding and then 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. Looking back at the stateProto, 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 (initially, 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 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 during this event loop, State.val is modified again, and the target value 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, it will still execute the necessary side effects.

PS: However, there are still issues with reference types, which can still cause shallowRef effects; changing deep values may not trigger the necessary 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 in this part is not too complex; it first 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, 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 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 changedStateArray, so if this State changes again, it will still trigger the corresponding side effects.

Conclusion#

There are actually some GC control mechanisms in there, but I am not very familiar with this part, so I won't study it further.

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 issues, still results in a tool that can build web pages (the official site of vanjs) that are comparable to 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 is a gist: Extended + Type Annotation Version, imported type reference from the official repository for learning.

I hope you learn something from this article❤️

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