AI 写前端,优化的是演示,不是交付
专栏:把 AI 管住(第 00 篇)
大模型写页面时,拟合的是它见得最多的那种前端:文档示例、博客 Demo、十几条字段齐全的 mock。那个分布里,列表很短,请求不会并发,用户也不会在返回前离开页面。
中后台的真实分布几乎处处相反。所以同一句「写个订单列表」,会得到一份能演示的代码,和一份能合并的代码。差的通常不是模型档位,而是规格里有没有写入真实分布。
下面用同一需求的两份生成物说明这件事。
两种规格
A 的规格是愿望:
写个订单列表页,Vue3,Element Plus。
B 的规格是分布:
只改 src/views/order/。按 3000 条设计。
筛选防抖 300ms,过期请求丢弃;空值不抛错;卸载 abort。
key 用 id。分页或虚拟列表,注释写明选择。
A 省略了四个教程里几乎不出现的约束:基数、竞态、空值、生命周期。模型不会主动补问。不问,就按八条数据生成。
两份生成物
A:
<script setup>
import { ref, watch } from 'vue'
import { getOrders } from '@/api/order'
const keyword = ref('')
const orders = ref([])
watch(keyword, async () => {
orders.value = await getOrders({ keyword: keyword.value })
})
</script>
<template>
<el-input v-model="keyword" />
<el-table :data="orders">
<el-table-column type="index" />
<el-table-column label="金额">
<template #default="{ row }">{{ row.amount.toFixed(2) }}</template>
</el-table-column>
</el-table>
</template>
B:
<script setup>
import { ref, watch, onBeforeUnmount } from 'vue'
import { getOrders } from '@/api/order'
const keyword = ref('')
const orders = ref([])
const loading = ref(false)
let timer = 0
let seq = 0
const ac = new AbortController()
async function load() {
const my = ++seq
loading.value = true
try {
// 3000 条走分页,禁止一次渲染全表
const data = await getOrders({ keyword: keyword.value, pageSize: 20 }, ac.signal)
if (my === seq) orders.value = data
} finally {
if (my === seq) loading.value = false
}
}
watch(keyword, () => {
clearTimeout(timer)
timer = setTimeout(load, 300)
})
load()
onBeforeUnmount(() => { ac.abort(); clearTimeout(timer) })
</script>
<template>
<el-input v-model="keyword" />
<el-table v-loading="loading" :data="orders">
<el-table-column prop="id" />
<el-table-column label="金额">
<template #default="{ row }">{{ row.amount?.toFixed(2) ?? '-' }}</template>
</el-table-column>
<template #empty>暂无订单</template>
</el-table>
</template>
B 在合并前仍改了空态文案。生成物是初稿。把初稿当交付,是 A 真正的问题。
表格
| 被省略的约束 | 教程默认 | 真实分布 | A 的结果 |
|---|---|---|---|
| 基数 | 8~20 条 | 10³ 量级 | 当短列表写 |
| 输入 | 一次提交 | 连续按键 | 无防抖,请求乱序 |
| 字段 | 总有值 | 可空 | toFixed 抛错 |
| 生命周期 | 页面常驻 | 会卸载 | 卸载后仍写入 |
前端不可替代的部分,经常就是把这四类约束从潜台词写成接口。这不是「写得更细」,这是在改模型的目标函数:从「像能跑」改到「在真实分布下可运维」。
和模型协作的三层接口
Rule、Skill 只是各工具的文件名。工程上它们对应三层早就存在的东西:
表格
| 层 | 有效期 | 常见载体 | 作用 |
|---|---|---|---|
| 仓库不变量 | 跨任务 | AGENTS.md、Cursor Rule、Copilot instructions、CLAUDE.md | 禁令:永远不准怎样 |
| 任务类型 SOP | 某类活 | Skill、斜杠命令、提示词模板 | 大列表、新页面怎么做 |
| 单次规格 | 这一单 | 当前提示词 | 范围、数据量、验收 |
只使用第三层,且第三层还是一句话,生成物会落在教程分布里。三层都写成可判定的句子,模型才有机会偏离 Demo。
工具可以换,层不能省。形容词不能当不变量:「注意性能」没有真值;「N>200 必须分页」有。
最小不变量
放入仓库根目录 AGENTS.md:
Vue3 + TS + Vite + Pinia + Element Plus。禁止另引 UI 库。
src/views/ 页面;src/api/ 接口;src/components/ 公共组件。
必须:script setup;列表用稳定 id;空/错/加载有 UI;卸载取消请求;金额先判空。
禁止:密钥进前端;v‑html 渲染接口数据;公共函数 any;顺手重构无关文件;
列表可能超过 200 条时按短列表实现(分页或虚拟列表,注释写明)。
提交前:空数组不抛错;快筛不串数据;离开页面无报错;diff 不含无关文件。
没有这一层,Rule 的文件格式再正确也没有约束可执行。格式放到下一篇。
单次规格
说明书管跨任务,管不住这一单的含糊。最低限度:
范围:
目标(用户侧可观察的结果,禁止「更好用」):
数据量 / 并发假设:
禁止:
验收(全满足才结束):
愿望填不进这些格子。填不进的格子,就是生产里先坏的那几处。
另外三件事会把规格再次打回教程分布:一次要求性能、样式、重构同时完成;不声明基数;不声明不可修改的路径。
本系列
先把「如何调用模型」写完,再谈如何把模型接到产品里。
表格
| 篇 | 问题 |
|---|---|
| 01 | 不变量写成作文,为何等于没有 |
| 02 | SOP 为何必须进仓库,而不是进聊天 |
| 03 | 提示词作为规格,而不是愿望 |
| 04 | 未注入的上下文等于不存在 |
| 05 | 补全、行内修改、Agent 的适用边界 |
| 06 | 生成物的审查:diff、测试、生命周期 |
| 07 | 哪些第一版不该由模型写 |
| 08 | 同一页面,规格完整与否的六处差别 |
结论
同一模型。改变的是规格密度,以及生成物在流程里的地位:初稿,还是交付。
专业前端要的不是页面存在,而是:真实基数下可交互,空值不抛错,生命周期结束后不再写入,下一迭代仍可修改。这些不会出现在五个字的愿望里。它们只出现在不变量、SOP 和单次规格里。
下一篇写不变量:为何两百行「编码规范」拦不住 v‑html。