为什么先立规格
上一篇(嵌套组件)把父模板里声明子组件这件事落成了第一张树形结果。本篇再往前走一步,进到 UXML 这一块的内容语言面。
前言把系统分成 Authoring、编译前端、运行时三段。在拆开任何一段的实现之前,先把语言面定下来:authoring 里到底会出现哪些记号(token),每种记号的形态、语义与归属管线。
token 表是整个系统的接口契约,两端都对着它工作:
- 编译前端(collector)按 token 收集:解析 UXML 时识别哪些属性是普通属性、哪些是指令、哪些文本要规范化;
- 运行时按 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>
契约:
- authoring 里的裸文本是一个”文本子节点”记号;它在生成期被逐个规范化:纯空白(缩进、换行)直接丢弃;非空白替换成
ui:Label。 - 它不新增任何运行时模型——运行时看到的永远是普通
VisualElement + Label树,“隐式”只存在于 authoring 与编译期之间。 - 多个分离的文本段是多个独立的 XText,各自判定,不会合并。
{{ }} 插值
文本里挖槽,槽里放表达式:
<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 ? "ready" : "pending"}}" class="demo-copy" />
契约:
- 插值只写 string 属性(
Label.text、Button.text……),不挑元素类型,同一个表达式可以同时写进 Label 和 Button。 - 允许与静态文本混排:
text="Visible count: {{ItemCount}}"是“静态前缀 + 插值”结构。 - 表达式面支持:属性路径、方法调用、可选链(
?.)、空值合并(??)、三元。结果永远落成一个字符串。 - 数据变化自动刷新:表达式读了 Ref,就订阅它,值变则重算并写回。
边界(与 bind-*):插值负责”在一句话里挖槽”——前后静态文字属于模板,只有槽里是活的;bind-* 是整体替换,中间不留模板自己的字。想混着说用 {{ }},想整句换用 bind-*。
族 2 · 属性通道
bind-*:属性整体写入
<ui:Label bind-text="FormatSummary()" class="demo-copy" />
<ui:Button bind-text="PrimaryActionLabel" />
契约:
bind-{属性名}="表达式"在激活期建立 Effect:表达式读 Ref 就订阅,Ref 变则重算并整体写回目标属性。- 目标属性是什么类型就写什么类型(string、bool……),不限于文本。
on-*:事件路由
<ui:Button text="Save" on-click="OnSave" />
契约:
on-{事件名}="方法名"把 UITK 事件路由到宿主上的同名方法;注册与注销的生命周期由 binding 管理。- 它只负责”把事件送进方法”,不写属性、不做双向写回。
v-model:双向合同
<ui:TextField label="Query" v-model="Query" />
<ui:Toggle label="Enabled Only" v-model="EnabledOnly" />
契约:
v-model="目标"= pull + writeback:把当前值拉进控件(pull),再把用户修改写回同一目标(writeback);目标必须可写。- 值类型跟随控件通道:TextField 走 string、Toggle 走 bool;同一目标可以镜像到多个控件(两个 TextField 绑同一个
Query,互相同步)。 bind-model是它的同义写法。- 与单向读数的边界:
{{DisplayState}}只是显示派生值,天然只读;能“显示”不等于能“写回”。
族 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>
契约:
- 条件链编译成一份结构蓝图,运行时只命中一个分支:命中即挂载整段子树,未命中即卸载——同一时刻只有一支存在。
- 按 authoring 顺序短路:v-if 不成立才看 v-else-if,全不成立才落 v-else。
- 结构约束:
v-else-if/v-else必须紧跟前面的v-if/v-else-if(同一 authoring 兄弟上下文);脱离链的裸v-else无意义。
v-show:显隐切换
<ui:VisualElement class="demo-strip" v-show="ShowStatusBar">
</ui:VisualElement>
契约:
- 节点保留在树上,只切 display;控件内部状态(滚动、焦点、展开)不因显隐重建。
- 与
v-if的边界:v-show是”藏起来”,v-if是”卸载”——需要保留状态用 v-show,需要整体卸载用 v-if。
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>
契约:
v-for="(item, index) in 列表"把一份循环体展开成多份子树;(item, index)作用域进入循环体。- 循环体可以是复杂结构:嵌套循环、v-if 分支、方法调用入参,全部随每份实例重复展开。
- 内层循环作用域叠加在外层之上:
v-for="tag in item.Tags"里同时可见item与tag。
bind-key:keyed identity
<ui:VisualElement class="demo-item" v-for="(item, index) in VisibleItems" bind-key="item.Id">
</ui:VisualElement>
契约:
- 循环体编译时记录 key 表达式(
bind-key="item.Id");列表更新时按 key 匹配身份——决定哪一项复用、哪一项换人、哪一项重排,不决定循环次数。 - 与 index 的边界:Replace 后 index 仍是 0 但 id 变了(换人);Rotate 后所有 index 变而 id 不变(换座)。key 表达”谁还是原来的同一个实体”。
族 4 · 寻址与投影
ref:命名寻址
<ui:TextField ref="queryField" label="Query Ref" />
契约:
ref="名称"是模板对节点的命名寻址:声明落在谁的模板上,就归谁的 Refs 登记,owner 内用GetRef("名称")按名拿到节点本身。- 它是”拿控件实例做命令式操作”,不是数据绑定;子组件内部的 ref 不会泄漏到外层 owner——ref 不跨 owner。
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>
契约:
- 插槽让宿主组件留坑、外层 authoring 填内容:
tpl:Slot name="名称"是宿主声明的出口,v-slot="名称"的 provider 内容按名配对,运行时投影进坑位。 - 两条互补通道:命名 slot(
v-slot="header"↔tpl:Slot name="header")与默认 slot(没有v-slot的 provider 内容 → 宿主的默认内容容器)。 - provider 属于外层 authoring 子树,在声明处的逻辑路径求值;
v-for循环体里的 provider 带 item/index 作用域稳定投影。 - slot 也可以承担非视觉职能:
display: none的 slot 可以作为 function slot 的注册锚点,宿主再通过对应的函数槽调用入口取回 provider 产出的内容。 - forward 场景下 slotName 可以跨层桥接多跳,不丢配对键。
横切规格:三条贯穿全部 token 的规则
单看 token 会漏掉系统的三条横切约束,它们对所有动态 token 同时成立:
- 表达式文法统一。
{{ }}、bind-*、v-if、v-for、bind-key里的值都走同一套表达式文法(属性路径 / 方法调用 / 可选链 / 空值合并 / 三元)。authoring 里不写复杂逻辑,逻辑收进宿主方法,模板只留拼装与声明。 - 每个绑定都是 Effect。不是”插值用了 Effect”,而是整个模板系统的每个绑定都用了 Effect:
{{ }}、bind-*、v-model、v-if、v-for、v-show全部在 Activate 阶段创建 Effect——读 Ref 即订阅,Ref 变即重算;on-*的注册/注销生命周期也由 binding 管理。 - authoring 便利在编译前端消化,运行时保持最小模型。隐式文本是它的样板:糖在生成期被吃掉,运行时加载到的已经是普通
VisualElement + Label树。
规格之后
token 表立在这里,接下来每一篇实现都在回答同一个问题:这张表的某几行,代码里是怎么落地的。
下一篇(token 开始生效:先看几类最小结果)先不急着钻进实现,先看最轻的一类结果:{{ }} 怎样把值写进模板文本。