不到100行代码构建响应式UI框架

在 Github 的时间线上看到了一个非常厉害的项目，自称是世界上最小的响应式 UI 框架

简单预览了一下，发现确实很小，而且很适合研究它的底层实现方式，虽然代码风格过于极简主义导致可读性比较差 (作者本人也指出)

但是通过一定的整理还是比较容易搞懂的，毕竟本体就不到 100 行（事实上只有 94 行）

简单研究了下帮忙跳了个类型体操完善了下类型定义，本身我是比较讨厌那套研究各种源码的卷劲的，但是基于这个库的轻量我觉得可以简单解析下

基于函数的构建 dom 形式

可能有人会问什么叫基于函数的，难道 JSX 的本质createElement不是函数嘛？

这个基于函数是单纯的通过一系列的函数构建标签而非使用 vdom 之流 (但其实体验和 jsx 很相像)，因为作者把这个库定义为前端的 bash 脚本（甚至是要你下载源码引入使用的 😄 不过发布到 npm 在计划中 ）所以使用 vdom 或者什么 jsx 不太符合它的设计哲学

库提供了van.tags通过解构出的属性可以作为函数直接创建 dom 标签，其本质就是document.createElement

但是他这一部分的实现是比较巧妙的，不需要声明所有的 tag 作为属性，而是通过 Proxy 包装一个函数做 target 和设置了一个 handler 来处理获取属性

Copy
// name形参实际上是解构的 properties name, ...args 才是最终实际上使用的函数参数
let tags = new Proxy((name, ...args) => {
// 由于允许不传标签 props/attrs 所以处理下使props有一个值
let [props, ...children] = protoOf(args[0] ?? 0) === objProto ? args : [{}, ...args]
let dom = document.createElement(name)
Obj.entries(props).forEach(([k, v]) => {
// 设置dom propeties 或者 attributes, 其实这里判断 undefined 的方法是有明显bug的, 永远会走 falsy 的情况
let setter = dom[k] !== _undefined ? v => dom[k] = v : v => dom.setAttribute(k, v)
// 处理 vanjs 的响应式 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 处理掉 name 参数,实际上 target 的第一个参数变成了使用到的 property name
tag.bind(_undefined, name)})

PS: 说实话这个 tags 内部的类型是真的不可能直出正确类型的，把源码扩展和加类型标注来阅读的时候，遇到这样的情况真的第一次让我感觉 TypeScript 是有缺陷的，想看扩展后和进行标注的代码放在文末 gist 链接

响应式基础

关于现在的响应式 UI 框架的基础其实还是换汤不换药，本质还是发布订阅模式或者观察者模式

但是 vanjs 走的极致压缩 size 路线，导致这部分除开这个实现逻辑的代码确实是很强悍，让我有一种回到 es5 手写原型链实现 class 的感觉

状态管理

vanjs 提供了 state 函数来提供状态，其实本质就是实现一个响应式变量作为发布者

但是其实现不像 vue2/vue3 这样通过 defineProperties 或者 Proxy 包装的，而是直接通过 getter/setter 简单的完成这个步骤

所以这里就有个缺陷，就是 state 的响应式只是浅层的，就类似于 vue3 的 shallowRef, 必须通过修改 State.val 才会触发

首先是定义了 stateProto 作为 state 的原型

Copy
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) },
}

实际上就是简单实现了

Copy
interface State<T = any> {
val: T
onnew(l: (val: T, oldVal: T) => void): void
}

如果用 class 来写应该大多数人会直接 class StateImpl implements State 但是 vanjs 为了极致的 size 没有选择 class (实际上几个 minor 之前还是 class :satisfied:)

vanjs 是怎么做的呢，其实很简单直接用个对象字面量以及将其__proto__指向这个stateProto就 ok 了，显著减少代码体积

如果有手写过原型链的朋友应该很熟悉这样的写法，但是脱离了构造函数而是直接对象字面量和__proto__属性 不过这里使用Object.create w/ Object.assgin可能会得到一点点性能提升 XD

绑定状态

vanjs 提供了bind函数来将状态和一些有副作用的调度任务进行绑定，内部如之前的 tags 中处理 props/attrs 更新的地方也是用到这个函数

我给 vanjs 贡献的就是这个函数的签名类型，简单的跳了个类型体操解决原先手写 10 个函数重载但实际上还是不够用的的签名😁

Copy
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
}

这部分函数中 statesToGc 实际上是管理 GC 的，和响应式没有太大关系啊

这函数主要是产生了一个binding然后将这个binding增加到状态的bindings列表里去了

然后就是当对 State.val进行修改的时候会触发调度任务来进行副作用的执行通过 updateDoms这个函数，回到stateProto这个原型对象中可以看到 val 的 setter 函数中的逻辑是当 setter 传入的值与当前值_val不同时会进行一系列逻辑

Copy
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))
}
}

首先如果保存的旧值oldVal和当前值_val一样时 (这里 state 初始化时这两个值是一样的)

即第一次变更状态时 vanjs 会将当前 state 实例加入到 changedStates 这个 Set<State>中，并通过一个没有 delay 参数的setTimeout来执行 updateDoms 的任务 (宏任务队列 下一次循环直接执行)

那么副作用的实际执行逻辑其实就是在 updateDoms

但是else if的分支是干什么的呢？

因为我们在上个分支中插入了一个updateDoms到宏任务队列，但是在本次事件循环中如果这个State.val再次被修改了，并且修改的目标值和oldVal一致 (值被复原了)

那么可以直接将原先加入到changedStates这个Set<State>的当前State删除，让其在updateDoms时不会执行当前State改变两次并变回原先的oldVal从而生成的错误的副作用

当然 如果 目标值和oldVal不一样就会老老实实的还是执行该有的副作用了

不过这里还是有引用类型比较的问题，还是造成 shlldowRef 的效果，改变深层值可能并不会触发该有的副作用，当然甚至修改状态也不会有🐷

执行副作用

Copy
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))
// 元素引用不同则视作dom变化 其实vanjs比较推荐直接修改一个元素引用的prop/attrs的 毕竟没有什么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)
}

这部分的逻辑也不算复杂，先是将changedStates这个Set<State>解构成了一个State[]变量然后把原先的changedStates置空

接着将获得的changedStatesArray获取里面的bindings并拍平得到了一个bindings的数组

通过new Set去重并遍历通过binding中的状态依赖、dom 元素、副作用函数获得新的元素，然后检测新老元素引用是否一致来更新 dom 节点和是否需要删除旧 dom 节点

最后再将 changedStateArray中的所有状态的oldVal赋当前_val值，这样如果此State再次更改就依旧会触发相应的副作用

总结

其实里面还有一些控制 GC 的东西，但是不太熟悉这部分就也没研究就罢了

其实说到底构建一个响应式前端框架的核心难度并不高，其生态构建和周边配套设施的开发才是难点，特别是你直接使用一些浏览器现有的 api 而不是什么虚拟 dom, 难度是非常低的，不过这个库的特点是尺寸十分轻量，虽然导致一些东西明显看着会有问题XD

但是这样的工具构建的网页(vanjs 的官网) 其实也是不输 vue/react 构建的，我也顺手做了个 todomvc 感觉还不错，还会继续关注这个项目有机会提提 pr

最后贴个 gist: 扩展 + 类型标注 版本 导入的类型参考官方仓库 供学习

希望看完本文你会学到一些什么❤️