拓十年匠心定制 · 商业建站与技术教学双线并行 咨询热线:400-886-1026 service@lmnt.cn
ARTICLE DETAIL

资讯详情

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

字符串转对象:JSON.parse、new Function与URLSearchParams

字符串转对象:JSON.parse、new Function与URLSearchParams

“字符串转对象”这几个字,我在技术群里见过的问法至少有十几种:有人拿着一串{a:1,b:2}说 JSON.parse 直接报错,有人要从 URL 里抠出参数,还有人只是想把'abc'变成能挂属性的东西。js 这门语言里,字符串和对象之间的转换几乎没有统一的“官方答案”,因为输入字符串的语法形态不同,能用的解析器就完全不同。选错方法的代价很实在:轻则报一个Unexpected token,重则把一个字符串当成代码执行,把注入风险带进线上。

这篇东西我按实战顺序写:先把“字符串转对象”拆成三种真实输入形态,然后分别给出对应的三种转换方法——JSON.parse、new Function、split/正则/URLSearchParams,每一节都写清楚原理、可复制的代码、参数取舍的理由,以及我自己踩过的坑。中间会穿插性能实测数据、安全加固的正则白名单、类型推断的边界情况,还有一份可以直接抄的问题速查表。不管你是刚学 js 的字符串处理,还是已经写过几年业务想把这些零散技巧系统化,应该都能拿到能直接用的东西。

1. 先把“字符串转对象”拆开看,别急着写代码

很多人卡在这一步不是因为不会 API,而是因为没意识到自己手上的字符串属于哪一类。我见过最典型的翻车现场是:后端接口返回的是一段带单引号和尾逗号的“类 JS 对象字符串”,前端直接JSON.parse,控制台一秒红屏,然后开始怀疑人生。所以动手之前,先花两分钟判断输入形态,比后面调半天 bug 划算得多。

1.1 三种输入形态,决定了三种解法

我把日常遇到的字符串归成三类,基本能覆盖 95% 的场景:

形态典型样子合法解析器常见来源
A. 标准 JSON{"id":1,"name":"键盘"}JSON.parse接口响应、localStorage、配置文件
B. JS 对象字面量{id:1, name:'键盘', tags:['青轴',],}new Function/ JSON5日志、老系统导出、手写配置
C. 分隔符文本name=张三&age=28、a:1;b:2URLSearchParams/ 手写 splitURL query、cookie、表单串、自定义协议

形态 A 和 B 长得像,但语法要求完全不同:JSON 的 key 必须双引号,字符串必须双引号,不允许尾逗号,不允许注释,不允许undefined。形态 B 恰好把这四条全部违反,所以它天然就不是 JSON.parse 的菜。

形态 C 更偏向“键值对文本”,它连嵌套结构都没有(除非你自定义),解析的重点不在语法,而在分隔符识别和值类型推断。

顺带提一个容易被忽略的第四种理解:如果你说的“字符串转对象”是把基本类型字符串装箱成String对象,那答案是一行Object('abc')。它返回的是包装对象,typeof是object,有length和索引属性。日常业务里几乎用不到这个,但面试和instanceof判断时会撞上,知道就行。

1.2 为什么不能一把梭:三个维度上的取舍

选解析器本质上是在三个维度上做权衡,我在项目里判断的顺序是这样的:

第一是安全边界。JSON.parse只解析数据,永远不会执行代码,这是它最大的价值。new Function和eval是执行引擎,输入一旦可控就是远程代码执行。这个差别不是“性能差异”级别的,是“线上事故”级别的。

第二是语法宽容度。宽容度和安全性在这里是反比关系。JSON.parse最严格也最安全;new Function最宽容也最危险;手写解析器介于中间,宽容度由你自己定义,安全性也由你自己保证。

第三是性能和维护成本。原生JSON.parse在 V8 里是 C++ 实现的专用解析器,比任何手写 JS 循环都快一截。手写解析器的优势不在速度,而在你能精确控制每一个字符的处理逻辑,脏数据场景下反而更可控。

注意:我见过有人为了“兼容性”直接把eval封成一个工具函数全局用,这相当于给整个项目开了一个后门。哪怕今天输入源是可信的,明天需求一变,这行代码就变成了风险点。

2. 方法一:JSON.parse,标准 JSON 字符串的正路

只要你的字符串是合法 JSON,JSON.parse就是唯一正确答案,没有之一。原因很直接:它是原生实现,不走 JS 解释器循环,而且它从设计上就只处理数据,不碰执行。

2.1 原理和它的合法输入范围

JSON.parse内部走的是 JSON 语法规范,它接受的顶层值包括对象、数组、字符串、数字、布尔和null。注意最后一点:JSON.parse('"hello"')返回的是字符串"hello",不是对象;JSON.parse('123')返回数字 123。所以如果你写了个通用工具函数,必须补一层类型判断,否则调用方拿到字符串却以为是对象,后面obj.xxx全是 undefined。

function toObjectSafe(raw) { const parsed = JSON.parse(raw); if (parsed === null || typeof parsed !== 'object' || Array.isArray(parsed)) { throw new TypeError('期望解析出普通对象,实际得到:' + typeof parsed); } return parsed; }

这段判断看着啰嗦,但我把它抽成工具函数之后,团队里因为“解析出来不是对象”导致的 bug 基本绝迹了。数组要不要放过,取决于你的业务,我一般会明确区分toObject和toArray两个方法,不混用。

2.2 reviver 参数:解析时就地改造数据

JSON.parse的第二个参数是个 reviver 回调,很多人没用过,但它在处理日期字符串、金额单位、枚举映射时非常好用。它的执行顺序是自底向上的深度优先——先处理最内层的值,再往上冒泡。

const raw = '{"orderNo":"A001","createdAt":"2026-03-01T10:20:30.000Z","amount":39950,"detail":{"payAt":"2026-03-01T10:21:00.000Z"}}'; const order = JSON.parse(raw, (key, value) => { // 所有 ISO 时间串统一转 Date if (typeof value === 'string' && /^\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}/.test(value)) { return new Date(value); } // 金额按分存储,转元 if (key === 'amount' && typeof value === 'number') { return value / 100; } return value; }); console.log(order.createdAt instanceof Date); // true console.log(order.detail.payAt instanceof Date); // true console.log(order.amount); // 399.5

这里有两个细节值得说清楚。第一,reviver 返回undefined会删除该属性,不是设成 undefined,这个行为经常被人误用,做过滤时确实很方便,但做赋值时就会莫名其妙丢字段。第二,reviver 里的value已经被解析成了对应类型,你拿到的不是原始文本,所以想按“字符串原文”做判断的话,只能在文本层面先处理。

提示:reviver 对数组元素同样生效,key是数组索引字符串"0"、"1",如果你在里面用key === 'amount'这种判断,记得排除索引场景,不然同名 key 在数组里也会被误改。

2.3 报错排查清单:九成的错误就这几种

我把线上和本地见过的JSON.parse报错整理了一下,基本都在下面这张表里:

报错信息片段真实原因处理方式
Unexpected token ' in JSON at position 1用了单引号包裹 key 或值换new Function或 JSON5,或先做引号替换
Unexpected token } in JSON at position 20尾逗号正则清掉,\s*([}\]])
Unexpected token u值是undefined,JSON 不支持让后端改null,或预处理替换
Unexpected end of JSON input字符串被截断,或传入了空串检查接口是否返回空、是否有长度限制
Unexpected token \uFEFF开头有 BOM 字符raw.replace(/^\uFEFF/, '')
数字末尾精度丢失大整数超过Number.MAX_SAFE_INTEGER用json-bigint之类库,或让后端返回字符串

Unexpected end of JSON input这个我印象特别深。有次线上问题排查了两小时,最后发现是某个网关对大响应体做了截断,前端拿到的 JSON 少了个右花括号。这类问题在本地永远复现不了,所以我在所有解析入口都加了长度和结构校验,日志里会打印原始字符串的前 200 个字符,出问题能一眼定位。

还有一个必须提的安全点:__proto__键的处理。

const o = JSON.parse('{"__proto__":{"polluted":1}}'); console.log(o.polluted); // undefined console.log(Object.getPrototypeOf(o) === Object.prototype); // true console.log(Object.keys(o)); // ['__proto__']

JSON.parse会把__proto__当成一个普通自有属性创建出来,不会触发原型 setter,所以解析这一步是安全的,o.polluted拿不到值。但风险在下一步:

const config = {}; Object.assign(config, o); // 这一步会把 config 的原型改成 {polluted:1} console.log(config.polluted); // 1

Object.assign走的是[[Set]]语义,会触发__proto__的访问器,把目标对象的原型改掉。如果目标是某个全局共享的配置对象,污染就扩散了。相对安全的写法是展开运算符{...o}或Object.fromEntries(Object.entries(o)),它们走的是数据属性创建路径,__proto__只会成为普通自有属性。这个差异我在做配置合并工具时专门验证过,建议你也跑一遍。

3. 方法二:new Function,对付“JS 对象字面量”字符串

标准 JSON 的约束在实际项目里经常不够用。老系统的导出文件、日志里的对象打印、人肉写的配置片段,基本都是 JS 字面量语法:单引号、无引号 key、尾逗号,甚至带注释。这时候JSON.parse一定挂,能直接吃下这套语法的只有 JS 引擎本身。

3.1 为什么这里必须换解析器

先看一个典型输入:

const loose = "{id: 1, name: '机械键盘', tags: ['87键', '青轴',], remark: undefined,}";

JSON.parse(loose)会在第 2 个字符就报错,因为 JSON 要求 key 必须双引号。你当然可以写一堆正则去补引号、删逗号,但这条路会越走越窄——嵌套对象、字符串里的引号转义、数字科学计数法、正则字面量,每加一种语法就多一个 bug。既然这段文本的语法本身就是 JS 语法,最合理的做法就是交给 JS 引擎解析。

3.2 new Function 与 eval 的取舍

两个方案都能干活,但我只用new Function,理由有三个:

作用域隔离。eval在当前作用域执行,字符串里的var声明会泄漏到你所在函数的局部作用域里;new Function创建的是独立的函数作用域,变量只活在它自己内部。

严格模式更容易控制。new Function的函数体开头写'use strict'就是严格模式,eval想进严格模式得靠调用方上下文,容易失控。

可复用的编译结果。new Function返回的是一个真正的函数,你可以保存引用反复调用,避免重复编译。

function parseLoose(raw) { if (typeof raw !== 'string' || raw.trim() === '') { throw new TypeError('输入必须是非空字符串'); } const fn = new Function('"use strict"; return (' + raw + ');'); const result = fn(); if (result === null || typeof result !== 'object') { throw new TypeError('解析结果不是对象'); } return result; } const obj = parseLoose("{id: 1, name: '机械键盘', tags: ['87键',],}"); console.log(obj.name); // 机械键盘 console.log(obj.tags.length); // 1

两个容易被忽略的写法细节。第一,return后面的括号非常关键。不加括号时,如果字符串以{开头,会被解析成块语句而不是对象字面量,直接抛SyntaxError。第二,'use strict'不只是为了规范,严格模式下this是undefined而不是全局对象,能挡住一批“字符串里写this.xxx偷偷改全局”的骚操作。

注意:new Function在启用了严格 CSP 的页面里会直接抛EvalError,错误信息通常是“Refused to evaluate a string as JavaScript because 'unsafe-eval' is not an allowed source”。如果你的页面要过安全审计,这个方法基本就废了,得换下面的 JSON5 方案。这个坑我是在一个客户的内网系统上踩到的,本地开发一切正常,部署到他们的环境就白屏。

3.3 安全加固:白名单校验是最低要求

如果你确实要做这个功能,那下面的校验不能省。核心思路是:先做字符白名单过滤,再做危险标识黑名单拦截,最后才执行。

// 只允许出现在字面量里的常见字符 const SAFE_CHARS = /^[\s\d\-+.eE'"a-zA-Z_$\u4e00-\u9fa5\[\]{}(),:;]*$/; // 明确禁止的构造 const DANGEROUS = /(constructor|__proto__|prototype|=>|function|import|require|globalThis|window|document|parent|top|self|fetch|XMLHttpRequest|eval|setTimeout|process|module|exports)/; function safeParseLoose(raw, { maxLength = 20000 } = {}) { if (typeof raw !== 'string') throw new TypeError('必须是字符串'); if (raw.length > maxLength) throw new RangeError('输入超过长度上限'); if (!SAFE_CHARS.test(raw)) { throw new SyntaxError('包含未授权的字符,已拒绝解析'); } if (DANGEROUS.test(raw)) { throw new SyntaxError('包含危险标识符,已拒绝解析'); } const result = new Function('"use strict"; return (' + raw + ');')(); if (result === null || typeof result !== 'object') { throw new TypeError('解析结果不是对象'); } return result; }

maxLength这个限制看着不起眼,但它能挡住“超长字符串构造绕过”那一类攻击,顺便也避免了超长输入把主线程卡死。我在生产环境用的是 20000 字符,实际配置基本都在几百字符以内,留了两个数量级的余量。

如果你能接受引第三方库,JSON5是更省心的选择,它实现了完整的宽松 JSON 语法,而且是纯解析器不执行代码:

npm install json5
import JSON5 from 'json5'; const obj = JSON5.parse("{id: 1, name: '机械键盘', tags: ['87键',], /* 备注 */}");

代价是打包体积会多十几 KB,而且它只支持 JSON5 那套语法,遇到真正的 JS 表达式(比如{t: Date.now()})照样解析不了。所以它和new Function不是互相替代,是覆盖不同的语法范围。

4. 方法三:URLSearchParams 与手写解析器,搞定键值对文本

第三类输入长得完全不一样:name=张三&age=28&city=北京。它没有嵌套,就是一堆键值对。这类数据的解析重点不在语法树,而在分隔符和类型推断。

4.1 URLSearchParams + Object.fromEntries,一行解决标准 query

浏览器原生提供了专门处理这类文本的 API,配合Object.fromEntries一行就能出结果:

const qs = '?name=%E5%BC%A0%E4%B8%89&age=28&city=%E5%8C%97%E4%BA%AC'; const obj = Object.fromEntries(new URLSearchParams(qs)); console.log(obj); // { name: '张三', age: '28', city: '北京' }

URLSearchParams的构造函数对开头的?是可选的,'?a=1'和'a=1'都能吃。它同时会自动做 URL 解码,所以%E5%BC%A0出来就是“张”。但有两个反直觉的行为必须记住:

第一,值永远是字符串。age拿到的是'28'而不是28,Object.fromEntries不会做类型推断。

第二,重复 key 会被覆盖。

const o = Object.fromEntries(new URLSearchParams('tag=a&tag=b&tag=c')); console.log(o.tag); // 'c',前两个丢了

想保留全部,得用getAll:

const params = new URLSearchParams('tag=a&tag=b&tag=c'); const multi = {}; for (const key of new Set(params.keys())) { const values = params.getAll(key); multi[key] = values.length > 1 ? values : values[0]; } console.log(multi.tag); // ['a','b','c']

还有个小细节:URLSearchParams会把+当成空格处理。如果值本身是 base64 或者带+的字符串(比如某些手机号前缀),解码后会变成空格,这种时候得先把+手动替换成%2B再解析。

4.2 手写解析器:分隔符不统一时的兜底方案

真实项目里的分隔符五花八门:a=1&b=2、a:1;b:2、a,1|b,2。我写了个支持自定义分隔符的通用版本:

function parsePairs(raw, { pairSep = /[&;|]/, kvSep = '=', trim = true, } = {}) { const result = {}; if (typeof raw !== 'string' || raw.trim() === '') return result; const pairs = raw.split(pairSep); for (const pair of pairs) { if (!pair) continue; const idx = pair.indexOf(kvSep); if (idx === -1) continue; // 没有分隔符,跳过 let key = pair.slice(0, idx); let val = pair.slice(idx + kvSep.length); if (trim) { key = key.trim(); val = val.trim(); } if (!key) continue; result[key] = val; } return result; } console.log(parsePairs('name=张三&age=28')); // { name: '张三', age: '28' } console.log(parsePairs('a:1;b:2', { kvSep: ':' })); // { a: '1', b: '2' }

这里有个我特意做的取舍:用indexOf找第一个分隔符,然后把后面全部当作值,而不是split(kvSep)然后取[0]和[1]。原因是值里可能本身就含有分隔符,比如url=http://a.com/?x=1,用后者会切成三段,值被截断成http:,这是我在解析埋点日志时踩过的真实坑。indexOf版本能正确处理这种情况。

4.3 值类型推断:什么时候该转,什么时候绝对不能转

拿到全字符串的对象后,下一步通常是转成真实类型。很多人会写一个“万能推断函数”,我的建议是不要盲目推断,能显式声明就显式声明。

function castValue(v) { if (v === 'true') return true; if (v === 'false') return false; if (v === 'null') return null; if (v === 'undefined') return undefined; if (/^-?\d+(\.\d+)?$/.test(v)) return Number(v); return v; }

上面这段看着方便,但它有个致命问题:你分不清“数字字符串”和“恰好只含数字的字符串”。举个常见例子,订单号"00123"会被转成数字123,前导零丢了;手机号"13800000000"转成数字没问题,但如果加了区号变成"8613800000000"就已经超过某些环境的精度处理范围,再转回去长度可能对不上。

我现在的做法是:在解析函数里接一个 schema,只有显式声明了类型的字段才转换。

const schema = { age: 'number', vip: 'boolean', tags: 'array:comma', remark: 'string', }; function castBySchema(obj, schema) { const out = {}; for (const [key, val] of Object.entries(obj)) { const type = schema[key]; if (!type) { out[key] = val; continue; } switch (type) { case 'number': out[key] = Number(val); break; case 'boolean': out[key] = val === 'true' || val === '1'; break; case 'array:comma': out[key] = val ? val.split(',') : []; break; default: out[key] = val; } } return out; } const raw = parsePairs('age=28&vip=true&tags=青轴,红轴&remark=00123'); console.log(castBySchema(raw, schema)); // { age: 28, vip: true, tags: ['青轴','红轴'], remark: '00123' }

注意remark字段,虽然值是纯数字,但因为 schema 里声明为string,前导零被完整保留了。这个设计我用了两年,配合接口文档一起维护,比任何自动推断都稳。

提示:schema 不要写死在调用处,放在接口配置里统一维护。一旦后端改了字段类型,你只需要改配置,不用满项目找Number()。

5. 三种方法的横向对比与选型决策

方法都讲完了,现在是选型环节。我做了组简单的基准测试,环境是 macOS + M1、Chrome 130,输入是同一个约 80 字节的结构体,循环 10 万次取平均。

5.1 性能实测数据与复现代码

const jsonRaw = '{"id":1,"name":"机械键盘","tags":["87键","青轴"],"price":399.5}'; const looseRaw = "{id:1,name:'机械键盘',tags:['87键','青轴'],price:399.5}"; const qsRaw = 'id=1&name=机械键盘&tags=87键,青轴&price=399.5'; const N = 100000; function bench(label, fn) { for (let i = 0; i < 1000; i++) fn(); // 预热,让 JIT 编译 const t0 = performance.now(); for (let i = 0; i < N; i++) fn(); const t1 = performance.now(); console.log(label.padEnd(26), ((t1 - t0) / N * 1000).toFixed(3) + ' us/op'); } bench('JSON.parse', () => JSON.parse(jsonRaw)); bench('new Function(每次新建)', () => new Function('return (' + looseRaw + ')')()); const cached = new Function('return (' + looseRaw + ')'); bench('new Function(复用引用)', () => cached()); bench('URLSearchParams', () => Object.fromEntries(new URLSearchParams(qsRaw)));

实测结果大致是这样:

方法平均耗时(us/op)相对倍数备注
JSON.parse0.321.0原生实现,最快
new Function(复用引用)0.611.9编译成本已摊销
new Function(每次新建)2.858.9每次都编译,代价明显
URLSearchParams1.183.7含 URL 解码开销

数据不是绝对标准,不同机器和版本会浮动,但相对关系很稳定。有两点结论可以直接用:

第一,new Function请务必复用编译结果。上面差了近 5 倍,全在编译开销上。如果你的输入结构相对固定,可以按某个“形状指纹”缓存解析函数,最简单的做法是按字符串长度分区缓存,实际收益已经很可观。

第二,解析频率高的话,JSON.parse的优势是真的明显。我在一个列表渲染场景里把每行的解析从手写 split 换成JSON.parse,首屏时间掉了 40ms,因为那是 2000 条数据各解析一次。

5.2 选型决策表

真正做决定时,我建议按这个顺序问自己三个问题:

判断条件推荐方法理由
输入是合法 JSON,且来源不完全可信JSON.parse不执行代码,有原生性能优势
输入是 JS 字面量,来源完全可信new Function(带白名单校验)语法宽容度最高
输入是 JS 字面量,但页面有严格 CSPJSON5 等纯解析库避免unsafe-eval限制
输入是 query / key=value 文本URLSearchParams+ 类型转换原生 API,自动解码
输入分隔符不统一、脏数据多手写解析器 + schema逻辑完全可控,可精确容错
需要保留前导零、超长数字显式 schema,禁止自动推断避免精度和信息丢失

6. 实际项目里的坑与排查实录

这一节是我最想写的部分,因为前面那些 API 文档里都有,下面的东西基本只能靠踩出来。

6.1 常见问题速查表

现象大概率原因排查动作
解析结果字段全是 undefined解析出的是字符串或数组,不是对象打印typeof和Array.isArray
中文变成\u5f20\u4e09字符串被多次转义检查是否JSON.stringify了两次
数字精度对不上超过Number.MAX_SAFE_INTEGER让后端返回字符串,或换大数库
Unexpected end of JSON input响应被截断或为空打印原始字符串长度和前 200 字符
页面白屏且报EvalErrorCSP 禁止unsafe-eval检查响应头 CSP 配置
解析后对象原型被改用了Object.assign合并__proto__改用展开运算符或fromEntries
URLSearchParams里+变空格query 语法中+表示空格先替换成%2B再解析
缓存取出来用不了localStorage存的是"[object Object]"检查是否漏了JSON.stringify

6.2 我踩过的几个坑,值得单独说

第一个坑:双层 stringify。有次排查一个“字段全 undefined”的诡异问题,最后发现是 A 模块存缓存时JSON.stringify了一次,B 模块读出来又JSON.stringify了一次,存进去的是"{\"a\":1}"。这种情况下JSON.parse一次出来还是字符串,得JSON.parse两次才行。我的解决办法是在工具函数里循环解析,直到结果不是字符串为止,但加了最大深度限制防止死循环。

function deepParse(raw, maxDepth = 3) { let cur = raw; for (let i = 0; i < maxDepth; i++) { if (typeof cur !== 'string') return cur; try { cur = JSON.parse(cur); } catch { return cur; // 解析不了就原样返回 } } return cur; }

第二个坑:BOM 头。Windows 上用记事本另存为 UTF-8 的配置文件,开头会多一个\uFEFF。JSON.parse遇到它直接报Unexpected token,而且这个字符在编辑器里看不见,肉眼检查完全发现不了。现在我在所有文件解析入口第一行就replace(/^\uFEFF/, '')。

第三个坑:JSON.stringify的循环引用。序列化时如果对象里有循环引用会抛Converting circular structure to JSON。我一般用WeakSet在 replacer 里做环检测,遇到重复就返回"[Circular]"占位,至少保证日志能打出来。

第四个坑:手写推断函数吃掉了手机号。前面提过了,一个“看起来很聪明”的自动类型推断把一个手机号字段转成了科学计数法。教训是:值类型的信息应该在数据源头就确定,而不是靠解析时猜。那之后我给自己定了个规矩——解析器只负责结构,不负责语义。

第五个坑:解析大文件卡住主线程。我在一个导入功能里要解析一个几 MB 的字符串,JSON.parse本身很快,但后面遍历处理几千个字段时把页面冻住了两秒。后来拆成了分片处理,每片用setTimeout让出主线程,虽然总时长没变,但页面不卡了,用户体验完全不同。

提示:判断一个解析器该不该用,我最常用的标准是——“如果这段字符串完全由外部输入控制,我能承受它被执行吗?”答案是否定的话,就老实用JSON.parse,别碰new Function。

最后再分享一个小技巧。我给团队的工具库加了个safeParse方法,签名是safeParse(raw, fallback),出错时不抛异常而是返回兜底值,同时把原始字符串和错误对象打到监控里。这样业务代码里到处都是的try/catch全都消失了,出错信息反而收得更全。这个改动上线之后,解析类问题从“用户反馈才发现”变成了“发布后五分钟告警”,值回票价。

返回列表