news 2026/10/17 15:12:04

前端序列化与反序列化:JSON边界与安全序列化工具实战

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
前端序列化与反序列化:JSON边界与安全序列化工具实战

前阵子在处理一个全局状态持久化的需求:用户在多标签页之间来回切换,我得把整个应用的关键状态存到本地,刷新后还得原样还原现场。刚接手时图省事,直接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 会丢 stack;Web 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正则源码和 flags
MapMap键值对数组
SetSet值数组
ArrayBufferArrayBufferbase64 字符串
ErrorErrorname / message / stack
ArrayArray元素数组和原始长度
普通对象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 的结果
updatedAt(Date)"2024-05-18T10:00:00.000Z"{__t__:'Date',d:1716012000000}
tags(Set){}{__t__:'Set',values:[...]}
meta(Map){}{__t__:'Map',entries:[...]}
defaultVolume(NaN)null{__t__:'num',d:'NaN'}
bigNumber(BigInt)抛异常{__t__:'bigint',d:'1000000...'}
onPlay(函数)属性被忽略{__t__:'fn',d:'onPlay'}
history 循环引用抛异常抛异常并提示路径

用 JSON.stringify 的结果,反序列化后 updatedAt 是普通字符串,tags 是空对象,defaultVolume 是 null,整个状态里"运行时信息"基本全丢了。我的工具类则能原样还原,Date 变回 Date,Map 变回 Map,NaN 还是 NaN,BigInt 还是 BigInt,函数虽然还原成 undefined,但至少在序列化文本里能看到曾存在过这个字段,排查时不会一脸问号。

4.3 性能与体积权衡

有人会担心这种类型标记写法太啰嗦,性能和体积都不如直接 JSON。我实际测过几组数据:一组是十万个纯数字对象的数组,safe-serializer 耗时大约是 JSON.stringify 的 2.5 到 4 倍;一组是几百个字段的业务状态对象,两者耗时几乎没差别,都在毫秒级别。体积方面,类型标记会让文本膨胀 40% 到 80% 左右,如果你存的是一整棵复杂状态树,序列化字符串可能从 5KB 涨到 8KB,localStorage 的 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。如果环境里没有structuredClone,Polyfill 也比较麻烦,更推荐用实现了结构化克隆语义的现成库,或者退而求其次用我的工具类做深拷贝——虽然它不支持循环引用还原,但覆盖面已经很广,大多数场景够用。

这半年巡下来,我对前端序列化这件事最大的感受是:别把JSON.stringify当万能钥匙,也别觉得自研工具多高级。序列化本质上是"定义对象在另一个时刻、另一个环境里如何重生",JSON 只是其中最基础的一种格式定义。做存储层、缓存层、跨端传输层的同学,建议花点时间把数据里会出现哪些类型盘清楚,再决定要不要引入通用序列化工具。真要做,就一定要把安全放在第一位,原型污染、白名单、版本号这些看起来不起眼的细节,才是线上不出幺蛾子的关键。最后再分享一个小技巧:序列化信封里除了版本号,我还会塞一个savedAt时间戳,排查问题时能快速知道这份数据是哪个版本、哪个时刻存的,配合版本号做迁移,心里会踏实很多。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/10/17 15:07:58

基于MobileNetV3的动物声音分类识别系统实现与部署实践

毕设选题年年都有&#xff0c;但真正能落地、有技术深度、还能拿得出手展示的&#xff0c;翻来覆去就那么几个方向。今天聊的这个题目&#xff0c;是基于MobileNetV3架构的动物声音分类识别与应用系统实现。这个题目我在带学生做项目时反复推荐过&#xff0c;从技术路线、训练效…

作者头像 李华
网站建设 2026/10/17 15:07:47

密钥轮转、SPIFFE零信任与CKKS同态加密工程落地实战

简介&#xff1a;本资源是第18届国际密码学会议Inscrypt 2022&#xff08;北京&#xff0c;2022年12月&#xff09;的修订精选论文集&#xff0c;聚焦密码学与网络安全前沿交叉领域&#xff0c;特别适用于从事学术研究、安全开发及区块链底层技术探索的中高级技术人员。内容涵盖…

作者头像 李华
网站建设 2026/10/17 15:06:23

RabbitMQ核心原理与SpringBoot实战:从异步解耦到可靠性保障

1. 为什么要用RabbitMQ&#xff1a;消息队列的核心价值拆解先说结论&#xff1a;RabbitMQ不是银弹&#xff0c;但绝大多数需要异步、解耦、削峰的业务场景&#xff0c;它都是最稳妥的选择之一。我最早接触消息队列&#xff0c;是因为一个电商项目的登录模块。用户注册成功后&am…

作者头像 李华
网站建设 2026/10/17 15:04:57

Vue动态组件绑定与多表单验证:父子组件校验通信完整方案

简介&#xff1a;面向 Vue 前端开发者的动态组件与表单验证实践资料&#xff0c;重点解决下拉框或选项卡切换时通过 if/else、switch 写大量逻辑导致的代码冗余与维护困难。资源以 selects 目录为例&#xff0c;说明 index 页只负责公共数据、comp 目录下的子组件通过 动态加载…

作者头像 李华
网站建设 2026/10/17 15:04:53

Unity中Instantiate与定时销毁实战:从克隆到生命周期管理

这期聊Instantiate和定时删除&#xff0c;这两个功能在Unity开发里几乎是形影不离的组合。做游戏的人都懂&#xff0c;子弹要飞出去&#xff0c;特效要闪一下&#xff0c;敌人要炸开&#xff0c;这些东西基本都是先克隆出来&#xff0c;过一阵子再销毁掉。C#基础系列到这个第26…

作者头像 李华
网站建设 2026/10/17 15:04:05

PYNQ-Z2上手YOLOv2硬件加速:HLS+Vivado+PYNQ全栈实战

简介&#xff1a;本资源是面向嵌入式AI加速与FPGA部署初学者的YOLOv2复现实践套件&#xff0c;适用于PYNQ-Z2开发板平台&#xff0c;聚焦HLS高层次综合、Vivado硬件设计及Jupyter Notebook协同调试全流程。压缩包含798个文件&#xff0c;主体为761张模型推理测试用PNG图像、10个…

作者头像 李华