触发器¶
命令和触发器用于连接 UI 与业务代码,区别在于通知的起点:
- 命令:UI 事件通知业务代码执行逻辑。例如,用户点击保存按钮,通过绑定的命令执行保存操作。
- 触发器:业务代码通知 UI 执行 HTML 中声明的行为。例如,应用收到“打开背包”输入动作后,触发 UI 更新,替换背包元素的 CSS 类。
应用中的输入动作、任务完成或其他业务事件不一定来自 HTML 元素。需要让这些事件驱动 UI 行为时,可以使用触发器,将事件来源与页面中的行为声明连接起来。触发器既可以更新元素属性,也可以执行页面绑定的命令。
语法¶
使用 @trigger 声明触发器,表达式由名称、参数和行为三部分构成:
- 名称:
name指定已注册的触发器。 - 参数:
parameter={...}保存触发器的自定义参数,可以包含多个字符串参数。 - 行为:使用
do:前缀声明触发后执行的行为。
<!-- name:已注册的触发器名称 -->
<!-- 外层 parameter:传给触发器的自定义参数 -->
<!-- do:command 中的 parameter:传给 Save 命令的参数 -->
<section @trigger="{
name='mytrigger',
parameter={param1='value1', param2='value2'},
do:command={Save, parameter={Name, Age, Selected}},
do:class={value='active', op='append'}
}">
<p>{{ Status }}</p>
</section>
对应的非内联写法,将行为拆分为独立属性:
<section
@trigger="{name='mytrigger', parameter={param1='value1', param2='value2'}}"
@trigger.do.command="{Save, parameter={Name, Age, Selected}}"
@trigger.do.class="{value='active', op='append'}">
<p>{{ Status }}</p>
</section>
两种写法等价。内联行为使用 do:command、do:class,非内联属性使用 @trigger.do.command、@trigger.do.class。非内联写法必须保留 @trigger 主属性,同一配置项或行为不能重复声明。
触发器参数
- 触发器可以定义多个参数。
param1、param2仅为示例名称;实际参数名称、含义和校验规则由具体触发器定义。 - 参数只接受字符串字面量,例如
param1='value1'。语法也允许省略单引号;本文示例统一保留单引号。 - 参数不支持数据绑定、属性路径求值或其他绑定表达式,不会随数据上下文变化而更新。
行为¶
行为声明触发后执行的操作。do:command 和 do:class 由 SDK 执行,不作为自定义参数传给触发器。
每组行为属于声明 @trigger 的当前元素。更新元素属性的行为只修改该元素,例如 do:class 不会修改父元素、子元素或其他声明了相同触发器的元素。do:command 则从该元素的有效数据上下文查找命令,业务代码执行后的影响由命令本身决定。
行为数据上下文¶
行为中的绑定表达式遵循当前元素的数据上下文和作用域规则。未指定命名上下文或别名时,命令路径、命令参数及属性绑定均从当前元素的默认数据上下文解析;该上下文可以由祖先元素继承,也可以通过 @context 指定。
例如,假设已注册 mytrigger,命名上下文 player 提供 Save 命令和 Name 属性:
<section @context="player">
<input
@value="{Name}"
@trigger="{
name='mytrigger',
parameter={param1='value1'},
do:command={Save, parameter={Name}},
do:class={value='saved', op='append'}
}">
</section>
当这个 <input> 对应的触发器触发时:
- 从
<input>当前继承的player数据上下文读取Save和Name。 - 调用
Save,将读取到的Name作为命令参数;参数来自数据上下文,不是直接读取输入框的 DOM 值。 - 向该
<input>的class属性追加saved,已存在时不重复追加,父级<section>不受此属性更新影响。
命令和绑定值在每次触发执行时重新读取。需要访问其他上下文时,可在表达式中使用已声明的命名上下文或作用域别名,详见数据上下文。
SDK 支持的行为¶
| 行为 | 用途 | 回弹 |
|---|---|---|
do:command |
执行绑定的命令;每次触发时重新读取命令和参数 | |
do:class |
激活时追加或替换元素的 CSS 类;停用时恢复激活前的状态 |
回弹的触发条件和恢复规则见本文后面的回弹小节。
command 行为¶
do:command 可以执行当前元素数据上下文中的命令,也可以调用已注册的全局命令:
{Save, parameter={Name}}:从当前数据上下文查找Save,并读取Name作为参数。{@sys.PlaySound, parameter={'open'}}:从全局注册表查找PlaySound,不依赖当前数据上下文。
例如,假设已注册 mytrigger 触发器和 PlaySound 全局命令:
<section @trigger="{
name='mytrigger',
do:command={@sys.PlaySound, parameter={'open'}}
}"></section>
全局命令的参数也可以来自当前元素的数据上下文,例如 {@sys.PlaySound, parameter={SoundName}}。命令来源与参数来源分别解析;使用 @sys 不会改变参数的数据上下文。
class 行为¶
使用 value 指定 CSS 类,op 指定操作。op 省略时默认为 'append'。
| 字段 | 用途 |
|---|---|
value |
必填;字符串字面量或绑定表达式,例如 'active' 或 {ActiveClasses} |
op |
'append' 追加尚不存在的类名;'replace' 替换完整的 class 属性 |
例如,元素最初具有 panel 类,触发器配置如下:
<section class="panel" @trigger="{
name='mytrigger',
do:class={value='active', op='append'}
}"></section>
调用 Activate() 后,元素保留 panel 并追加 active。以下仅展示行为更新后的 HTML 属性:
<section class="panel active"></section>
如果将操作改为 op='replace',激活后会移除原有的 panel,结果为:
<section class="active"></section>
两种操作在调用 Deactivate() 后都会恢复激活前的状态:
<section class="panel"></section>
value 中的绑定只在 Activate() 时求值,不建立持续订阅。op 为固定字符串,不支持绑定表达式。字符串和单个表达式的简写仍可使用,例如 do:class='active'。
replace 会替换完整属性
op='replace' 会替换元素原有的全部 CSS 类;默认的 op='append' 保留原有类,并按空白分隔的独立类名去重追加。
外层 parameter 配置触发器参数;do:command 内的 parameter 配置命令参数,两者用途不同。命令路径、作用域和参数类型遵循事件与命令的规则。
实现触发器¶
以下实现注册名为 input 的触发器,接收字符串参数 action。与之配套的 HTML 如下:
<section @trigger="{
name='input',
parameter={action='inventory.open'},
do:command={Save, parameter={Name, Age, Selected}},
do:class={value='inventory-open', op='append'}
}">
<p>{{ Status }}</p>
</section>
input 和 action 均由本例定义,可替换为实际项目的触发器名称和参数。
每个实际使用触发器的元素都有独立实例,包括条件分支和集合循环创建的元素。实现分为事件源、触发器和工厂三部分。
- 事件源:提供应用或引擎事件,以下使用可替换的示例事件源。
- 触发器:
Attach()校验参数并订阅事件,事件回调调用Activate(),Detach()解除订阅。 - 工厂:实现
ISyntaxTriggerFactory,为每个元素创建独立触发器。
在数据线程订阅和触发
工厂创建、Attach()、Detach() 和 Activate() 均在文档配置的数据线程执行。宿主事件来自其他线程时,先调度到该数据线程,再发布事件。
假设业务系统已提供以下事件源。这里只列出头文件声明;订阅管理和事件分发由业务系统实现,所有调用均在数据线程执行。
// input_events.h
#pragma once
#include <cstdint>
#include <functional>
#include <string>
namespace Example
{
class InputEvents final
{
public:
// 订阅指定动作,返回用于解除订阅的令牌。
uint64_t Subscribe(
std::string action,
std::function<void()> callback);
void Unsubscribe(uint64_t token);
// 发布动作,调用匹配的订阅回调。
void Publish(const std::string& action);
};
}
触发器(input_trigger.h):
// input_trigger.h
#pragma once
#include "input_events.h"
#include <cstdio>
#include <markupui/binding/syntax/syntax_trigger.h>
#include <memory>
#include <string>
#include <utility>
namespace Example
{
class InputTrigger final
: public MarkupUI::Binding::Syntax::ISyntaxTrigger
{
public:
explicit InputTrigger(std::shared_ptr<InputEvents> events)
: Events(std::move(events))
{
}
// 保留 noexcept;预期失败返回 false。
bool Attach(
const MarkupUI::Binding::Syntax::SyntaxTriggerContext& context,
const MarkupUI::Binding::Syntax::SyntaxArguments& arguments)
noexcept override
{
Detach();
MarkupUI::StringView action;
if (!arguments.TryGetString(
MARKUPUI_STR_VIEW("action"), action)
|| action != MARKUPUI_STR_VIEW("inventory.open"))
{
context.ReportError(
MARKUPUI_STR_VIEW(
"action must be 'inventory.open'."));
return false;
}
MarkupUI::Binding::Syntax::SyntaxTriggerRef trigger =
context.GetTrigger();
if (!trigger.IsValid())
{
return false;
}
// 参数视图只在 Attach 期间有效,订阅时复制名称。
std::string actionName(action.Data, action.Size);
SubscriptionToken = Events->Subscribe(
std::move(actionName),
[trigger]()
{
// 当前已经位于数据线程。
// SDK 负责执行 HTML 中声明的全部行为。
if (!trigger.Activate())
{
std::fputs("Trigger is unavailable; actions were not submitted.\n", stderr);
}
});
return true;
}
// 保留 noexcept,清理过程不得向外抛出异常。
void Detach() noexcept override
{
const uint64_t token = SubscriptionToken;
SubscriptionToken = 0;
if (token != 0)
{
Events->Unsubscribe(token);
}
}
private:
std::shared_ptr<InputEvents> Events;
uint64_t SubscriptionToken = 0;
};
}
工厂(input_trigger_factory.h):
// input_trigger_factory.h
#pragma once
#include "input_trigger.h"
#include <markupui/binding/syntax/syntax_trigger_factory.h>
#include <memory>
#include <utility>
namespace Example
{
class InputTriggerFactory final
: public MarkupUI::Binding::Syntax::ISyntaxTriggerFactory
{
public:
explicit InputTriggerFactory(
std::shared_ptr<InputEvents> events)
: Events(std::move(events))
{
}
// 保留 noexcept。
MarkupUI::SharedReference Create() noexcept override
{
return ShareTrigger(
std::make_shared<InputTrigger>(Events));
// 预期失败返回空引用。
// return nullptr;
}
private:
std::shared_ptr<InputEvents> Events;
};
}
事件源:
using System;
public sealed class InputSource
{
public event EventHandler? InventoryOpen;
public void RaiseInventoryOpen()
{
InventoryOpen?.Invoke(this, EventArgs.Empty);
}
}
触发器:
using System;
using MarkupUI.Sdk.Binding.Syntax;
public sealed class InputTrigger : ISyntaxTrigger
{
private readonly InputSource _input;
private SyntaxTriggerRef? _trigger;
public InputTrigger(InputSource input)
{
_input = input;
}
public bool Attach(
SyntaxTriggerContext context,
SyntaxArguments arguments)
{
if (!arguments.TryGetString("action", out string? action)
|| action != "inventory.open")
{
context.ReportError(
"action must be 'inventory.open'.");
return false;
}
_trigger = context.GetTrigger();
if (_trigger == null)
{
return false;
}
_input.InventoryOpen += OnInventoryOpen;
return true;
}
public void Detach()
{
// Attach 部分失败时也可能调用,清理应允许重复执行。
_input.InventoryOpen -= OnInventoryOpen;
SyntaxTriggerRef? trigger = _trigger;
_trigger = null;
trigger?.Dispose();
}
private void OnInventoryOpen(object? sender, EventArgs args)
{
// 当前必须位于文档的数据线程。
_trigger?.Activate();
}
}
工厂:
using MarkupUI.Sdk.Binding.Syntax;
public sealed class InputTriggerFactory : ISyntaxTriggerFactory
{
private readonly InputSource _input;
public InputTriggerFactory(InputSource input)
{
_input = input;
}
public ISyntaxTrigger Create()
{
return new InputTrigger(_input);
}
}
上下文和参数不能保存到字段或异步回调;保存 GetTrigger() 返回的引用,并在 Detach() 中释放。Detach() 也可能在 Attach() 部分失败后调用,清理应允许重复执行。
本例假设数据上下文已提供 Name、Age、Selected、Status 属性,以及依次接收姓名、年龄和选中状态的 Save 命令。命令定义方式见事件与命令。触发器无需自行查找或执行命令。
注册与触发¶
在页面激活前注册扩展,并在应用或插件的生命周期管理对象中保留注册句柄。名称在 SDK 库实例内全局注册,同名注册失败。
#include "input_trigger_factory.h"
#include <markupui/binding/syntax/syntax_trigger_registration.h>
#include <memory>
std::shared_ptr<Example::InputEvents> inputEvents =
std::make_shared<Example::InputEvents>();
std::shared_ptr<Example::InputTriggerFactory> factory =
std::make_shared<Example::InputTriggerFactory>(inputEvents);
MarkupUI::Binding::Syntax::SyntaxTriggerRegistration registration =
MarkupUI::Binding::Syntax::SyntaxTriggerRegistration::Register(
MARKUPUI_STR_VIEW("input"),
factory);
if (!registration.IsValid())
{
// 注册失败,例如名称已被注册。
return;
}
为文档设置数据上下文并打开包含上述 HTML 的页面。元素激活后,SDK 自动调用 Attach();收到输入动作时,在数据线程发布事件:
inputEvents->Publish("inventory.open");
先关闭使用扩展的页面,再撤销注册:
registration.Reset();
factory.reset();
inputEvents.reset();
以下代码假设 SDK 已初始化。变量应由应用或插件持有,不能在页面仍使用扩展时离开 using 作用域:
var input = new InputSource();
var registration = SyntaxTriggerRegistration.Register(
"input", new InputTriggerFactory(input))
?? throw new InvalidOperationException("Trigger registration failed.");
var player = new PlayerObject();
为现有文档设置 player 数据上下文并打开页面,等待文档就绪后再发布事件。dataDispatcher 是初始化时配置的数据线程 Dispatcher:
bool dispatched = dataDispatcher.Invoke(input.RaiseInventoryOpen);
退出时先关闭页面,再撤销注册;页面使用期间保留数据对象:
await document.CloseAsync();
GC.KeepAlive(player);
registration.Dispose();
触发器生命周期
撤销注册只影响后续查找,不会主动停止已经创建的触发器。
使用该触发器的页面全部关闭、实例释放后,才能卸载包含回调代码的模块。
回弹¶
有些界面效果只需要在特定条件持续期间生效。例如,鼠标进入物品卡片时显示高亮边框,离开后恢复原来的外观。如果只有执行行为,就需要分别定义“添加高亮”和“取消高亮”两套操作,还要自行保留卡片原有的 CSS 类,避免取消高亮时丢失其他样式。
回弹让同一组行为支持激活与恢复:触发器在鼠标进入时调用 Activate(),应用高亮类;离开时调用 Deactivate(),SDK 恢复首次激活前的 class 状态。无需再定义一套反向的 class 行为,也无需由业务代码记录原有类名。
这里的鼠标事件由自定义触发器订阅。宿主仍需在对应的结束事件中调用 Deactivate(),恢复过程由 SDK 完成;例如按住按键期间显示操作提示,也可以在按下时激活、松开时回弹。
回弹需要触发器与行为共同支持
- 触发器逻辑必须在适当时机调用
Deactivate(),否则不会回弹。 - 行为本身必须支持回弹。
class行为支持恢复激活前的状态;command行为不支持回弹,即使触发器调用了Deactivate(),也不会再次执行命令或撤销已执行的业务逻辑。
假设元素原有 class="panel",行为配置为 do:class={value='active', op='append'}:
| 操作 | 元素的 class | 说明 |
|---|---|---|
首次调用 Activate() |
panel active |
保存原始状态 panel,追加 active |
再次调用 Activate() |
panel active |
不重复追加,也不覆盖已保存的原始状态 |
调用 Deactivate() |
panel |
恢复首次激活前的状态 |
再次调用 Deactivate() |
panel |
已停用,不再改变状态 |
'append'和'replace'都支持回弹。原本没有class属性时,停用后移除该属性。- 激活期间普通绑定产生的
class更新不会改变恢复目标。停用后再次激活,会重新保存当时的状态。 - 回弹仅恢复
class,不会撤销do:command已执行的业务逻辑,也不会在停用时再次执行命令。
以下调用均需在文档的数据线程执行,trigger 是 Attach() 中取得并保存的触发引用:
// 业务事件开始,例如按下输入动作。
trigger.Activate();
// 业务事件结束,例如松开输入动作,恢复原有 class。
trigger.Deactivate();
// 业务事件开始,例如按下输入动作。
trigger.Activate();
// 业务事件结束,例如松开输入动作,恢复原有 class。
trigger.Deactivate();
生命周期与执行结果¶
- 元素激活时,SDK 查找工厂、创建实例并调用
Attach()。未注册的名称会导致激活失败并记录诊断。 - 宿主事件发生后,触发器调用
Activate()。SDK 在执行时读取当前上下文中的命令和参数,不保存旧上下文的数据。 - 页面关闭、条件分支移除或集合元素移除时,SDK 使触发引用失效并调用
Detach()。Attach()失败时也会清理已完成的部分订阅。
Activate() 返回成功只表示行为已提交,不表示命令或页面更新已经完成。行为按声明顺序提交,遇到失败停止;class 的页面更新是异步提交。Deactivate() 只撤销 class 覆盖,不执行或撤销命令;重复停用无副作用。Activate() 和 Deactivate() 都必须在数据线程调用,返回成功仅表示提交成功。
SyntaxTriggerRef 不保活元素或文档。元素解绑后,旧引用的 Activate() 返回 false;C++ 的 IsValid() 仅表示引用包装存在,不能用于判断元素仍然有效。C# 引用包装被 Dispose() 后再次调用 Activate() 会抛出 MarkupObjectDisposedException。