0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

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

Posted at

はじめに

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

過程と結果

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

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

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

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

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

Componentクラスの実装コードを見る場合はここをクリック

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

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

サンプルコード

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

# カウンターコンポーネント
# 一定値以上でボタンが非表示になる
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()

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

起動直後の画面数回クリック時の画面max_count回クリック時の画面
画像1枚目は起動直後の画面。画像2枚目、3枚目は2つのボタンをそれぞれ何回かクリックした後の画面。
各コンポーネントがそれぞれ状態を持つこと、またCounter.render()内のif文が機能していることが分かる。

試してみての感想

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

今後の方針

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

  • tkinterの機能のサポート
    • tkinterウィジェットのイベント(bind())や、レイアウトマネージャー(pack(), grid(), place())などの必須機能はサポートすべき
  • 関数コンポーネントのサポート
    • Reactでは関数コンポーネントが推奨されているとのことだが、フックの実装が恐ろしく面倒そう
    • デコレータを使用してクラスコンポーネントで疑似的に再現してよいならば、useState()も含め簡単に作れそう
  • スタイルとスクリプトの分離

参考

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

  • ReactPy
    • ReactのPython実装
  • tkintergalactic
    • 宣言的なtkinterフレームワーク
    • コンポーネント指向的な概念は未対応?
  • pyjsx
    • Pythonコード内でJSXを直接取り扱うライブラリ
    • Pythonのcodecsに自作のデコーダーを登録して実現しているっぽい
0
0
0

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
0
0

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?