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パラメータを処理します。実際には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に指向させるだけで、コードの体積を大幅に削減しています。

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) {
  // バンドルサイズを減らすために`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が異なる場合は、通常通り副作用が実行されます。

PS: ただし、ここには参照型に関する問題があり、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: 拡張 + 型注釈バージョン インポートの型は公式リポジトリを参考にしてくださいを学習用に貼っておきます。

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

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