系列 · 为 UITK 写一套模板系统 第 7 / 7 篇

token 开始生效:先看几类最小结果

上一篇(模板 token 规格)先把这套 authoring 语言里会出现哪些 token 立了出来。这一篇先不往实现里钻,先看一个 token 版的 hello world:文本插值、事件和值变化、条件分支,都是从这一页最小示例里开始长出来的。

文本插值:值开始进模板

组件配方 那一篇里,TemplateElement 先把组件的 UXML / USS 自动装配起来。到了这一篇,模板就不再只是静态结构了:模板树先在构造期装配出来,等树稳定挂载之后,{{ }}、on-click、v-if 这些 token 也开始在同一个宿主里生效。

所以这一页里的 TemplateBlogAppLayout,虽然仍然继承 TemplateElement,但它已经不是只负责装配的模板组件,而是一个开始消费 token 的模板宿主:

using UnityEngine.UIElements;
using Zhujing.Editor.UI.Framework;

namespace Zhujing.Editor.UI.Examples.TemplateBlogApp
{
    public class TemplateBlogAppLayout : TemplateElement
    {
        public new class UxmlFactory : UxmlFactory<TemplateBlogAppLayout, UxmlTraits> { }
        public new class UxmlTraits : VisualElement.UxmlTraits { }

        public string CurrentLessonTitle => "Text Interpolation";
        public int VisibleSectionCount => 2;
        public string SpotlightNote => null;
        public string FallbackNote => "Values now enter template text.";

        public string FormatInterpolationSummary()
        {
            return "Current phase: only {{ }} is active here.";
        }
    }
}

这一节先看 {{ }}:模板里的文本槽位怎样把属性值、空值分支和方法结果拼进最终字符串。

对应的模板可以先写成这样:

<?xml version="1.0" encoding="utf-8"?>
<ui:UXML xmlns:ui="UnityEngine.UIElements">
    <ui:VisualElement class="template-blog-page">
        <ui:VisualElement class="template-blog-page-shell template-blog-page-shell--centered">
            <ui:Label text="Template Blog App" class="template-blog-page-kicker" />
            <ui:Label text="Current lesson: {{CurrentLessonTitle}}" class="template-blog-page-copy" />
            <ui:Label text="Visible sections: {{VisibleSectionCount}}" class="template-blog-page-copy" />
            <ui:Label text="Spotlight note: {{SpotlightNote ?? FallbackNote}}" class="template-blog-page-copy" />
            <ui:Label text="Summary: {{FormatInterpolationSummary()}}" class="template-blog-page-copy" />
        </ui:VisualElement>
    </ui:VisualElement>
</ui:UXML>

这四行已经把 {{ }} 的几种最小形态摆出来了:CurrentLessonTitle 直接读取属性,VisibleSectionCount 把数值写进最终文本,SpotlightNote ?? FallbackNote 处理空值分支,FormatInterpolationSummary() 则把方法返回值接进来。模板里的文本槽位并不只接一类值,而是在同一套写法下接住这几种最常见的表达结果。

这时候模板里还没有结构切换,也没有交互回写;变化只发生在文本属性这一层。{{ }} 先把值送进最终字符串,界面上的几行文案也开始脱离纯静态文本。

Template Blog App 中模板文本开始出现插值结果后的截图
这一版只有文本插值在工作:几行文案开始消费属性值、空值分支和方法结果,但模板结构本身还没有继续变化。

事件和值变化:同一个值开始被交互推动

第一节里已经出现过 VisibleSectionCount。第二节就直接沿着这个值往前走一步:不再只是把它显示出来,而是给它补上一组最小的 + / - 交互,让同一个值开始在按钮点击之后变化。

根组件里可以先把这个值收成一份可变状态,再补两个事件入口:

private readonly Ref<int> _visibleSectionCount = new(2);

public int VisibleSectionCount => _visibleSectionCount.Value;

public void OnIncreaseVisibleSections()
{
    _visibleSectionCount.Value += 1;
}

public void OnDecreaseVisibleSections()
{
    if (_visibleSectionCount.Value <= 0)
        return;

    _visibleSectionCount.Value -= 1;
}

模板里对应加上一组最小的步进器:

<ui:VisualElement class="template-blog-stepper">
    <ui:Button text="-" on-click="OnDecreaseVisibleSections" class="template-blog-stepper-button" />
    <ui:Label text="{{VisibleSectionCount}}" class="template-blog-stepper-value" />
    <ui:Button text="+" on-click="OnIncreaseVisibleSections" class="template-blog-stepper-button" />
</ui:VisualElement>

这里接住同一个值的是两类 token:on-click 把按钮事件送回宿主方法,{{ }} 再把更新后的 VisibleSectionCount 写回中间那一格文本。到了这一步,模板里看到的已经不只是静态结果,而是一份会被交互持续推动着变化的状态。

VisibleSectionCount 不再只是静态插值结果;按钮事件会推动它变化,而模板中的文本读数会立即跟上。

条件分支:结构开始跟着同一个值切换

到了第三节,VisibleSectionCount 不再只负责“显示多少”,而是开始决定哪一段结构应该留在树里。前两节里同一个值先后接入了 {{ }} 和 on-click;这一节再把它接进 v-if / v-else,模板就从“文本会变”走到了“结构会切换”。

根组件里不用再引入一份新状态,只要从现有的计数值上再抬出一个布尔结果:

public int VisibleSectionCount => _visibleSectionCount.Value;
public bool HasVisibleSections => VisibleSectionCount > 0;

模板里把这份布尔结果接成一条最小条件链:

<ui:VisualElement class="template-blog-branch template-blog-branch--active" v-if="HasVisibleSections">
    <ui:Label text="Visible branch is mounted." class="template-blog-branch-title" />
    <ui:Label text="VisibleSectionCount is still above zero, so this branch remains in the tree." class="template-blog-branch-copy" />
</ui:VisualElement>
<ui:VisualElement class="template-blog-branch template-blog-branch--empty" v-else="">
    <ui:Label text="Empty branch is mounted." class="template-blog-branch-title" />
    <ui:Label text="Once the count reaches zero, the fallback branch takes over this slot." class="template-blog-branch-copy" />
</ui:VisualElement>

这里真正变化的已经不是某一行文本,而是节点树本身:HasVisibleSections 为真时,v-if 这一支被挂载;当计数降到 0,原来的分支卸载,v-else 接手同一个位置。{{ }} 负责把值写进文本,on-click 负责推动状态变化,v-if 则开始消费这份状态带来的结构结果。

同一个 VisibleSectionCount 继续被往下消费:大于 0 时保留可见分支,降到 0 时切到 fallback 分支,变化已经从文本层走到了结构层。

小结

这一篇看的其实就是一个 token 版的 hello world:先让值进文本,再让事件推动值变化,最后让同一个值开始决定结构切换。到这里,模板里已经不只是“写死的一页 UXML”,而是开始接住运行时状态,并把结果落到文本、交互和分支这三层上。

再往下一步,就不再只是看这些结果本身,而是回到它们背后的表达式抽象:{{VisibleSectionCount}}、HasVisibleSections、SpotlightNote ?? FallbackNote 这些写法为什么能被统一读懂、编译和执行。下一篇就进入这一层,看看模板系统是怎样把这些不同写法收进同一套表达式模型里的。

本文在 AI 协作下完成。