ARTICLE DETAIL

资讯详情

深耕网站建设与运营推广的一线实战洞察。

Streamlit 布局容器状态持久化深入解析:让 st.tabs、st.expander、st.popover 在重跑后“记住“用户状态

Streamlit 布局容器状态持久化深入解析:让 st.tabs、st.expander、st.popover 在重跑后“记住“用户状态 Streamlit 布局容器状态持久化深入解析让 st.tabs、st.expander、st.popover 在重跑后记住用户状态【免费下载链接】streamlitStreamlit — A faster way to build and share data apps.项目地址: https://gitcode.com/gh_mirrors/st/streamlitStreamlit 应用每次交互都会触发脚本重跑当布局容器st.tabs、st.expander、st.popover上方的条件元素发生变化时容器在渲染树中的 delta path 会偏移导致 React 组件 remount用户选中的标签页、展开的折叠面板、打开的弹层瞬间复位到默认状态。本篇文章基于仓库内 tech-spec.md 技术方案结合后端与前端源码完整拆解通过key提供稳定身份 前端elementStates状态存储的解决方案读者将掌握问题成因、Block.id的生成机制、前端状态读取/写入链路以及key与on_change两种模式的行为边界。问题背景条件元素导致布局容器失忆当前行为与根因在 Streamlit 中每次用户交互都会触发脚本完整重跑rerun前端通过 delta 协议增量更新渲染树。布局容器tabs / expander / popover在渲染树中的位置由其 delta path 唯一标识。当容器上方存在条件渲染元素时一旦该元素在两次重跑之间出现或消失容器自身的 delta path 就会发生偏移st.tabs上方的条件元素切换 → tabs 组件 remount → 当前激活标签页重置为默认st.expander上方的条件元素切换 → 折叠面板 remount → 恢复为expanded指定的默认状态st.popover上方的条件元素切换 → 弹层 remount → 弹层关闭。spec 中给出了一个非常典型的复现场景见 tech-spec.mdif st.toggle(Show summary): st.write(Here is a summary of the data) # 当 toggle 变化时st.write 在 tabs 上方出现/消失 # 使 tabs 的 delta path 发生偏移 → tabs remount → 激活标签页重置为默认 tab1, tab2, tab3 st.tabs([Overview, Details, Raw Data]) with tab1: st.write(Overview content) with tab2: st.dataframe(df) # 用户原本正在查看这个标签页 with tab3: st.json(data)这个现象对应的用户诉求来自上游 issuespec 中记录为 #8239希望改进st.tabs与st.expander的前端状态/挂载处理。本方案同时覆盖三者的同类问题激活标签页重置、折叠面板展开状态重置、以及弹层打开状态重置。方案范围界定本 spec 只覆盖无状态passive容器——即on_changeignore三个元素的默认值且用户显式提供key的场景。而有状态stateful元素on_changererun或传入回调已经通过后端 widget 状态作为事实来源重跑后状态天然保留不受 remount 影响因此不在本方案范围内。另外为没有显式key的元素稳定身份被列为后续跟进调研项不属于本方案交付内容。方案总览稳定身份 前端状态存储方案由两个互补部分组成后端稳定身份Stable Identity—— 用户提供key时通过compute_and_register_element_id计算Block.id。由于key参与哈希计算Block.id与元素在渲染树中的位置无关条件元素无论如何变化ID 都保持稳定前端状态存储State Store—— 复用已有的WidgetStateManager.setElementState/getElementStateAPI把激活标签、展开状态、弹层开关状态存入前端组件 remount 后重新读取恢复。关键设计点在于整个过程零 API 变更、零 widget 注册。Block.id与 widget 的 element-level ID 是两个不同层级的存在设置Block.id不会把容器变成 widget因此不会触发额外重跑也不会往session_state里写入任何东西。后端实现用compute_and_register_element_id生成稳定Block.idID 计算与注册的底层逻辑后端 ID 计算的实现位于 lib/streamlit/elements/lib/utils.py。_compute_element_id的哈希过程如下h util.create_fast_hasher() h.update(element_type.encode(utf-8)) if user_key: h.update(user_key.encode(utf-8)) for k, v in kwargs.items(): h.update(str(k).encode(utf-8)) h.update(str(v).encode(utf-8)) return f{GENERATED_ELEMENT_ID_PREFIX}-{h.hexdigest()}-{user_key}关键点ID 是确定性stable的同一组输入永远产生同一 ID因此元素 ID 不能在两次运行之间漂移ID 格式为$$ID-hash-user_key前缀便于识别user_key明文追加在末尾方便从前端反向解析出 keyuser_key同时进入哈希与明文后缀尽管哈希中已包含 key保证唯一性明文后缀仍保留用于前端提取 CSS 类名。compute_and_register_element_id在计算 ID 之外还会完成注册去重检查与上下文补充ctx get_script_run_ctx() # ... if ctx: # 加入 active_script_hash让不同页面/脚本上的元素拥有不同 ID kwargs_to_use[active_script_hash] ThreadState.get().active_script_hash if dg and not ignore_command_kwargs: kwargs_to_use[form_id] current_form_id(dg) kwargs_to_use[active_dg_root_container] dg._active_dg._root_container这里有两个对本方案至关重要的行为Fragment 兼容性compute_and_register_element_id会把ctx.active_script_hash纳入哈希见 utils.py因此Block.id在完整重跑与 fragment 重跑下都保持稳定key_as_main_identityFalseID 计算会纳入命令参数如tabs列表、width、height、default而非仅依赖 key因此只要参数不变ID 就稳定让 ID 仅基于 key、对参数变化也稳定的key_as_main_identityTrue模式被 spec 记为后续跟踪项#14416。三个容器在 layouts.py 中的实际分支在 lib/streamlit/elements/layouts.py 中st.tabs的实现严格区分两条路径is_stateful on_change ! ignore if is_stateful: element_id compute_and_register_element_id( tabs, user_keykey, key_as_main_identityFalse, dgself.dg, tabstuple(tabs), widthwidth, heightheight, defaultdefault, ) block_id element_id # ... register_widget(...) 有状态路径注册 widgetsession_state 为事实来源 elif key is not None: block_id compute_and_register_element_id( tabs, user_keykey, key_as_main_identityFalse, dgself.dg, ) # ... if is_stateful and element_id is not None: block_proto.tab_container.id element_id # element-level IDwidget 标识 if block_id is not None: block_proto.id block_id # Block.id容器身份st.expander与st.popover采用同样的模式stateful 时同时设置 element-level ID 与Block.idpassive 且带key时仅设置block_proto.id见 layouts.py 与 layouts.py。值得注意st.container此前已经先行采用了这一机制见 layouts.pyblock_proto.id compute_and_register_element_id(container, user_keykey, dgNone, key_as_main_identityFalse)其注释明确说明目前 ID 仅用于前端提取 key 并设置为 CSS 类未来计划用于更多容器特性——本方案正是把这条既有基础设施推广到 tabs、expander、popover。on_change模式切换如何影响身份语义passiveon_changeignorekey只设置Block.id不设置 element-level ID如tabContainer.id不调用register_widget。前端因缺少 element-level ID 而不会把容器当作 widget因此交互不会触发重跑statefulon_changererun或回调额外设置 element-level ID 并调用register_widgetwidget 状态成为事实来源前端不再读取elementStates中由Block.id键控的条目。两种模式都使用key_as_main_identityFalse意味着 ID 综合了 key 与全部参数参数不变则 ID 稳定。spec 特别强调element-level ID如tabContainer.id的存在与否正是widget与被动容器的区分标志。前端实现复用elementStates完成跨 remount 状态恢复状态 HookuseWidgetManagerElementState前端复用的是Video、Audio、PlotlyChart、DeckGlJsonChart等元素已经用于同一目的的状态机制。useWidgetManagerElementStateHook 封装了这套读写见 frontend/lib/src/hooks/useWidgetManagerElementState.tsx// 初始化从 widgetMgr 读取已存状态无状态时写入默认值 const [state, setStateInternal] useStateT( widgetMgr.getElementState(id, key) ?? defaultValue ) // 写入同步到 widgetMgr 与本地 state const setState useCallback( (value: T) { widgetMgr.setElementState(id, key, value) setStateInternal(value) }, [widgetMgr, id, key] )其本质是带持久化的useState状态既存在于 React 组件内也存在于 widget manager 中因此组件卸载再挂载后仍能恢复。三个元素的读写实现st.tabs见 frontend/lib/src/components/elements/Tabs/Tabs.tsxTabs 组件通过node.deltaBlock.tabContainer.idwidgetId与node.deltaBlock.idblockId区分两种身份const isDynamic Boolean(widgetId) // Passive keyed tabs有稳定 blockIdkey 提供但不是动态 widget无 on_changererun const isPassivelyKeyed Boolean(blockId) !isDynamicgetPersistedTabIndex从elementStates读取activeTabLabel再用allTabLabels.indexOf(stored)解析回当前标签列表中的索引——如果存储的标签已不存在标签被重命名或删除则回退到默认标签页function getPersistedTabIndex( widgetMgr: WidgetStateManager, blockId: string, allTabLabels: string[] ): { index: number; label: string } | null { const stored widgetMgr.getElementStatestring(blockId, activeTabLabel) if (!stored) return null const idx allTabLabels.indexOf(stored) return idx 0 ? { index: idx, label: stored } : null }用户切换标签时handleSelectionChange若为 passively keyed 模式则调用widgetMgr.setElementState(blockId, activeTabLabel, newLabel)见 Tabs.tsx标签列表变化的 reconciliation 逻辑同样优先读取持久化状态。Tabs 实现中还包含对默认索引变化的同步逻辑只有动态stateful模式才允许defaultTabIndex程序化变更覆盖当前选择并同步回widgetMgr以避免陈旧值覆盖session_state。st.expander见 frontend/lib/src/components/elements/Expander/Expander.tsxconst isPassivelyKeyed Boolean(blockId) !isWidget const [storedExpanded, setStoredExpanded] useWidgetManagerElementStateboolean({ widgetMgr, id: isPassivelyKeyed ? (blockId ?? ) : , key: expanded, defaultValue: element.expanded ?? false, }) const initialExpanded isPassivelyKeyed ? storedExpanded : element.expandedst.popover见 frontend/lib/src/components/elements/Popover/Popover.tsxconst widgetId element.id const isWidget Boolean(widgetId) const isPassivelyKeyed Boolean(blockId) !isWidget const [storedOpen, setStoredOpen] useWidgetManagerElementStateboolean({ widgetMgr, id: isPassivelyKeyed ? (blockId ?? ) : , key: open, defaultValue: element.open ?? false, })Popover 的开关逻辑handleToggle/handleClose在 widget 模式下走setBoolValue通知后端在 passively keyed 模式下走setStoredOpen持久化到前端存储见 Popover.tsx。三个组件都用 Hook 恒被调用、仅 passive 模式生效 的写法规避 React Hooks 规则问题非 passive 模式下传入空 id产生 no-op 条目。读取与写入的语义要点读取发生在渲染时有存储状态则用之否则以 proto 值即default/expanded/open参数作为初始默认写入发生在交互时切换标签、折叠/展开、打开/关闭时更新存储存储存在期间忽略默认值变更这与 keyed widget 的行为一致——默认值只是初始种子。想重置可以更换key或用on_changererun配合session_state[key]程序化控制不触发重跑因为 element-level ID 未设置前端不会把容器当 widget 处理交互不会产生 rerun更换key即换身份新Block.id在存储中无条目后端默认值生效——与 Streamlit 全局的 key 语义一致。状态清理blockIds纳入removeInactive活跃集合elementStates的条目由removeInactive垃圾回收当某 ID 不在activeWidgetIds中时即被清除。由于后端通过compute_and_register_element_id已将Block.id注册进widget_ids_this_run前端必须保证这些Block.id也出现在传给removeInactive的活跃 ID 集合中。spec 的方案是扩展ElementsSetVisitor在与现有 widget 遍历同一次遍历中收集Block.id// ElementsSetVisitor.ts — 在现有 elements 集合旁新增 public readonly blockIds: Setstring new Set() visitBlockNode(node: BlockNode): SetElement { if (node.deltaBlock?.id) this.blockIds.add(node.deltaBlock.id) for (const child of node.children) child.accept(this) return this.elements }AppRoot新增getActiveIds()方法一次性遍历 main / sidebar / event / bottom 四个根返回{ elements, blockIds }App.tsx中三处removeInactive调用点改为把blockIds并入activeWidgetIdsconst { elements, blockIds } this.state.elements.getActiveIds() const activeWidgetIds new Set([ ...Array.from(elements).map(getElementId).filter(notUndefined), ...blockIds, ]) this.widgetMgr.removeInactive(activeWidgetIds)这保证了被动容器的持久化条目在容器仍存在于渲染树时不会被误回收。附带收益st-key-keynameCSS 类首次覆盖三个布局容器设置Block.id首次让 tabs、expander、popover 三个元素获得st-key-keynameCSS 类此前已有st.container支持。$$ID-hash-user_key格式能被isValidElementId/getKeyFromId识别convertKeyToClassName生成 CSS 类因此无需改动现有 CSS key 基础设施。spec 强调了一个重要约束类必须只出现在最外层 DOM 元素上——若同时出现在嵌套 div 上st-key-mykey { padding: 10px }这类规则会同时命中两层。各元素的挂载点如下元素最外层元素实现位置与说明st.expanderStyledLayoutWrapper经BlockNodeRendererBlock.tsx挂载而非StyledExpandableContainerst.popoverStyledLayoutWrapper经BlockNodeRendererBlock.tsx挂载而非Box弹层内容渲染进document.bodyportal后代选择器无法触达需改用.stPopoverBodyst.tabsStyledTabContainer在Tabs.tsx中用node.deltaBlock.id而非tabContainer.id应用tabs 绕过StyledLayoutWrapperTabs 的实际代码印证了这一点见 Tabs.tsxStyledTabContainer className{[stTabs, convertKeyToClassName(userKey)].filter(Boolean).join( )} >tab1, tab2, tab3 st.tabs( [Overview, Details, Raw Data], keyanalysis_tabs, # 关键提供 key 即启用状态持久化 ) with st.expander(查看详情, expandedTrue, keydetails_expander): st.write(...) with st.popover(设置, keysettings_popover): st.write(...)这样即使容器上方有if st.toggle(...)之类的条件元素在重跑间出现/消失用户的标签页选择、展开状态与弹层状态都能在同一会话内得到保留。需要重置状态时修改key即可获得全新身份需要程序化控制时切换到on_changererun并使用session_state[key]。方案还附带了免费收益三个元素现在都支持st-key-keynameCSS 类可用于精确定位最外层 DOM 元素进行样式定制。本方案的完整设计细节、场景对比与验收清单可在 tech-spec.md 中查阅后端身份计算与分支逻辑见 lib/streamlit/elements/lib/utils.py 与 lib/streamlit/elements/layouts.py前端三个组件的持久化实现见 Tabs.tsx、Expander.tsx 与 Popover.tsx。【免费下载链接】streamlitStreamlit — A faster way to build and share data apps.项目地址: https://gitcode.com/gh_mirrors/st/streamlit创作声明:本文部分内容由AI辅助生成(AIGC),仅供参考
返回列表