Skip to content

记录、行内动作与变更证据 ​

可运行示例在浏览器中运行本章示例

这里运行与本地夹具相同的 Vue 3 源码。模型请求由服务端网关转发,供应商密钥不会进入浏览器。

本地运行pnpm tutorial:dev v3-07 --locale zh源码examples/tutorials/v3-07-records-row-actions-and-change-evidence/

上一章解决了“动作落到哪个元素”。当页面是列表时,还会出现另外两个问题:模型如何知道每一行代表什么,以及运行结果如何证明变化确实发生在预期的那一行。继续把每一行的复选框和删除按钮作为独立动作暴露,动作数量会随行数增长;只靠元素 id,模型也不知道被操作的是哪条业务记录。

本章解决的是页面对象的数据化表达与执行结果的可观察证据。对应的两个概念是记录投影与变更证据:动作不再只绑定到某个 DOM 元素,而是可以作用于一条被声明过的记录;重复的行内动作被折叠成一个带 record 参数的工具;执行后,框架比较页面前后快照,报告可见的字段、记录和动作变化。

这两个机制解决不同层面的问题。记录解决“操作对象是谁”,动作折叠解决“上下文规模”,变更证据解决“执行后看到了什么变化”。任何一项都不能替代权限、成功判据或事务语义。

问题与目标 ​

第一,动作清单仍然随行数增长。三条任务各有一个复选框和一个删除按钮,就会产生六个元素级动作 id;一百条任务就是两百个。把它们全部发给模型既浪费上下文,截断后又会让页面看起来像缺了一部分功能。

第二,toggle@t2 这样的 id 只说明要操作哪个元素,并不说明 t2 是什么。模型可以在不知道第二行属于「预约设计评审」、也不知道它是否未完成的情况下点击复选框。人通常按数据描述任务,而不是按内部元素 id 提要求。

本章在同一个 Todo 应用上加入两部分:

  • 用 v-record 暴露每一行的数据;
  • 把共享 id 前缀的行内动作折叠成一个带 record 参数的模型工具。

随后说明最后一段闭环:动作执行后,框架比较页面前后快照,返回变更证据。它把「动作被调用了」和「页面上可见的一部分确实变了」区分开。

业务状态和操作沿用上一章。新增的接入代码只有每行的记录声明,以及已有复选框、删除按钮上的两个动作声明。读者完成后,应能解释记录字段、行内动作折叠和变更证据各自回答什么问题,以及它们不能证明什么。

声明 Agent 可以读取的记录 ​

每条任务本来就是带 id、文本、完成状态和优先级的普通对象。列表项通过一个指令变成记录:

vue
<li
  v-for="task in visibleTasks"
  :key="task.id"
  v-record="{
    data: task,
    fields: {
      text: task.text,
      status: task.done ? 'completed' : 'open',
      priority: priorityLabel(task.priority),
    },
  }"
>

记录 id 是 task.id(t1、t2……)。Agent 看到的是一份独立、精简的行视图:

text
t1: text 检查发布清单; status open; priority 今天
t2: text 预约设计评审; status open; priority 近期
t3: text 跟进客户; status completed; priority 稍后

也支持更短的写法:

vue
<li v-for="task in tasks" :key="task.id" v-record="task">

它会从 task.id(其次 task.key)取 id,并暴露对象里的其余属性。这里使用完整写法,是因为原始值不一定便于模型判断:status: 'open' 比 done: false 更直接,显示标签「近期」也比内部枚举 soon 更有信息量。

规则来自数据的来源边界:Pling 读取声明的数据,不抓取行里的文本。DOM 抓取会把展示格式当成事实、丢失真实类型,还可能读到原本不该离开页面的信息。字段会影响 Agent 的判断,就暴露;不会,就不要暴露。

登记的是这一行,字段在组装快照时才读取。因此完成状态或优先级变化后,下一轮就能看到新值,不必重新登记整行;只有换成另一个 id 的对象时,才会重新登记。

行内动作的折叠 ​

复选框和删除按钮已经存在,只需要让动作 id 同时表达「做什么」和「作用于哪一行」:

vue
<input
  type="checkbox"
  v-tool="{
    id: `toggle@${task.id}`,
    desc: `将“${task.text}”标记为${task.done ? '未完成' : '已完成'}。`,
    kind: 'write',
  }"
  :checked="task.done"
  @change="toggleTask(task.id)"
/>

<button
  type="button"
  v-tool="{
    id: `remove@${task.id}`,
    desc: `删除“${task.text}”。此操作不可撤销。`,
    kind: 'danger',
  }"
  @click="removeTask(task.id)"
>
  ×
</button>

id 分别是 toggle@t1、toggle@t2、remove@t1……框架把最后一个分隔符前的部分当作动作模板,把后缀当作记录 id。

注册表里仍然保留每个元素级 id。精确执行、权限和审计都需要它。变化发生在给模型的投影里:当至少两个动作共享同一模板,并且对应记录 id 确实存在时,Pling 只输出一个工具,而不是每行一个。

折叠后的工具多出一个必填的 record 参数:

text
toggle(record)
  将“{record}”标记为未完成或已完成。
  record: one of t1 / t2 / t3

remove(record)
  删除“{record}”。此操作不可撤销。
  record: one of t1 / t2 / t3

动作数量于是与操作种类成正比,而不是与「行数 × 操作种类」成正比。一百行仍然使用同样两个行内工具。

两个限制是有意的。只有一行使用某个模板时不折叠,因为直接写 id 更短;没有记录时也不折叠,因为 record 必须指向模型真正见过的数据。行声明提供身份,框架不会把动作 id 本身当作业务数据来源。

记录选择与调用 ​

用户现在可以说:

text
将“预约设计评审”标记为已完成。

模型读取记录列表,认出这句话对应 t2,然后调用折叠工具:

json
{ "record": "t2" }

协议层把这次调用还原成精确的 toggle@t2。执行器仍然经过同一套权限、目标解析、可见性检查和事件路径。折叠只改变模型接口的形状,不改变业务操作的实现。

这也让审计保持清楚。模型在业务记录中选择对象;框架和应用仍然执行一个精确的元素级动作。日志可以保留 toggle@t2,而用户看到的是让这个选择有意义的任务文本。

record 必须来自当前记录列表。模型不应因为页面有十行就猜一个 t7,也不应把展示位置当成记录 id。滚动导致的视口外目标与长列表放在后续章节;本章只建立「引用必须来自已知记录」这条规则。

变更证据的生成 ​

上面的调用不会只返回 { ok: true }。执行器在动作前取一份快照,等待页面落定,再取一份快照,并比较模型能看见的部分。一次成功的完成切换会返回类似的证据:

json
{
  "changed": {
    "observed": true,
    "records": {
      "added": [],
      "removed": [],
      "updated": [{ "id": "t2", "fields": ["status"] }],
      "reordered": false,
      "omitted": { "added": 0, "removed": 0, "updated": 0 }
    }
  }
}

删除 t3 会同时报告消失的行,以及随它消失的动作:

json
{
  "changed": {
    "observed": true,
    "records": {
      "added": [],
      "removed": ["t3"],
      "updated": [],
      "reordered": false,
      "omitted": { "added": 0, "removed": 0, "updated": 0 },
      "total": { "from": 3, "to": 2 }
    },
    "actions": {
      "added": [],
      "removed": ["toggle@t3", "remove@t3"],
      "omitted": { "added": 0, "removed": 0 }
    }
  }
}

新增任务会报告新 id 进入 records.added。筛选或搜索则可能主要报告动作清单变化,因为当前可操作的行集合变了。

返回值保持窄小。updated 只列变化的字段名,不复制新旧值;每个集合最多列五条,其余用 omitted 报数。这足够让 Agent 确认预期的那一行变了,又不会把每次动作结果变成页面内容的第二份拷贝。

执行器在适配器的 settle 之后取证据。这对 Vue 很重要:v-for 引起的列表更新未必在同一个微任务里反映到 DOM。过早读取 after 快照会制造假证据,最常见的表现就是删除明明成功,diff 却说「无变化」。

变更证据的边界 ​

这里同时出现三个概念,不能混用:

机制回答的问题不能证明什么
权限 / 确认这个调用方是否可以先尝试?页面发生了变化。
expect声明的业务条件是否成立?周围所有东西分别变成了什么样。
changed两份快照之间,暴露的行、动作、状态或反馈有什么差异?变化是否完整、正确,或对所有读取方可见。

changed.observed: false 是告警,不是动作失败。控件可能执行了却没有效果,也可能改到了页面没有暴露的数据。结果只说明框架能看见哪一种情况,不会替未知部分下结论。只读动作不会因为无变化而告警,因为导航和滚动本来就可能不改变记录。

变更证据也不是事务。如果处理函数在 after 快照前先新增又删除了同一行,中间状态看不到;如果网络请求在快照之后才完成,也不在证据窗口内。需要跨多个资源保持的业务不变量仍属于应用或服务端,不能交给 DOM diff。

证据在运行结果中的呈现 ​

Agent 会在工具结果里收到 changed,并被要求依据它说明动作是否生效。助手界面里的执行记录会把同一结果变成人话,例如改了某条记录、删除了某一行,或者没有观察到变化。

这补上 DOM 自动化的一个具体盲点。模拟点击可能「成功」,但控件已禁用、状态已过期,或处理函数忽略了当前模式。没有前后比较时,Agent 只知道事件派出去了;有了记录和证据,它能说出页面哪一部分出现了可观察的差异。

它仍可能误解意图。证据报告「变了什么」,不判断这个变化是否符合用户目的。模型还要结合动作说明、参数和用户请求一起判断。

分层验证 ​

夹具不依赖模型回复的措辞,而是分别检查三层:

  1. 声明。 快照按页面顺序包含 t1、t2、t3,且只有声明的字段。
  2. 协议投影。 buildProtocol() 生成折叠后的 toggle 与 remove 工具,必填 record 接受当前 id;元素级 id 仍然可以精确执行。
  3. 执行证据。 直接执行 toggle@t2 会报告 status 更新;remove@t3 会报告行消失与动作减少;add_task 会报告新增行。回放模型再用 record 调用折叠工具,产生预期页面状态。

这样分工便于维护。记录声明坏了,不应让 diff 测试因无关原因失败;证据形状错了,也不应被一句听起来正确的模型回答掩盖。

验证本章示例 ​

在仓库根目录执行:

bash
pnpm tutorial:dev v3-07 --locale zh

脚本会打包框架包、在隔离目录安装,并在端口 5217 启动 Vite。

手动检查:

  1. 对助手说:把“预约设计评审”标记为已完成。 第二行应变为已完成,执行记录应报告一条更新记录。
  2. 说:删除“跟进客户”。 第三行应消失,执行记录应说明删除的行以及随它消失的行内动作。
  3. 说:添加“起草发布说明”,优先级设为稍后。 新行应出现,结果应指出新增记录。
  4. 在调试视图对照记录列表与三个字段,模型视图里不应出现未声明的对象属性。

本章边界与后续 ​

  • 长列表和视口外记录在后续章节处理;本示例只有三条可见任务。
  • 记录本身不是安全边界。它是有选择的视图;敏感值需要后面的数据限制机制。
  • diff 比较的是暴露出来的快照,看不到隐藏数据、服务端状态,也看不到证据窗口之后才完成的改动。
  • 它不是事务,不保证多个动作之间的顺序或原子性。
  • 行内动作仍然需要可信的元素路径或声明的 run;记录不会把不安全的目标变安全。
  • 示例使用组件本地状态。读取 Pinia 或 Vuex 属于另一类状态暴露问题,不能替代行声明。

下一章函数级工具处理没有可用 DOM 元素可点的业务操作。记录说明操作作用于哪个对象;函数级工具用于实现本来就是函数、而不是控件的操作。