
前阵子在处理一个全局状态持久化的需求用户在多标签页之间来回切换我得把整个应用的关键状态存到本地刷新后还得原样还原现场。刚接手时图省事直接JSON.stringify(state)加JSON.parse跑完一看Date 变成了字符串Map 变成空对象Set 只剩一个{}Error 对象直接蒸发更别提状态里还有个循环引用两行代码下去页面直接崩了。这就是今天想聊的核心前端序列化和反序列化。JSON.stringify和JSON.parse能解决 80% 的简单数据交换可一旦数据里出现 Date、Map、Set、Error、循环引用或者你得把数据从 A 页面搬到 B 页面再恢复成一个能干活的对象这两个 API 的局限就会立刻暴露。这篇文章会把它们的边界完整摸一遍再给出一套我自用的自定义通用安全序列化工具类代码直接能抄同时把设计取舍和踩坑记录一起放出来。适合被复杂状态持久化折磨过的中高级前端也适合正准备封装存储层、缓存层的同学。1. 两个老伙计的边界JSON.stringify 与 JSON.parse 到底能干什么1.1 先理解序列化的本质序列化本质上是把内存里的对象图变成一段扁平的、可传输、可存储的字节串或者字符串反序列化则是把这段字符串还原成对象。听起来简单但这里藏着一个关键差异真正的序列化要求还原后的对象能和序列化之前等价而不仅是字段数值看起来差不多。前端常用的 JSON 这两个方法实际是数据交换格式设计目标是跨越语言、跨越平台传数据压根没打算帮你把对象恢复成原来的模样。拿 JavaScript 来说JSON 只能直接表达六种数据string、number、boolean、null、array、object。其他所有类型比如 Date、RegExp、Map、Set、Error、BigInt都必须先乔装打扮成这六种类型才能被 JSON 文本接受。这个乔装过程就是坑的根源。你看到JSON.stringify好像没报错其实它自作主张帮你做了转换等JSON.parse还原回来类型早就不对了。更隐蔽的是某些类型连乔装都没法乔装直接抛异常或者被静默丢弃等代码运行到某个位置才发现数据没了。所以做序列化设计的第一步不是选工具而是明确需求你要存的到底是什么是纯数据记录还是包含复杂对象类型的运行时状态需要还原到字段一致就够还是必须还原成原型、方法、引用关系都和原来一致需求不一样方案完全不同。JSON 两个老伙计在简单场景下够用但复杂场景下一定要理解它们的边界才能决定是自己手动绕过还是写一个通用工具。1.2 JSON.stringify 的六类典型问题我在项目里把 JSON.stringify 的坑分成六类每一类都实打实遇到过数据情况JSON.stringify 的行为典型后果对象属性值是 undefined / function / symbol直接忽略该属性配置对象烧完只剩一半数组元素是 undefined / function / symbol转成 null数组长度还在但语义变了NaN / Infinity / -Infinity转成 null数值统计被污染Date转成 ISO 字符串还原不出 Date全是字符串Map / Set / RegExp / Error / WeakMap转成 {}所有内容全部丢失BigInt抛 TypeError直接中断循环引用抛 TypeError直接中断第一类问题最容易踩。对象里某个字段是undefined或者一个函数序列化后这个字段干脆消失。你看着字符串挺干净反序列化回来一用obj.handler没了程序报not a function。函数本来也不太适合跨环境传输丢失可以理解但 undefined 丢失经常导致逻辑判断出问题。数组里同样的值会被转成null长度不变但内部结构变了像操作arr.forEach一样能跑可遇到严格比较就翻车。日期问题是最冤的。JSON.stringify(new Date())不会报错会输出一个 ISO 字符串比如2024-05-18T10:00:00.000Z。表面上没毛病可JSON.parse还原回来是一个字符串不是 Date 对象。很多人用new Date(str)补救但全局搜索代码就能发现一旦有五六处日期存储每处都要手写补救逻辑总觉得不对劲。更麻烦的是有些第三方组件直接判断instanceof Date字符串传进去连渲染都异常。Map 和 Set 的问题同样隐蔽。JSON.stringify(new Map([[a, 1]]))输出的是{}因为 Map 本身没有可枚举的自有属性内部那些键值对只有通过迭代器才能拿到。JSON 字符串看到的是一个空对象还原不可能拿回数据。RegExp 也差不多正则源码和 flags 都藏在内部属性里序列化后直接蒸发。Error 更离谱name、message、stack这几个字段都是非枚举的存完就是一个{}这对错误上报场景来说等于亲手把线索扔了。BigInt 的问题最直接JSON.stringify(10n)直接抛TypeError: Do not know how to serialize a BigInt。页面里如果某个数字字段可能超过Number.MAX_SAFE_INTEGER比如订单号、时间戳一序列化整个流程中断。循环引用同样是硬中断父节点引用子节点子节点又引用回父节点JSON 无法表达这种引用关系直接崩溃。这一块很多人都有印象但真正踩坑时往往是因为引用的链条比较深报错信息也不直观得花时间定位。1.3 JSON.parse 的还原能力更受限如果说 stringify 是把类型信息偷偷丢掉那 parse 就是第一个接手这个烂摊子的。能还原回来的只有最基础的 object/array以及数字、字符串、布尔值。Date、Map、Set、RegExp、Error 全都不可能自动复活。即使原始代码里写了state.config.date new Date()序列化再反序列化后config.date已经变成一个字符串后续代码对它调用getTime()会直接报错。JSON.parse 对格式的要求也苛刻。手写 JSON 的时候少个逗号、多个尾逗号、用了单引号、加了注释全部解析失败。虽然现代开发基本不会手写 JSON但某些场景下从配置文件、URL 参数、旧系统拿到的看似 JSON 的文本会让 parse 直接抛异常。这种情况下你需要 try/catch 包裹还要考虑容错清洗远不是一行 parse 那么省心。还有一个安全话题原型污染。JSON.parse({__proto__: {polluted: true}})解析出的对象确实会有一个名为__proto__的自有属性但如果你把这个对象往另一个对象上Object.assign或者用某种递归合并逻辑处理__proto__会触发原型 setter导致目标对象的原型被篡改。很多原型污染漏洞就是从这个入口连环触发出来的。所以做通用反序列化工具时光靠 JSON.parse 远远不够还要对键名做防御处理。再补一句JSON.parse 虽然提供 reviver 参数可以在遍历的过程中对每个 key/value 做自定义转换但它只能拿到键和值拿不到对象类型上下文。想用它还原 Date、Map你得自己识别格式再转换逻辑又碎又容易漏。业务里错误处理、状态恢复、缓存重建靠 reviver 手搓基本属于边打补丁边过日子补到后面根本分不清哪块是自己写的转换哪块是 JSON 本身的行为。2. 为什么需要自定义序列化需求、选型与整体设计2.1 从业务场景反推需求清单我先说一个真实需求某音乐社区项目里要把用户当前页面状态整体存到 localStorage刷新后恢复到原来的样子。状态树里有歌单列表、当前播放器配置、用户筛选条件、错误信息还有一大堆运行时数据。简单分析后发现这份状态里至少包含如下成员播放器里的Date最近播放时间、到期时间筛选条件里的Set多选标签集合算法结果缓存里的Map歌单 ID 映射到推荐分数异常上报用的Error对象某些字段是BigInt歌曲时长微秒数甚至有一个配置对象被多个地方引用形成循环这还只是全局状态持久化一个场景。埋点上报里 Error 对象转 JSON 会丢 stackWeb Worker 消息里 postMessage 虽然有结构化克隆但函数、Symbol、某些自定义类型依然传不过去BroadcastChannel 同步多标签页状态时发送对象同样要序列化表单草稿里用户填的日期、多选数据都要原样恢复。你会发现一个规律凡是涉及保存现场、恢复现场的需求光靠 JSON 很难兜住。所以我在做这个需求之前先列了一个需求清单第一必须能区分并还原 Date、Map、Set、RegExp、Error、BigInt第二遇到函数和 Symbol 不能静默出错要有明确的处理策略第三循环引用必须能检测最好给出可定位的路径第四反序列化时不能信任输入防止原型污染第五序列化结果最好是可读的 JSON 文本方便联调和排查。这五条就成了后面工具类的设计纲领。2.2 方案选型为什么选择自研当时我先对比了几条路。第一条路是继续用 JSON手动写各类型的转换逻辑。优点是简单直接缺点是每加一种类型都要在序列化和反序列化两侧同时改代码很快会散落成十几个函数维护成本直线上升而且很容易漏掉某个嵌套层次。第二条路是引入现成的序列化库。功能确实全Map、Set、Date、循环引用都能处理但有两个顾虑一是体积前端项目每加一个依赖都要掂量掂量二是格式不可控不同库产出的字符串结构不一样一旦要跨端、跨版本处理适配成本很高。对于核心业务里需要长期维护的数据格式我更倾向于自己掌握格式的定义权。第三条路是直接用浏览器原生的structuredClone。这是现代浏览器提供的神器支持循环引用、Date、Map、Set、RegExp、ArrayBuffer用来做内存里的深拷贝体验极佳。但它有个硬伤结果不能直接变成可存储的字符串你拿到的是一个新对象想存到 localStorage 还是得再走一遍序列化。而且它不支持函数和 Symbol无法自定义某个业务类的还原逻辑。我的场景里既要存本地、又要保留部分不可克隆信息所以不能完全靠它。综合下来我选择自己实现一个约两百行、无依赖、序列化格式基于 JSON 文本的通用工具类。最关键的考量是可控格式是我定义的类型扩展是结构化的循环引用、原型污染这些问题可以在同一套代码里统一防御。对于某音乐社区项目这种有长期迭代压力的业务这个取舍是值得的。2.3 设计原则最少类型覆盖、统一类型标记、安全优先我给自己定了三个原则。第一个是最少类型覆盖只解决当前明确需要的类型不做大而全的插件体系避免过度设计。Date、Map、Set、RegExp、Error、BigInt、ArrayBuffer 已经覆盖 95% 的场景Function 和 Symbol 按既定策略降级处理就够了。第二个是统一类型标记。序列化后的每个值都要包成这样一个节点{ __t__: 类型名, d: 数据 }。外层再套一个信封{ v: 版本号, root: 根节点 }。类型名用来告诉反序列化器我是什么d存实际数据v是格式版本号为未来兼容提前留好口子。这个设计的好处是递归结构非常整齐每个节点都自带类型信息反序列化时不需要靠猜。第三个是安全优先。反序列化器面对的是外部输入任何字段都可能是伪造的。所以解码时必须用白名单识别类型标记未被识别的直接抛错对象键名使用Object.defineProperty写入避免__proto__触发原型 setter版本号不匹配直接拒绝解析防止旧格式数据混进来产生隐性 bug。这个原则让整个工具在严格模式下工作虽然少了点宽容但换来的是更可靠的线上表现。3. 核心实现safe-serializer 工具类的完整落地3.1 整体结构设计我的实现是一个SafeSerializer类对外暴露两个方法serialize(value)返回 JSON 字符串deserialize(text)返回还原后的对象。内部拆成三块encode函数负责把任意值递归转换成带类型标记的节点。decode函数负责把节点递归还原成 JavaScript 值。SafeSerializer类负责版本号、最大深度、循环引用追踪的上下文管理。类型标记的完整对照表长这样原始类型节点类型标记实际存储内容nullnull无undefinedundef无booleanbool布尔值stringstr字符串普通 numbernum数字NaN / Infinity / -Infinity / -0num特殊字符串标识bigintbigint数字字符串symbolsymbol描述文本functionfn函数名还原时丢弃DateDate时间戳毫秒数RegExpRegExp正则源码和 flagsMapMap键值对数组SetSet值数组ArrayBufferArrayBufferbase64 字符串ErrorErrorname / message / stackArrayArray元素数组和原始长度普通对象obj指定键值对这套标记的命名直接、可读日志里一眼能看出节点类型。选择__t__作为标记字段也是为了让普通用户数据几乎不可能撞上这个键名。3.2 序列化流程的关键实现先看encode函数。第一步是处理原始类型这里有个容易忽略的细节五种特殊数字要单独编码。NaN、Infinity、-Infinity如果直接放进 JSON 会被转成null所以我用字符串做标识还原时再映射回去-0也必须保留因为Object.is(-0, 0)为 false某些算法会依赖这个差别。第二步是循环引用检测。我用一个WeakMap记录当前递归路径上访问过的对象key 是对象本身value 是路径字符串。每次进入一个对象先检查它是否已经在路径表里如果在说明从某条路径绕了一圈又回来了直接抛错并带上两条路径。这里的关键是当前递归路径而不是所有已访问对象所以处理完这个对象后必须在 finally 里从WeakMap中删除。否则两个对象共享同一个子引用但并非循环时第二次访问会被误判成循环。下面这段是核心逻辑function encode(value, ctx, path, depth) { if (depth ctx.maxDepth) { throw new RangeError(超过最大序列化深度: ${path}); } const t typeof value; if (value null) return { [T]: null }; if (t undefined) return { [T]: undef }; if (t boolean) return { [T]: bool, d: value }; if (t string) return { [T]: str, d: value }; if (t number) { if (Number.isNaN(value)) return { [T]: num, d: NaN }; if (value Infinity) return { [T]: num, d: Infinity }; if (value -Infinity) return { [T]: num, d: -Infinity }; if (Object.is(value, -0)) return { [T]: num, d: -0 }; return { [T]: num, d: value }; } if (t bigint) return { [T]: bigint, d: value.toString() }; if (t symbol) return { [T]: symbol, d: value.description || }; if (t function) return { [T]: fn, d: value.name || anonymous }; if (ctx.seen.has(value)) { throw new TypeError( 检测到循环引用: ${path} 与 ${ctx.seen.get(value)} 指向同一对象 ); } ctx.seen.set(value, path); try { const tag getTag(value); switch (tag) { case Date: return { [T]: Date, d: value.getTime() }; case RegExp: return { [T]: RegExp, s: value.source, f: value.flags }; case Map: { const entries []; for (const [k, v] of value) { entries.push([ encode(k, ctx, ${path}[key], depth 1), encode(v, ctx, ${path}[value], depth 1), ]); } return { [T]: Map, entries }; } case Set: { const values []; for (const v of value) { values.push(encode(v, ctx, ${path}[item], depth 1)); } return { [T]: Set, values }; } case ArrayBuffer: return { [T]: ArrayBuffer, d: bufferToBase64(value) }; case Error: return { [T]: Error, n: value.name, m: value.message, s: value.stack, }; default: { if (Array.isArray(value)) { const d new Array(value.length); for (let i 0; i value.length; i) { if (i in value) { d[i] encode(value[i], ctx, ${path}[${i}], depth 1); } else { d[i] { [T]: hole }; } } return { [T]: Array, d, len: value.length }; } const d {}; for (const key of Object.keys(value)) { d[key] encode(value[key], ctx, ${path}.${key}, depth 1); } return { [T]: obj, d }; } } } finally { ctx.seen.delete(value); } }这里有几个实现细节值得展开。Object.keys只会取到自有可枚举属性所以原型链上的方法天然不会被序列化JSON 本来也是这么干的符合大家的预期。数组处理时我格外小心稀疏数组的空洞位置用{ [T]: hole }标记而不是转成null因为 JSON.stringify 会把空洞变成 null导致还原后数组语义变化。ArrayBuffer转 base64 时我用了分段String.fromCharCode的方式避免大文件转换时栈溢出。还有一个细节是 Map 的 key 也可以是一个对象。递归编码 key 时它同样走完整类型判断所以复杂 key 也能保存。当然如果 key 本身是个函数或者 Symbol按策略会被降级这点需要在设计文档里写清楚。3.3 反序列化与安全校验反序列化器decode是整个工具的防守端也是最需要较真的地方。核心思路就一句话不信任任何输入所有数据都要经过白名单校验。如果某个节点没有__t__标记就默认它不是本工具产出的自定义节点而是原始 JSON 对象递归解码每个字段即可。如果节点带了标记就按标记名分派到对应的还原逻辑。原型污染防御是我单独拎出来的重点。普通对象还原时不能用obj[key] value直接赋值因为当 key 是__proto__时这个赋值会触发原型 setter把 value 变成对象原型。正确的姿势是用Object.defineProperty写入自定义属性它不会触发原型 setter完全绕开这个陷阱。我把这个逻辑封装成safeSet反序列化普通对象和 JSON 对象时统一走它。function safeSet(obj, key, value) { Object.defineProperty(obj, key, { value, writable: true, enumerable: true, configurable: true, }); }decode的完整实现如下。注意几个还原细节Date 用时间戳毫秒数构造能精确还原到原始时刻Map 和 Set 遍历节点数组递归解码函数类型无法真正还原统一返回undefined避免伪造可执行代码Symbol 只能还原出描述不进入全局符号注册表Error 则用new Error(message)再手动覆盖 name 和 stack保证错误结构完整。function decode(node) { if (node null || typeof node ! object) return node; if (!node[T]) { const obj {}; for (const key of Object.keys(node)) { safeSet(obj, key, decode(node[key])); } return obj; } switch (node[T]) { case null: return null; case undef: return undefined; case bool: if (typeof node.d boolean) return node.d; throw new TypeError(非法的 bool 数据); case str: if (typeof node.d string) return node.d; throw new TypeError(非法的 str 数据); case num: { switch (node.d) { case NaN: return NaN; case Infinity: return Infinity; case -Infinity: return -Infinity; case -0: return -0; default: if (typeof node.d number) return node.d; throw new TypeError(非法的 num 数据); } } case bigint: try { return BigInt(node.d); } catch { throw new TypeError(非法的 bigint 数据); } case symbol: return Symbol(node.d || ); case fn: return undefined; case Date: return new Date(node.d); case RegExp: return new RegExp(node.s, node.f); case Map: { const m new Map(); for (const [k, v] of node.entries || []) { m.set(decode(k), decode(v)); } return m; } case Set: { const s new Set(); for (const v of node.values || []) { s.add(decode(v)); } return s; } case ArrayBuffer: return base64ToBuffer(node.d); case Error: { const err new Error(node.m || ); err.name node.n || Error; if (node.s) err.stack node.s; return err; } case Array: { const arr new Array(node.len); for (let i 0; i (node.d || []).length; i) { if (node.d[i] node.d[i][T] hole) continue; arr[i] decode(node.d[i]); } return arr; } case obj: { const obj {}; for (const key of Object.keys(node.d || {})) { safeSet(obj, key, decode(node.d[key])); } return obj; } default: throw new TypeError(无法识别的类型标记: ${node[T]}); } }反序列化入口处还必须做两件事。第一JSON.parse本身可能抛异常尤其是从外部存储读取脏数据时必须包一层 try/catch 并抛出语义明确的错误。第二校验信封里的版本号不匹配直接拒绝。这个问题我在后面版本兼容部分会细说但代码层面已经提上来了。3.4 完整可运行代码把上面所有代码拼起来就是一个无依赖、可直接使用的工具文件。这个文件在浏览器和现代 Node 环境都能跑需要留意的是btoa和atob在旧 Node 环境不存在我补了一个兼容小函数。const T __t__; const btoa globalThis.btoa || ((s) Buffer.from(s, binary).toString(base64)); const atob globalThis.atob || ((s) Buffer.from(s, base64).toString(binary)); function getTag(value) { return Object.prototype.toString.call(value).slice(8, -1); } function bufferToBase64(buffer) { const bytes new Uint8Array(buffer); let binary ; const chunk 0x8000; for (let i 0; i bytes.length; i chunk) { binary String.fromCharCode.apply(null, bytes.subarray(i, i chunk)); } return btoa(binary); } function base64ToBuffer(b64) { const binary atob(b64); const bytes new Uint8Array(binary.length); for (let i 0; i binary.length; i) { bytes[i] binary.charCodeAt(i); } return bytes.buffer; } function safeSet(obj, key, value) { Object.defineProperty(obj, key, { value, writable: true, enumerable: true, configurable: true, }); } function encode(value, ctx, path, depth) { if (depth ctx.maxDepth) { throw new RangeError(超过最大序列化深度: ${path}); } const t typeof value; if (value null) return { [T]: null }; if (t undefined) return { [T]: undef }; if (t boolean) return { [T]: bool, d: value }; if (t string) return { [T]: str, d: value }; if (t number) { if (Number.isNaN(value)) return { [T]: num, d: NaN }; if (value Infinity) return { [T]: num, d: Infinity }; if (value -Infinity) return { [T]: num, d: -Infinity }; if (Object.is(value, -0)) return { [T]: num, d: -0 }; return { [T]: num, d: value }; } if (t bigint) return { [T]: bigint, d: value.toString() }; if (t symbol) return { [T]: symbol, d: value.description || }; if (t function) return { [T]: fn, d: value.name || anonymous }; if (ctx.seen.has(value)) { throw new TypeError( 检测到循环引用: ${path} 与 ${ctx.seen.get(value)} 指向同一对象 ); } ctx.seen.set(value, path); try { const tag getTag(value); switch (tag) { case Date: return { [T]: Date, d: value.getTime() }; case RegExp: return { [T]: RegExp, s: value.source, f: value.flags }; case Map: { const entries []; for (const [k, v] of value) { entries.push([ encode(k, ctx, ${path}[key], depth 1), encode(v, ctx, ${path}[value], depth 1), ]); } return { [T]: Map, entries }; } case Set: { const values []; for (const v of value) { values.push(encode(v, ctx, ${path}[item], depth 1)); } return { [T]: Set, values }; } case ArrayBuffer: return { [T]: ArrayBuffer, d: bufferToBase64(value) }; case Error: return { [T]: Error, n: value.name, m: value.message, s: value.stack, }; default: { if (Array.isArray(value)) { const d new Array(value.length); for (let i 0; i value.length; i) { if (i in value) { d[i] encode(value[i], ctx, ${path}[${i}], depth 1); } else { d[i] { [T]: hole }; } } return { [T]: Array, d, len: value.length }; } const d {}; for (const key of Object.keys(value)) { d[key] encode(value[key], ctx, ${path}.${key}, depth 1); } return { [T]: obj, d }; } } } finally { ctx.seen.delete(value); } } function decode(node) { if (node null || typeof node ! object) return node; if (!node[T]) { const obj {}; for (const key of Object.keys(node)) { safeSet(obj, key, decode(node[key])); } return obj; } switch (node[T]) { case null: return null; case undef: return undefined; case bool: if (typeof node.d boolean) return node.d; throw new TypeError(非法的 bool 数据); case str: if (typeof node.d string) return node.d; throw new TypeError(非法的 str 数据); case num: { switch (node.d) { case NaN: return NaN; case Infinity: return Infinity; case -Infinity: return -Infinity; case -0: return -0; default: if (typeof node.d number) return node.d; throw new TypeError(非法的 num 数据); } } case bigint: try { return BigInt(node.d); } catch { throw new TypeError(非法的 bigint 数据); } case symbol: return Symbol(node.d || ); case fn: return undefined; case Date: return new Date(node.d); case RegExp: return new RegExp(node.s, node.f); case Map: { const m new Map(); for (const [k, v] of node.entries || []) { m.set(decode(k), decode(v)); } return m; } case Set: { const s new Set(); for (const v of node.values || []) { s.add(decode(v)); } return s; } case ArrayBuffer: return base64ToBuffer(node.d); case Error: { const err new Error(node.m || ); err.name node.n || Error; if (node.s) err.stack node.s; return err; } case Array: { const arr new Array(node.len); for (let i 0; i (node.d || []).length; i) { if (node.d[i] node.d[i][T] hole) continue; arr[i] decode(node.d[i]); } return arr; } case obj: { const obj {}; for (const key of Object.keys(node.d || {})) { safeSet(obj, key, decode(node.d[key])); } return obj; } default: throw new TypeError(无法识别的类型标记: ${node[T]}); } } class SafeSerializer { constructor(options {}) { this.version options.version ?? 1; this.maxDepth options.maxDepth ?? 128; } serialize(value) { const ctx { seen: new WeakMap(), maxDepth: this.maxDepth }; const envelope { v: this.version, root: encode(value, ctx, $, 0) }; return JSON.stringify(envelope); } deserialize(text) { let data; try { data JSON.parse(text); } catch { throw new TypeError(无法解析的 JSON 文本); } if (!data || data.v ! this.version) { throw new TypeError( 序列化版本不匹配: 期望 ${this.version}实际 ${data data.v} ); } return decode(data.root); } } export { SafeSerializer };这套代码我在一个中型业务项目里跑了小半年目前没出过还原事故。可读性、扩展性、安全性之间基本平衡代码量也克制在可维护范围内。4. 实操验证同一份复杂数据两种方案差异有多大4.1 构造一份多类型测试数据为了直观对比我构造了一份接近真实的全局状态对象模拟把某音乐社区项目的播放器页面状态保存起来const state { version: 1, title: 我的歌单, updatedAt: new Date(2024-05-18T10:00:00Z), tags: new Set([流行, 电子]), meta: new Map([ [creator, A同学], [plays, 1280], ]), config: { theme: dark, defaultVolume: NaN, bigNumber: 10n ** 30n, }, onPlay: (id) console.log(play, id), history: [], }; state.history.push(state); // 故意制造循环引用这份数据涵盖了 Date、Set、Map、NaN、BigInt、函数、循环引用七类问题。先不讨论循环引用把那一行去掉后再对比两份序列化结果差异非常直观。4.2 序列化结果对比数据成员JSON.stringify 的结果safe-serializer 的结果updatedAtDate2024-05-18T10:00:00.000Z{__t__:Date,d:1716012000000}tagsSet{}{__t__:Set,values:[...]}metaMap{}{__t__:Map,entries:[...]}defaultVolumeNaNnull{__t__:num,d:NaN}bigNumberBigInt抛异常{__t__:bigint,d:1000000...}onPlay函数属性被忽略{__t__:fn,d:onPlay}history 循环引用抛异常抛异常并提示路径用 JSON.stringify 的结果反序列化后 updatedAt 是普通字符串tags 是空对象defaultVolume 是 null整个状态里运行时信息基本全丢了。我的工具类则能原样还原Date 变回 DateMap 变回 MapNaN 还是 NaNBigInt 还是 BigInt函数虽然还原成 undefined但至少在序列化文本里能看到曾存在过这个字段排查时不会一脸问号。4.3 性能与体积权衡有人会担心这种类型标记写法太啰嗦性能和体积都不如直接 JSON。我实际测过几组数据一组是十万个纯数字对象的数组safe-serializer 耗时大约是 JSON.stringify 的 2.5 到 4 倍一组是几百个字段的业务状态对象两者耗时几乎没差别都在毫秒级别。体积方面类型标记会让文本膨胀 40% 到 80% 左右如果你存的是一整棵复杂状态树序列化字符串可能从 5KB 涨到 8KBlocalStorage 的 5MB 额度完全够用。所以我的建议很明确API 请求、纯数据缓存、服务端交互这些场景坚持用原生 JSON不要为了显摆工具类而强行替代本地持久化、跨页面同步、需要还原类型的复杂状态才值得用这套工具。工具本身最大价值是少踩类型丢失的坑而不是替代所有序列化场景。如果你发现自己的数据量巨大且全是纯 JSON 结构那直接 JSON.stringify 才是最优解没有必要引入任何额外层。5. 实战中的常见坑与排查技巧实录5.1 原型污染到底是怎么发生的原型污染在前端不是新鲜词但很多人觉得离自己很远直到线上出现诡异 bug 才来找原因。我之前遇到过一个案例后端接口返回一段用户可控的 JSON前端直接用某个递归合并函数把它合到全局配置对象上。对方在 JSON 里塞了__proto__: {evil: true}结果合并后项目里所有对象的原型都多了一个evil属性。排查了半天最后定位到合并函数里的target[key] source[key]把__proto__当普通键赋值了。这一点在我的工具里做了两层防御。第一层decode普通对象时不使用 setter 赋值而是走safeSet。这样即使数据里出现__proto__键也只会被定义为一个普通自有属性不会触发原型改写。第二层decode只认白名单类型标记未被识别的标记直接抛错防止恶意构造的特殊节点被执行到某个危险路径。建议所有做反序列化工具的人都把原型污染测试写进回归用例固定输入{__proto__:{polluted:1}}去跑保证还原后Object.getPrototypeOf(result).polluted undefined。5.2 循环引用报错信息不友好怎么办原生 JSON.stringify 遇到循环引用时报错信息是 Converting circular structure to JSON但不会告诉你具体在哪条路径上。数据是简单父子结构时还好定位牵扯到三四层嵌套、两个模块互相引用时这句报错基本等于白说。我的实现里把路径信息时刻带在递归上下文里一旦发现循环就直接抛出类似检测到循环引用: $.config.parent 与 $.config 指向同一对象的信息一眼就能定位。另外要注意区分循环引用和共享引用。两个不同的父对象指向同一个子对象比如a.shared c; b.shared c这不是循环是共享。我的工具目前会把它序列化成两份独立的数据还原后 a.shared 和 b.shared 不再严格指向同一个对象这是有意的简化。如果你的业务区分引用同一性就得参考后面第 6 节里的引用化方案那是复杂度更高的升级版。5.3 版本号救过我一命给序列化格式加上版本号是我从一次线上故障里学到的。当时工具类第一版刚上线Map 的存储字段用的是entries后来为了让 key 也能被还原我重构了内部存储结构。结果字段名一改旧版本序列化出来的数据在线上被新版本反序列化entries读出来是 undefined所有 Map 直接变成空 Map。如果有版本号我就能在deserialize入口直接判断这是旧格式走迁移函数而不是静默产出一个结构不完整的数据。所以我在信封结构里加了一个v字段。每次修改存储格式就递增版本号deserialize遇到不匹配的版本立刻抛错并在错误信息里标明期望版本和实际版本。这套机制对长期迭代尤其重要。旧数据迁移的通用做法是保留一个迁移函数表按版本号逐级升级比如 v1 数据先进 v2 的迁移逻辑再进 v3 的迁移逻辑直到当前版本。5.4 第三方对象挂 toJSON 的坑还有一种隐蔽情况值得单独拎出来说很多第三方库会在自己的原型上挂一个toJSON方法。这样 JSON.stringify 处理到这类对象时不会去读它的原始属性而是调用toJSON()的结果作为输出。看起来是贴心的自定义行为但结果往往不是你想要的。比如某些日期库的toJSON返回的可能是 UTC 字符串某些错误对象库的toJSON返回的字段和原生 Error 完全不一样运气差的时候还会把你精心准备的数据格式搅成一锅粥。我的工具类从设计上绕开了这个坑encode函数不调用任何toJSON只根据Object.prototype.toString判断内置类型然后从内部属性里读数据。这样即使对象挂了自己的toJSON也不会干扰序列化结果。对第三方自定义对象我的策略是抽象成普通对象处理读它的自有可枚举属性这虽然会丢失一些原型上的东西但至少行为是确定、可预期的。5.5 几个零散但容易阴沟翻船的小点第一个是 localStorage 的 5MB 限制。状态树复杂时序列化字符串可能轻松突破这个限制。报错信息是 QuotaExceededError很多人以为是代码 bug其实是存储容量不够。建议对超大状态做裁剪或者改走 IndexedDB。第二个是 base64 转换的性能。ArrayBuffer 很大时逐字节String.fromCharCode会很慢我用了分段转换规避实际项目里传输 10MB 级别的二进制数据时依然能稳定工作。第三个是atob和btoa的兼容性。现代 Node 已经有全局函数但老版本没有补一种基于 Buffer 的兼容方案更稳妥。第四个是严格模式太严格可能把用户历史数据全部拒绝掉。如果你要兼容纯 JSON 输入可以在deserialize加一个宽松选项没有版本号时按普通 JSON 对象解码。我没在一开始提供这个选项是因为我更倾向于让格式错误尽早暴露而不是悄悄容忍。6. 还能怎么扩展共享引用、插件化与结构化克隆的配合6.1 升级共享引用和循环引用的还原当前实现里循环引用直接报错共享引用会被拆成多份。如果你的业务确实需要还原后依然保持同一性那就要从对象升级成引用图来序列化。思路不复杂序列化时用 WeakMap 给每个对象分配一个唯一 id把所有对象统一放进 refs 列表普通节点里遇到对象就存一个引用 id比如{ __t__: ref, id: 3 }。反序列化时先创建 refs 列表的占位对象再逐个填充这样同一个对象被引用多少次还原出来都是同一个内存对象。复杂度主要在占位机制。反序列化 refs 时不能先解码完再回溯因为解码过程中可能碰到还没解码的 ref。更稳的做法是分两段先为每个 ref 创建空壳再逐个填充内容。这套机制实现起来大概会多 60 到 80 行代码但带来的复杂度提升也明显测试用例要覆盖循环、共享、自引用等好几种图结构。我目前业务里没有这个强需求所以选择了报错 拆份的简单策略把引用化作为扩展留口子。6.2 插件式自定义类型注册有些业务对象是内部自定义类比如class Avatar { constructor(url, crop) {} }。想让工具类认识它可以在encode和decode两侧同时加 case。但如果自定义类型多了每加一种都改核心文件会让工具变得很胖。更好的思路是做成插件式注册注册时提供类型名、编码函数、解码函数encode在默认 switch 之前先查一遍注册表decode同样如此。const registry new Map(); function registerType(name, encodeFn, decodeFn) { registry.set(name, { encodeFn, decodeFn }); }这种扩展方式特别适合业务模型级别。比如项目里的歌曲模型需要完整保留内部字段和版本序列化时走模型专属编码反序列化时还原成模型实例。注意插件注册的函数同样要遵循安全原则不要直接执行解码出来的代码。我在几个后续版本里都是这样子扩展的核心文件一直保持稳定业务模型的新增只影响注册文件。6.3 和 structuredClone 配合使用structuredClone应该算是浏览器原生序列化能力的补充而不是竞争对手。它在纯内存场景里非常香深拷贝一个包含嵌套结构、Date、Map、Set、循环引用的对象一行代码搞定性能也比 JSON.parse 加 JSON.stringify 好。但它有两个限制一是输出不能直接存储成字符串二是不能自定义类型的还原行为。所以我的经验是组合使用。内存里做深拷贝、给 Worker 传复杂数据直接structuredClone要落到 localStorage、要跨端传输、要恢复成自定义类型才走 safe-serializer。如果环境里没有structuredClonePolyfill 也比较麻烦更推荐用实现了结构化克隆语义的现成库或者退而求其次用我的工具类做深拷贝——虽然它不支持循环引用还原但覆盖面已经很广大多数场景够用。这半年巡下来我对前端序列化这件事最大的感受是别把JSON.stringify当万能钥匙也别觉得自研工具多高级。序列化本质上是定义对象在另一个时刻、另一个环境里如何重生JSON 只是其中最基础的一种格式定义。做存储层、缓存层、跨端传输层的同学建议花点时间把数据里会出现哪些类型盘清楚再决定要不要引入通用序列化工具。真要做就一定要把安全放在第一位原型污染、白名单、版本号这些看起来不起眼的细节才是线上不出幺蛾子的关键。最后再分享一个小技巧序列化信封里除了版本号我还会塞一个savedAt时间戳排查问题时能快速知道这份数据是哪个版本、哪个时刻存的配合版本号做迁移心里会踏实很多。