Github のタイムラインで、世界で最も小さなレスポンシブ UI フレームワークを自称する非常に素晴らしいプロジェクトを見つけました。
簡単にプレビューしたところ、確かに非常に小さく、底層の実装方法を研究するのに適しています。ただし、コードスタイルが極端にミニマリズムであるため、可読性があまり良くありません(作者自身も指摘しています)。
しかし、一定の整理を行えば比較的理解しやすいです。結局のところ、本体は 100 行にも満たない(実際には 94 行です)。
簡単に研究したところ、タイプ体操を手伝ってタイプ定義を改善しました。私は様々なソースコードを研究することがあまり好きではありませんが、このライブラリの軽量性に基づいて簡単に解析できると思いました。
関数ベースの DOM 構築形式#
「関数ベース」とは何かと尋ねる人もいるかもしれませんが、JSX の本質であるcreateElement
は関数ではないのでしょうか?
この関数ベースは、vdom のようなものを使用するのではなく、一連の関数を通じてタグを構築することを意味します(実際には JSX と非常に似た体験ですが)。作者はこのライブラリをフロントエンドの bash スクリプトとして定義しているため(ソースコードをダウンロードして使用する必要があります 😄 ただし、npm への公開は計画中です)、vdom や JSX を使用することはその設計哲学にはあまり合いません。
ライブラリはvan.tags
を提供しており、解構された属性を使って関数として直接 DOM タグを作成できます。本質的にはdocument.createElement
です。
しかし、この部分の実装は非常に巧妙で、すべてのタグを属性として宣言する必要はなく、Proxy を使って関数をラップし、ターゲットを設定して属性の取得を処理します。
// 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のプロパティまたは属性を設定します。実際にはここでundefinedを判断する方法には明らかなバグがあり、常にfalsyのケースに入ります
let setter = dom[k] !== _undefined ? v => dom[k] = v : v => dom.setAttribute(k, v)
// vanjsのレスポンシブ状態を処理します
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の最初の引数が使用されるプロパティ名になります
tag.bind(_undefined, name)})
PS: 正直に言うと、この tags 内部の型は本当に正しい型を出力することは不可能です。ソースコードの拡張と型注釈を読み取る際に、こういった状況に初めて直面し、TypeScript に欠陥があると感じました。拡張後のコードと注釈を文末の gist リンクに載せています。
レスポンシブの基礎#
現在のレスポンシブ UI フレームワークの基礎は、実際には新しいスープで古い薬を使っているだけで、本質的には発行 - 購読モデルまたはオブザーバーパターンです。
しかし、vanjs はサイズを極限まで圧縮するルートを選んでいるため、この部分は実装ロジックのコードを除いて非常に強力で、ES5 でプロトタイプチェーンを手書きしてクラスを実装しているような感覚を覚えます。
状態管理#
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) {
// バンドルサイズを減らすために`this`をエイリアスします。
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 StateImpl implements State
と書くでしょうが、vanjs はサイズを極限まで小さくするためにクラスを選択しませんでした(実際には数回のマイナーアップデートの前はクラスでした :satisfied:)。
vanjs はどうやっているのでしょうか?実際には、オブジェクトリテラルを直接使用し、その__proto__
をこのstateProto
に指し示すだけで済みます。これにより、コードのサイズが大幅に削減されます。
プロトタイプチェーンを手書きしたことがある友人には非常に馴染みのある書き方ですが、コンストラクタから離れ、直接オブジェクトリテラルと__proto__
属性を使用しています~~ただし、ここでObject.create
とObject.assign
を使用すると、わずかにパフォーマンスが向上するかもしれません XD~~
状態のバインディング#
vanjs はbind
関数を提供して、状態と副作用のあるスケジュールタスクをバインドします。内部では、以前の tags で props/attrs の更新を処理する際にもこの関数が使用されています。
私が 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
が変更されると、スケジュールタスクがトリガーされ、副作用の実行が行われます。stateProto
というプロトタイプオブジェクトに戻ると、val の setter 関数のロジックは、setter に渡された値が現在の値_val
と異なる場合に一連のロジックを実行します。
set "val"(value) {
// バンドルサイズを減らすために`this`をエイリアスします。
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 初期化時にこの 2 つの値は同じです)。
最初の状態変更時に、vanjs は現在の state インスタンスをchangedStates
というSet<State>
に追加し、delay パラメータのないsetTimeout
を使用してupdateDoms
タスクを実行します(マクロタスクキューの次のループで直接実行されます)。
副作用の実際の実行ロジックは実際にはupdateDoms
にあります。
しかし、else if
の分岐は何のためにあるのでしょうか?
前の分岐でupdateDoms
をマクロタスクキューに挿入しましたが、今回のイベントループ中にState.val
が再度変更され、変更の対象値がoldVal
と一致する場合(値が元に戻った場合)、元々changedStates
というSet<State>
に追加された現在のState
を削除することができます。これにより、updateDoms
で現在のState
が 2 回変更され、元のoldVal
に戻ることによって生成される誤った副作用を防ぎます。
もちろん、目標値がoldVal
と異なる場合は、正しく副作用が実行されます。
ただし、ここでは参照型の比較の問題があり、shallowRef の効果を引き起こす可能性があるため、深層値の変更が適切な副作用をトリガーしない場合があります。もちろん、状態を変更してもそうなることはありません🐷
副作用の実行#
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 を制御するためのいくつかのものもありますが、この部分にはあまり詳しくないので、研究することはありませんでした。
結局のところ、レスポンシブなフロントエンドフレームワークを構築する核心的な難しさはそれほど高くありません。エコシステムの構築や周辺の付属施設の開発が難しい点です。特に、仮想 DOM ではなく、ブラウザの既存の API を直接使用する場合、難易度は非常に低いです。しかし、このライブラリの特徴はサイズが非常に軽量であるため、いくつかのものが明らかに問題があるように見えることがありますXD
しかし、このようなツールで構築されたウェブページ(vanjs の公式サイト)は、vue/react で構築されたものに劣らないものであり、私は todomvc を手軽に作成しましたが、なかなか良い感じです。今後もこのプロジェクトを注視し、機会があれば PR を提案したいと思います。
最後に、gist: 拡張 + タイプ注釈バージョン インポートの型は公式リポジトリを参考にしてくださいを学習用に貼っておきます。
この記事を読んで、何かを学んでいただければ幸いです❤️