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

模板 token 规格:这套 authoring 语言要支持什么

为什么先立规格

上一篇(嵌套组件)把父模板里声明子组件这件事落成了第一张树形结果。本篇再往前走一步,进到 UXML 这一块的内容语言面。

前言把系统分成 Authoring、编译前端、运行时三段。在拆开任何一段的实现之前,先把语言面定下来:authoring 里到底会出现哪些记号(token),每种记号的形态、语义与归属管线。

token 表是整个系统的接口契约,两端都对着它工作:

先有这张表,后面的实现篇——TemplateElement 如何实现、collector 如何收集——都是在回答”某一行 token 是怎么被落地的”。这张表也天然是验收基准:一个 token 的语义契约可证伪,实现对不对、回归坏没坏,对照契约即可判定。

总览:四族 token

flowchart LR
    A["Authoring UXML"] --> B["文本与内容
隐式文本 · {{ }} 插值"] A --> C["属性通道
bind-* · on-* · v-model"] A --> D["结构指令
v-if 族 · v-show · v-for · bind-key"] A --> E["寻址与投影
ref · v-slot / tpl:Slot"] B --> F["编译前端:收集 · 规范化 · 蓝图"] C --> F D --> F E --> F F --> G["运行时:绑定 · 路由 · 结构切换 · 投影"]

四族的分工:文本与内容回答”界面上显示什么字”;属性通道回答”控件的属性/事件怎么跟数据走”;结构指令回答”树怎么随数据变形”;寻址与投影回答”怎么按名拿节点、怎么把内容投进别人留的坑”。

族 1 · 文本与内容

隐式文本

容器元素里直接写文字,不声明任何 Label:

<ui:VisualElement class="demo-strip">
    Hello Template
</ui:VisualElement>

契约:

{{ }} 插值

文本里挖槽,槽里放表达式:

<ui:Label text="Visible count: {{ItemCount}}" class="demo-copy" />
<ui:Label text="Current title: {{CurrentItem?.Title ?? FallbackTitle}}" class="demo-copy" />
<ui:Label text="Query hint: {{FormatHint()}}" class="demo-copy" />
<ui:Label text="Status: {{IsReady ? &quot;ready&quot; : &quot;pending&quot;}}" class="demo-copy" />

契约:

边界(与 bind-*):插值负责”在一句话里挖槽”——前后静态文字属于模板,只有槽里是活的;bind-* 是整体替换,中间不留模板自己的字。想混着说用 {{ }},想整句换用 bind-*。

族 2 · 属性通道

bind-*:属性整体写入

<ui:Label bind-text="FormatSummary()" class="demo-copy" />
<ui:Button bind-text="PrimaryActionLabel" />

契约:

on-*:事件路由

<ui:Button text="Save" on-click="OnSave" />

契约:

v-model:双向合同

<ui:TextField label="Query" v-model="Query" />
<ui:Toggle label="Enabled Only" v-model="EnabledOnly" />

契约:

族 3 · 结构指令

v-if / v-else-if / v-else:条件链

<ui:VisualElement class="demo-card" v-if="ShowAdvanced">
</ui:VisualElement>

<!-- 三分支条件链:v-if / v-else-if / v-else -->
<ui:VisualElement class="demo-empty" v-if="!HasResults">
</ui:VisualElement>
<ui:VisualElement class="demo-card demo-card--muted" v-else-if="CompactMode">
</ui:VisualElement>
<ui:VisualElement class="demo-card demo-card--muted" v-else="">
</ui:VisualElement>

契约:

v-show:显隐切换

<ui:VisualElement class="demo-strip" v-show="ShowStatusBar">
</ui:VisualElement>

契约:

v-for:列表展开

<ui:VisualElement class="demo-item" v-for="(item, index) in VisibleItems">
    <ui:Label bind-text="FormatItemTitle(item, index)" class="demo-item-title" />
    <ui:Label bind-text="FormatBadge(item)" v-if="item.IsPinned" />
    <ui:Label text="Category {{item.Category}} · {{item.Tags.Length}} tags" class="demo-copy" />
</ui:VisualElement>

契约:

bind-key:keyed identity

<ui:VisualElement class="demo-item" v-for="(item, index) in VisibleItems" bind-key="item.Id">
</ui:VisualElement>

契约:

族 4 · 寻址与投影

ref:命名寻址

<ui:TextField ref="queryField" label="Query Ref" />

契约:

v-slot / tpl:Slot:投影

<!-- 宿主侧:声明出口 -->
<tpl:Slot name="header" class="demo-slot-banner" />

<!-- 外层 authoring 侧:按名投放内容 -->
<ui:VisualElement v-slot="header" class="demo-slot-banner">
    <ui:Label text="header content" />
</ui:VisualElement>

契约:

横切规格:三条贯穿全部 token 的规则

单看 token 会漏掉系统的三条横切约束,它们对所有动态 token 同时成立:

  1. 表达式文法统一。{{ }}、bind-*、v-if、v-for、bind-key 里的值都走同一套表达式文法(属性路径 / 方法调用 / 可选链 / 空值合并 / 三元)。authoring 里不写复杂逻辑,逻辑收进宿主方法,模板只留拼装与声明。
  2. 每个绑定都是 Effect。不是”插值用了 Effect”,而是整个模板系统的每个绑定都用了 Effect:{{ }}、bind-*、v-model、v-if、v-for、v-show 全部在 Activate 阶段创建 Effect——读 Ref 即订阅,Ref 变即重算;on-* 的注册/注销生命周期也由 binding 管理。
  3. authoring 便利在编译前端消化,运行时保持最小模型。隐式文本是它的样板:糖在生成期被吃掉,运行时加载到的已经是普通 VisualElement + Label 树。

规格之后

token 表立在这里,接下来每一篇实现都在回答同一个问题:这张表的某几行,代码里是怎么落地的。

下一篇(token 开始生效:先看几类最小结果)先不急着钻进实现,先看最轻的一类结果:{{ }} 怎样把值写进模板文本。

本文在 AI 协作下完成。