Microsoft.UI.Reactor(Reactor)的 DataGrid<T> 是一张虚拟化表格,从一个 IDataSource<T> 惰性渲染行。契约的是数据源,不是数据:它按排序、筛选和搜索状态返回分页,声明自己的 Capabilities(服务端排序?可变更?),并为每一项产出一个稳定的 RowKey。网格是这个契约之上的一层薄视图 —— 它每次渲染都向数据源索取可见窗口,按键对返回的行做差异比对,只渲染发生变化的部分。这与 AG Grid"行数据 + 列定义"那种数组形输入相反,而更接近 TanStack Table 的无头切分思路:数据源拥有数据访问,DataGridState<T> 拥有排序/选择/编辑状态,而网格只是呈现。两列 Column<T>(...) 定义加一个 ListDataSource<T> 包装就是最小可用网格;换成 ObservableListDataSource<T> 就让它变成活的;针对你的 REST 或 GraphQL 端点实现一个自定义 IDataSource<T>,就能在不改动列代码的前提下把它变成服务端驱动的网格。先读数据源那一节 —— 本页其余各节讲的都是网格如何向它索取更多。
数据系统¶
Reactor 的数据系统提供一个由可插拔数据源抽象支撑的虚拟化 DataGrid<T>。你定义列(或自动生成它们),接上一个数据源,网格就会处理排序、筛选、搜索、选择和行内编辑。
数据源¶
所有数据都流经 IDataSource<T> —— 一个异步的、基于分页的抽象。你从不把裸列表交给网格;而是把数据包进一个能声明自身能力的数据源:
class DataSourceExample
{
// 包装一个内存列表 —— 支持客户端排序、筛选、搜索
static ListDataSource<Product> CreateSource() =>
new(SampleProducts.Items, p => (RowKey)p.Id);
// source.Capabilities → Sort | Filter | Search | Count | Mutate
}
ListDataSource<T> 包装一个内存列表并提供客户端的排序、筛选和搜索。对于数据绑定集合,用 ObservableListDataSource<T>,它跟踪 ObservableCollection<T> 的变更并触发 DataChanged。
| 数据源 | 最适合 |
|---|---|
ListDataSource<T> |
内存列表、本地数据 |
ObservableListDataSource<T> |
可观察集合、实时更新的数据 |
自定义 IDataSource<T> |
REST API、数据库、GraphQL 端点 |
Capabilities 标志就是协商点。一个返回 ServerSort | ServerFilter 的数据源告诉网格:把排序/筛选发送进 DataRequest 并信任分页响应;一个返回 None 的数据源则接受网格的客户端兜底路径。自定义数据源通常处在两者之间 —— 服务端排序、客户端搜索 —— 而网格对每个标志独立尊重。把 REST 端点包成 IDataSource<T> 而不把 HttpClient 泄漏进组件的模式,参见 async-resources。
定义列¶
用 Column<T>() 以流畅构造器定义列。每列有一个名称、一个访问器函数和可选配置:
class ExplicitColumnsDemo : Component
{
public override Element Render()
{
var source = UseMemo(() => new ListDataSource<Product>(
SampleProducts.Items, p => (RowKey)p.Id));
var columns = UseMemo(() => new FieldDescriptor[]
{
Column<Product>("Id", p => p.Id, width: 60),
Column<Product>("Name", p => p.Name, width: 180),
Column<Product>("Category", p => p.Category, width: 120),
Column<Product>("Price", p => p.Price, format: "C2", width: 100),
Column<Product>("Stock", p => p.Stock, width: 80),
});
return DataGrid<Product>(source, columns).Height(400);
}
}

ColumnBuilder<T> 支持串联:
| 方法 | 效果 |
|---|---|
.Validate(validators...) |
为行内编辑挂上校验器 |
.CellRenderer(fn) |
自定义单元格渲染函数 |
.NotSortable() |
禁用该列排序 |
.Build() |
定稿出 FieldDescriptor |
Column<T>(...) 和 AutoColumns<T>(...) 是 Microsoft.UI.Reactor.Advanced.Factories 上的静态方法 —— DataGrid 由可选的 Microsoft.UI.Reactor.Advanced 包提供(spec 062 §7)。加上包引用和第二个 using static:
自动生成列¶
为了快速 prototyping,AutoColumns<T>() 用反射从公开属性生成列:
class AutoColumnsDemo : Component
{
public override Element Render()
{
var source = UseMemo(() => new ListDataSource<Product>(
SampleProducts.Items, p => (RowKey)p.Id));
var registry = UseMemo(() => new TypeRegistry());
return DataGrid<Product>(source, registry).Height(400);
}
}

自动生成为自定义类型元数据使用 TypeRegistry(若可用)。要在不必手工定义全部列的情况下微调个别列,可以给 AutoColumns<T>() 传一个 overrides 函数,或给基于 registry 的 DataGrid<T>(source, registry, …) 重载传 columnOverrides:。
AutoColumns<T>() 是演示和管理后台的快路径。面向用户的网格请显式定义列 —— 自动生成的列遵循属性顺序(往往任意)、拿属性名当列头(对最终用户常常是错的),并且暴露每一个公开 getter(包括你并不想露出来的那些)。一旦有设计师碰这张网格,就切换到显式列。
排序与筛选¶
点击列头即可排序。网格把排序委托给数据源 —— ListDataSource 在客户端处理,而自定义数据源可以实现服务端排序:
class SortFilterDemo : Component
{
public override Element Render()
{
var source = UseMemo(() => new ListDataSource<Product>(
SampleProducts.Items, p => (RowKey)p.Id));
var columns = UseMemo(() => new FieldDescriptor[]
{
Column<Product>("Name", p => p.Name, width: 180),
Column<Product>("Category", p => p.Category, width: 120),
Column<Product>("Price", p => p.Price, format: "C2", width: 100),
Column<Product>("Stock", p => p.Stock, width: 80).NotSortable(),
});
return DataGrid<Product>(source, columns, showSearch: true).Height(400);
}
}

筛选使用 FilterDescriptor,其 FilterOperator 枚举有 13 个运算符:Equals、NotEquals、Contains、StartsWith、EndsWith、GreaterThan、GreaterThanOrEqual、LessThan、LessThanOrEqual、Between、In、IsNull 和 IsNotNull。
启用 showSearch: true 会加一个内置搜索栏,并高亮匹配的单元格。
选择¶
DataGrid 支持单选与多选模式。选择状态通过 onSelectionChanged 回调上报:
class SelectionDemo : Component
{
public override Element Render()
{
var initialSelection = UseMemo<IReadOnlySet<RowKey>>(() => new HashSet<RowKey>());
var (selected, setSelected) = UseState(initialSelection);
var source = UseMemo(() => new ListDataSource<Product>(
SampleProducts.Items, p => (RowKey)p.Id));
var columns = UseMemo(() => AutoColumns<Product>());
return VStack(12,
TextBlock($"Selected: {selected.Count} items").Opacity(0.6),
DataGrid<Product>(source, columns,
selectionMode: SelectionMode.Multiple,
onSelectionChanged: setSelected).Height(350)
);
}
}

| 模式 | 行为 |
|---|---|
SelectionMode.None |
不可选择(默认) |
SelectionMode.Single |
一次一行 |
SelectionMode.Multiple |
Ctrl+点击、Shift+点击、基于锚点 |
被选中的行由 RowKey 标识 —— 这是一个由你的数据源 GetRowKey 实现派生出的稳定身份。回调交给你的是完整快照(IReadOnlySet<RowKey>),而不是增删增量 —— 与 ListView 上的多选形状相同。把选择状态提升到父组件,这样它能扛住排序、筛选和刷新;参见下面的带提升选择的主从模式。
行内编辑¶
设置 editable: true 启用行内编辑。有两种编辑模式可用:
class InlineEditingDemo : Component
{
public override Element Render()
{
var source = UseMemo(() => new ListDataSource<Product>(
SampleProducts.Items, p => (RowKey)p.Id));
var columns = UseMemo(() => new FieldDescriptor[]
{
Column<Product>("Id", p => p.Id, width: 60),
Column<Product>("Name", p => p.Name, editable: true, width: 180),
Column<Product>("Price", p => p.Price, editable: true,
format: "C2", width: 100),
Column<Product>("Stock", p => p.Stock, editable: true, width: 80),
});
return DataGrid<Product>(source, columns,
editable: true,
editMode: EditMode.Cell,
onRowChanged: async (key, product) =>
{
// 持久化这次变更 —— 例如调用一个 API
}).Height(400);
}
}

| 模式 | 行为 |
|---|---|
EditMode.Cell |
一次编辑一个单元格;失焦/回车提交 |
EditMode.Row |
编辑整行;显式的保存/取消按钮 |
键盘行为随模式而变。在 EditMode.Cell 下,Tab 提交当前单元格并在下一个上打开编辑器。在 EditMode.Row 下,行是工作单元:Enter(或保存按钮、或点击别处)一次性提交所有待定单元格,Esc(或取消)把它们全部丢弃,而 Tab 在行内各可编辑单元格之间循环键盘焦点 —— 只读列被跳过,越过最后一个则绕回第一个 —— 且不提交任何东西。Shift+Tab 反向走同一个循环,从第一个可编辑单元格绕回最后一个,同样从不提交。保存和取消不属于那个 Tab 循环;Enter 与 Esc 是它们的键盘等价物。
打开编辑器会把真实的键盘焦点移进去,因此你可以立刻开始输入:在 EditMode.Cell 下是你打开的那个单元格,在 EditMode.Row 下是焦点环当时所在的那个单元格;若编辑是从该行的 Edit 按钮发起的,则是该行第一个可编辑单元格。
Shift+Tab 在所有适用之处都是 Tab 的镜像:纯导航时它把焦点单元格往后移;而在两种编辑模式下,它提交的东西与 Tab 提交的完全一致 —— EditMode.Cell 下是该单元格,EditMode.Row 下是什么都不提交 —— 区别只在于行走方向。它也镜像 Tab 的触及范围而不只是方向:在 EditMode.Cell 下它走到上一个单元格,不管那个单元格是否可编辑,只在可编辑时才重新打开编辑器;而在 EditMode.Row 下它留在本行内并跳过只读列。
编辑支持校验 —— 通过 Column<T>().Validate() 挂上校验器。onRowChanged 回调在一次成功提交之后触发,收到 RowKey 和更新后的项。对于可变类,网格就地更新;对于记录,它用变更后的值创建一个新实例。校验器目录与表单用的是同一套 —— Validate.Required()、Validate.Range(min, max)、Validate.Must<T>(predicate) 等等。
列宽调整与重排¶
用户可以拖拽列边框调整宽度、拖拽列头重排。列状态(宽度、顺序、可见性、固定)由 DataGridState 管理,并可持久化:
class ColumnFeaturesDemo : Component
{
public override Element Render()
{
var source = UseMemo(() => new ListDataSource<Product>(
SampleProducts.Items, p => (RowKey)p.Id));
var columns = UseMemo(() => new FieldDescriptor[]
{
Column<Product>("Id", p => p.Id, width: 60,
pin: PinPosition.Left),
Column<Product>("Name", p => p.Name, width: 200),
Column<Product>("Category", p => p.Category, width: 140),
Column<Product>("Price", p => p.Price, format: "C2", width: 120),
Column<Product>("Stock", p => p.Stock, width: 100),
});
return DataGrid<Product>(source, columns).Height(400);
}
}

把列固定到 PinPosition.Left 或 PinPosition.Right,让它们在横向滚动时保持可见。在列定义里设置 width 作为初始宽度,或让网格自动定尺寸。
增量分页¶
对于大数据集,DataPageCache<T> 在用户滚动时按块加载数据。网格为未加载的块显示占位行:
class PagingDemo : Component
{
public override Element Render()
{
var source = UseMemo(() =>
{
var products = Enumerable.Range(1, 10_000)
.Select(i => new Product(i, $"Product {i}",
i % 3 == 0 ? "Electronics" : i % 3 == 1 ? "Furniture" : "Accessories",
Math.Round(10 + i * 0.99, 2), i % 200))
.ToList();
return new ListDataSource<Product>(products, p => (RowKey)p.Id);
});
var columns = UseMemo(() => AutoColumns<Product>());
// DataPageCache 按需加载 50 行一块,LRU 缓存保留 20 块
return DataGrid<Product>(source, columns).Height(400);
}
}

缓存采用 LRU 淘汰策略 —— 达到 maxBlocks 时,最久未访问的块被淘汰。BlockLoaded 事件在某个块加载完成时触发,引起受影响行的重新渲染。
DataPageCache<T> 遵循拉取模型:网格索取某个行索引,缓存返回已加载的块,或发起获取并返回一个 Loading 占位符。这与 Compose Paging 3 所用的分页形状相同,并且与 VirtualList.onVisibleRangeChanged 的"滚动即获取"模式不同 —— 它以行索引为键,而不是滚动位置。当你想要一个"总数已知"的表面时用 DataPageCache<T>;当你想要一个"总数未知"的无限信息流时用可见范围回调。
行详情¶
展开个别行以显示额外的详细内容。传入一个 rowDetailTemplate 来渲染每行下方的可展开内容:
class RowDetailsDemo : Component
{
public override Element Render()
{
var source = UseMemo(() => new ListDataSource<Product>(
SampleProducts.Items, p => (RowKey)p.Id));
var columns = UseMemo(() => AutoColumns<Product>());
return DataGrid<Product>(source, columns,
rowDetailTemplate: (product, key) =>
VStack(8,
TextBlock($"Product ID: {product.Id}").Bold(),
TextBlock($"Full details for {product.Name}"),
TextBlock($"Category: {product.Category}"),
TextBlock($"Unit price: {product.Price:C2}, Stock: {product.Stock}")
).Padding(16).Background(Theme.CardBackground)
).Height(400);
}
}

行详情是惰性渲染的 —— 模板函数只在某行被展开时运行。用它来展示关联数据、内联表单或嵌套网格。
用 DataGridState 做无头测试¶
DataGridState<T> 是网格内部使用的无头状态机 —— 排序描述符、筛选描述符、选择、聚焦单元格、编辑缓冲区。它没有任何 UI 依赖;你可以在单元测试里针对一个 ListDataSource<T> 构造一个,派发排序/选择调用,然后断言结果状态,而无须挂载网格。这与 TanStack Table 在核心逻辑与呈现之间划出的分离是一致的。大多数应用从不直接碰 DataGridState<T> —— 网格挂载时自己拥有一个 —— 但如果你要发布一个自定义数据层,无头状态就是你的测试应该驱动的东西。与之配对的渲染器 fixture 模式见 testing。
注意: 不要在
Render()里内联构造ListDataSource<T>。每次渲染都创建一个新实例,网格那个以数据源身份为键的useMemo就会失效,分页缓存被清空,滚动位置重置,选择也被清空(选择键是相对数据源的GetRowKey来解释的,而不是相对那些项)。正确的形状是UseMemo(() => new ListDataSource<T>(items, x => (RowKey)x.Id), items)—— 只有底层列表引用变化时数据源才重建。同样的规则也适用于AutoColumns<T>()和显式Column<T>()数组:稳定身份很重要。第一个失效症状通常是"我一改任何东西选择就消失" —— 那就是数据源身份在 churn。
模式¶
带提升选择的主从¶
网格在左,详情面板在右,父组件持有被选中的键。网格的 onSelectionChanged 直通写入父级状态;详情面板从同一份状态读取。选择能扛住排序变化、筛选变化和刷新,因为那份状态住在网格之外:
class MasterDetailDemo : Component
{
public override Element Render()
{
// 选择住在父级,因此能扛住排序、筛选和刷新。
var (selected, setSelected) = UseState<RowKey?>(null);
var source = UseMemo(() => new ListDataSource<Product>(
SampleProducts.Items, p => (RowKey)p.Id));
var columns = UseMemo(() => new FieldDescriptor[]
{
Column<Product>("Name", p => p.Name, width: 180),
Column<Product>("Price", p => p.Price, format: "C2", width: 100),
});
var detail = SampleProducts.Items.FirstOrDefault(
p => selected is { } key && (RowKey)p.Id == key);
return HStack(0,
DataGrid<Product>(source, columns,
selectionMode: SelectionMode.Single,
// 回调交给你的是完整选择快照,而不是增量。
onSelectionChanged: keys => setSelected(
keys.Count == 0 ? null : (RowKey?)keys.First())
).Width(480).Height(350),
detail is null
? Border(Caption("Select a product")).Padding(24)
: VStack(4,
SubHeading(detail.Name),
Caption($"{detail.Category} — {detail.Price:C2}")
).Padding(24).Width(360));
}
}
带子网格、乐观更新和异步详情加载的完整模式见 recipes/master-detail 范例。关键的结构点在于:选择状态要比网格活得更久。
通过 ObservableListDataSource 接入实时数据¶
当你的数据是一个由 UseObservableTree 来源或后台 worker 驱动的 ObservableCollection<T> 时,ObservableListDataSource<T> 就是那座桥。它监听 CollectionChanged,触发 IObservableDataSource<T>.DataChanged,网格则以重新获取可见分页来响应。没有渲染循环 Hook;网格在挂载时订阅、卸载时退订:
class ObservableSourceDemo : Component
{
public override Element Render()
{
// 一个稳定的集合实例。UseMemo 接受工厂方法,因此集合只分配一次;
// UseRef 会在每次渲染时重新求值其参数,然后把那份副本丢掉。
var collection = UseMemo(() => new ObservableCollection<Product>(SampleProducts.Items));
var source = UseMemo(() => new ObservableListDataSource<Product>(
collection, p => (RowKey)p.Id), collection);
// 数据源订阅了 CollectionChanged 且是 IDisposable,所以仅仅
// 记忆化它还不够 —— 卸载时要 dispose,否则集合会一直
// 把数据源(及其逐项订阅)拽在手里。
UseEffect(() => () => source.Dispose(), source);
var columns = UseMemo(() => new FieldDescriptor[]
{
Column<Product>("Name", p => p.Name, width: 180),
Column<Product>("Stock", p => p.Stock, width: 80),
});
return VStack(12,
// 变更引发 CollectionChanged -> DataChanged -> 网格重新获取。
Button("Add product", () => collection.Add(new Product(
collection.Count + 1, "New item", "Accessories", 9.99, 1))),
DataGrid<Product>(source, columns).Height(320));
}
}
这个变更模式对 incoming 的服务端推送同样有效(SignalR 信息流、WebSocket 流)—— 在 UI 线程上推进那个可观察集合(参见 threading-and-dispatch),网格自会跟上。
用自定义 IDataSource 做服务端驱动分页¶
对于藏在分页 REST 或 GraphQL 端点后面的数据,直接实现 IDataSource<T>。GetPageAsync(DataRequest request, CancellationToken cancellationToken = default) 通过 DataRequest 收到排序/筛选/搜索状态和所请求的分页偏移,取消令牌作为单独参数;返回一个带各项和 TotalCount(若已知)的 DataPage<T>。把网格挂到你的数据源上 —— 同样的列代码,同样的选择回调。设置 Capabilities = ServerSort | ServerFilter | ServerCount 以退出客户端兜底路径(DataSourceCapabilities 还带有 ServerSearch、ServerSelect、Mutate 和 Refresh)。recipes/paginated-list 范例为列表把这个形状从头走到尾;网格的接线方式完全相同。
常见错误¶
每次渲染都重建数据源¶
// 不要这样:
public override Element Render()
{
var source = new ListDataSource<Product>(SampleProducts.Items, p => (RowKey)p.Id);
return DataGrid<Product>(source, columns);
}
class ExplicitColumnsDemo : Component
{
public override Element Render()
{
var source = UseMemo(() => new ListDataSource<Product>(
SampleProducts.Items, p => (RowKey)p.Id));
var columns = UseMemo(() => new FieldDescriptor[]
{
Column<Product>("Id", p => p.Id, width: 60),
Column<Product>("Name", p => p.Name, width: 180),
Column<Product>("Category", p => p.Category, width: 120),
Column<Product>("Price", p => p.Price, format: "C2", width: 100),
Column<Product>("Stock", p => p.Stock, width: 80),
});
return DataGrid<Product>(source, columns).Height(400);
}
}
每次渲染都新建一个 ListDataSource<T>,会 churn 网格的内部身份、清空分页缓存并清掉选择。把数据源构造包在一个以底层数据为键的 UseMemo 里 —— 数据源跨渲染存活,网格保住滚动位置,选择也能在下一次状态更新中存活。
把选择存在网格内部¶
// 不要这样:
DataGrid<Order>(source, columns,
selectionMode: SelectionMode.Multiple)
// (没有 onSelectionChanged —— 选择状态只存在于网格内部)
网格确实在内部维护选择,但读取它需要一个 ref,而且那份状态对你组件的其余部分不可见。请把选择提升出来:var initialSelection = UseMemo<IReadOnlySet<RowKey>>(() => new HashSet<RowKey>()) 加上 var (selected, setSelected) = UseState(initialSelection) 和 onSelectionChanged: setSelected。把状态声明为 IReadOnlySet<RowKey> 而不是 HashSet<RowKey> —— 那正是回调的确切参数类型,setter 可以直接绑定。工具栏、徽标和详情面板要读"哪些被选中了",现在都能读到。
在生产环境用 AutoColumns¶
AutoColumns<T>() 适用于演示和管理工具。生产网格显式定义列:Column<Order>("Order #", o => o.Id, width: 80) —— 列头经过审阅,顺序是刻意的,你也不会不小心把 o.InternalAuditFlag 露出来。多出的这五行,在设计师第一次要求用"Order #"而不是"Id"时就回本了。
提示¶
从 ListDataSource 加显式列开始。 自动列和自定义数据源都会增加复杂度。先用一份简单的内存列表把网格跑通,再演进。
电子表格式编辑用 EditMode.Cell。 单元格模式对快速编辑更快。当编辑在提交前需要跨多个字段校验时,用 EditMode.Row。
固定 ID 或键列。 当可能横向滚动时,把标识列固定住,这样用户始终知道自己在看哪一行。
不可变数据优先用记录。 网格同时支持可变类和不可变记录。记录更简单也更安全 —— 网格会自动创建 with 副本。
等高行请设置 rowHeight。 固定高度启用 O(1) 的滚动偏移计算。只有当行高确实参差时才省略它。
下一步¶
- WinForms 互操作 —— 下一篇主题:在 WinForms 应用内承载 Reactor 组件
- 集合 —— 面向非表格数据的更简单列表与网格元素
- 表单与输入 —— 网格编辑所用的受控输入与校验模式
- 高级模式 —— 性能调优、错误边界与可观察数据绑定
- Hooks —— 支撑 DataGrid 内部状态的 Hook 系统