在 Github 的時間線上看到了一个非常厲害的項目,自稱是世界上最小的響應式 UI 框架
簡單預覽了一下,發現確實很小,而且很適合研究它的底層實現方式,雖然代碼風格過於極簡主義導致可讀性比較差 (作者本人也指出)
但是通過一定的整理還是比較容易搞懂的,畢竟本體就不到 100 行(事實上只有 94 行)
簡單研究了下幫忙跳了個類型體操完善了下類型定義,本身我是比較討厭那套研究各種源碼的卷勁的,但是基於這個庫的輕量我覺得可以簡單解析下
基於函數的構建 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: 擴展 + 類型標註 版本 導入的類型參考官方倉庫 供學習
希望看完本文你會學到一些什麼❤️