尧图精选

JSON.parse报错终极指南:从Unexpected token u到safeParse封装实战

🕒 发布时间:2026/10/1 23:58:58 📁 来源:尧图网络
1. 这两个报错到底在说什么同一类事故两种表面症状1.1 JSON.parse 第一步先把输入“强行”变成字符串先说结论SyntaxError: Unexpected end of JSON input和SyntaxError: Unexpected token u in JSON at position 0本质上都是**JSON.parse()收到了一个不可能是合法 JSON 的输入**只是输入内容不同报错的“死法”也不同。很多人第一次遇到Unexpected token u时会下意识去翻后端接口觉得是接口返回坏了。但真相往往更简单——JSON.parse接收到的不只是字符串它在解析前会先做一次 JavaScript 的ToString强转。这意味着JSON.parse(null)不会报错因为String(null)是字符串null它是合法 JSON返回nullJSON.parse(undefined)一定会报错因为String(undefined)是字符串undefined而undefined根本不是 JSON 标准里的值JSON.parse(0)返回0JSON.parse(false)返回false这类边缘情况很多人不知道所以当你看到Unexpected token u in JSON at position 0第一反应该是有个undefined被丢进了JSON.parse。position 0 意味着第一个字符就是u解析器在这里直接不认账了。1.2 V8 解析器的“第一眼”判断V8 引擎的 JSON 解析器是递归下降解析器它对合法 JSON 值的起始字符是有“白名单”的。JSON 标准定义的合法值只有六种对象、数组、字符串、数字、布尔值、null。对应的首字符非常有限合法 JSON 值起始字符示例对象{{a:1}数组[[1,2,3]字符串hello数字0-9或-123、-1.5布尔值 truettrue布尔值 falseffalsenullnnull除了这些字符其他任何开头都会被判定为非法 token。undefined开头是u不在白名单里于是直接抛出Unexpected token u。而空字符串的情况更特殊解析器还没看到任何字符就发现输入已经结束了因此报的是Unexpected end of JSON input意思是“我还没开始你就没了”。顺带一提不同浏览器/JS 引擎的报错文案会略有差异。Firefox 可能会报SyntaxError: JSON.parse: unexpected end of dataSafari 的措辞也不一样。你搜到的这两个错误是 V8 系Chrome、Node.js、Deno、Electron的标准文案。所以排查思路是通用的不用纠结措辞细节。1.3 一些“看着合理但实际会炸”的输入很多人以为JSON.parse能解析任何 JS 对象字面量这是天大的误会。JSON 是严格子集不是 JavaScript 对象语法的简化版。几个高频翻车案例输入结果原因JSON.parse(undefined)报错undefined 不是 JSON 类型JSON.parse(NaN)报错NaN 不是 JSON 类型JSON.parse({a:1})报错JSON 的 key 必须双引号JSON.parse({a:1,})报错JSON 不允许尾逗号JSON.parse({a:/*注释*/1})报错JSON 不允许注释JSON.parse(null)返回字符串null这是合法的 JSON 字符串值JSON.parse(null)返回null合法的 JSON null我见过有人手工拼接配置字符串在 JSON 里写了// 注释结果一上线全站接口数据渲染不出来。JSON 这个格式就是这么“犟”它只认标准不认人情。2. 最常见的五个生产事故现场JSON 是在哪一步“坏”掉的定位这类问题永远不要只盯着JSON.parse报错的那一行。报错位置是案发现场但凶手通常在前面的某个环节。我总结的高频事故现场按出现概率排序如下。2.1 localStorage 里的“幽灵 undefined”这是我个人踩过最深的坑也几乎是Unexpected token u的头号来源。很多人会写这样的代码// 某个地方存用户信息 localStorage.setItem(user_info, JSON.stringify(undefined));看到问题了吗JSON.stringify(undefined)返回的不是字符串而是undefined。setItem碰到非字符串参数时会强转成字符串结果undefined被转成了字符串undefined存进了 localStorage。下次读出来const user JSON.parse(localStorage.getItem(user_info)); // SyntaxError: Unexpected token u in JSON at position 0到这里就炸了。更迷惑的是用户清理缓存或换个浏览器后getItem返回null而JSON.parse(null)是合法的页面反而不炸了。这导致问题复现极不稳定排查时容易怀疑人生。还有一类情况是某个 key 在逻辑上根本不应该存在但代码以为是已存在的直接JSON.parse(localStorage.getItem(xxx))。当 key 不存在时getItem返回nullJSON.parse(null)返回null不报错但如果某个逻辑把值写成了空字符串JSON.parse()就会报Unexpected end of JSON input。这类问题在 A/B 实验、灰度发布、多端同步数据时尤其常见——旧版本写入的 key新版本读出来是坏的。2.2 网关/后端返回了 HTML前端却拿去 res.json()第二高频的场景是接口层。前端用fetch去请求接口res.json()内部本质上就是读取 body 文本再调JSON.parse。如果后端或者中间层网关、Nginx、WAF、单点登录系统返回的不是 JSON而是 HTML 错误页JSON.parse一样会炸。典型错误信息会变成SyntaxError: Unexpected token in JSON at position 0注意这里的就是 HTML 的第一字符。你只要看到报错信息里的 token 是基本可以断定响应体是 HTML不是 JSON。常见场景包括未登录时跳转到了统一登录页返回的是login.html网关做了 WAF 拦截返回一个“访问被拒绝”的 HTML 页面但 HTTP 状态码是 200后端服务挂了Nginx 返回了 502 的 HTML 错误页而前端没判断res.ok排查技巧很简单打开浏览器 Network 面板找到对应请求看Response标签页里的原始内容再看Response Headers里的content-type。如果content-type是text/html而前端在等application/json问题就实锤了。但要注意有些网关即使返回 JSON 格式的错误体HTTP 状态码也可能是 200这又属于另一类坑。2.3 同一个 response 被消费了两次fetch返回的 response body 是一个ReadableStream它只能被消费一次。如果你在处理链路里调了一次res.json()或res.text()后面又调了一次第二次拿到的就是空流去JSON.parse()自然报Unexpected end of JSON input。这个场景多发生在中间件或拦截器逻辑里。比如你在请求封装层里为了做日志先调了res.clone().text()把 body 读了一遍又或者在某个拦截器里已经res.json()解析过一次把结果挂到了公共状态上但业务代码里又执行了一次res.json()。这种问题比前两种更隐蔽因为 Network 面板里显示的响应体是完好的、完整的 JSON但代码里就是报“unexpected end”。排查时重点看两处代码里对同一个response变量调了几次.json()/.text()有没有中间件、拦截器、service worker 提前消费过 body需要共享响应内容时用response.clone()复制一份再消费const text await response.clone().text(); // 用于日志 const data await response.json(); // 用于业务2.4 手写 JSON 字符串拼接前端手写 JSON 字符串是另一个事故高发区。典型代码长这样const payload {id: id ,name: name };只要name里包含双引号、反斜杠、换行符拼出来的字符串就是非法 JSON。比如用户名字叫张三拼出来就是{id:1,name:张三}解析器看到第一个就当成 key 或字符串结束符了直接报错。这类问题在搜索框、评论、富文本内容相关的场景特别容易触发因为用户的输入完全不可控。另一个变体是手工维护的 JSON 配置文件。很多项目里有config.json之类的手写配置内容多了以后难免出现尾逗号、缺引号、多注释。比如{ apiBaseUrl: https://example.com, version: 1.0, // 这里写了个注释 features: [a, b,], }这段在 JS 对象字面量里能通过语法检查有的 ESLint 配置甚至允许尾逗号但在 JSON 解析器里每个都是致命错误。遇到这类问题把配置内容复制到带 JSON 校验的编辑器或离线格式化工具里报错行号会直接指出来。2.5 SSR 内联数据被 HTML 解析器“截胡”服务端渲染SSR场景里经常会把后端数据通过内联script标签传给浏览器script window.__INITIAL_STATE__ {user:{name:/scriptscriptalert(1)/script}}; /script你看到了如果 JSON 数据里含有/script字符串浏览器 HTML 解析器会把这个字符串当成脚本结束标签导致 JS 代码被硬生生切断。后续的代码可能变成一堆乱七八糟的语法或者变量被定义到一半最终在解析阶段就报各种奇怪的SyntaxError甚至在JSON.parse之前就已经炸了。这个问题的标准解法是在服务端序列化时对 HTML 敏感字符做转义尤其是把转成\u003c把转成\u003e把转成\u0026把\u2028和\u2029转义后面这两个是 JS 字符串里的行分隔符容易被某些浏览器解释为换行。推荐的替代做法是不要直接嵌裸 JSON而是先做一次JSON.stringify再把结果放进 HTML 实体转义后的标签里。3. 排查链路我是如何在五分钟内锁定源头模块的定位这类问题有一套固定打法按顺序执行大多数情况五分钟内能找到源头。3.1 先在“案发现场”打印原始输入报错栈一定指向JSON.parse调用行但这不是问题的起点。最简单的第一步是把将要传给JSON.parse的原始内容完整打出来try { const data JSON.parse(str); } catch (e) { console.error([json-parse-input], { raw: str, type: typeof str, length: str?.length, error: e.message, }); throw e; }注意type和length这两个字段很重要。字符串和undefined在视觉上都“挺短”但typeof和首字符会直接暴露真相。尤其是length为 0 时说明你拿到的是空字符串问题在“这个值为什么是空的”而不是“JSON 为什么非法”。3.2 去 Network 面板看原始响应如果报错发生在接口数据解析阶段直接看 Network 面板找到报错请求点击看 Response 标签页里的原文确认是不是 JSON看 Response Headers 里的content-type是application/json还是text/html看content-encoding字段确认有没有被压缩如果 Response 预览里显示的 JSON 是完整的但代码里报 empty那大概率是上面的“response 被消费两次”问题看原文时注意一点浏览器 Network 面板里如果显示的是格式化后的 JSON 树有时会“美化”掉一些不可见字符。建议切到源码模式或者点击右键把 Response 复制出来用能显示原始字符的工具检查头尾是否完整。3.3 curl 复现绕过前端框架的层层包装前端框架、拦截器、状态管理会包很多层干扰判断。直接用 curl 复现接口能绕过所有前端逻辑直达 HTTP 层curl -i --compressed \ -H Accept: application/json \ -H Content-Type: application/json \ https://api.example.com/users?page1-i会把响应头也打出来--compressed让 curl 自动处理 gzip/br 压缩。如果 curl 返回的是一整段 HTML问题就是后端或网关返回了错误页面如果返回的 JSON 在某个位置被切断比如你看到字符串在 8192 个字节处戛然而止那多半是代理缓冲或服务端输出被截断。3.4 临时“监控”所有 JSON.parse全局补丁大法如果项目里JSON.parse分散在各个模块一个个加日志太痛苦。排查阶段可以临时给JSON.parse打一个全局补丁把非法输入和错误调用栈一起打出来const originalParse JSON.parse; JSON.parse function (...args) { try { return originalParse.apply(this, args); } catch (e) { console.error([json.parse], { input: args[0], inputType: typeof args[0], stack: new Error().stack, }); throw e; } };这个补丁会拦截所有JSON.parse调用把原始输入和调用来源都打出来方便快速锁定“是哪个模块、哪一行数据”出的问题。开发调试时非常好用但上线前必须移除。需要注意的是它拦截不了 Web Worker、Node.js 子进程或其他独立 JS 环境里的调用因为那些是不同的全局作用域。3.5 格式化工具的正确用法当你手里有一段报错的字符串想快速定位它到底坏在第几个字符本地离线 JSON 格式化工具比在线工具更合适。在线工具需要把数据粘贴到浏览器如果数据里有敏感信息等于主动送数据。我一般用 VS Code 的 JSON 格式化插件或者直接用 Node 跑一句解析脚本node -e try { JSON.parse(process.argv[1]); console.log(ok) } catch(e) { console.error(e.message) } {a:1,}这样能在命令行里快速验证一个 JSON 字符串是否合法不经过任何中间界面错误信息里的 position 也能直接对上。4. 给 JSON.parse 穿上防弹衣一套 safeParse 封装解决八成问题排查只是治标真正治本是写代码的时候就把这类问题挡住。我推荐在团队公共工具库里放一个safeParse统一替代散落各处的裸JSON.parse。4.1 一版够用的基础封装function safeParse(raw, fallback null) { try { return JSON.parse(raw); } catch (e) { console.warn([safeParse] invalid JSON, fallback to default, e); return fallback; } }这是最简版。但有两点必须注意第一JSON.parse的参数如果是undefined或者函数、Symboltypeof raw string的判断可以拦截一部分但不够。更稳妥的做法是在函数入口处直接对类型做预处理function safeParse(raw, fallback null) { if (typeof raw ! string) { // undefined、number、object 都走这里 return fallback; } try { return JSON.parse(raw); } catch (e) { console.warn([safeParse] invalid JSON, fallback to default, e); return fallback; } }第二fallback参数绝对不能省。如果你返回undefined调用方接下来访问data.xxx又会抛Cannot read properties of undefined等于把一个坑换成了另一个坑。列表场景给[]对象场景给{}或者给一个业务空实体。4.2 专门处理 localStorage 的版本针对 localStorage 场景封装还要加一个“坏数据自动清理”的逻辑function getJSON(key, fallback null) { try { const raw localStorage.getItem(key); return JSON.parse(raw) ?? fallback; } catch (e) { try { localStorage.removeItem(key); } catch (_) { // 某些隐私模式下 setItem/removeItem 会抛异常忽略 } return fallback; } }为什么必须删掉坏缓存因为如果不删每次页面加载都会走一遍“读缓存 → parse 失败 → catch → 返回 fallback”的流程。短时间看不出问题但会导致所有用到这个 key 的页面都慢半拍而且用户永远得不到修复。主动删掉坏数据后下次代码可以重新写入干净的缓存整个链路自愈。4.3 接口响应的专门封装接口响应和本地缓存的诉求不一样。本地缓存坏了可以删接口响应坏了不能删后端数据但可以选择降级和上报。我惯用的方案是让parse返回一个判别联合discriminated union调用方根据ok字段做分支处理async function parseResponse(response) { try { const data await response.json(); return { ok: true, data }; } catch (e) { console.error([parseResponse] response is not valid JSON, { status: response.status, url: response.url, error: e.message, }); return { ok: false, error: e }; } }调用时const result await parseResponse(res); if (!result.ok) { // 走降级逻辑比如展示空态、加载兜底数据、上报监控 return; } const data result.data;这里要提醒一句response.json()在 body 为空时抛的是Unexpected end of JSON input在 body 是 HTML 时抛的是Unexpected token 两者都会被这个封装捕获。捕获之后不要只记日志要做业务降级否则用户看到的就是白屏。4.4 语法合法≠结构正确再进一步做 schema 校验safeParse能拦下 JSON 语法错误但拦不住“JSON 合法但结构不对”的情况。比如接口返回了nullJSON.parse完全不报错但你紧跟着访问data.list照样炸。我见过太多项目在接口返回结构变化时报错信息五花八门。解决思路是引入 schema 校验库如zod、io-ts、yup把“后端返回的结构”也纳入契约管理import { z } from zod; const UserSchema z.object({ id: z.number(), name: z.string(), email: z.string().email().optional(), }); const parsed UserSchema.safeParse(JSON.parse(raw)); if (!parsed.success) { // 这里可以上报具体的字段错误而不是一句笼统的JSON 解析失败 console.error([schema] invalid user data, parsed.error.issues); return fallbackUser; }用 schema 校验不是小题大做。尤其是对接第三方接口、动态配置、用户可编辑 JSON 的场景后端某天改了个字段名前端如果没有校验轻则页面少一块数据重则整个功能白屏。schema 校验至少能让你在“错误发生的地方”收到准确提示。4.5 团队 Code Review 时盯死三个检查点有了公共safeParse接下来是习惯问题。我所在团队在代码评审时会对以下三个点做强制检查所有从 localStorage、sessionStorage、cookie 读取 JSON 的代码必须走getJSON或safeParse不允许裸JSON.parse所有res.json()调用确认同一个response没有被消费两次任何手写 JSON 字符串拼接一律打回重写改用JSON.stringify这三条检查点看着简单但能挡住我上面列出的前四个高频事故现场。让团队省下的排查时间远超写封装的成本。5. JSON.stringify 与 JSON.parse 的不对称陷阱存得对不等于读得出这一类问题最阴险。它不是语法错误而是数据在序列化过程中被静默篡改等你读出来使用时才发现业务逻辑全乱了。这类问题不报错但比报错更难排查。5.1 序列化会“静默丢弃”数据JavaScript 的JSON.stringify有几个反直觉的规则原始值在数组中的序列化结果在对象中的序列化结果undefinednull整个 key 消失函数null整个 key 消失Symbolnull整个 key 消失NaNnullnullInfinitynullnull举个例子const original { a: undefined, b: NaN, c: function () {}, d: [undefined, function () {}], }; JSON.stringify(original); // {b:null,d:[null,null]}a这个 key 整个没了b变成了null数组里的值变成了null。如果你在写入缓存前看到original里明明有a读出来后访问却得到undefined排查效率极低。这种情况最常发生在把整个 Vuex/Redux state 塞进 localStorage 做持久化state 里有函数、NaN、undefined存进去再读出来时结构已经悄悄变了。5.2 循环引用直接抛 TypeErrorJSON.stringify遇到循环引用时会直接抛TypeError: Converting circular structure to JSON。这在处理树形结构、图数据、或者含有父节点引用的对象时特别常见。举一个典型场景一个TreeNode对象里保存了parent和children指针你想把整棵树存到 localStorage直接JSON.stringify(root)必定炸。正确做法是序列化前裁剪成纯数据 DTOfunction toPlainNode(node) { return { id: node.id, name: node.name, children: node.children ? node.children.map(toPlainNode) : [], }; }如果遇到的是无法轻易裁剪的对象可以用JSON.stringify的 replacer 参数在遍历时跳过循环引用const seen new WeakSet(); const json JSON.stringify(obj, (key, value) { if (typeof value object value ! null) { if (seen.has(value)) { return undefined; // 丢弃循环引用不输出这个 key } seen.add(value); } return value; });注意replacer 里返回undefined表示不输出这个 key。这个方法能救急但会静默丢数据不建议作为常规方案只是兜底。5.3 大整数丢精度这是后端同学和前端同学最容易互相甩锅的一个问题。JSON 数字类型对应的是 JavaScript 的NumberIEEE 754 双精度浮点数它能精确表示的整数范围只有Number.MAX_SAFE_INTEGER也就是2^53 - 1 9007199254740991。如果你的接口返回了一个雪花 ID比如9007199254740993在 JSON.parse 之后读到的可能是9007199254740992。数据在传输和解析过程中已经错了页面展示、详情跳转、数据比对全部出错。这个问题的标准解法是约定前后端所有大整数统一用字符串传输。比如订单 ID、数据库主键、第三方平台的雪花 ID后端序列化为字符串前端直接用字符串展示和传参。如果你做的是前端主导的项目可以自己在请求层对某些字段做转换const BIG_INT_FIELDS [id, orderId, userId]; function reviveBigInts(key, value) { if (BIG_INT_FIELDS.includes(key) typeof value string) { return value; } return value; } const data JSON.parse(raw, reviveBigInts);JSON.parse的第二个参数是 reviver可以在解析完成后遍历所有字段。不过这个方案需要列白名单不如后端直接用字符串省心。5.4 数据模型里的 toJSON 钩子很多内置对象和第三方库实现了自己的toJSON方法序列化结果和你的直觉完全不同Date序列化出来是 ISO 字符串如2024-06-01T00:00:00.000ZMoment对象序列化出来也是 ISO 字符串BigInt直接抛TypeError: Do not know how to serialize a BigInt某些 ORM 实体、富文本编辑器实例序列化出来的形状可能只有一部分字段自定义类也一样。如果你实现了toJSONJSON.stringify就用toJSON的返回值而不是遍历对象属性。很多库作者会在内部对象上定义toJSON这导致你console.log打印对象时看着正常存到缓存里再读出来已经是另一副面孔。所以往 localStorage 写数据之前先做一次“预演还原”const test JSON.parse(JSON.stringify(obj)); // 对比 test 和 obj 的关键字段确认形状一致对于关键业务数据这个几毫秒的预演能帮你省下大量排查时间。5.5 读写对称检查写入前先问自己三个问题这个对象里有undefined、函数、Symbol、NaN、BigInt 吗这个对象里有循环引用吗如果有我裁剪了吗这里面的数字有超过2^53的吗如果有我转成字符串了吗三个问题都过了再往 localStorage 或接口里写。做不到这三点后面读出来出问题就是大概率事件。6. 两个能救命的习惯别在渲染函数里解析解析完立刻验结构前面讲的是具体问题和工具封装。最后分享两个我长期养成的习惯能帮你从根本上减少这类问题的发生频率。6.1 渲染函数和 reducer 里禁止 JSON.parseReact 的 render、Vue 的 computed 和 watch这些函数会被非常频繁地执行。如果在这些位置写JSON.parse每次渲染都可能触发解析。一旦数据有问题页面会在用户看不到的地方反复抛错甚至导致整个应用崩溃。正确做法是把解析和校验放在副作用阶段比如事件回调、onMounted、useEffect里解析一次后把结果存入 state 或缓存。这样即使解析失败你也只要处理一次错误边界能兜住页面不会反复崩溃。6.2JSON.parse不抛异常不代表结果可用特别提一下这个JSON.parse(null)是合法的它返回null不抛任何异常。但如果你接着执行null.list照样炸。很多人只包了try-catch以为没问题了结果踩了Cannot read properties of null的坑。所以我建议每次解析完成后立刻验证结构是不是预期的类型function isNonNullObject(value) { return typeof value object value ! null !Array.isArray(value); } function isNonEmptyArray(value) { return Array.isArray(value) value.length 0; }在safeParse返回后调用方习惯性做一次类型判断再访问字段。这也是为什么我前面强调 schema 校验——它不仅是规范更是防杠精工具能把“JSON 合法但结构不对”的问题在早期拦截下来。最后再说一个实用技巧。团队公共工具库里常备一个formatJSON小工具输入任意字符串输出美化后的 JSON 或者清晰的错误位置。遇到同事求助“这个 JSON 到底哪里坏了”让他把字符串粘进去两秒钟就知道答案。很多问题看起来复杂其实就是某个值在源头被写坏了。把源头管住比在JSON.parse外面包一百层try-catch有用得多。
上一篇/下一篇内容由系统自动关联 返回资讯列表 →