enpitsulin

enpitsulin

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

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

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

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

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

image

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

基于函数的构建 dom 形式#

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

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

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

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

// 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 的原型

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

实际上就是简单实现了

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 了,显著减少代码体积

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

绑定状态#

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

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

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不同时会进行一系列逻辑

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不一样就会老老实实的还是执行该有的副作用了

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

执行副作用#

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: 扩展 + 类型标注 版本 导入的类型参考官方仓库 供学习

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

加载中...
此文章数据所有权由区块链加密技术和智能合约保障仅归创作者所有。