ARTICLE DETAIL

资讯详情

深耕郑州网站建设与运营推广的一线实战洞察。

JS字符串转对象:JSON.parse、new Function与自定义解析

JS字符串转对象:JSON.parse、new Function与自定义解析 接手一个老项目时最怕看到接口返回这么一段东西{name: zhang, age: 18, tags: [a, b],}。它不是 JSON但你必须在几行代码内把它变成 js 对象否则后面所有data.name的取值逻辑都得跟着改。js 字符串转换为对象格式这件事表面上看就是调一个 API真上手会发现坑比想象中密单引号、尾逗号、undefined、超长数字、带 BOM 的响应体任何一个都能让代码在线上直接抛异常。这篇就按我自己的实际处理思路把常见的三类字符串形态拆开把 JSON.parse、new Function含 eval、自定义解析这三条路各自的适用边界讲透顺带把我在项目里踩过的几个坑和排查链路完整还原一遍。无论你是刚学 JS 的新手还是写过几年业务、被后端数据格式折磨过的老手都能从里面找到能直接抄走的代码和判断依据。1. 动手之前先看字符串长什么样三类形态的差异拿到一个字符串就急着JSON.parse是绝大多数报错的源头。转换方法只有那么几种但字符串的形态至少有三大类选错方法是必然踩坑的。我的习惯是先做一次快速嗅探判断它属于哪一类再决定用哪个解析入口。这个判断过程通常只花几秒钟却能省掉半小时的调试。1.1 标准 JSON 串双引号、无函数、无注释标准 JSON 的定义收得很紧。键名必须用双引号包起来字符串值也必须用双引号允许的值类型只有字符串、数字、布尔、null、对象、数组这六种。不能出现undefined、函数、正则、Date实例、注释、尾随逗号、NaN、Infinity。前后可以有空白字符空格、制表符、换行、回车但仅此而已。合法的例子长这样{id: 1, name: abc, active: true, extra: null}下面这些全是非法 JSON虽然它们在 JS 代码里完全合法{id: 1} // 键名没引号 {name: abc} // 单引号 {a: 1,} // 尾逗号 {a: undefined} // undefined {a: /regex/} // 正则 {a: new Date()} // 表达式 {/* 注释 */ a: 1} // 注释很多后端同学口头说的“我返回的是 JSON”实际返回的经常是第 2 到第 6 行那种“长得像 JSON 的 JS 字面量”。搞清楚这一点后面的方法选择就顺了。1.2 JS 对象字面量串单引号、函数、undefined 都能塞这类字符串是 JS 源码的一部分只是被当作文本传了过来。它遵守 JS 的对象字面量规则键名可以不加引号字符串可以用单引号、双引号或反引号值可以是任意 JS 表达式甚至可以带函数和Symbol。配置类文件、老的模板引擎输出、某些低代码平台导出的成品都属于这一类。典型长这样{a: 1, b: x, c: undefined, d: function () { return 1; }}这类字符串用JSON.parse一定失败只能靠 JS 解析器本身去啃也就是new Function或者eval这条路。但这条路有安全代价第 3 节会详细说。1.3 类查询串与自定义分隔串a1b2 这种半结构化文本第三类既不是 JSON 也不是 JS 字面量而是用某个分隔符拼起来的键值对。最常见的是 URL query 形式a1b2chello%20world还有各种自定义的a:1;b:2、a|1,b|2。这类数据的特点是结构简单但值的转义规则、重复键的处理、编码方式都可能有各种变体。它不能用 JSON.parse也最好别用 eval因为里面就是普通文本用 eval 反而是引入风险又没收益。正确做法是用URLSearchParams或者手写一个小的状态机解析器第 4 节会给完整代码。1.4 一张判断表拿到字符串先做格式嗅探我在工具函数里通常会先跑一段轻量的嗅探逻辑把字符串归到某一类function sniff(str) { const s String(str).trim(); if (s ) return empty; // 键名必须带双引号这是 JSON 的强特征 const looksLikeJson /^[[{]/.test(s) /\s*:/.test(s); if (looksLikeJson) return json; // 单引号键、无引号键、含函数关键字归为 JS 字面量 if (/^[[{]/.test(s)) return js-literal; // 含等号、还有分隔符归为查询串 if (/[]/.test(s)) return query; return unknown; }这段正则不求绝对严谨只求把明显的格式分流出去剩下的交给try/catch兜底。我踩过的坑是有人用判断 JSON结果字符串值是双引号但键名没引号的也被误判了所以判断条件里必须带上\s*:这个键名特征。2. JSON.parse标准 JSON 串的正解也是报错最凶的那个如果字符串确实是标准 JSON那JSON.parse是唯一值得用的方法。它是引擎原生实现速度快而且它做的事情是“解析”不是“求值”——字符串里的内容不会被当作代码执行这一点是它和 eval 系列最本质的区别。2.1 它的工作逻辑一次扫描不做任何求值JSON.parse内部走的是一套独立的词法和语法分析流程产物是一个全新的对象。它不会去查找变量不会调用构造函数不会触发任何 getter 或函数的副作用除了 reviver 那一步。这意味着即使字符串里出现window、require这种词也只是普通文本最多因为不符合 JSON 语法而抛错不会有任何执行风险。这一点值得反复强调只要数据来源不完全可控你的第一选择永远是 JSON.parse而不是 eval 系列。有些同学为了“兼容性好一点”随手写个 eval 封装这在安全审查里是要被打回的。2.2 reviver 的妙用在解析过程中改值JSON.parse的第二个参数很多人从来没用过但它能省掉一整轮遍历。reviver 是个函数会对解析出来的每个键值对调用一次参数是(key, value)返回值会替换原来的值。如果返回undefined这个键会被直接删掉。几个实战里常用的写法// 把 ISO 时间字符串还原成 Date const obj JSON.parse(json, (key, value) { if (typeof value string /^\d{4}-\d{2}-\d{2}T/.test(value)) { return new Date(value); } return value; }); // 下划线 key 批量转驼峰 const camel JSON.parse(json, (key, value) { if (key.includes(_)) { this[key.replace(/_(\w)/g, (_, c) c.toUpperCase())] value; } return value; });第二个例子里用到了 reviver 的一个特性this在执行过程中指向当前正在构建的对象也就是那个被修改的容器。所以往this上挂新键、返回undefined把旧键删掉这是一个很顺手的重命名技巧。注意 reviver 是自底向上遍历的子节点的 reviver 一定先于父节点执行所以依赖顺序的逻辑要按这个来设计。2.3 那些让 JSON.parse 直接抛错的写法下面这些错误信息基本每个前端都见过但真正能立刻从报错定位到字符位置的人不多。我把它们的成因整理成表原始字符串报错特征根因{a:1}位置 1 附近报错单引号不是合法 JSON 字符{a:1,}指向}尾随逗号{a:undefined}指向uundefined不是 JSON 值{a:1}extra指向后面的字符尾部有多余内容空字符串Unexpected end of JSON input输入为空\uFEFF{a:1}位置 0 报错BOM 头没被剥掉最后一条特别隐蔽。有些接口在返回体开头带了 BOM浏览器fetch拿到response.text()时不会自动去掉。我的处理方式是统一在入口做一次清洗function cleanBOM(str) { return String(str).replace(/^\uFEFF/, ); }顺便说一个认识上的误区JSON.parse允许前后有空白字符因为 JSON 规范把空白符排除在语法之外但不允许中间出现注释。很多人以为 JSON5 的那些宽松特性是通用行为其实不是。2.4 大整数精度丢失一个几乎没人注意的静默坑这是我在做订单系统时被坑过的一次。接口返回{orderId: 1234567890123456789}JSON.parse之后obj.orderId变成了1234567890123456800。原因是 JS 的 number 是双精度浮点安全整数上限是Number.MAX_SAFE_INTEGER也就是 2 的 53 次方减一约 9 万亿亿。超过这个范围的整数在解析时就会被静默地取整到最近的可用值不报错不警告。订单号、雪花算法生成的 ID、社交平台的长数字 ID 都会命中这个坑。三种处理方式让后端把长整型字段返回成字符串这是最干净的方案改一次接口模板就行。用JSON.parse的 reviver 配合预处理先用正则把超长数字加引号再解析。换成支持大整数的解析库把这类字段还原成BigInt。第二种的临时方案长这样function safeParse(str) { const patched String(str).replace( /:\s*(-?\d{16,})/g, : $1 ); return JSON.parse(patched); }这个正则有边界风险比如字符串值内部如果恰好有:加长数字的组合会被误伤所以只适合临时救火。真要长期用还是推动后端改类型。3. new Function 与 eval啃非标准字面量的兜底方案当字符串是{a: 1, b: x}这种 JS 字面量时JSON.parse 一定失败。这时候只剩两条路把字符串规整成合法 JSON或者用 JS 自己的解析器去求值。前者是第 3.4 节要讲的降级方案后者就是new Function和eval。3.1 为什么不直接用 eval而是 new Function两者都能解析 JS 源码但差异很关键。eval是直接调用时会在当前作用域里执行它能读写当前函数的局部变量也会把新声明的变量泄漏到当前作用域new Function创建的函数体默认在全局作用域执行拿不到调用处的局部变量。从隔离性上说new Function明显更干净。写法上有个细节必须注意就是外面要套一层括号function parseLoose(str) { return new Function(return ( str ))(); }加括号的原因是如果字符串以{开头不加括号时 JS 会把它当作代码块而不是对象字面量返回值就变成了undefined。这是个非常经典的错误我见过不少人的封装漏了这一层。还有一个性能上的小技巧如果同一个形状的字符串要反复解析把编译结果缓存起来能省掉重复的编译开销。function makeParser(str) { return new Function(return ( str )); } const fn makeParser({a: 1}); const obj1 fn(); const obj2 fn();3.2 作用域差异为什么这个区别在封装时很重要举个具体的例子。假设你写了这样一个工具函数function build(extra) { return eval(({a: 1, b: extra})); }在非严格模式下这段能跑通因为eval能看到extra这个局部变量。但如果你把它换成new Function(return ({a: 1, b: extra}))就会直接抛extra is not defined。这个差异意味着基于 eval 写的老代码在迁移到 new Function 时凡是依赖局部变量的都会挂。反过来看这个特性也带来了一个安全收益new Function里能访问到的只有全局作用域也就是window、globalThis那一层攻击面比直接 eval 小一截。所以在必须做动态求值的场景里我一直推荐new Function。3.3 必须加上的一层形状白名单校验哪怕用new Function也绝不能把字符串原样丢进去。我的做法是在编译之前先过一道白名单只允许出现特定的字符集合并且显式拒绝一批危险词。const FORBIDDEN /\b(function||class|new|require|import|axios|fetch|XMLHttpRequest|window|globalThis|self|document|location|top|parent|constructor|prototype|__proto__|setTimeout|setInterval|postMessage)\b/; function assertSafe(str) { const s String(str); if (!/^[\s{}[\]:,.\w\-*/%()\u4e00-\u9fa5]*$/.test(s)) { throw new Error(字符串含非法字符); } if (FORBIDDEN.test(s)) { throw new Error(字符串含危险关键字); } return s; }这个白名单的思路是字符集里只放普通标识符、数字、括号和常见标点中文字符也放开因为文案类数据里经常有中文。一旦出现$、\、;、、!这些很少在纯数据里出现的字符就直接拒掉。经验上纯数据字符串的形状是很规律的白名单收紧一点误杀率并不高真被误杀了再加字符也比放宽后出事强。3.4 什么时候才允许用它三个前置条件我的判断标准很朴素三个条件同时满足才用new Function数据来源完全可控比如项目仓库里打包进去的固定配置文件、写死的模板文案字符串不经过网络传输也不来自任何用户输入或 URL 参数白名单校验在调用前一定执行。这三条里缺任何一条我都会退回“字符串规整 JSON.parse”的方案。规整方案的核心是把非标准写法改造成标准 JSON几个常见的替换function normalize(str) { let s String(str).trim(); // 去掉 BOM s s.replace(/^\uFEFF/, ); // 无引号的键名加双引号 s s.replace(/([{,]\s*)([A-Za-z_$][\w$]*)\s*:/g, $1$2:); // 单引号字符串值换成双引号只处理简单的无嵌套情况 s s.replace(/([^\\]*)/g, $1); // 去掉尾随逗号 s s.replace(/,\s*([}\]])/g, $1); return s; }这套替换对结构简单的字面量效果很好但对嵌套引号、模板字符串、函数表达式就不行了。所以它的定位是“处理结构简单的脏数据”不是通用方案。4. 自定义分隔串的解析URLSearchParams 与手写状态机第三类字符串的处理方式跟前两类完全不同它简单到不需要语法分析但细节上的坑一点不少尤其是编码和重复键。4.1 URLSearchParams 能做什么不能做什么URLSearchParams是浏览器和 Node 都有的原生 API专门处理a1b2这种格式。const sp new URLSearchParams(a1bhello%20worldc%E4%B8%AD%E6%96%87); sp.get(b); // hello world sp.get(c); // 中文 Object.fromEntries(sp); // { a: 1, b: hello world, c: 中文 }它能做的是自动做 percent 解码并且把解析成空格这正好符合表单编码的规则。不能做的是不会自动把1转成数字不会处理重复键Object.fromEntries只会保留最后一个也不会处理嵌套结构。有一个容易忽略的点如果你的字符串用的是;分隔而不是URLSearchParams是认不出来的。老版本规范允许;但现在主流实现只认所以遇到;分隔要先做一次替换。4.2 重复键、编码、数组怎么处理a1a2这种重复键用getAll拿到数组const sp new URLSearchParams(a1a2b3); sp.getAll(a); // [1, 2]如果要转成对象并保留数组得自己遍历function queryToObject(qs) { const out {}; for (const [k, v] of new URLSearchParams(qs)) { if (k in out) { out[k] Array.isArray(out[k]) ? out[k].concat(v) : [out[k], v]; } else { out[k] v; } } return out; }还有一种情况是值本身包含或比如urlhttps%3A%2F%2Fa.com%2Fx%3Fa%3D1%26b%3D2。这种必须在编码时就处理好解析端不需要做特殊处理因为URLSearchParams是按第一个切分的后面的都算值的一部分。这个行为常被误以为需要特殊处理其实不用。4.3 手写一个容错的解析器当分隔符是自定义的比如;或者|或者需要在解析时顺手做类型转换手写一个更直接。下面这个版本处理了空值、重复键和转义function parsePairs(str, pairSep , kvSep ) { const result {}; if (str null) return result; const text String(str).trim(); if (text ) return result; for (const segment of text.split(pairSep)) { if (segment ) continue; const idx segment.indexOf(kvSep); let key, value; if (idx -1) { key segment; value ; } else { key segment.slice(0, idx); value segment.slice(idx kvSep.length); } key decodeURIComponent(key.trim()); value decodeURIComponent(value.trim()); if (Object.prototype.hasOwnProperty.call(result, key)) { result[key] [].concat(result[key], value); } else { result[key] value; } } return result; }这段代码里有两个刻意的设计。一是用hasOwnProperty而不是in判断键是否存在避免原型链上的键比如toString造成误判——如果你的数据里恰好有个 key 叫constructor用in判断就会出问题。二是decodeURIComponent放在trim之后因为有些编码值末尾带的空格是编码后的%20先 trim 不会误伤。4.4 值的类型还原字符串到底要不要变成数字这是我在做配置中心时纠结过很久的一个问题。pageSize20拿到的是字符串20下游如果直接做数学运算就会出问题。但如果无脑转数字又会毁掉一批数据原始值无脑转数字的结果正确结果0755755保留字符串这是区号0011保留字符串这是编号1.101.1保留字符串这是版本号9007199254740993精度丢失保留字符串20240101数字看起来正常但语义是日期保留字符串我的结论是不要做自动类型推断。要么让数据源明确类型比如用namestring:20这种带类型标记的格式要么在业务层按已知字段做映射转换。自动推断看起来聪明实际上是在给未来埋雷。5. 三条路的选型对照与安全边界讲完三种方法最后得给一个能直接照着选的判断依据。我把几个维度拉平了对比。5.1 对照表输入形态决定了唯一选项方法适用输入安全性容错能力是否会执行代码JSON.parse严格 JSON高低格式稍有偏差就抛错否new Function/evalJS 对象字面量低取决于白名单高能吞下大部分 JS 语法是URLSearchParamsa1b2高中否手写解析器自定义分隔高高可自己定义否字符串规整 JSON.parse简单的伪 JSON高中对嵌套结构无效否选型的顺序建议是能规整就规整规整不了且数据可信才上new Function任何来自网络或用户的数据一律只用不执行代码的方案。5.2 性能量级别在错误的层面上优化性能上有个大致的量级关系。JSON.parse是原生实现处理同样长度的标准 JSON 通常是最快的。new Function因为有编译开销第一次调用明显慢但如果把生成的函数缓存起来复用后续调用会和JSON.parse处于同一量级。字符串规整方案的成本全在几次正则替换上正则是 C 层实现速度不差但如果字符串很长比如几兆的配置几次全量替换的耗时可能比直接解析还高。手写解析器在短字符串上表现不错但一旦字符串变长split产生的中间数组和逐个字符处理的开销就会显现。我实测过一次同样解析 1 万个键值对URLSearchParams加Object.fromEntries明显比手写循环快因为原生实现没有 JS 层的函数调用开销。不过这些都是次要的。真正的性能问题基本都出在架构层面——比如每次请求都重新编译new Function或者在一个循环里反复解析同一个字符串。把结果缓存住收益比换方法大得多。5.3 安全红线什么字符串绝对不能进 eval 系列这条我很坚持任何来自用户输入、URL 参数、第三方接口响应体的字符串都不允许进入eval或new Function。原因是这两者本质上是在执行代码而不是在解析数据。一个精心构造的字符串可能被解释成任意代码造成的影响范围远超数据本身。举个直观的例子。假设你把一个 URL 参数直接喂给了new Function参数值是(function(){ /* 任意逻辑 */ })()这样的形状。不管你原本想拿它当数据还是别的它都会被当成代码跑起来。加白名单能挡掉大部分但白名单本身很难做到滴水不漏因为 JS 语法的组合空间太大了。所以最稳妥的策略是根本不走这条路而不是指望白名单兜底。6. 项目里真实踩过的坑与完整排查链路前面讲的是方法本身这一节讲的是我实际排障时的过程。这类问题的难点往往不在修复而在定位——报错信息只说“位置 1”但字符串可能有几千个字符。6.1 伪 JSON 连环报错先定位再规整印象最深的一次是某个运营后台的配置接口。返回的字符串里混了单引号、尾逗号和没引号的键名JSON.parse报错位置是 1看起来是单引号的问题。但把单引号替换掉之后报错位置变成了 87是尾逗号。再替换掉之后位置又变了。这种“改一处报一处”的情况很折磨人。后来我总结出一个更高效的顺序不要一处一处改而是先把能批量修的几类问题一次性处理掉再解析。也就是前面normalize函数里的那四步去 BOM、给键名加引号、单引号换双引号、去尾逗号。一次性过完再试通常一次就能通过剩下的少数特例再单独看。6.2 从报错位置反推字符一个通用的调试片段不管是 Node 还是浏览器JSON 解析的报错里通常都会带一个字符位置。这个位置信息很有用但很多人不知道怎么看。我的做法是打印出错位置前后的片段function debugParse(str) { try { return JSON.parse(str); } catch (err) { const m /position (\d)/.exec(err.message); if (m) { const pos Number(m[1]); const start Math.max(0, pos - 30); const end Math.min(str.length, pos 30); const raw str.slice(start, end); const pointer .repeat(pos - start) ^; console.error(出错位置附近\n raw \n pointer); } else { console.error(原始字符串, str.slice(0, 200)); } throw err; } }这个小工具救过我很多次。它把“位置 87 出错”这种抽象信息变成了一个带光标指向的片段一眼就能看出是哪个字符有问题。注意新版本 V8 的报错格式有变化有些会输出at position 87 (line 1 column 88)这种带行列号的形式正则要写得宽松一点或者用line和column分别提取。6.3 空字符串、null、undefined 这几种“空”的处理差异这几种空值在转换时行为完全不同必须分清楚空字符串JSON.parse()直接抛Unexpected end of JSON input。业务上通常应该返回一个空对象或null而不是让它抛错。字符串nullJSON.parse(null)返回null这是合法的不要把它和空字符串混为一谈。字符串undefined非法抛错。因为undefined不是 JSON 值。传入null或undefined本身JSON.parse(null)会先被转成字符串null返回nullJSON.parse(undefined)转成undefined抛错。我的封装里会先做一层拦截function safeParse(str, fallback null) { if (str null || str undefined) return fallback; if (typeof str object) return str; // 已经是对象直接返回 const s String(str).trim().replace(/^\uFEFF/, ); if (s || s undefined) return fallback; try { return JSON.parse(s); } catch { return fallback; } }注意typeof str object那一行。有些接口在出错时会返回一个空对象而不是字符串如果不判断String({})得到[object Object]解析必然失败。这个判断在对接不稳定接口时很实用。6.4 引用共享缓存带来的隐蔽污染最后一个坑和转换方法没关系是使用方式带来的。有些同学为了性能给解析函数加了一层基于字符串的缓存const cache new Map(); function parseWithCache(str) { if (cache.has(str)) return cache.get(str); const obj JSON.parse(str); cache.set(str, obj); return obj; }单看这层缓存没问题JSON.parse每次返回的都是全新对象不会有引用共享。但如果下游拿到缓存返回的对象后修改了它下一次调用拿到的就是被改过的脏数据。我见过一次线上故障就是某个组件改了配置对象里的一个属性导致其他所有引用同一配置的模块都跟着变了。解决办法有两个一是缓存里存字符串而不是对象每次重新解析二是在返回前做一次浅拷贝{...obj}或深拷贝。选哪个取决于对象的嵌套深度和调用频率。我的倾向是只对特别重的解析做缓存并且返回前一定做一层拷贝宁可多花一点拷贝开销也不留一个共享状态的隐患。另外提一句深拷贝用JSON.parse(JSON.stringify(obj))是常见写法但它会丢掉undefined、函数、Date会变成字符串、Map和Set会变成空对象。如果你的对象里有这些类型得换用其他拷贝方式别指望这一行能通用。最后分享一个我一直在用的习惯在所有解析入口统一包一个safeParse内部走“空值拦截 → BOM 清洗 → 尝试 JSON.parse → 失败时记录原始字符串并返回兜底值”这条链路。这样线上出问题时日志里能直接看到是哪个字符串解析失败了定位时间从半小时缩短到几分钟。至于要不要在失败时尝试规整或者new Function兜底我的建议是单独开一个开关默认关闭只在排查阶段打开避免把安全风险带到生产环境里。
返回列表