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: 擴展 + 類型標註 版本 導入的類型參考官方倉庫 供學習

希望看完本文你會學到一些什麼❤️

載入中......
此文章數據所有權由區塊鏈加密技術和智能合約保障僅歸創作者所有。