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パラメータを処理し、実際にはターゲットの最初のパラメータが使用されるプロパティ名になります
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 を制御するためのいくつかのものが含まれていますが、その部分にはあまり詳しくないので、研究しませんでした。
実際、レスポンシブなフロントエンドフレームワークを構築する核心的な難しさはそれほど高くありません。そのエコシステムの構築や周辺の付随施設の開発が難点です。特に、既存のブラウザの API を直接使用する場合、仮想 DOM ではなく、難易度は非常に低いです。しかし、このライブラリの特徴はサイズが非常に軽量であるため、いくつかのものが明らかに問題に見えることがありますXD
しかし、このようなツールで構築されたウェブページ(vanjs の公式サイト)は、vue/react で構築されたものに劣らないものであり、私は todomvc を手軽に作成しましたが、なかなか良い感じです。このプロジェクトを引き続き注目し、機会があれば PR を提案したいと思います。
最後に、gist: 拡張 + タイプ注釈バージョン インポートの型は公式リポジトリを参考にしてくださいを学習用に貼っておきます。
この記事を読んで、何かを学んでいただければ幸いです❤️