快轉到主要內容
  1. 教學文章/

Streamlit Fragments + Dialogs:局部 Rerun、即時面板與 Modal Workflow

·9 分鐘· loading · loading · ·
Python Streamlit Fragments Dialogs Rerun Session State Developer-Tools
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 109: 本文

featured

Streamlit 最迷人的地方,也是最容易讓人困惑的地方: 使用者只動一個 slider,整支 Python script 就從頭跑一次。

小 App 沒什麼。 當頁面開始查資料庫、呼叫 API、畫三張圖,再加一個確認刪除流程, 「全部重跑」就會變成肉眼可見的等待,甚至重複觸發副作用。

這篇拍拍君不再重講 Widget、cache 或 Session State 的基礎。 我們只處理兩個很明確的 execution-flow 工具:

  • @st.fragment:讓一小塊畫面獨立 rerun。
  • @st.dialog:把輸入或確認流程放進 modal,並在 modal 內獨立 rerun。

如果你還不熟 Streamlit 的完整 rerun 模型, 先看 Streamlit 進階篇; 如果你想把互動流程寫成自動化測試,則接著看 Streamlit AppTest 實戰

一. 先理解問題:Rerun 不是 Bug
#

Streamlit 預設的思考方式很單純:

  1. 使用者操作 Widget。
  2. 後端收到新值。
  3. Script 從上到下重新執行。
  4. 前端依照新結果更新。

這個模型讓一般 Python 程式很容易變成互動 App。 但它也表示下面的慢函式可能一直重跑:

import time
import streamlit as st
def load_expensive_summary() -> dict[str, int]:
    time.sleep(2)
    return {"queued": 8, "running": 2, "done": 41}
summary = load_expensive_summary()
st.metric("完成", summary["done"])
priority = st.slider("優先度", 1, 5, 3)
st.write("目前優先度:", priority)

每次拖動 slider,都會重新等待兩秒。

你當然可以用 @st.cache_data 快取資料, 但 cache 和 fragment 解決的是不同問題:

工具 核心問題 執行方式
st.cache_data 同一個函式結果不要重算 Script 仍會跑,只跳過 cache hit 的函式
st.form 多個輸入不要每次都送出 等 submit 後再 full rerun
callback Widget 改變時先做一件事 callback 後仍進入正常 rerun
st.fragment 只想更新頁面的一部分 只重新執行 fragment function
st.dialog 互動應該留在 modal 裡 只重新執行 dialog function

所以 fragment 不是「更厲害的 cache」。 它是在切執行邊界。

二. 安裝與範例專案
#

Fragments 在 Streamlit 1.37.0 起正式提供。 Dialogs 與 dismissal 選項也持續演進,實作前建議使用近期版本。

mkdir pypy-rerun-demo
cd pypy-rerun-demo
uv init
uv add streamlit pandas
uv run streamlit version

建立 app.py

touch app.py
uv run streamlit run app.py

本文所有狀態都只放示範資料。 正式 App 若會刪資料、扣款或呼叫外部服務, 仍然要在資料層做 transaction、權限檢查與 idempotency。

三. 第一個 Fragment:只讓計數器重跑
#

先看一個最小範例:

from datetime import datetime
import streamlit as st
st.title("拍拍君的工作台")
st.caption(f"完整 App 執行時間:{datetime.now():%H:%M:%S}")
@st.fragment
def local_counter() -> None:
    st.subheader("局部計數器")
    if "clicks" not in st.session_state:
        st.session_state.clicks = 0
    if st.button("加一", key="fragment-plus"):
        st.session_state.clicks += 1
    st.metric("Clicks", st.session_state.clicks)
    st.caption(f"Fragment 執行時間:{datetime.now():%H:%M:%S.%f}")
local_counter()
st.button("完整 App rerun")

按下 fragment 裡的「加一」時:

  • local_counter() 會重新執行。
  • Fragment 裡的時間與 metric 會更新。
  • Fragment 外的「完整 App 執行時間」保持不變。

按下最下面的按鈕時,才會 full rerun。

這個差異看似很小,卻是整篇最重要的觀念: Widget 屬於哪一個執行邊界,就會觸發哪一層 rerun。

四. st.rerun()scope="fragment"
#

在 fragment 裡,常見需求有兩種:

  • 只重跑這個 fragment。
  • 事情完成後,讓整個 App 重新同步。
import streamlit as st
@st.fragment
def editor() -> None:
    name = st.text_input("任務名稱", key="draft-name")
    col1, col2 = st.columns(2)
    if col1.button("清空輸入"):
        st.session_state["draft-name"] = ""
        st.rerun(scope="fragment")
    if col2.button("保存並更新全頁", disabled=not name.strip()):
        st.session_state.saved_name = name.strip()
        st.rerun()
editor()
st.write("全頁摘要:", st.session_state.get("saved_name", "尚未保存"))

沒有指定 scope 的 st.rerun() 預設是 full app。

st.rerun(scope="fragment") 則有嚴格限制: 它只能在「fragment rerun 期間」從該 fragment 裡呼叫。 如果你在 fragment 初次隨 full app 執行時就無條件呼叫, 或在 fragment 外呼叫,Streamlit 會丟出例外。

也不要把 rerun 當 continue。 呼叫後,當次執行會立即停止,後面的 statements 不會繼續跑。

五. run_every:做一塊自動更新面板
#

Fragments 可以設定 run_every, 讓使用者沒有操作時也定期更新。

from datetime import datetime
import random
import streamlit as st
@st.fragment(run_every="3s")
def live_queue() -> None:
    queued = random.randint(2, 12)
    running = random.randint(1, 4)
    col1, col2 = st.columns(2)
    col1.metric("等待中", queued)
    col2.metric("執行中", running)
    st.caption(f"最後更新:{datetime.now():%H:%M:%S}")
st.title("任務監控")
live_queue()
st.write("這段說明不需要每三秒重畫。")

run_every 可以是秒數、timedelta, 或 Pandas Timedelta 可解析的字串,例如 "500ms""10s""1m"

但定時更新不是免費的。 每個開啟中的 session 都可能定期執行查詢, 所以正式環境要思考:

  • 查詢成本是否可接受?
  • 瀏覽器分頁閒置時是否還需要更新?
  • API 有沒有 rate limit?
  • 是否應先用 cache 或後端聚合資料?

兩秒更新一次 dashboard 很酷。 兩秒對資料庫做一次昂貴 full scan,就只是很貴。

六. 副作用要能重複執行
#

Fragment 可能因 Widget、timer 或程式呼叫而重跑。 因此這種程式很危險:

@st.fragment(run_every="5s")
def bad_panel() -> None:
    audit_log.append("panel refreshed")
    send_webhook("still alive")
    st.write("OK")

每五秒 append 一次、送一次 webhook,通常不是你真正想要的行為。

顯示型程式適合放在 fragment:

@st.fragment(run_every="5s")
def good_panel() -> None:
    status = repository.read_status()
    st.metric("進度", f"{status.percent}%")

寫入型操作則綁在明確事件上:

@st.fragment
def retry_panel() -> None:
    task_id = st.selectbox("失敗任務", repository.failed_ids())
    if st.button("重新排隊", type="primary"):
        repository.enqueue_once(task_id=task_id)
        st.session_state.last_retried = task_id
        st.rerun()

enqueue_once() 這個名字不是裝飾。 資料層最好有唯一鍵或 idempotency key, 避免雙擊、斷線重送或多 session 造成重複工作。

七. 第一個 Dialog:Modal 裡也有獨立 Rerun
#

@st.dialog 會把 function 變成 modal dialog。

import streamlit as st
@st.dialog("替任務加註記", width="medium")
def add_note(task_id: str) -> None:
    st.write(f"任務:`{task_id}`")
    note = st.text_area("註記")
    if st.button("保存", type="primary", disabled=not note.strip()):
        st.session_state.notes[task_id] = note.strip()
        st.rerun()
if "notes" not in st.session_state:
    st.session_state.notes = {}
if st.button("編輯 task-42"):
    add_note("task-42")
st.json(st.session_state.notes)

使用者在 text_area 輸入時, 只會 rerun add_note(),不會重跑整個 App。

保存後呼叫 st.rerun()

  1. Dialog 當次執行停止。
  2. Full app rerun。
  3. 因為新一輪沒有再次呼叫 add_note(),dialog 關閉。
  4. 外層 st.json() 顯示最新結果。

這就是很實用的 modal workflow。

八. 做一個不能隨便略過的確認 Dialog
#

刪除操作可以設定 dismissible=False, 要求使用者按明確按鈕結束流程:

import streamlit as st
@st.dialog(
    "確認刪除",
    width="small",
    dismissible=False,
    icon="🚨",
)
def confirm_delete(task_id: str) -> None:
    st.warning(f"即將刪除 `{task_id}`。")
    confirmed = st.checkbox("我知道這個操作無法復原")
    col1, col2 = st.columns(2)
    if col1.button("取消", use_container_width=True):
        st.rerun()
    if col2.button(
        "刪除",
        type="primary",
        disabled=not confirmed,
        use_container_width=True,
    ):
        st.session_state.deleted.add(task_id)
        st.session_state.last_message = f"已刪除 {task_id}"
        st.rerun()
if "deleted" not in st.session_state:
    st.session_state.deleted = set()
if st.button("刪除 task-42"):
    confirm_delete("task-42")
if message := st.session_state.pop("last_message", None):
    st.success(message)

dismissible=False 只是 UI 行為,並不是安全機制。 真正的刪除 API 仍然必須驗證登入身分、角色權限與資料所有權。

九. Fragment + Dialog:即時面板裡開確認視窗
#

兩個工具可以自然搭配:

import streamlit as st
TASKS = ["task-17", "task-23", "task-42"]
@st.dialog("重新執行任務", dismissible=False)
def retry_dialog(task_id: str) -> None:
    st.write(f"確定要重新執行 `{task_id}`?")
    if st.button("返回"):
        st.rerun()
    if st.button("確認重新執行", type="primary"):
        st.session_state.retry_request = task_id
        st.rerun()
@st.fragment(run_every="10s")
def failed_tasks_panel() -> None:
    st.subheader("失敗任務")
    for task_id in TASKS:
        col1, col2 = st.columns([3, 1])
        col1.code(task_id)
        if col2.button("Retry", key=f"retry-{task_id}"):
            retry_dialog(task_id)
failed_tasks_panel()
if task_id := st.session_state.pop("retry_request", None):
    st.success(f"已送出重試:{task_id}")

互動順序是:

  1. Panel 自己每十秒更新。
  2. 使用者按 Retry,只 rerun panel。
  3. Panel 呼叫 dialog function。
  4. Dialog 裡的 Widget 只 rerun dialog。
  5. 確認後 full rerun,讓整頁同步。

要注意:同一次 script run 只能呼叫一個 dialog function。 所以不要讓程式在一輪執行裡同時嘗試開兩個 modal。

十. 常見踩雷
#

1. 把 Fragment 當 Cache
#

Fragment 控制「哪段程式執行」,cache 控制「某次計算能否重用」。 慢查詢仍可能需要 st.cache_data,兩者不要混為一談。

2. 在每次 Fragment Rerun 寫入資料
#

顯示狀態可以定期跑,付款、寄信、刪資料不行。 把 mutation 放在明確 button event,資料層再做 idempotency。

3. 期待外層畫面自動同步
#

Session State 的值可以變,fragment 外的舊元素卻不會自動重畫。 需要跨區域同步時,明確呼叫 full st.rerun()

4. 無條件呼叫 Fragment-scoped Rerun
#

scope="fragment" 只能在 fragment rerun 期間使用。 不要在初次 full-app 執行路徑無條件呼叫。

5. 在外部 Container 累積元素
#

跨 container 寫入前先理解清除規則。 優先使用 st.empty() placeholder,或讓 fragment 完整擁有自己的 UI。

6. Dialog 裡使用 Sidebar
#

Dialog function 不支援 st.sidebar。 Modal 應該保持單一、短小、可完成的任務。

7. 把 dismissible=False 當權限控管
#

使用者不能按 X,不代表他通過授權。 安全檢查一定要在 server-side operation 再做一次。

8. Timer 設得太積極
#

run_every="100ms" 不是即時系統架構。 先問資料真的多久變一次,再選更新頻率。

十一. 上線前檢查清單
#

在你把 fragment 與 dialog 送進 production 前,確認:

  • Fragment 內的副作用可重複,或只由明確事件觸發。
  • 跨 fragment 的資料使用 Session State 或持久化 storage。
  • 外層 UI 需要同步時,有明確 full rerun。
  • run_every 頻率符合 API 與資料庫成本。
  • Dialog 關閉、取消、提交三條路徑都處理過。
  • 同一輪執行不會同時呼叫兩個 dialogs。
  • 刪除與敏感操作在後端再次檢查權限。
  • 外部 container 不會在 fragment rerun 後累積內容。
  • Widget keys 唯一且穩定。
  • 自動更新 panel 不會悄悄製造大量 log 或 webhook。

結語:先畫清楚執行邊界
#

Streamlit 的 full rerun 模型沒有錯。 它用很低的心智負擔,換來非常快的 App 開發速度。

當 App 長大,真正需要的也不是到處塞 st.rerun(), 而是把互動切成幾個清楚的執行邊界:

  • 哪一塊可以自己更新?
  • 哪個流程應留在 modal?
  • 哪個狀態要跨邊界保存?
  • 哪個操作完成後真的需要全頁同步?

st.fragment 讓小區塊獨立呼吸, st.dialog 讓短流程留在自己的舞台。

邊界清楚以後,App 不只比較快, 也會比較不容易發生「按一下,整個世界又重跑一次」的迷惑場面。✨

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 109: 本文

相關文章

Streamlit AppTest 實戰:用 pytest 測 Widgets、Session State 與 CI
·9 分鐘· loading · loading
Python Streamlit AppTest Pytest Testing Session State Ci
Streamlit + SQLModel 實戰:做一個本機 CRUD 小後台
·9 分鐘· loading · loading
Python Streamlit SQLModel SQLite CRUD Developer-Tools
FastAPI + Streamlit 實戰:API 後端與互動前端分工
·9 分鐘· loading · loading
Python FastAPI Streamlit Api Frontend Developer-Tools
Python Alembic 實戰:資料庫 Migration、版本控管與團隊協作
·9 分鐘· loading · loading
Python Alembic SQLAlchemy Database Migration Developer-Tools
Python pytest fixtures 進階:conftest、factory 與測試資料管理
·8 分鐘· loading · loading
Python Pytest Fixtures Testing Conftest Monkeypatch Developer-Tools
uv 管理 Python 版本:Install、Find、Pin、Upgrade 與直譯器選擇
·9 分鐘· loading · loading
Python Uv Python Versions Interpreter Virtualenv Developer-Tools