一. 前言:內建 Widget 不夠用時,先別硬湊 #
Streamlit 的魅力,是不用先蓋一整套前端專案,幾行 Python 就有互動介面。 但產品長大後,總會遇到內建元件很難漂亮解決的需求:
- 想做可搜尋、可排序、可多選的標籤面板;
- 想接一個既有 JavaScript 視覺化 library;
- 想同時回傳「目前狀態」和「剛才發生的事件」;
- 想把元件包成 Python 套件,給多個 App 共用。 這時候 Custom Components 才是正確邊界。 本文用 Components v2 做一個標籤選擇器:
- Python 把選項與預設值送到瀏覽器;
- JavaScript 回傳目前選取狀態;
- 使用者按下確認時,再送出一次性事件;
- Python 可以主動清空或改寫選取值;
- 最後把元件打包,並分層測試。 如果你只是想顯示一段固定 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,確認:
- component asset 回傳 200;
- 多個 instance 不會撞 key;
- state 在普通 rerun 後仍存在;
- trigger 不會在下一次 rerun 重播;
- light / dark theme 都能閱讀;
- keyboard focus 與 ARIA 狀態正常。 既有的 AppTest 很適合測一般 Streamlit widget 與 Session State;custom frontend 的 DOM 互動,仍應交給 frontend test 或真正的 browser E2E。 不要因為 Python 測試綠燈,就假設 bundle、DOM 和資產路徑也都正常。
十八. 常見踩雷 #
- 每次 wrapper 都重新註冊: registration 放 module scope,wrapper 只負責 mounting。
- 把 state 和 event 混用: 目前選取用 state;確認、刪除、下載等一次性動作用 trigger。
- 改 Session State 卻沒更新
data: JavaScript 看不到 Python 的心念,要在 mount 時送入新值。 - Frontend 每次 render 都回傳 state: 這會製造 rerun 迴圈,只在值真的改變時送回。
- 用
innerHTML顯示外部資料: 優先用textContent,style isolation 不是安全 sandbox。 - Wheel 沒包含 build assets: 要收進
frontend/build/**/*和 component manifest。 - 只測開發模式: editable install 和 production wheel 不同,發布前要做乾淨環境 smoke test。
結語:把 Component 當成一份小型 API #
Custom Components 的難點不是 JavaScript 語法,而是兩個 runtime 之間的契約。 拍拍君的實用順序是:
- 先用 inline v2 component 驗證互動;
- 明確區分 Python → JS 的
data; - 用 state 表示持續狀態,用 trigger 表示一次性事件;
- 透過 wrapper 統一型別、預設值與 Session State 同步;
- 複雜後再搬進官方 package template;
- 分別測純 Python、frontend 邏輯、bundle 與 wheel。 當元件真的變成一份有版本、有測試的 API,Streamlit App 才不會被一段神祕 JavaScript 綁架。前端可以華麗,但邊界要冷靜。