Streamlit 核心 (1.58.x) — 執行模型、狀態、重新整理
唯一的心智模型: 每次使用者互動(觸發任何 widget),Streamlit 都會從頭到尾重新執行整個腳本。沒有事件處理器、沒有持續的區域變數、也沒有主迴圈。一次執行是 st.session_state 加上目前 widget 值的純函數。以下所有內容都源自於此。
每個說法的可執行證明:此技能資料夾中的 st_core_lab.py(執行 python -m streamlit run ~/.roo/skills/st-core/st_core_lab.py)。已在 Streamlit 1.58 上驗證,API 在 1.55 上檢查過:此技能中的所有內容在這兩個版本上都存在(st.fragment(run_every=) 需要 ≥1.37)。
硬性規則(違反 = 典型的壞掉應用程式)
- 永遠不要在腳本層級使用
while True:。腳本必須在每次執行時結束。若要有一個即時更新的區段,請使用@st.fragment(run_every="2s")(見下方)。只有在一次執行內進行有限的工作(進度條、st.write_stream)時,才可以在迴圈內部使用迴圈。 - 永遠不要無條件呼叫
st.rerun()(或在下次執行仍然成立的條件下)—— Streamlit 沒有迴圈保護;它會無止盡地旋轉。每個st.rerun()都必須放在一個你會先翻轉的狀態守衛之後:if st.session_state.get("needs_refresh"): st.session_state.needs_refresh = False # 在 rerun 之前翻轉 refresh_data() st.rerun() - 永遠不要使用
time.sleep(n); st.rerun()做自動重新整理。頁面永遠不會穩定:widget 會閃爍,點擊會在 sleep 期間被吃掉。請使用@st.fragment(run_every=...),或者如果「重新整理」只是為了取得更新的資料,使用st.cache_data(ttl=...)。沒有原生的st.autorefresh(已驗證——dir(st)中沒有此項目)。streamlit-autorefresh這個 PyPI 套件是第三方的,它會在每次 tick 重新執行整個腳本(重置所有 widget,搶走焦點);建議使用原生的限定範圍@st.fragment(run_every=...)。 st.experimental_rerun()不存在(已在 1.27+ 中移除)。現在是st.rerun()。同樣地,st.experimental_memo/singleton→st.cache_data/st.cache_resource;st.experimental_fragment→st.fragment;@st.cache也已經移除。- 大多數的
st.rerun()呼叫都是不必要的。Widget 互動已經會觸發 rerun,而且on_click/on_changecallback 會在該次 rerun之前執行,因此它們的狀態寫入在沒有要求另一次 rerun 的情況下就可見。合理的使用情況:關閉st.dialog、應用在渲染位置下方所做的程式化狀態更改(規則 2 的模式)。
st.button 是瞬間的——排名第一的輸出消失錯誤
st.button() 只會在一次執行中回傳 True(點擊的那一次),然後又回傳 False。所以 if st.button("run"): st.write(result) 會顯示結果,直到使用者碰到任何其他東西,然後結果就會消失。而且巢狀按鈕永遠不可能同時為真:點擊內層按鈕會讓外層再次變成 False(已驗證)。
# 錯誤的寫法 # 正確的寫法——透過狀態持久化
if st.button("compute"): if st.button("compute"):
r = model() st.session_state.r = model()
st.write(r) # 下次執行就消失 if "r" in st.session_state:
st.write(st.session_state.r)
多步驟流程:在 session_state 中使用一個 step 鍵 + 按照規則 2 使用 st.rerun(),永遠不要按鈕裡面再放按鈕。
session_state 規則
- 初始化一次:
st.session_state.setdefault("k", default)(或if "k" not in st.session_state:的形式)。在頂端、widget 之前執行。 - 在本次執行中,如果在建立
key="k"的 widget之後才寫入st.session_state.k,會拋出StreamlitAPIException: cannot be modified。要以程式方式設定 widget,請在 widget 那一行之前寫入該鍵(通常做法:設定一個旗標 +st.rerun(),然後在下次執行的頂端套用該寫入)——或者在 callback 中執行,這永遠是安全的。 - 如果
key已經存在於 session_state 中,value=會被忽略——狀態優先;唯一的痕跡是在伺服器日誌中的一個警告(「created with a default value but also had its value set via the Session State API」),UI 中沒有任何顯示。不要同時傳入兩者並期望預設值會被套用。 - 某次執行中沒有被執行的 widget 那一行,會遺失它的狀態(條件隱藏的頁籤、折疊的分支——已驗證:設定為 8 的滑桿在回來時變成 0)。為了在隱藏時存活下來,請鏡像到你自己的鍵:
st.slider(..., key="_w", on_change=lambda: st.session_state.update(w=st.session_state._w))並透過「寫入在 widget 之前」的模式將它餵回來。(st.tabs是安全的——所有頁籤的程式碼每次都會執行,無論是否隱藏;這也是為什麼重的頁籤需要快取的原因。) - 兩個具有相同類型和參數的 widget 會拋出
StreamlitDuplicateElementId——請傳入不同的key=。在迴圈中的 widget:key=f"row-{i}"。
Callback 勝過手動 rerun
在渲染點下方改變的狀態,會在一次執行中讀取到過時的值(已驗證:if st.button(...) 分支是在它上方的計數器被繪製之後才執行的)。Callback 解決了順序問題——它們在腳本重新執行之前執行:
st.session_state.setdefault("count", 0)
def inc(): st.session_state.count += 1
st.button("+1", on_click=inc) # count 在同一次執行中就是新的
st.metric("count", st.session_state.count)
on_change 在輸入/選取元件上也是同樣運作。Callback 可以寫入任何狀態鍵(甚至是 widget 的,因為它的那一行尚未執行)。不需要 st.rerun()。
- 在 callback 內部呼叫
st.rerun()是無效操作(已驗證):它會顯示一個使用者可見的警告Calling st.rerun() within a callback is a no-op.並且中止其餘的 callback 主體——沒有額外的 rerun,也沒有例外。點擊已經安排了 rerun;在 callback 中只需修改session_state然後回傳。在那裡加上st.rerun()「以套用更改」會默默地截斷你的 callback。
表單 — 停止每次按鍵都 rerun
每次 widget 互動都會重新執行腳本;N 個輸入代表使用者在填寫它們時會產生 N 次 rerun(每次都重新打你的資料層)。將它們批次處理:
with st.form("params"):
a = st.slider("a", 0, 10)
b = st.selectbox("b", opts)
go = st.form_submit_button("套用")
if go: run_query(a, b)
在提交之前不會有任何 rerun(已驗證:執行計數器總共 +1)。在表單內部:不能有 st.button,除了提交按鈕之外不能有每個 widget 的 callback。
「我打字時它一直在 rerun」幾乎從來都不是因為按鍵。 Widget 在不同事件上提交(Streamlit 前端行為):text_input/text_area/number_input 在按下 Enter 或失去焦點時觸發,slider/select_slider 在放開時觸發——不是每次按鍵、不是拖曳到一半;checkbox/radio/selectbox/toggle 會立即觸發。一次提交的更改 = 一次 rerun,而且當值不變時前端不會發送任何東西。所以打字時持續 rerun 意味著一個同時進行的全應用自動重新整理(計時器 / st_autorefresh / sleep+rerun)搶走了焦點——將該重新整理限定在一個不包含該輸入的 @st.fragment 中,或者將輸入包在 st.form 中。不要對按鍵做防抖;Streamlit 不需要。
快取 — 大多數「糟糕的重新整理」其實是這個
@st.cache_data— DataFrame/可序列化的結果。每次呼叫回傳一個新的副本(修改安全,已驗證is為False)。ttl="15m"用於應該要重新整理的資料;在 ↻ 按鈕上使用Foo.clear()或st.cache_data.clear()。@st.cache_resource— 連線、模型、執行緒。同一個物件回傳給所有工作階段(已驗證)——永遠不要對每個使用者修改它,如果需要則加入鎖。- 引數必須是可雜湊的,並構成快取鍵;將無法雜湊的引數前綴加上
_以排除它們(def load(_conn, day):)。 - 沒有快取,每次 widget tick 都會重新執行腳本中的每個載入——應用程式「感覺卡住了」,因為每次點擊都要花費數秒。先快取,再除錯 rerun。
正確的即時更新 — st.fragment
@st.fragment(run_every="2s") # 整數秒 / timedelta / "2s" 字串
def ticker():
st.metric("last", get_price()) # 只有這個函式會重新執行
ticker()
- 頁面的其餘部分不會重新執行(已驗證:外層執行計數器在 fragment 滴答時凍結)。Fragment 內部的 widget 只會重新執行 fragment 範圍;
st.rerun(scope="fragment")只重新啟動它。 - 陷阱(已驗證):如果 fragment 主體是作為全應用執行的一部分(首次繪製)在執行,
scope="fragment"會拋出例外——請保護它,或者翻轉狀態並讓run_every接手。 - Fragment 可以自由地讀寫 session_state;若要將更改推出到整個頁面,請寫入狀態然後
st.rerun()(scope為"app",預設值)。 - 從外部腳本停止或重新設定
run_everyfragment 的計時——內部的 widget 無法做到。run_every只會在全應用執行時重新讀取,所以在 fragment 內部的間隔滑桿或停止按鈕(只重新執行 fragment)永遠不會重新設定計時器(已驗證)。從外部控制 fragment 的呼叫:在全應用重新執行時,secs = st.slider("間隔", 1, 10, 2) # 在 fragment 外部 run = st.session_state.get("run", True) tick = st.fragment(run_every=secs if run else None)(_body) tick() if st.button("停止"): st.session_state.run = False; st.rerun()run_every=None(或不呼叫它)不會發出自動重新整理訊息,計時器就會停止。 - Fragment 的回傳值只在全應用執行時才會到達呼叫者(已驗證)。在
run_every滴答或內部的 fragment widget 更改時,外部腳本不會被重新執行,所以data = ticker()會保留過時的值,下游圖表永遠不會更新。將即時資料透過在 fragment 內部寫入st.session_state來推出(st.session_state.data = fetch()),在外部讀取——或者直接在 fragment 內部繪製圖表。不要把 fragment 當作一個普通的函式,並期望能在不同 tick 之間賦值使用。 - Fragment 是在應用程式的單一執行緒上執行——它不是背景執行緒。 在它內部使用
time.sleep會在那段時間內凍結整個頁面;讓run_every來決定節奏,永遠不要 sleep。巢狀 fragment 是允許的(已驗證),但每個都是一個獨立的 rerun 範圍——子 tick 不會重新執行它的父層,所以將run_every放在擁有資料的 fragment 上;不要為了同樣的資料巢狀計時器。對於真正的背景工作(提交一個慢速任務、繼續瀏覽、等待結果)→ [st-jobs]。 - 不要在 fragment 內部和外部建立相同鍵的 widget。
- Fragment 在滴答,但值從不改變?你的即時讀取被快取了。 一個沒有
ttl的@st.cache_data載入器只會執行其主體一次(已驗證),並在每次 tick 回傳那個凍結的值——run_every會重新執行函式,但快取短路了它。這是「我的 fragment 不會重新整理」的頭號原因。修正:不要快取即時讀取,或者給它一個 ≤ 重新整理頻率的ttl(@st.cache_data(ttl="2s")),或者每次 tick 變化一個引數。
Fragment 作為一個限定範圍的工作盒 — 現在擷取,稍後使用(無全應用 rerun)
沒有 run_every 的 fragment 是一個頁面內的盒子,其內部的 widget 只會重新執行該盒子——這是一種模式,用於「在這裡查詢參考資料,在後續動作中使用它」,而不需要在每次查詢時重新執行整個頁面:
@st.fragment
def fetch_box(): # 這裡的點擊只會重新執行該盒子
with st.form("lookup"): # fragment 內部的表單:合法(已驗證)
code = st.selectbox("實體", codes, key="box_code")
go = st.form_submit_button("擷取詳細資料")
if go:
st.session_state.details = get_details(code) # 透過 STATE 交接
st.success(f"已擷取 {code}")
fetch_box()
if st.button("執行請求"): # 外部 → 它的點擊就是全應用 rerun,
d = st.session_state.get("details") # 會讀取暫存的詳細資料
if d is None: st.warning("請先擷取")
else: run_request(d)
- 消費者不需要
st.rerun():它自己的點擊就是一次完整的執行,會讀取最新的狀態(已驗證)。只有當外部頁面必須立即顯示已擷取的資料(在下次點擊之前)時,你才會在寫入狀態後在 fragment 內部執行st.rerun()—— 代價是一次全應用執行。 - 透過
session_state交接,永遠不要使用 fragment 的回傳值(在僅限盒子的 rerun 之間會過時——見上方子彈)。使用@st.cache_data來快取查詢,這樣重新擷取同一個實體就是免費的。
工作中的狀態(在一次執行內——允許迴圈)
ph = st.empty() # 佔位符,在原地覆寫
for i, step in enumerate(steps):
ph.text(f"{i+1}/{len(steps)} {step.name}")
step.run()
ph.empty()
還有 st.progress、st.status("...", expanded=True)、st.spinner、st.toast。這些會在一次執行內動畫——不涉及 rerun,沒有迴圈風險。
除錯檢查清單 — 「卡在迴圈 / 持續重新整理」
- 搜尋
st.rerun——每個是否都在一個「先翻轉」的狀態守衛之後(規則 2)? - 搜尋腳本層級的
while True/time.sleep→ 改用 fragment(規則 1/3)。 - 一個 widget 寫入一個狀態鍵,而另一行又寫回該鍵 → 形成乒乓 rerun;每個鍵只保留一個寫入者。
key=衝突或每次執行都變化的 f-string 鍵(例如包含time.time())——每次執行一個新鍵 = widget 每次執行都重置。- 未快取的慢速載入讓正常的 rerun看起來像是迴圈——加上
st.cache_data和一個執行計數器(st.session_state.runs += 1)來看看實際發生了什麼。 - 即時區段在自己的計時器上重新整理,但顯示過時的值 → 一個沒有
ttl的@st.cache_data函式提供了它;fragment 重新執行,但快取回傳了舊值。加上ttl或者不要快取即時讀取。