先记住这个答案
JSON.stringify 序列化对象时,只看它自己的可枚举字符串属性。Map 的数据存在内部的条目里,不是属性,所以直接序列化只能得到 {},数据悄悄丢了。正确做法是先转成条目数组:JSON.stringify([...map]),恢复时 new Map(JSON.parse(str))。这个方法保留了键的类型和插入顺序,但仍然受 JSON 本身的限制:键是对象时恢复出来的是新对象,undefined、NaN 这些值会变成 null,遇到 BigInt 还会直接报错。
Map的条目不是属性,JSON.stringify看不见- 保存转成
[...map],恢复时交给new Map - 对象键恢复后是新对象,原来的引用查不到
Object.fromEntries会把键都转成字符串
条目数组的保存和恢复
比如把各状态的任务数量存进 localStorage,下次打开页面再恢复。
const source = new Map([['draft', 2], ['ready', 5]])
console.log(JSON.stringify(source)) // 直接序列化,数据丢了
const encoded = JSON.stringify([...source]) // 转成条目数组
console.log(encoded)
const restored = new Map(JSON.parse(encoded)) // parse 得到数组,再建 Map
console.log(restored.get('ready'))
console.log(JSON.stringify([...restored.keys()])) // 顺序也保留了查看输出与解释
{}
[["draft",2],["ready",5]]
5
["draft","ready"][...source] 展开得到的是 [['draft', 2], ['ready', 5]] 这样的二维数组,每一项是一对键值,JSON 能完整表示。JSON.parse 还原出来的只是普通数组,new Map 接收条目数组,才把它变回 Map。
也可以写一个 replacer 和 reviver 交给 JSON.stringify 和 JSON.parse,遇到 Map 时自动转成 { dataType: 'Map', value: [...] } 这样带标记的结构,适合 Map 嵌套在大对象里的情况。
为什么不推荐 Object.fromEntries
另一种常见写法是 JSON.stringify(Object.fromEntries(map)),把 Map 转成普通对象。键都是普通字符串时没问题,但它有两个坑。
- 键类型被抹平
- 对象的键只能是字符串或 Symbol,数字 1 和字符串
'1'转成对象后都变成属性"1",后一个会覆盖前一个。键是对象时更糟,都会变成"[object Object]"。另外对象会把整数键按数值大小重新排序,Map原来的插入顺序也丢了。 条目数组没有这些问题,数字键恢复后还是数字,所以默认用[...map]更稳。
容易答错的地方
- 看到
{}以为Map本来是空的 - 这是很隐蔽的问题:
Map里明明有数据,存进localStorage后再读出来就没了,也不报错。判断Map是否为空要看map.size,不能看序列化结果。 - 以为条目数组能无损保存任何
Map - 条目数组只解决了「条目看不见」的问题,JSON 本身的限制还在。值里有
Date会变成字符串,有undefined在数组里会变成null,有BigInt会抛TypeError,有循环引用也会报错。存之前要清楚数据里有哪些类型。
面试官还会怎么问?
键是对象的 Map,恢复后为什么查不到?
Map 按引用比较对象键。恢复出来的键是 JSON.parse 新建的对象,字段一样但不是同一个引用,拿原对象去 get 当然查不到。跨页面、跨接口保存时,应该用稳定的 id 当键,而不是对象本身。
可以给 Map.prototype 加一个 toJSON 吗?
技术上可以,JSON.stringify 遇到有 toJSON 方法的对象会先调用它。但这会影响项目里所有 Map 的序列化,还可能和第三方库冲突。更好的做法是写一个显式的转换函数,或者只在需要的地方传 replacer。
条目数组里有重复的键,恢复后会怎样?
new Map 按顺序逐个 set,后面的值会覆盖前面的,但键的位置保留第一次出现的位置。数据来自外部时,如果重复键意味着数据有问题,应该在恢复前检查,而不是静默覆盖。
参考资料
示例用于理解所注明的运行环境与边界;延伸学习可结合原文中的更多案例。