SPA 内联编辑与发布同步加固
解决单页应用中三类「数据看起来在、实际没存住」的问题:表格内联编辑回滚、发版后客户端拿不到新版、多视图数据不同步。
功能概述
这三类问题的共同特征是:不报错、不抛异常,只是安静地不生效。控制台干净、请求也返回 200,值就是没存住。所以排查不能靠看日志,必须按「值在哪一步被丢掉」逐层定位。
本 skill 给出:四条经真实返工验证的铁律、可直接套用的草稿模式代码骨架、跨视图同步方案、SPA 版本自感知实现、以及六个必测用例和一个可执行的探测脚本。
触发方式
命中任意一条即加载本 skill(用户原话常是下面这些):
- 「这个单元格填不进去 / 打不了字」
- 「改完一闪又变回原来的值」「显示已保存,刷新就没了」
- 「填完鼠标一移开就变没」
- 「筛一下之后刚改的又变回去了」
- 「更新完还要我手动刷新才看到新的」
- 「A 页面改的,B 页面看不到」
技术特征:Vue 3 + Element Plus 的 el-table 内联编辑、SPA(vite 构建)、keep-alive 多标签页。如果症状只在表格里出现、换成普通表单就正常,几乎一定是本 skill 覆盖的问题。
四条铁律
1. 表格行对象可能是副本,禁止 v-model 直连
只要表格 :data 来自 computed,且元素是 map / {...row} / recomputeParent(row, kids) 生成的,那么:
v-model="row.field"写的是副本- 值能显示、能保存,但任何一次重算(筛选变化、切页重载、keep-alive 激活)副本重建 → 值回退
- 用户感知:「改完一闪又没了」「这个字段根本不能编辑」
解法:草稿 + 显式提交(见下)。
2. 受控组件只给 :model-value 不回写,一定会回滚你
el-input / el-input-number / el-date-picker 都是受控组件。只传 :model-value 而不监听 @update:model-value 时,组件会按旧 prop 把用户输入重置。
| 组件 | 症状 |
|---|---|
el-input |
打不进字(input.value 永远是空串) |
el-input-number |
能输入,失焦后被格式化成旧值 |
el-date-picker |
日历点选无反应(选中值立刻被回滚) |
结论:表格内联编辑优先用原生 <input>(无组件状态、无回滚)。必须用组件时,一律走 v-model + 草稿。
3. undefined(没草稿)≠ ''(用户清空)
这是最隐蔽的一条。草稿模式里写成:
const raw = (draft[key] ?? '').trim(); // ❌ 把两种语义合并了
提交时 delete draft[key] 会触发重渲染,组件/表格重新 patch 时又抛一次 blur。第二次读到空串,就当成「用户清空」——发出一个空值请求,把刚保存成功的值覆盖掉。
症状就是:填完鼠标移开就变没。
const draft = draftMap[key];
if (draft === undefined) return; // ✅ 这次失焦没有新输入,直接返回
delete draftMap[key];
4. SPA 页面不会自己重新拉 index.html
index.html 只在「打开页面 / F5」时请求一次。服务端换了构建产物,已打开的标签页跑的仍是内存里的旧 JS——这不是缓存头配错,是 SPA 的固有行为。
解法:构建产出 version.json + <meta name="app-build">,前端定时比对(见 references/release-awareness.md)。
标准实现:草稿 + 显式提交
<script setup lang="ts">
import { reactive } from 'vue';
/** 草稿按业务主键存,与表格数据是否重建无关 */
const draft = reactive<Record<string, string>>({});
function onInput(key: string, e: Event): void {
draft[key] = (e.target as HTMLInputElement).value;
}
function onBlur(row: Row, key: string): void {
const value = draft[key];
if (value === undefined) return; // 铁律 3
delete draft[key];
const next = value.trim() === '' ? null : value.trim();
if (next === (row.field ?? null)) return; // 没变不写库
row.field = next;
void save(row);
}
async function save(row: Row): Promise<void> {
await api(row);
// 写回源数组:表格行是 computed 副本,只改副本会在下次重算时回退
const src = rows.value.find((r) => r.id === row.id);
if (src) src.field = row.field;
bumpSyncRev(); // 通知其它视图
}
/** 重载数据后清草稿,界面一律以库为准 */
function load(): Promise<void> {
// ...
for (const k of Object.keys(draft)) delete draft[k];
}
</script>
<template>
<!-- 原生 input:DOM 自己管显示,只有草稿变化才回写,不会「卡在第一个字符」 -->
<input
:value="draft[row.id] ?? (row.field == null ? '' : String(row.field))"
maxlength="200"
@input="(e) => onInput(row.id, e)"
@blur="onBlur(row, row.id)"
@keyup.enter="(e) => (e.target as HTMLInputElement).blur()"
/>
</template>
要点:
- :value 读草稿(?? 只判 null/undefined,用户清空产生的 '' 会保留)
- @input 写草稿驱动渲染,输入才留得住
- @blur 才提交,避免每个字符一次请求
- @keyup.enter 主动 blur,符合表格编辑直觉
- 提交成功后写回源数组
跨视图同步
两个页面读同一份接口数据、各自被 keep-alive 缓存时,A 页保存后 B 页仍是旧值。
// stores/sync.ts
export const dataRev = ref(0);
export function bumpDataRev(): void { dataRev.value += 1; }
// 每个相关页面
let lastRev = dataRev.value;
onActivated(() => {
if (dataRev.value === lastRev) return;
lastRev = dataRev.value;
void reload();
});
写操作成功后 bumpDataRev() 并同步自己的 lastRev(避免切回来重载自己)。
不要每次 onActivated 都重载——会丢树表展开状态、白等一次请求。
排查顺序(按命中率排)
- 打开 DevTools,直接在输入框里敲字符,看
input.value有没有留下来
- 留不住 → 铁律 2(受控组件回滚)
- 留得住但失焦消失 → 铁律 3(重复 blur 空覆盖)
- 一直正常但重算后回退 → 铁律 1(副本) - 看 Network:请求发了几条?有没有
field=(空值)这种多余请求
- 有 → 铁律 3
- 完全没有 →@input没触发,或字段绑错了行 - 看请求参数对不对:值是不是变成了「相邻列的文字拼接」
- 是 → 列索引/选择器算错了(测试脚本常见),不是业务 bug - 有筛选就清掉再试,对比有无筛选的差异
- 只在有筛选时复现 → 铁律 1
验证要求(交付前必查)
- 连续输入多字符,不是只测一个字符
- 二次失焦:点进单元格 → 不输入 → 点走,值必须仍在
- 主动清空仍然生效(不能因为守卫把「清空」也挡了)
- 有筛选 + 清除筛选后值仍在
- 刷新页面后与库一致
- 另一页/另一视图同步可见
参考文件
references/inline-edit.md— 四类组件的回滚机制、正确写法对照、el-table副本来源references/release-awareness.md— SPA 版本自感知完整实现(vite 插件 + composable + 提示条)references/verification-playbook.md— 真实数据安全验证、Playwright 探测、headless 与 headful 差异scripts/probe-inline-edit.cjs— Playwright 探测脚本:逐字符输入、二次失焦、列定位,改配置即可跑
使用示例
场景一:报价表「提报成本」列,用户反馈填完一移开就变没
- 按「排查顺序」第 1 步,在输入框里敲
123,input.value留得住 → 排除受控组件回滚 - 敲完点别处,值消失;Network 面板里出现两条请求:
cost=123和cost=(空) - 定位为铁律 3:
onBlur用?? ''把「没有草稿」当成「用户清空」,重渲染后第二次 blur 把值写成 null - 加守卫
if (draft[key] === undefined) return;,重新验证「二次失焦」用例通过
场景二:两个页面读同一份接口,A 页改了 B 页看不到
- 确认两页都在
keep-alive内、各自持有一份接口数据 - 加全局修订号
dataRev,写操作成功后bumpDataRev() - 两页在
onActivated里比对dataRev !== lastRev才重载 - 验证:用 hash 路由切页(
location.hash = '#/other',不能用page.goto否则整页刷新测不出onActivated)
场景三:每次构建完,同事都要手动刷新才能看到新功能
- 先排除缓存误判:
vite preview对 html/json 本来就发no-cache,不是缓存头问题 - 加
buildVersionPlugin()产出version.json+ 注入<meta name="app-build"> - 前端每 30 秒比对,不一致则顶部提示并 8 秒后自动刷新
- 加
setDirty上报,有未保存草稿或光标在输入框时暂停倒计时 - 验证时必须真跑一次
npm run build,只改version.json会造成误判
版本迭代记录
| 版本 | 日期 | 更新内容 |
|---|---|---|
| v1.0.0 | 2026-10-29 | 首次发布:四条铁律、草稿模式代码骨架、跨视图同步、SPA 版本自感知、验证手册与探测脚本 |