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です。

しかし、この部分の実装は非常に巧妙で、すべてのタグを属性として宣言する必要はなく、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.createObject.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: 拡張 + タイプ注釈バージョン インポートの型は公式リポジトリを参考にしてくださいを学習用に貼っておきます。

この記事を読んで、何かを学んでいただければ幸いです❤️

読み込み中...
文章は、創作者によって署名され、ブロックチェーンに安全に保存されています。