ReactライクなUIフレームワークをPython tkinterで試す

## はじめに

今までjavascriptを触ったことすらなかったが、[Reactのチュートリアル](https://ja.react.dev/learn)を覗いてみたところ状態管理の概念が面白く、興味が湧いた。
Pythonのtkinter(GUI用の標準ライブラリ)でも似たようなことをできそうな気がするので、実際に作って試しながらReactへの理解を深める。

## 過程と結果

cursorに上記アイデアを投げ、修正のやり取りを何回か行った。
結果として、`Component`クラスを継承して`render()`メソッドを実装する、クラスコンポーネントと呼ばれる形式のコードが得られた。

その際の主な設計方針は次の通り。

- UIは仮想DOMを模した辞書のツリー(`VNode`ツリー)で表現する
- `Component`クラスを継承して独自コンポーネントを作成する
- 独自コンポーネントは`render()`メソッドで`VNode`ツリーを返す
- stateの更新に応じて、`VNode`ツリーと実際のtkinterウィジェットを差分更新する（diff & patch）

Pythonコード内でJSXを直接取り扱えないため、ここでは辞書型で代用している。
また、レイアウトマネージャーは簡単のため`pack()`のみ考慮した。

コード全文は長いので省略。

<details><summary>Componentクラスの実装コードを見る場合はここをクリック</summary>

動作確認をあまり行っていないため、コードを使用される場合は注意。
おそらく差分反映処理が不完全と思われる。

```python
import tkinter as tk
from typing import Any, Optional, Union, TypedDict


# 仮想ノード型
tkProps = dict[str, Any]
class VNode(TypedDict):
    type: Union[str, type["Component"]]
    props: tkProps
    children: list["VNode"]


# 仮想ノード→実ウィジェット変換・差分更新
_WIDGET_TYPE_MAP = {
    "Frame": tk.Frame,
    "Label": tk.Label,
    "Button": tk.Button,
}


def create_widget(
    master: Any, vnode: VNode, component_instance: Optional["Component"] = None
) -> tk.Widget:
    widget_type = vnode["type"]
    props: tkProps = vnode.get("props", {})
    children: list[VNode] = vnode.get("children", [])
    # Component型の場合
    if isinstance(widget_type, type) and issubclass(widget_type, Component):
        # 子Componentを生成し、Frameにマウントして返す
        frame = tk.Frame(master)
        comp = widget_type(master=frame, props=props)
        comp.mount(frame)
        frame._vchildren = [comp._widget]  # _widgetはComponentのroot widget
        return frame
    WidgetClass = _WIDGET_TYPE_MAP[widget_type]
    widget = WidgetClass(master, **props)
    widget._vchildren = []
    for child in children:
        child_widget = create_widget(widget, child, component_instance)
        child_widget.pack()
        widget._vchildren.append(child_widget)
    return widget


def diff_and_patch(
    master: Any,
    old_vnode: Optional[VNode],
    new_vnode: VNode,
    widget: Optional[tk.Widget],
    component_instance: Optional["Component"] = None,
) -> tk.Widget:
    if old_vnode is None or widget is None:
        new_widget = create_widget(master, new_vnode, component_instance)
        new_widget.pack()
        return new_widget
    # Component型VNodeの場合は作り直し（簡易実装）
    if (
        isinstance(old_vnode["type"], type) and issubclass(old_vnode["type"], Component)
    ) or (
        isinstance(new_vnode["type"], type) and issubclass(new_vnode["type"], Component)
    ):
        widget.destroy()
        new_widget = create_widget(master, new_vnode, component_instance)
        new_widget.pack()
        return new_widget
    if old_vnode["type"] != new_vnode["type"]:
        widget.destroy()
        new_widget = create_widget(master, new_vnode, component_instance)
        new_widget.pack()
        return new_widget
    # propsの差分反映
    for k, v in new_vnode.get("props", {}).items():
        if old_vnode.get("props", {}).get(k) != v:
            widget.config({k: v})
    # 子の差分反映（単純な数・順序一致のみ対応）
    old_children: list[VNode] = old_vnode.get("children", [])
    new_children: list[VNode] = new_vnode.get("children", [])
    # 余分な子を削除
    while len(widget._vchildren) > len(new_children):
        w = widget._vchildren.pop()
        w.destroy()
    # 既存の子を更新 or 新規追加
    for i, child_vnode in enumerate(new_children):
        if i < len(widget._vchildren):
            child_widget = diff_and_patch(
                widget,
                old_children[i],
                child_vnode,
                widget._vchildren[i],
                component_instance,
            )
            widget._vchildren[i] = child_widget
        else:
            child_widget = create_widget(widget, child_vnode, component_instance)
            child_widget.pack()
            widget._vchildren.append(child_widget)
    return widget


class Component:
    master: Any
    props: tkProps
    state: dict[str, Any]
    _container: Any
    _mounted: bool
    _vnode: Optional[VNode]
    _widget: Optional[tk.Widget]

    def __init__(self, master: Any = None, props: Optional[tkProps] = None) -> None:
        self.master = master
        self.props = props or {}
        self.state = {}
        self._container = None
        self._mounted = False
        self._vnode = None
        self._widget = None

    def set_state(self, new_state: dict[str, Any]) -> None:
        self.state.update(new_state)
        self.rerender()

    def render(self) -> VNode:
        """
        サブクラスでオーバーライド。仮想ノード(dict)を返すこと。
        """
        raise NotImplementedError

    def mount(self, container: Any) -> None:
        self._container = container
        self._mounted = True
        self._vnode = self.render()
        self._widget = create_widget(container, self._vnode, self)
        self._widget.pack()

    def unmount(self) -> None:
        if self._widget is not None:
            self._widget.destroy()
            self._widget = None
        self._mounted = False
        self._vnode = None

    def rerender(self) -> None:
        if not self._mounted:
            return
        new_vnode = self.render()
        self._widget = diff_and_patch(
            self._container, self._vnode, new_vnode, self._widget, self
        )
        self._vnode = new_vnode
```

</details>

## サンプルコード

`Component`クラスを使用した動作確認用のサンプルコードを次に示す。

```python
# カウンターコンポーネント
# 一定値以上でボタンが非表示になる
class Counter(Component):
    def __init__(self, master: Any = None, props: Optional[tkProps] = None) -> None:
        super().__init__(master, props)
        self.state = {"count": 0}
        props = props if props is not None else {}
        self.max_count = props.get("max_count", float("inf"))

    def increment(self) -> None:
        self.set_state({"count": self.state["count"] + 1})

    def render(self) -> VNode:
        if self.state["count"] <= self.max_count:
            return {
                "type": "Frame",
                "props": {},
                "children": [
                    {
                        "type": "Label",
                        "props": {"text": f"Count: {self.state['count']}"},
                        "children": [],
                    },
                    {
                        "type": "Button",
                        "props": {"text": "Increment", "command": self.increment},
                        "children": [],
                    },
                ],
            }
        else:
            return {
                "type": "Label",
                "props": {"text": f"Count is greater than {self.max_count}"},
                "children": [],
            }


# 複合Componentの例
class MainApp(Component):
    def render(self) -> VNode:
        return {
            "type": "Frame",
            "props": {},
            "children": [
                {"type": Counter, "props": {"max_count": 10}, "children": []},
                {"type": Counter, "props": {}, "children": []},
            ],
        }


if __name__ == "__main__":
    root = tk.Tk()
    root.title("Component Framework Demo")
    app = MainApp(master=root)
    app.mount(root)
    root.mainloop()
```

このサンプルコードを実行すると次のようになる。

![起動直後の画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3789334/20b7cb29-4b17-4687-922f-67aea557efbc.png)![数回クリック時の画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3789334/49eea343-a2b5-4501-ace3-5b95622c923f.png)![max_count回クリック時の画面](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/3789334/72a7e3db-ddf4-47b2-9515-020efa9c9fe7.png)  
画像1枚目は起動直後の画面。画像2枚目、3枚目は2つのボタンをそれぞれ何回かクリックした後の画面。
各コンポーネントがそれぞれ状態を持つこと、また`Counter.render()`内のif文が機能していることが分かる。

## 試してみての感想

- 宣言的UIのイメージをなんとなく分かった気がする
- tkinterに限らず、他のGUIライブラリでも同様のアプローチで実現できそう
- [PEP8](https://peps.python.org/pep-0008/)が推奨しているインデント幅4だと深くなりすぎるので、辞書型でノードを表現すると少しつらい

## 今後の方針

おそらくやることはないが、追及していく場合の方針。

- tkinterの機能のサポート
  - tkinterウィジェットのイベント(`bind()`)や、レイアウトマネージャー(`pack()`, `grid()`, `place()`)などの必須機能はサポートすべき
- 関数コンポーネントのサポート
  - Reactでは関数コンポーネントが推奨されているとのことだが、フックの実装が恐ろしく面倒そう
  - デコレータを使用してクラスコンポーネントで疑似的に再現してよいならば、`useState()`も含め簡単に作れそう
- スタイルとスクリプトの分離
  - css的なものをPythonでも用意するとよさそう
  - ただ、調べた感じだとスクリプト内でスタイリングする手法が割とある？ (参考: [Reactにおけるスタイリング手法まとめ](https://zenn.dev/chiji/articles/b0669fc3094ce3))

## 参考

本記事を書いている時に知ったライブラリ一覧。

- [ReactPy](https://reactpy.dev/docs/index.html)
  - ReactのPython実装
- [tkintergalactic](https://github.com/leontrolski/tkintergalactic)
  - 宣言的なtkinterフレームワーク
  - コンポーネント指向的な概念は未対応？
- [pyjsx](https://github.com/tomasr8/pyjsx)
  - Pythonコード内でJSXを直接取り扱うライブラリ
  - Pythonのcodecsに自作のデコーダーを登録して実現しているっぽい