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

Streamlit Custom Components:雙向事件、狀態同步、打包與測試

·8 分鐘· loading · loading · ·
Python Streamlit Custom Components JavaScript State Testing Packaging
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 134: 本文

featured

一. 前言:內建 Widget 不夠用時,先別硬湊
#

Streamlit 的魅力,是不用先蓋一整套前端專案,幾行 Python 就有互動介面。 但產品長大後,總會遇到內建元件很難漂亮解決的需求:

  • 想做可搜尋、可排序、可多選的標籤面板;
  • 想接一個既有 JavaScript 視覺化 library;
  • 想同時回傳「目前狀態」和「剛才發生的事件」;
  • 想把元件包成 Python 套件,給多個 App 共用。 這時候 Custom Components 才是正確邊界。 本文用 Components v2 做一個標籤選擇器:
  1. Python 把選項與預設值送到瀏覽器;
  2. JavaScript 回傳目前選取狀態;
  3. 使用者按下確認時,再送出一次性事件;
  4. Python 可以主動清空或改寫選取值;
  5. 最後把元件打包,並分層測試。 如果你只是想顯示一段固定 HTML,Custom Component 可能太重;但只要資料要跨越 Python / JavaScript 邊界,就值得先把協定設計清楚。

二. 先選 v2,不要從舊範例開始抄
#

網路上很多教學仍使用 streamlit.components.v1.declare_component()。 那套 v1 API 仍受支援,但新專案應優先使用 v2:

面向 Components v2 Components v1
DOM 整合進頁面,可用 Shadow DOM 隔離樣式 iframe 隔離
回傳資料 多個 state 與 trigger 主要是一個 component value
資料交換 JSON、bytes、Arrow 以基本 JSON 為主
開發方式 可 inline,也可 package-based 通常從 template 建前端
新專案建議 推薦 相容舊元件
v2 最大的觀念差異,不是少寫幾行 code,而是把輸出拆成:
  • state:現在是什麼狀態;
  • trigger:剛剛發生了什麼事件。 這兩者混在一起,rerun 後就很容易重複執行副作用。

三. 安裝與最小專案
#

先建立乾淨環境:

uv init streamlit-tag-picker
cd streamlit-tag-picker
uv add 'streamlit>=1.51'
uv add --dev pytest
uv run streamlit run app.py

開發初期先用 inline component 很方便,不必立刻加入 Node.js、Vite 和套件建置。 等協定穩定、需要 TypeScript 或要發布時,再搬進官方 template。

四. 先定義跨邊界協定
#

拍拍君建議在寫 UI 前,先把輸入與輸出寫成表格:

方向 名稱 型別 用途
Python → JS options list[str] 可選標籤
Python → JS selected list[str] 程式主動指定的狀態
JS → Python selected state list[str] 使用者目前選取
JS → Python submitted trigger object 一次性的確認事件
data 是 Python 傳給 JavaScript 的輸入。
setStateValue() 和 setTriggerValue() 則是反方向的橋。
這個協定故意不傳整段 HTML,也不讓前端猜 Python 的資料結構。邊界愈小,愈容易測試與升級。

五. 第一個 Inline Component
#

把 component registration 放在 module scope,避免每次呼叫 wrapper 都重複註冊:

# tag_picker.py
from collections.abc import Callable, Iterable
import streamlit as st
HTML = """
<section class="picker">
  <div class="options" role="group" aria-label="Tag picker"></div>
  <button class="submit" type="button">確認選取</button>
</section>
"""
CSS = """
.picker { display: grid; gap: 0.75rem; font-family: var(--st-font); }
.options { display: flex; flex-wrap: wrap; gap: 0.5rem; }
.tag, .submit {
  border: 1px solid var(--st-border-color);
  border-radius: 999px;
  padding: 0.4rem 0.75rem;
  background: var(--st-secondary-background-color);
  color: var(--st-text-color);
  cursor: pointer;
}
.tag[aria-pressed="true"] {
  border-color: var(--st-primary-color);
  background: color-mix(in srgb, var(--st-primary-color) 18%, transparent);
}
.submit { width: fit-content; border-radius: 0.5rem; }
"""

HTML 只提供固定骨架,動態資料全部走 data。 這樣不需要用 Python 字串插值把使用者內容塞進 HTML,也能減少 XSS 風險。

六. JavaScript:State 與 Trigger 分開送
#

接著加入前端行為:

JS = r"""
export default function(component) {
  const { data, parentElement, setStateValue, setTriggerValue } = component;
  const container = parentElement.querySelector(".options");
  const submit = parentElement.querySelector(".submit");
  const selected = new Set(data.selected ?? []);
  container.replaceChildren();
  for (const option of data.options ?? []) {
    const button = document.createElement("button");
    button.type = "button";
    button.className = "tag";
    button.textContent = option;
    button.setAttribute("aria-pressed", String(selected.has(option)));
    button.onclick = () => {
      selected.has(option) ? selected.delete(option) : selected.add(option);
      button.setAttribute("aria-pressed", String(selected.has(option)));
      setStateValue("selected", [...selected]);
    };
    container.appendChild(button);
  }
  submit.onclick = () => {
    setTriggerValue("submitted", {
      selected: [...selected],
      count: selected.size,
    });
  };
}
"""
_tag_picker = st.components.v2.component(
    "dailypypy_tag_picker",
    html=HTML,
    css=CSS,
    js=JS,
)

這裡有兩條不同生命週期:

  • 每次點標籤,用 setStateValue("selected", ...) 保存持續狀態;
  • 每次按確認,用 setTriggerValue("submitted", ...) 發出一次事件。 state 會跨 rerun 保留;trigger 只在一次 rerun 裡有值,之後回到 None。 如果把送出動作也放進 state,下一次無關的 rerun 可能再次看到舊資料,誤寄通知或重複寫入資料庫。

七. Python Wrapper:把底層協定藏起來
#

使用者不需要知道 raw component 的 default 字典和 callback 命名規則。 我們提供一個有型別、有驗證的 wrapper:

def _normalize(options: Iterable[str]) -> list[str]:
    cleaned = [item.strip() for item in options if item.strip()]
    return list(dict.fromkeys(cleaned))

def tag_picker(
    label: str,
    options: Iterable[str],
    *,
    default: Iterable[str] = (),
    key: str,
    on_selected_change: Callable[[], None] | None = None,
    on_submitted_change: Callable[[], None] | None = None,
):
    choices = _normalize(options)
    initial = [item for item in _normalize(default) if item in choices]
    component_state = st.session_state.get(key, {})
    selected = component_state.get("selected", initial)
    selected = [item for item in selected if item in choices]
    return _tag_picker(
        data={
            "label": label,
            "options": choices,
            "selected": selected,
        },
        default={"selected": initial},
        key=key,
        on_selected_change=on_selected_change or (lambda: None),
        on_submitted_change=on_submitted_change or (lambda: None),
    )

每個 state / trigger 都要對應 on_<name>_change callback。 即使目前不需要額外邏輯,也傳入 lambda: None,讓回傳物件穩定保有對應 attribute。 注意 st.session_state[key] 對 custom component 而言是個字典,不是像一般 widget 那樣直接等於單一值。

八. 在 App 使用元件
#

現在 app.py 不需要碰 JavaScript:

import streamlit as st
from tag_picker import tag_picker
def record_submit() -> None:
    event = st.session_state["topics"].get("submitted")
    if event:
        st.toast(f"收到 {event['count']} 個標籤")
st.title("拍拍君文章分類器")
result = tag_picker(
    "文章標籤",
    ["Python", "Streamlit", "LLM", "Testing", "Tooling"],
    default=["Python"],
    key="topics",
    on_submitted_change=record_submit,
)
st.write("目前選取:", result.selected)
if result.submitted:
    st.json(result.submitted)

callback 執行時,最新值已經寫進 Session State;而 component 呼叫回傳後,可以用 result.selected 與 result.submitted 讀取。 這種 wrapper 也讓日後從 inline 換成 package-based component 時,App 端 API 不必跟著大改。

九. Python 主動更新前端:避免同步迴圈
#

Custom component 不會因為你改了 Session State,就自動把值送回 JavaScript。 要由 Python 主動控制前端,必須在下一次 mount 時,把最新狀態重新放進 data。 App 可以加一個清除按鈕:

if st.button("清空標籤"):
    current = st.session_state.get("topics", {})
    current["selected"] = []
    st.session_state["topics"] = current
tag_picker(
    "文章標籤",
    ["Python", "Streamlit", "LLM"],
    key="topics",
)

前端收到新 data.selected 後才更新畫面。 不要在每次 render 無條件呼叫 setStateValue();那會造成 Python rerun → JS 回傳 → Python rerun 的循環。 真正需要回傳時,再由使用者事件觸發。

十一. Style Isolation 不是 Security Sandbox
#

v2 預設 isolate_styles=True,component 會放在 Shadow DOM 裡。 它能避免 component CSS 污染 App,但不是安全邊界;v2 JavaScript 仍能接觸主頁面 DOM。不要把使用者輸入或 LLM 輸出拼進 html、css、js,動態文字優先用 textContent。Component 是 plugin,不是關住不可信 code 的沙盒。

十二. 何時搬到 Package-Based Component?
#

Inline 適合驗證概念,但以下情況應該進入正式套件:

  • 需要 TypeScript 型別;
  • 依賴 React、D3、Chart.js 等 npm package;
  • 前端已拆成多個 module;
  • 多個 App 或團隊要共用;
  • 需要 bundle optimization、版本與發布流程。 用官方 v2 template 起步:
uvx --from cookiecutter cookiecutter \
  gh:streamlit/component-template \
  --directory cookiecutter/v2

Template 會把 Python API、component manifest、TypeScript source、Vite 設定與 build assets 分開。不要手刻整份 scaffold;官方版本會跟著 API 更新,少漏掉很多打包細節。

十三. Package Manifest 與 Asset
#

套件內層 pyproject.toml 要告訴 Streamlit 資產在哪裡:

[project]
name = "streamlit-tag-picker"
version = "0.1.0"
[[tool.streamlit.component.components]]
name = "tag_picker"
asset_dir = "frontend/build"

Python 端用 qualified name 註冊,並用 glob 對應含 hash 的 bundle:

import streamlit as st
_tag_picker = st.components.v2.component(
    "streamlit-tag-picker.tag_picker",
    html='<div class="picker-root"></div>',
    js="index-*.js",
    css="styles-*.css",
)

Vite 的 production build 通常會產生:

frontend/build/
├── index-a1b2c3d4.js
└── styles-e5f6g7h8.css

glob 必須剛好匹配一個檔案;零個或多個都會報錯。 而且 asset_dir 內容會由 Streamlit 公開提供,絕對不要把 token、.env 或內部設定放進去。

十四. 打包與本機驗證
#

開發時通常開兩個 terminal:

# Terminal 1:監看 frontend
cd streamlit_tag_picker/frontend
npm install
npm run dev
# Terminal 2:執行 example app
uv pip install -e .
uv run streamlit run example.py

發布前建立 production assets 與 wheel:

cd streamlit_tag_picker/frontend
npm run build
cd ../..
uv build

接著檢查 wheel 內容:

unzip -l dist/*.whl | grep -E 'frontend/build|pyproject.toml'

只在 editable install 可用,安裝 wheel 後壞掉,最常見原因就是 package data 沒收進去。

十五. 測試一:先測純 Python 契約
#

不要一開始就把所有測試塞進 browser。 先測 deterministic normalization:

# tests/test_tag_picker.py
from tag_picker import _normalize

def test_normalize_removes_blank_and_duplicate_options():
    assert _normalize([" Python ", "", "Streamlit", "Python"]) == [
        "Python",
        "Streamlit",
    ]

同樣把「刪除未知預設值」抽成純函式測試。這一層不啟動 Streamlit,速度快,也最適合覆蓋空值、重複值與刪除選項等 edge cases。

十六. 測試二:Frontend 邏輯不要只靠肉眼
#

Package-based component 可用 Vitest 測 selection toggle 等純 TypeScript 邏輯,再用 DOM 測試驗證 renderer。 DOM 測試至少要確認:

  • aria-pressed 和 state 一致;
  • 點擊時只送一次 setStateValue;
  • 確認按鈕送的是 trigger,不是永久 state;
  • 新的 data.selected 能更新畫面;
  • unmount 時 event listener 或 timer 有清理。 如果 component 註冊了 observer、timer 或全域 listener,JavaScript default function 應回傳 cleanup function。

十七. 測試三:Wheel 與 App Smoke Test
#

最後一層才測整合:

npm run typecheck
npm test -- --run
npm run build
uv build

建立全新環境,安裝 wheel:

uv venv .smoke-venv
uv pip install --python .smoke-venv/bin/python dist/*.whl
.smoke-venv/bin/python -c 'import streamlit_tag_picker'

再啟動 example.py,確認:

  1. component asset 回傳 200;
  2. 多個 instance 不會撞 key;
  3. state 在普通 rerun 後仍存在;
  4. trigger 不會在下一次 rerun 重播;
  5. light / dark theme 都能閱讀;
  6. keyboard focus 與 ARIA 狀態正常。 既有的 AppTest 很適合測一般 Streamlit widget 與 Session State;custom frontend 的 DOM 互動,仍應交給 frontend test 或真正的 browser E2E。 不要因為 Python 測試綠燈,就假設 bundle、DOM 和資產路徑也都正常。

十八. 常見踩雷
#

  1. 每次 wrapper 都重新註冊: registration 放 module scope,wrapper 只負責 mounting。
  2. 把 state 和 event 混用: 目前選取用 state;確認、刪除、下載等一次性動作用 trigger。
  3. 改 Session State 卻沒更新 data: JavaScript 看不到 Python 的心念,要在 mount 時送入新值。
  4. Frontend 每次 render 都回傳 state: 這會製造 rerun 迴圈,只在值真的改變時送回。
  5. 用 innerHTML 顯示外部資料: 優先用 textContent,style isolation 不是安全 sandbox。
  6. Wheel 沒包含 build assets: 要收進 frontend/build/**/* 和 component manifest。
  7. 只測開發模式: editable install 和 production wheel 不同,發布前要做乾淨環境 smoke test。

結語:把 Component 當成一份小型 API
#

Custom Components 的難點不是 JavaScript 語法,而是兩個 runtime 之間的契約。 拍拍君的實用順序是:

  1. 先用 inline v2 component 驗證互動;
  2. 明確區分 Python → JS 的 data;
  3. 用 state 表示持續狀態,用 trigger 表示一次性事件;
  4. 透過 wrapper 統一型別、預設值與 Session State 同步;
  5. 複雜後再搬進官方 package template;
  6. 分別測純 Python、frontend 邏輯、bundle 與 wheel。 當元件真的變成一份有版本、有測試的 API,Streamlit App 才不會被一段神祕 JavaScript 綁架。前端可以華麗,但邊界要冷靜。

延伸閱讀
#

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

相關文章

Streamlit AppTest 實戰:用 pytest 測 Widgets、Session State 與 CI
·9 分鐘· loading · loading
Python Streamlit AppTest Pytest Testing Session State Ci
uv Resolver 除錯:版本衝突、Markers、Constraints 與 Lockfile 診斷
·7 分鐘· loading · loading
Python Uv Dependency Resolver Lockfile Packaging Debugging
Python contextvars 實戰:Async Context、結構化 Logging 與隔離測試
·8 分鐘· loading · loading
Python Contextvars Asyncio Logging Concurrency Testing Standard-Library
Polars Rolling / Dynamic Windows:時間窗聚合、邊界語意與 QA
·6 分鐘· loading · loading
Python Polars Time-Series Rolling-Window Dynamic-Window Data-Engineering Testing
Textual Command Palette + Actions:快捷鍵、命令搜尋與可測試操作
·8 分鐘· loading · loading
Python Textual TUI Command-Palette Actions Key-Bindings Testing
Streamlit 效能診斷:Rerun、Cache Hit、記憶體與 Latency Profiling
·8 分鐘· loading · loading
Python Streamlit Performance Profiling Cache Latency Memory