はじめに
今まで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()
このサンプルコードを実行すると次のようになる。
画像1枚目は起動直後の画面。画像2枚目、3枚目は2つのボタンをそれぞれ何回かクリックした後の画面。
各コンポーネントがそれぞれ状態を持つこと、またCounter.render()
内のif文が機能していることが分かる。
試してみての感想
- 宣言的UIのイメージをなんとなく分かった気がする
- tkinterに限らず、他のGUIライブラリでも同様のアプローチで実現できそう
- PEP8が推奨しているインデント幅4だと深くなりすぎるので、辞書型でノードを表現すると少しつらい
今後の方針
おそらくやることはないが、追及していく場合の方針。
- tkinterの機能のサポート
- tkinterウィジェットのイベント(
bind()
)や、レイアウトマネージャー(pack()
,grid()
,place()
)などの必須機能はサポートすべき
- tkinterウィジェットのイベント(
- 関数コンポーネントのサポート
- Reactでは関数コンポーネントが推奨されているとのことだが、フックの実装が恐ろしく面倒そう
- デコレータを使用してクラスコンポーネントで疑似的に再現してよいならば、
useState()
も含め簡単に作れそう
- スタイルとスクリプトの分離
- css的なものをPythonでも用意するとよさそう
- ただ、調べた感じだとスクリプト内でスタイリングする手法が割とある? (参考: Reactにおけるスタイリング手法まとめ)
参考
本記事を書いている時に知ったライブラリ一覧。
-
ReactPy
- ReactのPython実装
-
tkintergalactic
- 宣言的なtkinterフレームワーク
- コンポーネント指向的な概念は未対応?
-
pyjsx
- Pythonコード内でJSXを直接取り扱うライブラリ
- Pythonのcodecsに自作のデコーダーを登録して実現しているっぽい