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に指向させるだけで済みます。これにより、コードのサイズが大幅に削減されます。

PS: 手書きのプロトタイプチェーンを経験したことがある友人は、このような書き方に非常に慣れているはずですが、コンストラクタを離れ、オブジェクトリテラルと__proto__属性を直接使用していますただし、ここでObject.create w/ Object.assignを使用すると、わずかにパフォーマンスが向上する可能性があります 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>に追加し、setTimeoutを使ってupdateDomsタスクを実行します(マクロタスクキューの次のループで直接実行)。

副作用の実行ロジックは実際にはupdateDomsにあります。


しかし、else ifの分岐は何のためにあるのでしょうか?

前の分岐でupdateDomsをマクロタスクキューに挿入しましたが、今回のイベントループ中にState.valが再度変更され、変更のターゲット値がoldValと一致する場合(値が元に戻った場合)、元々changedStatesというSet<State>に追加された現在のStateを削除できます。これにより、updateDomsで現在のStateが 2 回変更され、元のoldValに戻ることによって生成される誤った副作用を防ぐことができます。

もちろん、ターゲット値がoldValと異なる場合は、正しく副作用が実行されます。

PS: ただし、ここには参照型の比較の問題があり、深層値の変更が期待される副作用をトリガーしない可能性があります。もちろん、状態の変更もトリガーされないことがあります🐷

副作用の実行#

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: 拡張 + 型注釈バージョン インポートの型は公式リポジトリを参考に] (https://gist.github.com/enpitsuLin/cc51f3b326708a04f76caf797eaf46d6) を学習用に貼っておきます。

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

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