Open15

reactiveフレームワークの構築

みつきみつき

pyqt元々のapiが気に食わないので自作reactive fameworkでラップすることになった。これはその情報収集。
言語は英語中国語日本語混在する。
自分は現存フレームワークの構造や設計をしているものの、細かいコードの実現方法がわかってないのでまずそこから。
まずモデル駆動で一番人気のあるreact
https://react.iamkasong.com/
https://github.com/7kms/react-illustration-series
https://github.com/ZacharyL2/mini-react/blob/master/src/mini-react.ts

そしてfine-grainedのVue
https://www.yangyitao.com/vue3/04.Vue3响应式系统源码实现1.html

コアは同じくfine-graindedですが、APIデザインにおいてreact哲学を準じたsolidjs
https://dev.to/ryansolid/building-a-reactive-library-from-scratch-1i0p

みつきみつき

テストに簡単なreactive-systemを書く

参考:
https://docs.solidjs.com/advanced-concepts/fine-grained-reactivity#building-a-reactive-system

current_subscriber = None


class Signal:
    def __init__(self, init_value):
        super().__init__()
        self._value = init_value
        self._subscribers = set()

    def get(self):
        if current_subscriber:
            self._subscribers.add(current_subscriber)
        return self._value

    def set(self, next_value):
        if callable(next_value):
            raise NotImplementedError("Callable not supported yet")
        if self._value == next_value:
            return
        self._value = next_value
        for subscriber in self._subscribers:
            subscriber()

    def subscribe(self, cb):
        self._subscribers.add(cb)


def create_effect(cb):
    global current_subscriber
    prev_subscriber = current_subscriber
    current_subscriber = cb
    cb()
    current_subscriber = prev_subscriber


def create_signal(value):
    s = Signal(value)
    return (s.get, s.set)


if __name__ == "__main__":
    import threading

    def set_timeout(func, sec):
        timer = threading.Timer(sec, func)
        timer.start()

    name, set_name = create_signal("Hello")
    create_effect(lambda: print(name()))
    set_timeout(lambda: set_name("World"), 3)

みつきみつき

SolidJS

solid/src/render/components.ts - line24
Component => A function that take props and return a JSX.Element

CreateComponent()

function createComponent() {
    // ...
    return untrack(() => Comp(...))
}

untrack()
Listener is a global variable
一旦cuurent_listenerのlinkを解除して、fn()の実行結果を返してからまたlisnterを登録する

export function untrack<T>(fn: Accessor<T>): T {
    const listener = Listener;
    Listener = null
    try {
        return fn()
    } finnaly {
        Listenr = listener
    }
}

なぜnullにするのか、こちらを参照してわかるように、solidjsは今実行すべきlistnerをグローバル変数として保存してるので、listenerを実行したくないときは一旦外すようにしている、だから関数名もuntrack

onMountAPIがまさにこれに基づいて実現している

export function onMount(fn: () => void) {
  createEffect(() => untrack(fn));
}

そしてこのlibのrender関数をcallして、domを更新する

https://juejin.cn/post/7145478474919051277
この記事はレンダリングプロセスを細かく分解してくれている

みつきみつき

dom-expresssions & solid & rxcore

dom-expressions は solid の一番コアになっているlibの一つである、が、solidjsのrepoに入っていない。
作者のryan氏の野望でもあると思うが、まさにdom-expressionsのrepoのreadmeの一番下に書いてあるように

My goal here is to better understand and generalize this approach to provide non Virtual DOM alternatives to developing web applications.

未来のバーチャルDOMのないweb開発のための基盤になれたらと思います。
だからsolidjsと別に単独なlibとして出しているかもしれません。(あくまで筆者個人の憶測)

で本題に入るですが、solidjsのコードを読んでると、rxcoreという訳のわからないimportが出てくると思いますが、これはbebelのimport nameを書き換えるpluginと一緒に使うことが前提になっているからです。

solidjs-repo/packages/solid/babel.config.jsを覗いてみると

[
  "babel-plugin-transform-rename-import",
  {
    replacements: [
      {
        original: "rxcore",
        replacement: path.join(__dirname, "../../packages/solid/web/src/core")
      },
      {
        original: "^solid-js$",
        replacement: path.join(__dirname, "src"),
      }
    ]
  }
],

とあります。

そしてsolidjsのソースコードがdom-expressionsをimportしており
solidjs-repo/packages/solid/web/src/client.ts

export * from "dom-expressions/src/client.js";

そのdom-expressionsの中もこう書いているので

import {
  root,
  effect,
  memo,
  getOwner,
  createComponent,
  sharedConfig,
  untrack,
  mergeProps
} from "rxcore";

結果から言うと、このsolidjs構築においては、dom-expressionsのこのimportは、solidjs-repo/packages/solid/web/src/core.tsからimportすることになっている。つまりビルド時は以下にようイメージです

import {
  root,
  effect,
  memo,
  getOwner,
  createComponent,
  sharedConfig,
  untrack,
  mergeProps
} from "solidjs-repo/packages/solid/web/src/core.ts";

この分かりづらいが巧妙なやり方で、dom-expressions libが"未来のパッケージ"をimportすることができるようになっている。つまり他の人でも

{
  root,
  effect,
  memo,
  getOwner,
  createComponent,
  sharedConfig,
  untrack,
  mergeProps
}

これらの方法が実装されたライブラリーであれば、誰でもこのdom-expressions libが利用できると言うことになる。

まだsolidjsの構築に戻ると、dom-expressionsは実際solidの中に実装されているこれらのmethodを利用してreactivite性を提供している。

solidjs -> reactive apiの提供
dom-expressions -> これらのapiを実際のdom操作に変換

だからsolidjsを理解するには、dom-expressionsを理解しないといけない。

みつきみつき

dom-expressions: insert()

ここで一番重要なinsert functionのロジックを簡単にしていきます
元のコード

export function insert(parent, accessor, marker, initial) {
  if (marker !== undefined && !initial) initial = [];
  if (typeof accessor !== "function") return insertExpression(parent, accessor, initial, marker);
  effect(current => insertExpression(parent, accessor(), current, marker), initial);
}

上で知っている通り、solidjsにおいては、effect = createRenderEffect、なので色々省いて、この関数はこう書ける

function insert() {
    createRenderEffect(...)
}
みつきみつき

dom-expressions: render()

solidjsや反応式のライブライーの入り口となる関数

元の関数

export function render(code, element, init, options = {}) {
  if ("_DX_DEV_" && !element) {
    throw new Error(
      "The `element` passed to `render(..., element)` doesn't exist. Make sure `element` exists in the document."
    );
  }
  let disposer;
  root(dispose => {
    disposer = dispose;
    element === document
      ? code()
      : insert(element, code(), element.firstChild ? null : undefined, init);
  }, options.owner);
  return () => {
    disposer();
    element.textContent = "";
  };
}

省いて置き換えて

export function render(code, element, init, options = {}) {
  let disposer;
  createRoot(dispose => {
    disposer = dispose;
    element === document
      ? code()
      : insert(element, code(), element.firstChild ? null : undefined, init);
  }, options.owner);
  return () => {
    disposer();
    element.textContent = "";
  };
}

solidjsがexport * from "..."を多用しているので、どっからこの関数が来ているのか探すのがガチで苦労する

ここのcreateRootはsolidjs-repo/packages/solid/src/reactive/signal.tsの中にある

export function createRoot<T>(fn: RootFunction<T>, detachedOwner?: typeof Owner): T {}
みつきみつき

これから三項演算子をできるだけ書かないようにしよう。。。特に繋がっているやつ(見てて目が回るw
次回からさらにレンダリングロジックを紐解いいく

みつきみつき

https://github.com/ZacharyL2/mini-react/blob/master/src/mini-react.ts)
これに基づいてreactのrender-processを理解してみた

window.requestIdleCallback(workLoop)でworkLoopを起動させる

React.render(document.getElementById("root")!, <App />);

build container fiber node(id=rootのあれ)

nextUnitOfWork = 先buildしたやつ

workLoopが常にremainTimeがある時動いているの、nextUnitOfWorkがnullでなくなったことを検知

nextUnitOfWork = performUnitOfWork(nextUnitOfWork)をやる

performUnitOfWorkのreturnがfiber-treeの次のものになっているので、workLoopとnextUnitOfWorkで再帰的なことを再帰callせずにやっている(実際は違うのですが、一旦この理解で良い)

みつきみつき

performUnitOfWorkを展開していく

// 実際このようにcallしているので
performUnitOfWork(nextUnitOfWork)

だから実際の処理を見ていく時はこのように理解できる

const performUnitOfWork = (nextUnitOfWork: FiberNode) => {
    switch {
        // FiberNodeの種類によって違う前処理をしてから
        reconcileChildren(nextUnitOfWork, fiberNode.props.children)
        // をcallする
    }

    // reconcileChildren(nextUnitOfWork, fiberNode.props.children)を完了
    if (fiberNode.child) {
        return fiberNode.child; // reactのfiber-treeの中では, child = children[0], sibling = children[1,2,3...]
    }

  // paramaterを変更しないようにlocal変数を作ってparamaterを一旦保存しとく
  let nextFiberNode: FiberNode | undefined = nextUnitOfWork;

  while (typeof nextFiberNode !== "undefined") {
    if (nextFiberNode.sibling) {
      return nextFiberNode.sibling; // childrenがまだ残っているのなら関数ごとreturnしてworkLoopに返す
    }

    nextFiberNode = nextFiberNode.return; // returnがfiber-treeの中のparentの意味
    // つまりこのwhileはparent == undefinedまでloopする
    // つまりもしこのnodeの全childrenの処理が終わったら何もせずにwhileを抜ける
  }

  return null; // nullをworkLoopに返す(詳細は次のworkLoopで)
}
みつきみつき

workLoop

const workLoop: IdleRequestCallback = (deadline) => {
  while (nextUnitOfWork && deadline.timeRemaining() > 1) {
    // remain timeがあればperformUnitOfWorkを実行(前回)
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  // performUnitOfWorkはchildrenがなくなると最終的にnullを返してnextUnitOfWork = nullになるので
  if (!nextUnitOfWork && wipRoot) {
    // その場合これが呼ばれる
    commitRoot();
  }

  window.requestIdleCallback(workLoop);
};

次回がさらにperformUnitOfWorkを深く入る