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 預設的思考方式很單純:
- 使用者操作 Widget。
- 後端收到新值。
- Script 從上到下重新執行。
- 前端依照新結果更新。
這個模型讓一般 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():
- Dialog 當次執行停止。
- Full app rerun。
- 因為新一輪沒有再次呼叫
add_note(),dialog 關閉。 - 外層
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}")
互動順序是:
- Panel 自己每十秒更新。
- 使用者按 Retry,只 rerun panel。
- Panel 呼叫 dialog function。
- Dialog 裡的 Widget 只 rerun dialog。
- 確認後 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 不只比較快, 也會比較不容易發生「按一下,整個世界又重跑一次」的迷惑場面。✨