学习前提
理解JSON、对象校验、异常和本地存储恢复。
本课完成后你能做什么
把不可靠的输入转换成明确数据结构,用可重复测试保护迁移与恢复。
先理解机制
JSON解析成功只说明语法正确,不能保证tasks一定是数组或done是布尔值。数据可能来自旧版本、用户修改或部分损坏。把解析、验证、迁移和存储分开:先得到普通值,再转换成当前结构,最后才决定是否写回。
给存储对象增加version明确格式。旧版本可能只有字符串数组,新版本需要id、text和done。迁移函数应是纯函数:同样输入得到同样输出,不读DOM、不写localStorage、不依赖随机数。这样可以直接测试,不必每次操作界面。
错误策略由业务决定:本例过滤无效条目,但未知版本直接拒绝,避免误覆盖未来版本数据。保留原始字符串直到迁移和保存成功,再更新键。不要在catch里无条件清空全部存储,恢复不应扩大损失。
迁移需要兼容性测试与幂等性检查:当前版本再次经过规范化不应改变业务含义。稳定id要唯一;无效和重复id怎样处理必须可解释。示例重新为旧字符串数据分配确定id,不宣称能合并多设备的记录。
可运行示例
运行方式:在独立练习网页的开发者工具Console中运行JavaScript;不要在含有重要表单的页面练习。修改输入后重新运行完整示例。
function migrate(input) {
if (!input || typeof input !== 'object') throw new Error('Invalid envelope');
if (input.version === 1 && Array.isArray(input.tasks)) {
const tasks = input.tasks.filter(x => typeof x === 'string' && x.trim()).map((text,i) => ({id:i+1,text:text.trim(),done:false}));
return {version:2,tasks};
}
if (input.version === 2 && Array.isArray(input.tasks)) {
const seen = new Set();
const tasks = input.tasks.filter(x => {
if (!x || !Number.isSafeInteger(x.id) || x.id < 1 || seen.has(x.id) || typeof x.text !== 'string' || !x.text.trim() || typeof x.done !== 'boolean') return false;
seen.add(x.id);return true;
}).map(x => ({id:x.id,text:x.text.trim(),done:x.done}));
return {version:2,tasks};
}
throw new Error('Unsupported version');
}
const converted=migrate({version:1,tasks:[' HTML ','',42,'CSS']});
console.log(converted);
if(JSON.stringify(migrate(converted))!==JSON.stringify(converted))throw new Error('Not idempotent');
let rejected=false;try{migrate({version:99,tasks:[]});}catch{rejected=true;}
if(!rejected)throw new Error('Unknown version accepted');
console.log('Migration checks passed');
推演与验证
旧数据应产生HTML与CSS两项,id为1与2,done均false。二次迁移结果相同。未知版本必须抛错。为version2加入重复id、字符串done和空text,确认被过滤且原输入未被修改;可在运行前冻结输入辅助发现意外写入。
常见错误与边界
此示例没有设置产品级条数和文本长度限制,接入真实存储时应按业务补齐。过滤策略不适合必须逐条追溯的数据,届时应返回错误详情而不是静默丢弃。
动手练习
让migrate返回warnings,说明丢弃多少条以及原因。未知版本仍拒绝,原数据保留不覆盖。
参考解法与理由
把验证写成返回明确原因的函数,按输入位置累计警告;成功结果与警告分别展示。存储调用者只有在接受该策略后写入新版本,测试同时检查数据与警告,不只检查数组长度。
深入实验:迁移必须可以重复而不重复改数据
升级本地数据时,直接给所有记录追加字段可能覆盖已经保存的新版本。设计一个返回新对象的纯迁移函数,先确认版本,再转换旧结构。下例不读写localStorage,因此可以把迁移逻辑和浏览器存储失败分开测试。
在现代浏览器Console中独立运行这个实验;它不替换上方主示例。
function migrate(value) {
if (!value || typeof value !== 'object') throw new Error('Invalid envelope');
if (value.version !== 1 && value.version !== 2) throw new Error('Unsupported version');
if (!Array.isArray(value.items)) throw new Error('Invalid items');
const items = value.items.map(item => {
if (!item || typeof item.title !== 'string') throw new Error('Invalid title');
if (value.version === 1) {
if (typeof item.done !== 'boolean') throw new Error('Invalid done');
return {title: item.title, stage: item.done ? 'done' : 'todo'};
}
if (!['todo', 'doing', 'done'].includes(item.stage)) throw new Error('Invalid stage');
return {title: item.title, stage: item.stage};
});
return {version: 2, items};
}
const old = {version: 1, items: [{title: 'HTML', done: false}]};
const next = migrate(old);
console.log(JSON.stringify(next));
console.assert(JSON.stringify(migrate(next)) === JSON.stringify(next));
console.assert(old.items[0].done === false && !('stage' in old.items[0]));预期观察
结果为{"version":2,"items":[{"title":"HTML","stage":"todo"}]}。第二次迁移结果不变,旧对象也没有被原地修改。
原因、取舍与适用边界
这里验证的是幂等的结果,而非要求返回同一个对象引用。未来版本必须明确拒绝,不能当作旧版强行降级。真实迁移还需验证唯一ID、长度上限和记录数量;本实验只聚焦版本与状态字段,不是通用数据清洗器。应先完整验证新对象,再尝试保存;写入失败要保留旧数据并告诉用户当前更改尚未持久化。
进阶练习
如果旧版done为字符串"false",为什么不能直接用它的真假值?
解法与推理
非空字符串为真,会把未完成事项变成done。默认拒绝类型不符合契约的数据;若确有已知历史格式,单独写"true"/"false"的显式映射,并为其他字符串保留失败分支。