LoginSignup
22
23
お題は不問!Qiita Engineer Festa 2023で記事投稿!

PythonでReactを実現するreactPyをさわってみた

Last updated at Posted at 2023-06-13

reactPy

PythonやReactを実装されている方は、既に目にしているかもしれませんが、6月になってからでしょうか。reactPyに関する話が増えた気がしています。
まず、どういうものなのか、githubを見て、情報を得ることにしました。

ReactPy is a library for building user interfaces in Python without Javascript. ReactPy interfaces are made from components that look and behave similar to those found in ReactJS.

JavaScriptを使わずに(Pythoを使って)、Reactのようなふるまいをするコンポーネントを構築するライブラリとのことです。

公式ドキュメントを見てみましたが、まだまだ機能追加中のようです。(工事中のようなアイコンがある機能は開発中)
image.png
今後実現できることが増えそうで楽しみではありますが、まずは機能がそれほど多くないこの段階で、一度キャッチアップをしておきたいと思い、簡単なアプリケーションを作成することにしました。

実装準備

どんなものを作るか

React経験者には大変なじみのあるuseStateuseEffectを用いたり、ルーティング機能を実装したりすることにしました。
デザインにはtailwindcssMaterial UIが使えるようです。今回はtailwindcssを導入することにしました。

また、デプロイ先はfly.ioにしました。(既にデプロイ経験があり、作業が進めやすいと思ったため。)

必要なライブラリ

pipコマンドでインストールします。
(Pythonはインストール済の前提です。バージョンはPython 3.11.3です。)

  • reactPy
  • reactPy-router
pip install reactPy reactPy-router

create-react-appコマンドはなさそう

公式ドキュメントで確認した限りは見つかりませんでした。
Reactでいうpackage.jsonを自動で作成してくれるわけではないので、自分で用意する必要があります。
今回は、fly.ioにデプロイした関係で、requirements.txtに最低限必要なライブラリを定義しています。

requirements.txt
fastapi==0.96.0
fonttools==4.39.4
httpcore==0.16.3
httplib2==0.22.0
numpy==1.23.5
packaging==23.1
pytest==7.3.2
python-dotenv==1.0.0
reactpy==1.0.0
reactpy-router==0.0.1
uvicorn==0.22.0
websockets==11.0.3
wsproto==1.2.0
requests==2.30.0
requests-oauthlib==1.3.1
typing-inspect==0.8.0
typing_extensions==4.5.0
dataclasses-json==0.5.7

FastAPIをベースに実装

FastAPIを使った実装経験がある関係で選択しました。
公式ドキュメントを見ますとFlaskやSanic?など、何種類か対応しているようです。
FastAPIを用いた実装例があります。今回も基本的な構造はこのファイルの通りです。

実装開始

※作成したアプリのリポジトリは最後に記載してます。

コンポーネント実装

ReactPyというだけあって、Reactで実装している感覚とそれほど変わらない、と思っています。画面にHello, World!をと表示する場合は以下のように実装します。
参考ページ

sample.py
# 必要なライブラリ定義
from reactpy import component, html 
from reactpy.backend.fastapi from configure
from fastapi import FastAPI

@component
def hello_world():
    # 画面に表示する内容を戻り値に設定
    return html.h1("Hello, World!")

app = FastAPI()
configure(app, HelloWorld)

Routing

FastAPIベースといいましたが、どのPathに対してどのコンポーネントを表示するのか、ライブラリreactpy-routerを使って定義します。

定義したmy_routerコンポーネントをFast APIのconfigureに対して設定して、Routingを実現します。

view.py
from fastapi import FastAPI
from uvicorn import run
from reactpy import component, html
from reactpy.backend.fastapi import configure, Options
from reactpy_router import route, simple

from src.config import head
from src.pages import Home, Detail, Data, FilterableList, Error

@component
def my_router():
    return simple.router(
        route("/", Home()),
        route("/data", Data()),
        route("/table", FilterableList()),
        route("/detail/{names}", Detail()),
        route("/er", Error()),
        route("*", html.h1("Not Found")) # 404エラー対応
    )

# Router情報を設定
configure(
    app,
    my_router,
    Options(head=head.create_head(tailwind_config={})),
)

if __name__ == "__main__":
    run(app)

※いずれのRouteにも該当しない場合(404エラー対応)、参考にしていた動画や、こちらでも以下のように実装しています。ですが動作確認してみると、存在しないパスにアクセスした際、画面が真っ白になってしまいます・・・。ここは今後追加確認を行い、修正が必要な箇所です。

route("*", html.h1("Not Found"))

tailwindcssでデザイン適用

htmlでは、headタグ内部にscriptタグを記述して、必要なライブラリを定義します。その要領でtailwindcssを定義します。
今回は純正なhtmlではないので、同じとはいきませんが、CDNを適用したい場合に、以下のように記述します。

今回参考した記事と同じ実装ですが、CDNの適用をReactPyバージョンで実装しています。

return (
        html.title("reactPy Sample Site"),
        html.link(
            {
                "href": "https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600;700&display=swap",
                "rel": "stylesheet",
            }
        ),
        html.script({"src": "https://cdn.tailwindcss.com"}, ""),
    )

アプリケーション作成

React経験者には大変なじみのあるuseStateやuseEffectを用いたり、ルーティング機能を実装したりすることにしました。

始めの方に記載した上記内容の詳細です。以下の機能を実装しました。

  1. qiita APIからデータを取得して表示 + 条件を変更して再取得する機能
  2. use_stateを用いて、リアルタイム検索
  3. pathParameterを用いて、別ページ遷移時に、パラメータの値を画面に表示

参考にした記事・動画は以下の通りです。
use_effectを用いてデータ取得

use_stateを用いて、リアルタイム検索

pathParameterを用いて別ページでその情報を表示

フォルダ構成

Reactに近い形にしたいと思って、pagesとcomponentsとconfigにしました。
(componentsフォルダには参考記事のリポジトリのコードも含まれています。)
image.png

実装を通じて

全体的な実装の進めやすさ

他の言語と比べれば、Pythonは比較的シンプルに記述できる言語だと思ってます。
Reactの要素が入った場合に、複雑になってしまうのか?実装がほぼ完成した段階で、コードを眺めてみました。個人的な感覚で恐縮ですが、Djangoのように「pythonで処理を実装し、htmlファイルに対して変数の値を埋め込む」といった、ファイルを見比べて実装するよりは、進めやすかったです。

アプリケーションの起動

ここはFastAPIの話になりますが、以下のようにPythonコマンドで起動できます。(view.pyはリポジトリのルートディレクトリにあるファイル)

python view.py

ですが、開発中はホットリロードが助かるので、以下コマンドで起動した方がいいかもしれません。

uvicorn view:app --reload
共通Layoutの作成

reactとほぼ同じでした。使用するライブラリがどれか分かれば、あと実装するだけでした。
ページの中心部分に表示する内容をページごとに設定します。
今回以下画像の黒線の外側(ピンク線の内側)を、共通レイアウトとしました。
image.png

layout.py
from reactpy.core.types import VdomChildren

@component
def Layout(children: VdomChildren):
return html.main(
        html.div(
            {
                "class": "bg-gray-200 min-h-screen dark:bg-gray-900 dark:text-white",
            },
            html.div(
                {
                    "class": "container mx-auto p-2",
                },
                html.div(
                    {"class": "flex justify-center items-center my-8"},
                    html.img(
                        {
                            "src": "https://reactpy.dev/docs/_static/reactpy-logo-landscape.svg",
                            # "src": "https://avatars.githubusercontent.com/u/106191177?s=200&v=4",
                            "style": {"width": "35%"},
                            "alt": "reactPy",
                        }
                    ),
                ),
                children,
            ),
        ),
    )
エラーへの対応

特定の画面を表示しようとした場合に、エラーが発生することはあります。そのときは、コンソールにその内容が表示されます。本格的な実装までしてはいないので断定できないですが、Chromeのデベロッパーツールにエラーは表示されず、基本的にはコンソールに表示されていました。

return html._(
    html.label(
        "Search by Food Name: ",
    ),
    html.input(
        {
            "class": "my-2 round",
        },
        {"value": value, "on_change": handle_change}),
)

コンソール

TypeError: 'input' nodes cannot have children.

ブラウザではエラーが発生すると、該当箇所(ピンク枠部分)は表示されない
image.png
指示に従って修正します。

return html._(
    html.label(
        "Search by Food Name: ",
    ),
    html.span(
        {
            "class": "round",
        },
        html.input(
            {"value": value, "on_change": handle_change}),
    )
)

デザイン適用前なので、見栄えはよろしくないですが・・・
image.png

エラーはないが、画面に要素が表示されない

恥ずかしい話ですが、Pythonのインデント階層を間違えました。
テキストフィールドを表示しようと思って実装しましたが、画面に表示されず・・・
間違っていたときの実装は以下の通りです。
正しくはSearchメソッドに対するreturnに対してhtml要素を設定するのですが、
そのすぐ下にあるhandle_changeメソッドのreturnとしてhtml要素を設定してしまいました。pythonの文法上はエラーではないため、なぜ??と15分くらいは時間を要したところでした。

@component
def Search(value, set_value):
    def handle_change(event):
        set_value(event["target"]["value"])

        return html._(
            html.label(
                "Search by Food Name: ",
            ),
            html.span(
                html.input(
                    {"value": value, "on_change": handle_change}),
            )
        )
use_effectを用いたデータ取得

qiita APIからデータを取得する処理を実装しました。
データが取得できる場合・そうでない場合があるので、出力制御を加えるといった処理を加えてみました。(Qiita APIでデータ取得するときに、たまに403エラーが発生することがある・・・)
またデータ取得するだけでは少々面白さに欠けるので、日付とストック数を入力して、動的にデータを取得できる機能を追加しました。
入力項目が設定されているので、バリデーションも合わせて実装しています。
image.png
image.png
image.png

実装を貼り付けました!多少長めかと思うので、折りたたんでいます。
最後にコードのリポジトリも載せていますので、そちらで確認いただく形でも大丈夫です!!

【実装】
from reactpy import html, component, use_state, use_effect, event
from src.components.layout import Layout
from src.components.returnhome import ReturnHomeButton
import requests
from datetime import date, timedelta


@component
def pageTitle():
    return html.div(
        {
            "class": "text-center mb-4",
        },
        html.label(
            {
                "class": "text-4xl font-serif text-green-500",
            },
            "Qiita Titles"
        ),
    )


@component
def TableHeader():
    style_table_header_td = ["text-center w-1/3 text-xl font-semibold"]
    return html.tr(
        html.td(
            {"class": style_table_header_td},
            "作成日(from)"
        ),
        html.td(
            {"class": style_table_header_td},
            "作成日(to)"
        ),
        html.td(
            {"class": style_table_header_td},
            "ストック数"
        )
    )


@component
def Titles(data, search_content):
    # Qiitaのデータ取得結果によって、表示内容制御
    if (len(data) > 0):
        display_html = html.div(
            {
                "class": "text-center",
            },
            html.ul(
                [
                    html.li(
                        {
                            "class": "text-xl font-serif my-2",
                        },
                        html.span(
                            {
                                "class": "text-xl my-2 text-blue-500 hover:underline hover:text-blue-700",
                            },
                            html.a(
                                {"href": f"{i['url']} ", "target": "_blank"},
                                f"{i['title']}",
                            ),
                        ),
                        html.span(
                            f" ( get {i['stocks_count']} stock Count ) "
                        ),
                    ) for i in data
                ])
        )
    else:
        display_html = html.div(
            {
                "class": "text-center text-xl text-red-500 mt-8",
            },
            "Sorry... Fetch No Data Or Happened Error"
        )
    return html.div(
        {
            "class": "text-center",
        },
        html.div(
            {
                "class": "text-md font-semibold mb-8 text-gray-500",
            },
            search_content
        ),
        display_html
    )


# 画面に表示するDummyデータ
dummy_data_obj = {
    "title": "Dummy(Failed Fetch)",
    "url": "https://qiita.com/api/v2/docs",
    "stocks_count": 10
}


@component
def Data():
    # 直近2週間に公開されて、100より多くストックされている記事のデータ取得
    init_to_date = date.today()
    td = timedelta(days=14)
    init_from_date = init_to_date - td
    # 画面で管理する値
    from_date, set_from_date = use_state(str(init_from_date))
    to_date, set_to_date = use_state(str(init_to_date))
    stock_count, set_stock_count = use_state(100)
    search_content, set_search_content = use_state('')
    msg, set_msg = use_state('')
    data, set_data = use_state([])

    # QiitaのAPIからデータ取得
    url, set_url = use_state(
        f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={from_date}+created:<={to_date}+stocks:>={stock_count}')

    def get_data():
        set_search_content(
            f'※{from_date}~{to_date}の作成記事で、ストック数が{stock_count}以上を表示しています')
        r = requests.get(url)
        if r.status_code == 200:
            data = r.json()
            set_data(data)
        else:
            print("Fetch Data Error")
            set_data([dummy_data_obj])

    # 初回データの読み込み
    use_effect(get_data, [])

    # available research button
    def available_research():
        if msg == '':
            return False
        else:
            return True

    # validation helper
    def check_under_one(value=stock_count):
        if not value:
            return True
        if int(value) < 1:
            set_msg("ストック数は1以上を設定してください")
            return True
        return False

    # validation helper
    def check_date_order(from_date, to_date):
        if from_date >= to_date:
            set_msg("作成日(to)は作成日(from)より未来を設定してください")
            return True
        return False

    # validation
    def check_allowed_search(value, field):
        if field == 'from_date':
            if check_date_order(value, to_date):
                return False
            if check_under_one():
                return False
        if field == 'to_date':
            if check_date_order(from_date, value):
                return False
            if check_under_one():
                return False
        if field == 'stock_count':
            if check_under_one(value) or check_date_order(from_date, to_date):
                return False
        set_msg('')
        return True

    # 作成日(from)更新時の処理
    def update_from_date(value):
        set_from_date(value)
        if check_allowed_search(value, 'from_date'):
            set_url(
                f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={value}+created:<={to_date}+stocks:>={stock_count}')

    # 作成日(to)更新時の処理
    def update_to_date(value):
        set_to_date(value)
        if check_allowed_search(value, 'to_date'):
            set_url(
                f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={from_date}+created:<={value}+stocks:>={stock_count}')

    # ストック数更新時の処理
    def update_stock_count(value):
        if not value:
            # invalid literal for int() with base 10: ''
            return
        set_stock_count(value)
        if check_allowed_search(value, 'stock_count'):
            set_url(
                f'https://qiita.com/api/v2/items?page=1&per_page=50&query=created:>={from_date}+created:<={to_date}+stocks:>={value}')

    # テーブルの列要素
    @component
    def TableBody():
        style_table_rows = ["px-2 py-2 rounded-md m-2"]
        from_date_td = html.td(
            html.input(
                {
                    "class": style_table_rows,
                    "type": "date",
                    "value": from_date,
                    "on_change": lambda event:
                    {
                        update_from_date(event["target"]["value"])
                    },
                },
            ),)
        to_date_td = html.td(
            html.input(
                {
                    "class": style_table_rows,
                    "type": "date",
                    "value": to_date,
                    "on_change": lambda event:
                    {
                        update_to_date(event["target"]["value"])
                    }
                },
            )
        )
        stock_count_td = html.td(
            html.input(
                {
                    "class": style_table_rows,
                    "type": "number",
                    "value": stock_count,
                    "on_change": lambda event:
                    {
                        update_stock_count(event["target"]["value"])
                    }
                },
            )
        )
        return html.tr(from_date_td, to_date_td, stock_count_td)

    @component
    def Table():
        # 検索情報設定
        return html.div(
            {"class": "flex justify-center"},
            html.table(
                html.thead(TableHeader()),
                html.tbody(TableBody())
            ),
        )

    @component
    def ValidationMsg():
        # バリデーションメッセージ表示
        return html.div(
            {"class": "text-center font-bold text-red-600 mb-4"},
            msg
        )

    # データ再取得
    def re_get_data():
        get_data()

    @component
    def ReGetButton():
        # 再検索ボタン
        return html.div(
            {"class": "flex justify-center mt-2"},
            html.button(
                {
                    "class": "font-semibold rounded items-center bg-green-400 disabled:bg-gray-400 disabled:text-gray-600 hover:bg-green-600 px-4 py-2 mb-4",
                    "disabled": available_research(),
                    "on_click": lambda event: {re_get_data()}
                },
                "再検索"
            ),
        )

    children = html.div(
        ReturnHomeButton(),
        pageTitle(),
        Table(),  # 検索情報設定
        ValidationMsg(),  # バリデーションメッセージ表示
        ReGetButton(),  # 再検索ボタン
        Titles(data, search_content)  # 取得結果表示
    )
    return Layout(children)

tailwindcssの補完機能

reactでtailwindcssを使って、デザイン適用をされたことがある方はご存じかと思いますが、VsCodeで実装中、classNameの中で、途中まで入力してctrl + spaceを押すと、候補が表示されます。
ですが、reactPyでtailwindcssを用いた場合に、それが今のところは機能しませんでした。未経験の状態で導入される場合は、最初は大変かもしれません!

完成

画面レイアウト

以下のような画面が完成しました。
image.png
image.png

デプロイ

冒頭にも記載した通り、fly.ioにデプロイしました。
ターミナルで以下を入力します。(インストール手順はこちらです。)

flyctl launch

Regionを最初に聞かれ、その後Database(Postgresql),UpStash RedisについてSet Upするか質問されます。今回はNoを選択して進めます。
image.png
コマンド実行後に、ファイルが追加されていることが確認できます。(文字色が緑色のファイルが追加されたファイルです)
image.png
この後、Procfileというファイルを編集します。to fit your needsとあるように、自身の環境に適した形に修正します。

# Modify this Procfile to fit your needs
web: gunicorn server:app

私は以下のように変更しました。

# Modify this Procfile to fit your needs
web: uvicorn view:app --host "0.0.0.0" --port "8080"

※ホストの設定については、fly.ioのログに以下エラーが表示されたため

instance refused connection. is your app listening on 0.0.0.0:8080? make sure it is not only listening on 127.0.0.1

fastapiのサイトを参考に設定しました。

https://fastapi.tiangolo.com/ja/deployment/docker/
以下コマンドを実行してデプロイが開始されます。

flyctl deploy

デプロイ完了後のマネジメントコンソール
image.png
ブラウザで確認
image.png
fly.ioのMonitoring画面
image.png

今後と実装詳細

今回はGETリクエスト中心のサイトとなっています。公式ドキュメントを見ると、今後登場する機能で気になるところはあります。
今後の機能拡張を眺めながら、POSTリクエストを用いた実装・DBとのやりとり、などなど、より大きなものにできればと思います。

作成したサイトのコードです。


更新履歴

更新日付 内容
23/06/15 Qiita APIのデータ取得:動的に再検索できるよう、日付とストック数の条件追加
22
23
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
22
23