刷新后草稿还在吗:核对本地保存与恢复
用保留首尾空格和真实换行的虚构草稿,核对本地保存、回读与同源页面重载恢复;通过坏记录和显式异常替身,验证恢复失败不覆盖旧值、写后回读失败不冒称成功。
执行信息
- 测试环境
- Chrome 154.0.8037.93 · Playwright 1.62.1 · Node.js 22.22.0;隔离上下文、同源 loopback HTTP;显式 storage adapter 异常替身
- 输入
- 请查看正文中的输入说明
- 产出
- 请查看正文中的产出说明
- 实测结果
- 请以作者提供的实测记录为准
AgentField 编辑整理|本文只使用虚构草稿,在隔离的本机浏览器环境中验证保存与页面重载。异常来自明确标注的测试替身,不代表发生了真实浏览器存储故障。
做完一个草稿框,点击保存后文字还在,容易误以为已经验收完成。其实这时文字可能只留在页面内存里。另一个容易漏掉的情况是:旧记录读不出来,页面却把空白当作新草稿保存,覆盖了本来还可以排查的记录。
这篇适合正在验收表单草稿、笔记输入框和 AI 生成页面的人。目标很小:证明指定的文字写到了指定的本地存储键,并且同源页面重载后能原样恢复;遇到无法确认的结果,让提示保持准确。
一、先定保存契约,再写页面
固定键名为 agentfield.demo.draft.v1,记录只能有 schemaVersion 和 body 两个字段:版本是数字 1,正文是字符串。正常输入为“两个空格+第一行+一个真实 LF 换行+第二行+两个空格”。它的字符串表示是 " 第一行\n第二行 ",这里的 \n 表示一个换行,不是反斜杠和字母 n。页面不得对正文调用 trim()。
保存后的原始字符串应精确等于 {"schemaVersion":1,"body":" 第一行\n第二行 "}。先比较原始字符串,再解析并比较字段;这样不会把丢空格、换行变化或错字段当成成功。
Web Storage 的值是字符串,所以这里用 JSON 序列化对象;键不存在时,getItem() 返回 null。这和“存着空字符串”是不同情况:本例把 null 当作无记录,而把无法解析的已有记录当作恢复失败。Storage.setItem 文档和 Storage.getItem 文档给出了这些接口约定。
恢复时也不能只看 JSON 能否解析。坏 JSON 会使 JSON.parse() 抛出异常;合法 JSON 仍可能是版本 2、数组或缺少正文的对象。解析后还要检查本例约定的字段、版本和类型。JSON.parse 文档
二、恢复失败后,先保留排查空间
本例采用一个明确但保守的策略:只要已有记录无法读取或不符合结构,当前页面就禁用保存,不自动清空、不自动修复、不用空值覆盖旧记录。输入框仍可输入和复制文字,但提示保持“恢复失败”,不会因为继续打字而掩盖故障。重新载入只重试恢复;示例没有“强制覆盖”按钮。
这是演示的选择,不是完整的草稿迁移产品。用户应先保留新输入,排查旧记录后再决定如何恢复。今后若加入“丢弃旧记录”或版本迁移,应另定确认、备份与验收条件,不能混进这次正常保存测试。
保存分成两步:先调用写入,再回读同一个键,精确比较原始字符串。只有两步都完成才显示“已写入并回读本浏览器草稿”。写入调用抛错时,只说本次未确认新内容已存储;写入返回而回读失败时,也只说结果未确认。后一种情况底层可能已经写入,不能擅自宣告成功或失败。
三、可运行的最小页面
把下面内容保存为 index.html,放在一个只包含演示文件的目录,通过本机 HTTP 静态服务打开。只输入虚构文字。保持协议、主机和端口不变,再进行刷新测试;换端口就换了来源。不要把直接双击 file: 页面得到的行为当作同样的验收环境,MDN 指出该场景的存储行为没有统一保证。Window.localStorage 文档
<!doctype html>
<html lang="zh-CN">
<meta charset="utf-8">
<meta name="viewport" content="width=device-width,initial-scale=1">
<title>刷新后草稿还在吗</title>
<style>
body { max-width: 720px; margin: 40px auto; padding: 0 20px; font: 17px/1.6 system-ui; color: #20353c; background: #f4f6f2; }
h1 { line-height: 1.3; }
label { display: block; margin: 24px 0 8px; }
textarea { box-sizing: border-box; width: 100%; min-height: 180px; padding: 14px; border: 1px solid #8a9c94; border-radius: 6px; font: inherit; white-space: pre-wrap; }
button { margin-top: 16px; padding: 10px 20px; border: 0; border-radius: 6px; background: #176b58; color: white; font: inherit; }
button:disabled { background: #7a827e; }
#status { min-height: 3em; padding: 12px; background: white; border-left: 4px solid #176b58; }
#status[data-state="error"], #status[data-state="unconfirmed"] { border-color: #ae4b2d; }
</style>
<main>
<h1>刷新后草稿还在吗</h1>
<p>只输入虚构文字。这里验证当前浏览器、当前来源的保存和页面重载;不是云端备份。</p>
<label for="draft">虚构草稿(保留空格与换行)</label>
<textarea id="draft" spellcheck="false"></textarea>
<button id="save" type="button">保存草稿</button>
<p id="status" role="status"></p>
</main>
<script>
'use strict';
const key = 'agentfield.demo.draft.v1';
// Tests may inject an explicit adapter. Ordinary use delegates to Web Storage.
const storage = globalThis.__draftStorage || {
getItem: key => window.localStorage.getItem(key),
setItem: (key, value) => window.localStorage.setItem(key, value)
};
const draft = document.querySelector('#draft');
const save = document.querySelector('#save');
const status = document.querySelector('#status');
let recoveryBlocked = false;
function show(kind, text) {
status.dataset.state = kind;
status.textContent = text;
}
function decode(raw) {
const value = JSON.parse(raw);
if (!value || Array.isArray(value) || typeof value !== 'object' ||
Object.keys(value).length !== 2 || value.schemaVersion !== 1 ||
typeof value.body !== 'string') throw new Error('invalid-draft-record');
return value;
}
try {
const raw = storage.getItem(key);
if (raw === null) {
draft.value = '';
show('empty', '没有可恢复的本地草稿。');
} else {
draft.value = decode(raw).body;
show('restored', '已从本浏览器恢复草稿。');
}
} catch {
recoveryBlocked = true;
save.disabled = true;
show('error', '恢复失败,原记录未改动;保存已禁用,请先保留文字并排查旧记录。');
}
draft.addEventListener('input', () => {
if (!recoveryBlocked) show('dirty', '有未保存的修改。');
});
save.addEventListener('click', () => {
if (recoveryBlocked) return;
const raw = JSON.stringify({ schemaVersion: 1, body: draft.value });
try {
storage.setItem(key, raw);
} catch {
show('error', '保存调用失败;请保留页面文字,本次未确认新内容已存储。');
return;
}
try {
if (storage.getItem(key) !== raw) throw new Error('readback-mismatch');
show('saved', '已写入并回读本浏览器草稿。');
} catch {
show('unconfirmed', '写入已返回,但回读失败或内容不符;保存结果未确认,请保留页面文字。');
}
});
</script>
</html>