页面上下文与生命周期
import { RenderAppContextScope, RenderCommandScope, RenderStackPanel, RenderText, createAppContextKey,} from 'ds-ui'
复杂业务系统通常由工作台、tab 页面、浮动窗口、弹窗、侧栏和长生命周期服务共同组成。DirectSurface UI 推荐的基本原则是:页面拥有自己的上下文、命令、状态和资源,页面关闭时统一释放。
这篇文档说明页面应该如何组织,而不是规定业务域对象。具体患者、医嘱、病历、审核流程等业务模型由业务系统自己定义。
页面职责
页面层负责组合 UI 和管理页面范围资源。
页面应该负责:
- 创建页面根布局。
- 注入页面上下文。
- 注册页面命令。
- 保存页面私有状态。
- 管理页面级订阅、定时器、controller 和缓存。
- 在关闭时释放页面资源。
页面不应该负责:
- 定义框架级通用组件。
- 把当前页面状态写入应用级全局上下文。
- 在 render 的
performLayout()或performPaint()中发起业务请求。 - 页面关闭后继续接收异步回调并更新 UI。
推荐结构
Page Root
RenderAppContextScope
RenderCommandScope
RootLayout
Header
QueryArea
Content
Footer
如果页面由 TabbedWorkspace 或 DockWorkspace 管理,可以在打开文档时通过 contextValues 注入页面参数。工作区会在文档内容外创建上下文范围。
最小页面骨架
const currentRecordKey = createAppContextKey<{ id: string; title: string }>('record.current') const pageContent = new RenderStackPanel({ orientation: 'vertical', spacing: 8, crossAxisAlignment: 'stretch',}) pageContent.addChild(new RenderText('病历页面')) const commandScope = new RenderCommandScope({ commandManager, debugLabel: 'record-page', commands: [ { id: 'record.refresh', title: '刷新', execute: () => reload(), }, ], child: pageContent,}) const pageRoot = new RenderAppContextScope({ values: [ [currentRecordKey, { id: 'r001', title: '入院记录' }], ], child: commandScope,})
这个结构让页面内的按钮、菜单、浮层和命令都可以读取当前页面上下文,并且页面关闭时释放页面 scope。
生命周期阶段
| 阶段 | 应做事项 | 不应做事项 |
|---|---|---|
| 创建 | 创建 layout、上下文、命令、controller。 | 发起依赖尺寸的重计算。 |
| 挂载 / attach | 进入 render tree,获得 owner、theme、focus、popup 能力。 | 假设所有异步数据已经返回。 |
| 激活 | 刷新命令状态、恢复焦点、按需刷新可见数据。 | 重建整页对象树。 |
| 隐藏 / 切换 | 保留页面状态,暂停可见性相关任务。 | 清空页面上下文或销毁 controller。 |
| 关闭 | 关闭页面浮层,释放订阅、定时器、缓存、上下文、命令 scope。 | 继续执行旧异步回调。 |
| dispose | 清理 render 子树和底层资源。 | 再访问已释放上下文。 |
页面状态放在哪里
| 状态 | 推荐位置 | 原因 |
|---|---|---|
| 登录用户、租户、全局权限服务 | 应用级上下文 | 应用全局共享。 |
| 当前 tab 的业务对象 | 页面级上下文 | 跟随页面打开和关闭。 |
| 当前选中行、查询条件 | 页面对象或页面级上下文 | 页面私有,不应污染全局。 |
| 表格内部 hover、滚动、编辑状态 | 组件内部 | 组件自己的交互状态。 |
| 弹窗草稿 | 浮层上下文或弹窗 controller | 随弹窗关闭释放。 |
| 大文档数据、图片缓存 | 文档/页面 controller | 关闭文档后释放。 |
页面级上下文
页面级上下文应包住页面根布局:
const currentRecordKey = createAppContextKey<{ id: string; title: string }>('record.current')const pageScope = new RenderAppContextScope({ values: [ ['page.kind', 'record'], [currentRecordKey, { id: 'r001', title: '入院记录' }], ], child: content,})
使用规则:
- 页面打开时注入页面参数。
- 页面内部组件通过上下文读取当前对象。
- 页面打开的浮层继承页面上下文。
- 页面关闭时释放页面上下文。
- 不要把页面上下文中的对象提升到应用级。
页面级命令
页面命令应放到页面 RenderCommandScope:
const commandScope = new RenderCommandScope({ commandManager, debugLabel: 'orders-page', commands: [ { id: 'orders.save', title: '保存', canExecute: () => canSave(), execute: () => save(), }, ], child: content,})
页面状态变化时调用 commandManager.invalidate()。页面关闭时,scope dispose 后命令自动失效。
弹窗和浮层
由页面打开的弹窗应继承页面上下文,并追加本次弹窗参数。
Page context
currentRecord = r001
Modal context
currentRecord = r001
dialogMode = edit
这样弹窗按钮、内部表单和命令都能读取当前页面对象。不要让弹窗默认从应用根查找上下文,否则在 tab 浮动、工作区拆分或多页面并存时容易读错对象。
异步请求
页面关闭后,异步请求可能仍然返回。页面应有“是否仍然有效”的判断。
let disposed = false const load = async (): Promise<void> => { const rows = await service.search({}) if (disposed) return rows.length} disposed = true
更复杂的业务可以使用页面 controller、请求取消器或请求 token。关键是不要在页面关闭后继续更新已经释放的 render 对象。
关闭流程
推荐关闭顺序:
- 阻止新的页面操作进入。
- 关闭页面拥有的 popup、modal、floating window。
- 取消订阅和定时器。
- 取消或忽略异步请求返回。
- dispose 页面 controller 和缓存。
- dispose 页面 command scope。
- dispose 页面 app context。
- dispose render 子树。
如果使用 RenderAppContextScope 和 RenderCommandScope 创建页面范围,正常 dispose render 子树时会自动释放它们内部创建的 context/scope。
工作区页面
TabbedWorkspace 和 DockWorkspace 适合承载长期打开的业务文档。
打开文档时:
- 用稳定 document id 管理 tab。
- 通过
contextValues注入当前文档参数。 - 页面内容用 controller 管理业务状态。
- 关闭前检查脏状态或关闭保护。
切换文档时:
- 不要销毁非当前页面状态。
- 可以刷新命令状态,因为当前 active 页面变了。
- 可以恢复焦点或滚动位置。
关闭文档时:
- 释放页面上下文和命令 scope。
- 释放文档 controller、缓存和输入会话。
- 确认没有 popup 仍持有页面对象。
调试和验证
| 问题 | 检查方式 |
|---|---|
| 页面读不到当前对象 | 用 Layout Inspector 查看页面根是否有 RenderAppContextScope。 |
| 命令状态不刷新 | 确认业务状态变化后调用了 commandManager.invalidate()。 |
| 弹窗读错页面数据 | 确认打开弹窗时传入了 source 或继承了页面上下文。 |
| 页面关闭后内存不下降 | 用 Heap Snapshot 查 page root、controller、context、popup、缓存是否仍被引用。 |
| 旧页面命令还能执行 | 确认命令没有注册到 root scope,页面 scope 是否已 dispose。 |
常见错误
页面数据放到应用级上下文
多 tab 场景会互相覆盖,页面关闭后还会被全局引用。页面私有状态必须放页面对象或页面级上下文。
页面命令不随页面释放
如果用 manager.register() 注册页面命令,命令会进入 root scope。页面关闭后它仍然存在。页面命令应使用 RenderCommandScope 或页面私有 CommandScope。
弹窗失去调用页面上下文
浮层如果挂到应用 overlay 层,视觉上不在页面下面,但逻辑上仍应继承调用页面上下文。打开浮层时要明确 source/contextValues。
dispose 只释放 controller
页面可能还持有 popup、输入会话、事件订阅、图片缓存、定时器和上下文对象。关闭页面要按资源清单逐项释放。
在 layout 中同步页面状态
performLayout() 可能频繁执行。不要在 layout 中调用 setItems()、发请求、注册命令或写上下文。页面状态同步应在事件、数据回调或 controller 层完成。
页面开发清单
创建新业务页面时至少确认:
- 是否定义了页面级上下文 key。
- 页面根是否包了
RenderAppContextScope。 - 页面命令是否包了
RenderCommandScope。 - 页面状态变化后是否刷新命令状态。
- 页面打开的浮层是否继承页面上下文。
- 页面关闭时是否释放 controller、订阅、定时器、缓存和 popup。
- 异步请求返回时是否判断页面仍然有效。
- 大数据组件是否自己管理滚动和虚拟化。