资讯详情 JS 字符串转对象:JSON.parse、eval 与手写解析实战
📅 2026/10/20 6:00:51
1. 先搞清楚字符串转对象到底在转什么做前端这些年字符串转对象这个需求出现的频率高到离谱。接口返回的 body、localStorage 里躺着的缓存、URL 上挂着的 query、DOM 上写的 data 属性、后端透传过来的一坨配置文本全都是字符串形态但你在业务代码里要的是能obj.a.b这样点出来的结构。这个转换动作本身没什么神秘的麻烦的是字符串的来源五花八门格式标准程度参差不齐选错方法轻则报错重则埋雷。先明确一个前提JS 里说的字符串转对象绝大多数情况下指的是把符合 JSON 语法的文本还原成对象而不是把任意一段人类可读的文字变成对象。这两件事的难度差了三个数量级。前者是解析后者是自然语言理解。很多人一上来就想找万能转换函数方向就偏了。我把这个需求拆成三类真实场景你可以对号入座。第一类是标准 JSON 文本接口返回、缓存读写、跨端通信基本都属于这类特征明显双引号包 key、双引号包字符串、没有尾逗号、没有注释。第二类是类 JSON 文本常见于后端老哥手工拼的配置、运维写的伪 JSON、某些脚本导出的数据特征是单引号、无引号 key、尾逗号、甚至写了注释。第三类是完全非 JSON 的键值文本比如a1b2这种 query 串或者key: value一行一条的配置格式。这三种场景对应的技术方案完全不同混用必出问题。所以下面我给的方法不是并列的三选一而是有优先级、有边界的三种武器JSON.parse是标准答案必须第一个上eval/new Function是核武器能解决类 JSON但杀伤范围不可控手写解析器是最后的兜底用在格式完全不标准、又不信任执行环境的场合。搞清楚什么时候该换武器比记住语法重要得多。另外提醒一句判断一个字符串是不是合法 JSON别用正则去猜也别用「包含不包含花括号」这种土办法。浏览器和 Node 都提供了JSON.parse本身作为唯一可靠的判定器包一层 try/catch 就是最准的判断逻辑。后面我会给一个可直接抄的isJSON实现。2. 方法一JSON.parse90% 场景的标准答案2.1 基本用法和那个被忽略的第二个参数JSON.parse的签名是JSON.parse(text[, reviver])。绝大多数人只用第一个参数第二个参数reviver常年被无视但它能省掉不少后处理代码。基础用法没什么好讲的const str {name:张三,age:28,tags:[前端,Node]}; const obj JSON.parse(str); console.log(obj.name); // 张三reviver是一个回调签名(key, value) newValue。它会在解析过程中被自底向上地调用也就是说子属性先处理父对象后处理。返回undefined会把这个属性从结果里删掉返回其他值则替换原值。这个特性在两类场景里特别好用。第一类统一做类型规整。比如后端把所有数字都当字符串传你想在解析阶段就转回来const raw {count:12,price:19.9,name:商品A}; const obj JSON.parse(raw, (key, value) { if (key ) return value; // 根对象本身直接放行 if (value ! null !isNaN(value) typeof value string value.trim() ! ) { return Number(value); } return value; }); // { count: 12, price: 19.9, name: 商品A }注意key 这个判断根对象进来时 key 是空字符串如果你不加拦截整个对象可能被你的转换逻辑误伤。这个坑我第一次用 reviver 的时候就踩了返回了个Number({...})得到NaN排查了半天。注意reviver里的this指向当前正在处理的属性的宿主对象。如果你需要访问同级属性做联动判断用箭头函数是拿不到this的得用普通函数。第二类过滤敏感字段。比如日志上报前想把password、token这类字段直接剔除const safe JSON.parse(logStr, (key, value) [password, token, idCard].includes(key) ? undefined : value );这比解析完再递归删字段干净得多而且只遍历一次。2.2 报错的三种典型形态和定位思路JSON.parse的报错信息在不同运行环境下差异很大这点必须先说清楚否则你搜错误信息会搜到一堆不相关的答案。在较老的浏览器里报错通常是SyntaxError: Unexpected token x in JSON at position 12。在较新的 Chrome 和 Node 20 以后报错会带上上下文形如Unexpected token , {a:1b... is not valid JSON甚至直接把你原始字符串里出错的位置前后几个字符打出来。这个改进非常实用因为中文标点、全角符号这类问题光看 position 数字是很难定位的。我把高频报错归纳成三类报错关键词典型原因定位方法Unexpected token in JSON at position 0字符串用了单引号包裹或整体带了 BOM 头打印首字符的 charCode65279 就是 BOMUnexpected token ,/Unexpected token }尾逗号如{a:1,}搜索,}和,]Unexpected token 全角逗号中文标点混入全局搜全角字符UTF-8 BOM 这个坑值得单独拎出来。后端返回的 JSON 如果是从 Windows 记事本另存出来的或者经过某些中间件处理开头可能带\uFEFF。肉眼完全看不出来str.length还会比预期多 1JSON.parse直接报 position 0 错误。处理办法很简单function safeParse(text) { if (typeof text ! string) return null; // 去掉 BOM 和首尾空白 const cleaned text.replace(/^\uFEFF/, ).trim(); try { return JSON.parse(cleaned); } catch (e) { console.warn([safeParse] 解析失败:, e.message, cleaned.slice(0, 120)); return null; } }这里我特意在 catch 里打印了前 120 个字符。实测下来90% 的解析失败只要打印出原始文本开头问题一目了然。比在控制台里一层层扒调用栈高效太多。2.3 大数字精度丢失这个隐形炸弹JSON.parse还有个不容易被发现的问题超过安全整数范围的大数字会被静默截断精度而且不报错。JSON.parse({id: 1234567890123456789}).id; // 1234567890123456800 ← 尾数已经变了后端如果用雪花算法生成 ID或者用bigint存订单号直接JSON.parse会拿到一个错误的值。更恶心的是这个错误值看起来「挺像那么回事」不仔细核对根本发现不了。等到了数据库查询报「记录不存在」的时候你才回头查是哪一步丢的精度。解决办法是让后端把大数字序列化成字符串前端解析后用BigInt或string承载。如果后端改不了就得在reviver里做拦截但问题是reviver拿到的 value 已经是被解析过的Number了精度已经丢了拦不住。真正能兜住的方案是用支持大数字的解析库或者在解析前用正则把大数字部分加引号包起来function parseWithBigInt(text) { const patched text.replace( /:\s*(-?\d{16,})/g, : $1 ); return JSON.parse(patched); } // JSON.parse 后 16 位以上的数字会变成字符串交给业务层用 BigInt 处理这个正则方案有边界问题比如字符串里恰好有: 1234567890123456这种内容会被误伤所以只推荐用在数据结构可控的接口场景。真要严谨还是得上json-bigint这类库。3. 方法二eval 和 new Function能干活但要戴手套3.1 eval 为什么被明令禁用eval的用法极其简单const obj eval(( str ));那对额外的括号是关键因为eval期望一个语句序列{开头会被当成代码块而不是对象字面量加括号把它变成表达式才能正确求值。它最大的好处是容忍度高。单引号、无引号 key、尾逗号、注释、甚至里面写个函数调用都能跑。这也是为什么早年间很多配置文件解析器都用它。但代价是什么eval会在当前作用域里执行传入的字符串这意味着它能访问你函数里的所有局部变量能修改它们能读取闭包里的任何东西。如果这个字符串来自用户输入或者不受控的接口攻击者完全可以构造一段代码把你的页面数据打包发走。这已经不是「性能差」的问题是安全漏洞。浏览器 CSP内容安全策略默认会拦截eval如果你在开启了严格 CSP 的项目里用它控制台会直接抛出Refused to evaluate a string as JavaScript。很多团队在构建阶段就通过eslint的no-eval规则禁掉了它代码提交都过不了。所以我的态度很明确只要字符串来源不可信eval一律不用即使是内网可控数据也优先用new Function替代。3.2 new Function 的沙箱错觉与正确姿势new Function的写法const obj new Function(return ( str ))();它和eval的核心区别是作用域隔离。new Function创建的函数运行在全局作用域下拿不到你当前函数的局部变量和闭包这在一定程度上降低了信息泄露的风险。但它依然是在全局环境下执行任意代码依然能被 CSP 拦截依然能访问window和document。// 这段照样能跑照样能改全局状态 const o new Function(return (window.location.href))();所以「new Function是安全沙箱」这个说法是错的它只是缩小了攻击面没有消除攻击。真要执行不可信代码得上Web Worker或者iframe加sandbox属性做真正的隔离。那new Function的合理使用场景是什么我总结了两条。第一条是处理可控来源的类 JSON 配置。比如你自己在管理后台写的、存在数据库里的一段配置文本用的是单引号加无引号 key 的宽松写法。这种情况下用它没问题但强烈建议配合白名单校验function parseLoose(str) { // 先做一次白名单校验只允许字面量、括号、逗号、冒号、引号、数字、空白 if (!/^[\s{}\[\]:,0-9a-zA-Z_\-.$#/\\]$/.test(str)) { throw new Error(含有非法字符拒绝解析); } return new Function(return ( str ))(); }这个正则是粗粒度的但能挡住绝大多数明显的注入尝试比如函数调用、模板字符串、反引号、分号。第二条是在构建工具里替代 eval 做动态取值。有些低代码平台需要根据用户配置的表达式路径去取值比如user.profile.address.city用new Function(data, return data.user.profile.address.city)会比手写路径解析快而且逻辑更直观。这种场景下表达式是平台自己生成的来源可控。实操心得不管用哪种动态执行方式务必在开发环境加日志把待执行的字符串原文打出来。上线前再考虑关闭。我见过太多线上故障是因为某段配置文本里混进了一个中文引号本地没打印原文Debug 花了两个小时。4. 方法三手写解析非标准格式的最后兜底4.1 什么时候标准方案一定会失败有三种情况JSON.parse必定失败new Function又不能用安全审计不允许或者 CSP 拦死了。一是格式松散到不成样子比如{name: 张三, age: 28}值都没有引号。这种连 JS 引擎都爱莫能助因为张三是未定义的标识符。二是需要边解析边做业务校验比如解析过程中就要把某些字段转成Date某些字段要做金额分转元用reviver也绕因为拿不到完整的上下文。三是运行环境受限比如某些嵌入式脚本环境里JSON对象不完整或者需要解析比 JSON 更宽松的超集格式。4.2 一个能打的轻量解析器实现下面这个looseParse是我整理出来的支持单双引号、无引号 key、或:作分隔符、尾逗号、true/false/null/数字自动识别。代码 80 行左右没有外部依赖可以直接贴进项目。function looseParse(input) { let i 0; const s String(input); const WS /\s/; const skipWS () { while (i s.length WS.test(s[i])) i; }; const peek () s[i]; function parseValue() { skipWS(); const c peek(); if (c {) return parseObject(); if (c [) return parseArray(); if (c || c ) return parseString(); return parseLiteral(); } function parseString() { const quote s[i]; let out ; while (i s.length) { const c s[i]; if (c \\) { const n s[i 1]; const map { n: \n, t: \t, r: \r, b: \b, f: \f }; out map[n] ! undefined ? map[n] : n; i 2; continue; } if (c quote) { i; return out; } out c; i; } throw new SyntaxError(字符串未闭合); } function parseLiteral() { const start i; while (i s.length !/[,\]\}\s]/.test(s[i])) i; const raw s.slice(start, i).trim(); if (raw ) throw new SyntaxError(空值); if (raw true) return true; if (raw false) return false; if (raw null || raw undefined) return null; if (/^-?\d(\.\d)?([eE][-]?\d)?$/.test(raw)) return Number(raw); return raw; // 裸字符串比如 {name: 张三} 里的 张三 } function parseObject() { i; // 跳过 { const obj {}; skipWS(); if (peek() }) { i; return obj; } while (i s.length) { skipWS(); if (peek() }) { i; return obj; } let key; if (peek() || peek() ) { key parseString(); } else { const start i; while (i s.length !/[:,\s]/.test(s[i])) i; key s.slice(start, i).trim(); } skipWS(); if (peek() : || peek() ) i; obj[key] parseValue(); skipWS(); if (peek() ,) { i; continue; } if (peek() }) { i; return obj; } } throw new SyntaxError(对象未闭合); } function parseArray() { i; // 跳过 [ const arr []; skipWS(); if (peek() ]) { i; return arr; } while (i s.length) { arr.push(parseValue()); skipWS(); if (peek() ,) { i; continue; } if (peek() ]) { i; return arr; } } throw new SyntaxError(数组未闭合); } const result parseValue(); skipWS(); if (i s.length) throw new SyntaxError(存在多余字符: s.slice(i, i 20)); return result; }跑几个用例验证一下looseParse({name: 张三, age: 28, tags: [前端, Node,], active: true}); // { name: 张三, age: 28, tags: [前端, Node], active: true } looseParse(a1b2.split().map(x x.split())); // [[a,1],[b,2]]这个实现刻意避开了正则回溯陷阱全部走字符游标复杂度是 O(n)比用一堆正则替换再JSON.parse要稳得多也不会出现「正则改错了一个地方导致整个字符串结构被破坏」的问题。当然它有边界不支持嵌套注释、不支持模板字符串、不支持 BigInt 字面量。但对配置文件级别的场景完全够用。你要真有更复杂的需求老老实实上专门的解析库别自己造轮子。注意手写解析器最大的风险是错误处理不到位。上面这个版本在遇到不闭合的引号、括号时都会抛SyntaxError调用方一定要 try/catch。千万别让一个格式错误的配置串把整个页面搞白屏。5. 场景实战三类数据的转换细节差异5.1 URL query 和 dataset 的特殊性JSON.parse不是万能的因为很多场景下数据根本不是 JSON 格式。URL query 串是最典型的。location.search拿到的是?a1b2c%E4%B8%AD%E6%96%87这玩意儿扔给JSON.parse必然报错。正确姿势是用URLSearchParamsconst params Object.fromEntries(new URLSearchParams(location.search)); // { a: 1, b: 2, c: 中文 }注意URLSearchParams会自动做 URL 解码所以中文能正确还原。但它有个坑同名参数只保留最后一个。如果 URL 是?tagatagbfromEntries拿到的是{ tag: b }前面的丢了。要保留全部得用getAll()const sp new URLSearchParams(location.search); const multi {}; for (const key of new Set(sp.keys())) { const vals sp.getAll(key); multi[key] vals.length 1 ? vals : vals[0]; }另一个高频场景是 DOM 上的>div idbox>const el document.getElementById(box); const config JSON.parse(el.dataset.config); // 对象 const count Number(el.dataset.count); // 数字必须手动转这里的关键认知是dataset拿到的值永远是字符串不会自动推断类型。>function autoType(v) { if (v true) return true; if (v false) return false; if (v null) return null; if (v ! !isNaN(v)) return Number(v); return v; }5.2 三种方法的性能实测对比我用一份约 200KB、包含 5000 条记录的 JSON 文本做了轮对比测试跑 1000 次取平均环境是 Chrome 稳定版具体机型就不报了看量级就行。方法平均耗时相对倍数内存占用JSON.parse约 3.2ms1x基准new Function约 4.8ms1.5x略高looseParse手写约 42ms13x明显更高结论很清楚标准格式永远优先用JSON.parse。它是原生实现走的是高度优化的 C 路径任何 JS 层面的解析都追不上。手写解析器只在格式不标准时作为兜底千万别因为「不想处理报错」就无脑上宽松解析13 倍的差距在批量处理场景下会直接拖垮页面。顺带说一个容易被忽略的性能相关点不要重复解析同一个字符串。我在项目里见过这样的代码同一份配置在渲染函数里被JSON.parse了七八次每次都重新解析。正确做法是解析一次缓存起来const cache new WeakMap(); function parseOnce(str) { if (cache.has(str)) return cache.get(str); const r JSON.parse(str); cache.set(str, r); return r; }用WeakMap的好处是键是字符串对象不会阻止垃圾回收。不过要注意如果字符串是拼接生成的每次都是新对象缓存会失效所以这招适用于「同一份配置文本被反复使用」的场景。6. 常见问题和避坑清单6.1 报错速查表现象根本原因修复方式position 0 报错BOM 头 / 空字符串 / 首字符是单引号先replace(/^\uFEFF/, ).trim()再判断尾逗号报错手工拼的 JSON 留了,}预处理替换/,(\s*[}\]])/g, $1全角标点报错中文输入法敲的全局替换全角为半角后再解析解析成功但数值不对超过 2^53 的大整数丢精度让后端返字符串或正则加引号预处理undefined字段消失JSON 不支持 undefined序列化时被丢掉后端用 null 代替 undefinedDate变成字符串JSON 没有日期类型reviver或用 schema 校验后手动重建CSP 报错Refused to evaluate用了eval或new Function换JSON.parse或调整 CSP 策略6.2 几个我想单独强调的坑第一个是原型污染。JSON.parse本身不会造成原型污染因为解析出来的__proto__是普通属性。但如果你随后用Object.assign(target, parsed)或者递归合并函数把它合并进配置对象{ __proto__: { isAdmin: true } }这种载荷就可能污染原型链。防御方式很简单function safeMerge(target, source) { for (const key of Object.keys(source)) { if (key __proto__ || key constructor || key prototype) continue; const v source[key]; if (v typeof v object !Array.isArray(v)) { target[key] safeMerge(target[key] || {}, v); } else { target[key] v; } } return target; }用Object.keys而不是for...in也是关键for...in会把原型链上的可枚举属性也遍历出来。第二个是空值和边界。JSON.parse()会报错JSON.parse(null)会返回null因为null被字符串化成了nullJSON.parse(null)返回nullJSON.parse(123)返回数字123而不是对象JSON.parse(abc)返回字符串。这些行为都符合规范但业务代码里如果默认「解析出来一定是对象」就会在这些输入上翻车。所以我建议所有跨边界的地方都加一层类型断言function ensureObject(v, fallback {}) { return v typeof v object !Array.isArray(v) ? v : fallback; }第三个是并发写入导致的缓存污染。有些团队会把解析结果挂在全局对象上做缓存key 用 URL 或者接口名。这在单页应用里遇到并发请求时会有问题两次请求返回的数据不同但缓存 key 相同后到的会把先到的覆盖而先到的请求回调还拿着旧引用在用。这种 bug 极难复现因为依赖请求时序。老老实实用Map加请求 ID 做 key别图省事。第四个是关于调试。当你在一段复杂链路里怀疑某次解析出了问题最快的定位方法不是加断点而是在解析前后各打印一次console.log([before], typeof str, str.length, str.slice(0, 80)); const obj JSON.parse(str); console.log([after], JSON.stringify(obj).slice(0, 80));打出长度和首尾片段比直接console.log(str)有用得多。因为大字符串在控制台里会被折叠你还得手动展开效率低。而JSON.stringify回显能帮你确认解析结果的结构是不是你预期的那样有时候问题不在解析而在解析之后被别的代码改掉了。最后分享一个我在多个项目里沿用的小习惯所有涉及外部数据解析的地方统一走一个safeParse函数并在里面打点上报。上报内容只包含「哪个接口、第几个字符出错、错误类型」不打原始数据避免日志膨胀和隐私问题。这样线上一旦出现解析失败你能立刻知道是哪个数据源的问题而不是等用户来投诉页面白屏。这套东西用下来字符串转对象这件小事基本不会再给你添麻烦。真正需要花心思的永远是判断「这个字符串到底该用哪种方式转」这一步剩下的都是熟练度问题。