Microsoft.UI.Reactor(以下简称 Reactor)中的 Command,是 Reactor 应用里"用户可以做什么"的基本单元。它把动作及其标签、图标、键盘加速器、描述与可用状态打包进一条不可变记录,你可以同时从多个调用点绑定它。执行该动作的按钮、工具栏溢出区里的菜单项、右键浮出层中的条目,以及 Ctrl+S 键盘绑定,引用的都是同一条记录——禁用这个命令,所有界面会同步禁用;为了本地化改它的标签,所有界面会一起改标签。这套契约是跨界面的状态同步——与 WPF 的 RoutedCommand 模型正好相反,后者中每个界面各自注册绑定,再由 CanExecuteChanged 事件把失效通知扇出。当你有一个动作出现在不止一处(工具栏 + 菜单 + 键盘)、需要带"运行中禁用"语义的异步追踪,或正在把一个对话框的主按钮接到一个真实动作而不是内联 lambda 上时,就来读这一页。
命令(Commanding)¶
Command 把一个动作与它的元数据打包在一起。定义一次,即可在按钮、菜单、工具栏和对话框之间复用——元数据在各处始终保持一致。
定义一个命令¶
带上需要的属性创建一个命令:
class BasicCommandExample : Component
{
public override Element Render()
{
var (text, setText) = UseState("Hello, World!");
var (saved, setSaved) = UseState(false);
var saveCmd = new Command
{
Label = "Save",
Execute = () => setSaved(true),
CanExecute = !saved,
Icon = SymbolIcon("Save"),
Accelerator = Accelerator(VirtualKey.S, VirtualKeyModifiers.Control)
};
return VStack(12,
TextBox(text, v => { setText(v); setSaved(false); }, header: "Document")
.Width(400),
HStack(8,
Button(saveCmd),
When(saved, () => TextBlock("Saved!").Foreground(Theme.SystemSuccess))
)
).Padding(24);
}
}

把一个 Command 传给 Button()、MenuItem() 或 AppBarButton(),标签、图标、加速器与可用状态就会自动接好。你不需要在每个控件上逐一设置它们。
速查¶
| 成员 | 类型 | 用途 |
|---|---|---|
Label |
string(必填) |
渲染在按钮 / 菜单项 / 工具提示上的文本。 |
Execute |
Action? |
同步动作。与 ExecuteAsync 互斥。 |
ExecuteAsync |
Func<Task>? |
异步动作——配合 UseCommand 以获得状态追踪。 |
CanExecute |
bool(默认 true) |
该动作此刻是否可以执行。 |
IsExecuting |
bool |
由 UseCommand 在异步工作在途期间管理。 |
DebounceMs |
int(默认 0) |
前沿去抖窗口,单位为毫秒。0 = 关闭。由 UseCommand 实现。 |
IsDebouncing |
bool |
由 UseCommand 在处于 DebounceMs 窗口内时管理。 |
Icon |
IconData? |
SymbolIcon(name)、FontIcon(...) 或 BitmapIcon(uri)。 |
Description |
string? |
工具提示 / 无障碍描述。 |
Accelerator |
KeyboardAcceleratorData? |
键盘绑定(Accelerator(VirtualKey.S, Control))。 |
AccessKey |
string? |
菜单项的单字符 Alt 前缀快捷方式。 |
IsEnabled |
bool(计算得出) |
CanExecute && !IsExecuting && !IsDebouncing——每个界面读取的都是它。 |
Command<T> 暴露同样的成员,但 Execute 是 Action<T>?、ExecuteAsync 是 Func<T, Task>?——动作会从调用点接收一个参数。
标准命令¶
StandardCommand 为最常见的 16 种应用动作提供了工厂方法。每一种都预置了标签、图标与键盘加速器:
class StandardCommandsExample : Component
{
public override Element Render()
{
var (log, updateLog) = UseReducer(new List<string>());
var cut = StandardCommand.Cut(() => updateLog(l => [.. l, "Cut"]));
var copy = StandardCommand.Copy(() => updateLog(l => [.. l, "Copy"]));
var paste = StandardCommand.Paste(() => updateLog(l => [.. l, "Paste"]));
var undo = StandardCommand.Undo(
() => updateLog(l => [.. l, "Undo"]),
canExecute: log.Count > 0);
return VStack(12,
CommandBar(
primaryCommands: new[] { AppBarButton(cut), AppBarButton(copy),
AppBarButton(paste), AppBarButton(undo) }
),
TextBlock($"Actions: {string.Join(", ", log)}").Padding(12)
).Padding(24);
}
}

可用的有:Cut、Copy、Paste、Undo、Redo、Delete、SelectAll、
Save、Open、Close、Share、Play、Pause、Stop、Forward、
Backward。每个工厂方法都以动作作为第一个参数,并接受可选的 canExecute: / label: 覆盖——图标与加速器来自预设。
一个命令,多个界面¶
这个模型的要点就是:一份声明驱动所有界面。把同一个 Command 绑定到一个 Button 和一个 MenuFlyout 条目——点击任意一个都会执行该动作,禁用这个命令则两处一起禁用,而键盘加速器也会路由到同一个目标:
class ButtonAndMenuExample : Component
{
public override Element Render()
{
var (saves, setSaves) = UseState(0);
// One Command. Two surfaces. Identical enabled-state, label, icon, accelerator.
var save = new Command
{
Label = "Save",
Icon = SymbolIcon("Save"),
Accelerator = Accelerator(VirtualKey.S, VirtualKeyModifiers.Control),
Execute = () => setSaves(saves + 1),
CanExecute = saves < 3,
};
return VStack(12,
// Button surface.
Button(save),
// MenuFlyout surface — same Command record.
MenuFlyout(
Button("File…"),
MenuItem(save)),
TextBlock($"Saved {saves} time(s); CanExecute={save.CanExecute}")
.Foreground(Theme.SecondaryText)
).Padding(24);
}
}

价值就在于这份状态同步的契约。没有它,每个界面都得自己计算 IsEnabled,各自携带一份重复的标签,键盘绑定也得手工接线。有了它,Command 记录就是真相来源;各个界面只是它的投影。
自定义内容按钮:.Command() 修饰符¶
Button(command) 工厂渲染的是一个纯文本标签。当你需要更丰富的内容——标签旁带一个图标、一种堆叠布局,或任意自定义元素树——那就从内容构建按钮,再用 .Command(command) 这个流畅修饰符把命令挂上去:
class CommandModifierExample : Component
{
public override Element Render()
{
var saveCmd = new Command { Label = "Save", Execute = () => { } };
return VStack(12,
// Plain label — the factory is enough:
Button(saveCmd),
// Custom content — compose the layout, then bind the command:
Button(HStack(8, Icon(SymbolIcon("Save")), TextBlock("Save")))
.Command(saveCmd)
).Padding(24);
}
}
.Command() 接通的是与工厂相同的状态同步契约:它把点击路由到 Execute / ExecuteAsync,应用命令的 Icon / Description / Accelerator / AccessKey 元数据,并绑定 IsEnabled,使得只要 command.IsEnabled 为 false,按钮就自动禁用。关键在于它会在每一次更新时重新应用 IsEnabled,因此一个切换 CanExecute(或经由 UseCommand 切换 IsExecuting)的命令会直接贯通——你再也不需要手工反复写 .IsEnabled(command.IsEnabled)。
这个修饰符适用于每一个可点击的元素——Button、
HyperlinkButton、RepeatButton、ToggleButton 和 AppBarButton——因此它们各自的自定义内容变体都能与自己的命令保持同步。
它可以与
.IsDisabledFocusable()组合:一个已禁用命令挂在一个"禁用但仍可聚焦"的按钮上时,该按钮仍可通过 Tab 到达(只是点击被抑制),而不会从 Tab 顺序中被剔除。
绑定路径是统一的¶
工厂与 .Command() 修饰符设置的都是元素记录上同一个强类型的 Command 属性。该属性是公开的,因此你也可以用记录初始化器直接设置它——下面每一条路径的绑定效果完全相同:点击时派发动作,且 IsEnabled 取自命令。
class BindingPathsExample : Component
{
public override Element Render()
{
var saveCmd = new Command { Label = "Save", Execute = () => { } };
return VStack(12,
// Factory — plain label:
Button(saveCmd),
// Modifier — custom content:
Button(HStack(8, Icon(SymbolIcon("Save")), TextBlock("Save"))).Command(saveCmd),
// Record-init — the typed property is public (the Label ctor arg is required):
new ButtonElement(saveCmd.Label) { Command = saveCmd },
// `with` on an existing element hits the same property:
Button("Save") with { Command = saveCmd },
// The typed property also covers the split buttons:
new SplitButtonElement(saveCmd.Label) { Command = saveCmd }
).Padding(24);
}
}
这个强类型的 Command 属性在全部六种支持命令的元素上都可用——ButtonElement、HyperlinkButtonElement、RepeatButtonElement、
ToggleButtonElement、SplitButtonElement 和 ToggleSplitButtonElement——因此一个朴素的 new SplitButtonElement(cmd.Label) { Command = cmd },其派发与禁用行为和 SplitButton(cmd) 工厂完全一致。(.Command() 修饰符是面向上面那部分可点击内容的便利写法;强类型属性则连拆分按钮也一并覆盖了。)
当同一个元素上同时存在命令与显式回调时,适用一条优先级规则:
- 记录初始化 /
with会同时保留两者——显式的OnClick(或切换回调)在派发上优先,而命令仍提供元数据与IsEnabled。若想让命令负责派发,就只设置命令(不要给回调)。 .Command()修饰符则让命令完全接管——它会清除任何冲突的回调,使命令成为唯一的派发路径。
异步命令与 UseCommand¶
当一个命令带有 ExecuteAsync 动作时,请用 UseCommand 这个 Hook 把它包起来,以获得自动的 IsExecuting 追踪与重入保护。异步操作运行期间按钮会自行禁用;第二次点击会被丢弃:
class AsyncCommandExample : Component
{
public override Element Render()
{
var (status, setStatus) = UseState("Ready");
var saveCmd = UseCommand(new Command
{
Label = "Save to Cloud",
ExecuteAsync = async () =>
{
setStatus("Saving...");
await Task.Delay(2000);
setStatus("Saved at " + DateTime.Now.ToString("HH:mm:ss"));
},
Icon = SymbolIcon("Save")
});
return VStack(12,
HStack(8,
Button(saveCmd),
TextBlock(status).Foreground(Theme.SecondaryText)
),
When(saveCmd.IsExecuting, () =>
ProgressRing().Width(20).Height(20))
).Padding(24);
}
}

UseCommand 会在调用被包装的动作之前同步设置 IsExecuting = true,并在 finally 块中把它清除——因此一个抛出异常的
ExecuteAsync 依然会解除忙碌状态。任何想渲染进度的地方都可以读取 command.IsExecuting。
class AsyncWithProgressExample : Component
{
public override Element Render()
{
var (progress, setProgress) = UseState(0.0);
var upload = UseCommand(new Command
{
Label = "Upload",
Icon = SymbolIcon("Upload"),
ExecuteAsync = async () =>
{
for (var i = 0; i <= 100; i += 10)
{
setProgress(i / 100.0);
await Task.Delay(120);
}
},
});
return VStack(12,
HStack(8,
Button(upload),
When(upload.IsExecuting, () =>
TextBlock($"{(int)(progress * 100)}%")
.Foreground(Theme.SecondaryText))
),
When(upload.IsExecuting, () =>
Progress(progress * 100).Width(300))
).Padding(24);
}
}

注意:
UseCommand在被 await 的函数体运行之前同步设置IsExecuting = true,并在其完成之后于finally块中清除——但绑定到两个界面上的同一条Command记录,共享的是同一个IsExecuting标志。对常见情况而言这是有意的设计(一个保存按钮 + 一个保存菜单项在保存期间应当同时禁用,以避免重复提交),但在跨页面的场景下就令人意外了:如果同一个Command实例被两个 NavigationView 页面绑定,这个禁用状态也会跨页面——从页面 A 保存,会让页面 B 的保存按钮一直禁用,直到 await 完成。把这个Command提升到导航根组件,行为就是正确的;当你想要按界面隔离时,就按页面身份用UseMemo在每个页面重新创建它。
用 DebounceMs 去抖双击¶
IsExecuting 追踪的是一个 ExecuteAsync lambda 的生命周期——当确实有异步工作要等待时,它非常好用。但常见的"别让双击把同一个动作再触发一次"场景却没有异步工作可追踪:动作是同步的(启动一个进程、触发父组件重新渲染)并立即返回,于是 IsExecuting 只闪现几微秒,第二次点击就溜过去了。历史上的变通办法是把这个同步动作包进 ExecuteAsync,纯粹为了塞进一个 Task.Delay,结果在源码里留下了魔法数字。
DebounceMs 是框架提供的正牌替代品。它施加的是一个前沿去抖:第一次触发被接受,其后的 DebounceMs 窗口内的每一次触发都被丢弃,并且 IsEnabled 在这段时间内报告 false,于是被绑定的控件会明显地禁用,然后在窗口结束时重新启用。
class DebounceExample : Component
{
public override Element Render()
{
var (runs, setRuns) = UseState(0);
// Sync action + framework-managed debounce — no fake async, no Task.Delay.
var runCmd = UseCommand(new Command
{
Label = "Run",
Execute = () => setRuns(runs + 1),
DebounceMs = 1500,
});
// Async action — IsExecuting still tracks the lambda; DebounceMs keeps the
// button disabled past the lambda's return (the disabled window is the
// longer of the two).
var regenCmd = UseCommand(new Command
{
Label = "Re-gen",
ExecuteAsync = () => { setRuns(runs + 1); return Task.CompletedTask; },
DebounceMs = 250,
});
return VStack(12,
HStack(8, Button(runCmd), Button(regenCmd)),
TextBlock($"Fired {runs} time(s)").Foreground(Theme.SecondaryText)
).Padding(24);
}
}
注意:
DebounceMs需要UseCommand。 去抖窗口及其重新启用的计时器是持久状态,而一条朴素的Command记录是不可变的、每次渲染都会重建——它没有地方存放这份状态。UseCommand把它存放在组件的 Hook 表中(与异步重入保护放在一起),因此带去抖的命令务必要经过UseCommand。一个直接绑定、从未交给UseCommand的裸new Command { DebounceMs = … }不会产生去抖效果。去抖在设计上是前沿且固定时长的——先触发,再忽略。后沿的"等输入稳定下来,然后触发一次"式去抖(输入即搜索)是另一个概念,不是
DebounceMs所提供的东西。
带参数的命令¶
Command<T> 让一个命令可以应用于列表的每一行、网格中的每一个选中项,或任何"对这个东西做 X"的模式。动作从调用点接收参数:
record TodoItem(int Id, string Title);
class ParameterizedCommandExample : Component
{
public override Element Render()
{
var (items, setItems) = UseState<IReadOnlyList<TodoItem>>(
UseMemo(() => new[] { new TodoItem(1, "Buy milk"), new TodoItem(2, "Walk dog"), new TodoItem(3, "Ship doc") }, []));
// One Command<TodoItem> drives every row.
var delete = new Command<TodoItem>
{
Label = "Delete",
Icon = SymbolIcon("Delete"),
Execute = item => setItems(items.Where(i => i.Id != item.Id).ToList()),
};
return VStack(8,
ForEach(items, item =>
HStack(8,
TextBlock(item.Title).Width(180),
// Inline button — Command<T> doesn't have a Button(cmd, arg) overload
// by design, so call .Execute(arg) directly from the click handler.
Button(delete.Label, () => delete.Execute?.Invoke(item))
.AutomationName($"Delete {item.Title}")
.IsEnabled(delete.IsEnabled))
.WithKey(item.Id.ToString()))
).Padding(24);
}
}

若要集成到菜单中,可变参数的 MenuItem(Command<T>, T parameter)
重载会把该行的数据绑定到菜单项上:
class MenuItemParameterizedExample : Component
{
public override Element Render()
{
var (log, setLog) = UseState("");
var item = new TodoItem(1, "Buy milk");
var deleteCommand = new Command<TodoItem>
{
Label = "Delete",
Execute = i => setLog($"Deleted {i.Title}"),
};
var renameCommand = new Command<TodoItem>
{
Label = "Rename",
Execute = i => setLog($"Renamed {i.Title}"),
};
// The row content is the flyout target; each MenuItem carries the row's data.
return VStack(8,
MenuFlyout(TextBlock(item.Title).Padding(8),
MenuItem(deleteCommand, item),
MenuItem(renameCommand, item)),
TextBlock(log).Foreground(Theme.SecondaryText)
).Padding(24);
}
}
这与在列表行上右键的模式是同一个形态——一个带参数的命令、一份上下文菜单声明,每一行各自携带自己的数据。
与命令栏集成¶
CommandBar 配合 AppBarButton 是规范的工具栏界面。主命令内联渲染;次要命令则折叠进溢出菜单:
class CommandBarExample : Component
{
public override Element Render()
{
var (text, setText) = UseState("Edit me");
var save = StandardCommand.Save(() => { });
var copy = StandardCommand.Copy(() => { });
var delete = StandardCommand.Delete(
() => setText(""), canExecute: text.Length > 0);
return VStack(0,
CommandBar(
primaryCommands: new[] {
AppBarButton(save), AppBarButton(copy) },
secondaryCommands: new[] {
AppBarButton(delete) }
),
TextBox(text, setText, header: "Document").Margin(16)
);
}
}

每种按钮的 AppBarButton 变体都直接支持 Command;不存在第二个接线步骤。若要在选中项旁渲染同样的形态,请使用 CommandBarFlyout——参见
对话框与浮出层。
与菜单集成¶
命令在菜单栏中同样可用。加速器文本(如 Ctrl+S)会自动显示在菜单项旁边:
class MenuBarExample : Component
{
public override Element Render()
{
var (text, setText) = UseState("Document text");
var save = StandardCommand.Save(() => { });
var close = StandardCommand.Close(() => setText(""));
var undo = StandardCommand.Undo(() => { });
var redo = StandardCommand.Redo(() => { });
return VStack(0,
MenuBar(
Menu("File", MenuItem(save), MenuItem(close)),
Menu("Edit", MenuItem(undo), MenuItem(redo))
),
TextBlock(text).Padding(16)
);
}
}

MenuItem(Command) 与 MenuItem<T>(Command<T>, T parameter) 是两个工厂重载。后者正是 ListView 与 DataGrid 中逐行上下文菜单的动力来源。
键盘加速器¶
Command 上的 Accelerator 属性是键盘绑定的真相来源。加速器是在 WinUI 层面接线的——绑定存在于正在渲染该命令的那个界面上(按钮或菜单项),而 Reactor 继承了 WinUI 的规则:"键盘加速器的作用域是已聚焦元素的祖先链"。若想要一个无论焦点在哪都会触发的全局加速器,就从窗口根节点的 MenuBar 或 CommandBar 渲染该命令——两者都会把各自的加速器挂到窗口的 KeyboardAccelerators 集合上,而那是窗口级作用域。
class AcceleratorScopeExample : Component
{
public override Element Render()
{
var (saves, setSaves) = UseState(0);
var save = new Command
{
Label = "Save",
Accelerator = Accelerator(VirtualKey.S, VirtualKeyModifiers.Control),
Execute = () => setSaves(saves + 1),
};
// Window-scoped via MenuBar at the root.
return VStack(0,
MenuBar(Menu("File", MenuItem(save))),
TextBlock($"Saved {saves} time(s)").Padding(16));
}
}
想要命令面板式的全局快捷键目录,参见 recipes/command-palette。
模式¶
异步确认对话框¶
经典的删除确认——主按钮执行一个 ExecuteAsync,对话框在 await 完成之前保持打开,主按钮在途期间禁用。用 UseCommand 包起来,把 IsPrimaryButtonEnabled 绑定到 command.IsEnabled,再从异步函数体内部关闭对话框:
class AsyncConfirmDialogExample : Component
{
public override Element Render() => Memo(ctx =>
{
var (open, setOpen) = ctx.UseState(false);
var (status, setStatus) = ctx.UseState("Ready");
var delete = ctx.UseCommand(new Command
{
Label = "Delete",
ExecuteAsync = async () =>
{
await Task.Delay(500); // stands in for api.DeleteAsync(id)
setStatus("Deleted");
setOpen(false);
},
});
return VStack(12,
Button("Delete…", () => setOpen(true)),
TextBlock(status).Foreground(Theme.SecondaryText),
ContentDialog("Delete?", TextBlock("This cannot be undone."),
primaryButtonText: "Delete") with
{
IsOpen = open,
IsPrimaryButtonEnabled = delete.IsEnabled,
OnClosed = r =>
{
if (r == ContentDialogResult.Primary) delete.Execute?.Invoke();
else setOpen(false);
},
}
).Padding(24);
});
}
完整模式参见对话框与浮出层。
本地化命令¶
StandardCommand 的预设自带英文标签。可以在每次渲染时用 with { ... } 表达式覆盖返回的记录:
class LocalizedCommandExample : Component
{
public override Element Render()
{
var intl = UseIntl();
var save = StandardCommand.Save(() => { }) with
{
Label = intl.Message(new MessageKey("Commands", "save.button")),
Description = intl.Message(new MessageKey("Commands", "save.tooltip")),
};
return VStack(12, Button(save)).Padding(24);
}
}
UseIntl 访问器与 MessageKey 的命名空间/键形态见本地化。
一个命令、三个界面、一个加速器¶
正是这个形态催生了整套模型——无论用户是在编辑器正文里按下 Ctrl+S,还是打开"文件"菜单点击保存,或是点击工具栏上的保存按钮,触发的都是同一个动作:
class ThreeSurfacesExample : Component
{
public override Element Render()
{
var (saves, setSaves) = UseState(0);
var save = new Command
{
Label = "Save",
Icon = SymbolIcon("Save"),
Accelerator = Accelerator(VirtualKey.S, VirtualKeyModifiers.Control),
ExecuteAsync = () => { setSaves(saves + 1); return Task.CompletedTask; },
};
var saveWrapped = UseCommand(save);
return VStack(0,
MenuBar(Menu("File", MenuItem(saveWrapped))),
CommandBar(primaryCommands: new[] { AppBarButton(saveWrapped) }),
TextBlock($"Saved {saves} time(s)").Padding(16));
}
}
常见错误¶
在渲染中创建命令而不做记忆化¶
// Don't: re-create the Command on every render — each render allocates a
// fresh command record (and its captured closures). Memoizing keeps a
// stable instance across renders. Lift to a memo or hoist out of Render().
class DontCreateInRender : Component
{
public override Element Render()
{
// BAD — a fresh Command (and closure) is allocated every render:
// var save = new Command { Label = "Save", Execute = () => { } };
// GOOD — UseMemo pins identity until deps change:
var (count, setCount) = UseState(0);
var save = UseMemo(() => new Command
{
Label = "Save",
Execute = () => setCount(count + 1),
}, count);
return VStack(8, Button(save), TextBlock($"Saved {count}")).Padding(24);
}
}
每次渲染都重建 Command 记录,会分配出一个全新的命令(以及它捕获的闭包)。在宿主于协调时重建键盘加速器的地方,它会清除并重新添加它们——这是一次有界的重新接线,而非无界泄漏;而绑定到按钮上的命令是按值做差异比对的,因此一个全新但相等的命令不会重新应用任何东西。可以省下的只是那次按渲染发生的分配,而分析器会为这种内联构造发出
REACTOR_PERF_FUNCREF。请用 UseMemo 带上正确的依赖项把它包起来,或把命令提升到组件之外。
忽略 CanExecute 的变化¶
class CanExecuteDontExample : Component
{
public override Element Render()
{
var (text, setText) = UseState("");
// Don't — the guard hides inside Execute, so every surface still
// looks enabled and the user clicks expecting an action.
var save = new Command
{
Label = "Save",
Execute = () => { if (text.Length > 0) Save(); },
};
return VStack(12, TextBox(text, setText, header: "Document").Width(300), Button(save))
.Padding(24);
void Save() { }
}
}
把守卫塞进 Execute 里能工作,但丢掉了跨界面的同步禁用——工具栏按钮看起来仍是启用的,菜单项依然可聚焦,于是用户点了下去却期待有动作发生。请把这个判定提升为 CanExecute:
class CanExecuteDoExample : Component
{
public override Element Render()
{
var (text, setText) = UseState("");
// Do — promote the predicate to CanExecute so every surface
// disables together.
var save = new Command
{
Label = "Save",
Execute = Save,
CanExecute = text.Length > 0,
};
return VStack(12, TextBox(text, setText, header: "Document").Width(300), Button(save))
.Padding(24);
void Save() { }
}
}
这样一来按钮变灰、菜单项禁用、加速器空转——一次决策,处处生效。
在非异步的事件处理器里 await¶
这个内联的 async lambda 是无人管理的——没有 IsExecuting,没有重入保护,也没有可供渲染的忙碌状态。用户在 await 期间点五次,就会启动五个并发的保存。请把这个动作提升为 Command 上的 ExecuteAsync,用 UseCommand 包起来,再把按钮绑定到该命令。重入保护会丢弃重复点击,而 IsExecuting 会点亮转圈指示器。
小贴士¶
常见操作请用 StandardCommand。 它省去了为最常见的 16 种动作手工指定图标与键盘加速器的麻烦,并让你的界面与 WinUI 的惯例保持一致。
异步命令务必用 UseCommand 包起来。 它通过重入保护防止重复执行、追踪 IsExecuting,并在 finally 中清除忙碌标志,使异常不会把 UI 卡在禁用状态。
用 command.IsExecuting 渲染加载指示器。 绑定到已包装的异步命令的任意界面,都可以用同一个标志渲染转圈——UseCommand 包装器保证该标志在重新渲染之间是实时的。
命令是记录——用 with 来做定制。 为本地化覆盖标签:
StandardCommand.Save(action) with { Label = "Guardar" }。原始预设对下一个调用者保持完好。
一次性的命令在调用点定义,被共享的命令则提升出去。 单个保存按钮可以在内联声明它的命令。而一个同时出现在工具栏、菜单、键盘绑定与对话框主按钮上的保存动作,则应放在父组件里,或用 UseMemo 固定,以保证身份稳定。