
上周帮同事看一个"莫名其妙"的卡顿。用户点开报表页,Loading 转圈转得好好的,数据一到,整个页面"咯噔"一下白屏小半秒,连点击都没反应。
同事第一反应是渲染慢,围着 React.memo 和虚拟列表折腾了一下午,卡顿一点没少。我让他打开浏览器的 Performance 面板录一段——那根长长的黄色任务条,写着两个字:Parse。一次 JSON.parse,把主线程堵了将近 300 毫秒。
JSON.parse 大概是前端用得最顺手、也最不设防的一个 API 了。一行代码,字符串进去、对象出来,谁会怀疑它?可一旦数据量上来,它就从"厨房里削水果的小刀"变成了埋在主线程里的一颗雷。
这篇咱们不堆术语,从头把这件事讲明白:序列化到底在干嘛、JSON.parse 藏了哪三个坑、怎么自己动手排查、以及数据大了该换什么招。哪怕你才写前端没多久,也能跟下来。
别被这两个词唬住。
你电脑内存里的一个 JavaScript 对象,比如 { name: '小明', age: 18 },它是"活的"、有结构的。但网络传输也好、存到 localStorage 也好,它们只认一串字符,不认"对象"这种东西。
所以就有了两个动作:
JSON.stringify):把活的对象"压扁"成一串字符串,好塞进网线、塞进硬盘。就像你把一件立体的羽绒服真空压缩成一块板砖,方便快递。JSON.parse):把这串字符串"还原"回一个能用的对象。收到快递,撕开真空袋,羽绒服"噗"地弹回原样。const obj = { name: '小明', age: 18 };
const str = JSON.stringify(obj); // 序列化 → '{"name":"小明","age":18}' 一串字符
const back = JSON.parse(str); // 反序列化 → 又变回能用的对象
back.name; // '小明'
你每天写的 fetch(...).then(r => r.json())、往 localStorage 里 setItem 存对象、WebSocket 收推送……底下全是这一压一还。用得越顺手,越容易忘了它是有代价的。今天要拆的,就是"还原"这一步——JSON.parse——的三个坑。
这是三个坑里最要命的一个,也是最反直觉的。
很多人以为解析是异步的——数据不都是 fetch 慢慢回来的嘛?错。fetch 等数据回来这段是异步的,但真正把字符串"还原"成对象那一下,是同步的、跑在主线程上的、而且中途不能打断。
const raw = await fetch('/api/report').then(r => r.text());
const data = JSON.parse(raw); // ← 执行这一行的这几百毫秒里,页面完全冻结
什么叫"主线程"?你可以把浏览器的主线程想成餐厅里唯一的一个服务员。他既要给客人点单(响应点击)、又要上菜(渲染画面)、又要续水(跑动画)。平时手脚麻利看不出问题。可一旦你派他去后厨洗一大盆碗(JSON.parse 一大坨数据),在他洗完之前——点单没人理、菜端不上来、水也没人续。客人只会觉得:这店咋卡住了?
用一条时间线看更直观:
主线程时间轴(一格 ≈ 16ms,也就是一帧的预算)
│
├─[点击]─[响应✓]─[响应✓]─┐
│ │ JSON.parse 开始(服务员进后厨洗碗)
│ ▼
│ ╳────────────────────────────────────╳ ← 页面卡死区间
│ ↑ 点了没反应 ↑ 动画掉帧 ↑ 输入框打字延迟
│ │ 解析完成(服务员回来了)
│ ▼
├──────────────────────────────[恢复响应]───┘
人眼能感知到的卡顿门槛,大约在 50ms 以上。也就是说,只要解析超过 3 帧的时间,用户就能"看"出来页面顿了一下。几百 KB 一般无所谓,但几十 MB 的接口——分页没做好、后端一股脑全返回、或者地图/图表那种天生的大数据——堵个几百毫秒非常常见。
哪些场景最容易踩? 给你一份"高危名单":
parse;JSON.stringify 后塞进 localStorage,下次进页面同步 getItem + parse,直接拖慢首屏;FileReader 读完一把梭解析。第二个坑很阴,因为它不报错,等你发现时往往已经在线上了。
JSON 这个格式本身很"穷",它只认识几样东西:字符串、数字、布尔、null、数组、对象。它压根没有"日期""正则""undefined"这些概念。 所以你把一个带 Date 的对象压扁再还原,日期是回不来的:
const before = { name: '小明', birthday: new Date('2008-05-20') };
const after = JSON.parse(JSON.stringify(before));
console.log(typeof after.birthday); // "string" —— Date 变成了字符串!
after.birthday.getFullYear(); // ❌ 报错:它已经不是 Date,没有这个方法了
打个比方:序列化就像把羽绒服真空压缩。大部分衣服压完还能弹回来,但有几样"娇气"的东西压完就废了——Date 被压成一句普通字符串、undefined 和函数直接凭空消失、NaN 和 Infinity 变成了 null。收快递的人不知道,还当它们好好的,一用才发现坏了。
这类 bug 的可怕之处在于:代码不报错,值"看起来"也对("2008-05-20" 看着不就是个日期嘛),直到某天有人调了一下 .getFullYear(),当场翻车。
好在 JSON.parse 留了个后门——第二个参数 reviver(直译"复活器",名字挺形象)。它是一个在还原过程中、逐个字段回调的函数,你可以趁这个机会把想要的类型"接住、复活":
// TypeScript 版本
const reviver = (key: string, value: unknown): unknown =>
key.endsWith('Date') || key === 'birthday' // 约定:这些字段是日期
? new Date(value as string) // 就地复活成 Date
: value;
const person = JSON.parse(raw, reviver) as { birthday: Date };
person.birthday.getFullYear(); // ✅ 2008,货真价实的 Date 对象
// JavaScript 版本(一样的思路,去掉类型标注)
const reviver = (key, value) =>
key.endsWith('Date') || key === 'birthday'
? new Date(value)
: value;
const person = JSON.parse(raw, reviver);
person.birthday.getFullYear();
靠字段名后缀(xxxDate)来判断当然有点糙,更稳的做法是序列化时给值打个类型标记、还原时再认标记复活。但核心就一句话:类型不会自己回来,你得在解析入口主动把它接住。
前两个坑关乎"能不能用",这个坑关乎"安不安全",平时不发作,一旦被利用就是安全事故。
问题出在 __proto__ 这个特殊的键上。先看代码:
const evil = '{"__proto__": {"isAdmin": true}}';
const obj = JSON.parse(evil);
好消息是:JSON.parse 本身对这一手是有防御的,上面这行不会真的去改原型链,__proto__ 只会作为一个普通属性存在。所以别被一些老文章吓到,说"parse 就能注入",那不准确。
真正的危险在下一步——当你把这个不可信的对象,丢进一个不设防的"深合并"里:
// 很多人手写的、或者老版本库里的深合并,没防这一手
merge(myConfig, obj);
// 污染就顺着原型链扩散了:
({}).isAdmin; // 可能变成了 true —— 凭空多出来一个人人都有的属性
打个比方:__proto__ 就像小区所有住户共用的一份"公共通知栏"。正常改自己家门牌没事,但坏人趁你不注意,往公共通知栏上贴了一张"所有人今天免物业费"——结果每一户打开门都看到了这条假通知。原型链就是那块公共通知栏,一旦被写脏,影响的是所有对象。
对于来自用户输入、第三方接口这类不可信来源的数据,这是一条真实存在的攻击面。防御其实不难:
Object.create(null) 创建没有原型的对象(相当于拆掉那块公共通知栏);__proto__、constructor、prototype 这三个危险键;merge 已经内置了防护)。讲了半天,你怎么知道自己项目里到底是不是这个问题?别猜,三步实测:
如果栈里赫然写着 Parse、JSON.parse 或者你调 .json() 的那一行,占了大几十甚至上百毫秒——实锤了,就是它在堵主线程。这时候再去优化渲染纯属南辕北辙,得从解析下手。
顺便,如果那根长条写的是别的(比如某个 render、某个 for 循环),那说明卡在别处,别冤枉了 JSON.parse。先测量,再优化,这是排查性能问题的铁律。
三个坑里,类型和安全都能靠 reviver、无原型对象补上;唯独"堵主线程"没法靠一个参数解决——它是同步 API 的原罪。真要扛大数据,得换思路。下面几招,按数据量从小到大排,对号入座就行,别一把梭。
第 1 招 · 数据不大,只是想保住类型:reviver 就够了。几百 KB 以内,JSON.parse 本身够快,你唯一要操心的是类型别丢,上面那段 reviver 就是全部。别过度设计——不是所有数据都值得上 Worker,杀鸡不用牛刀。
第 2 招 · 想传得更小更快:换掉 JSON,用二进制格式 MessagePack。JSON 是"给人看的",机器读它其实挺浪费——字段名一遍遍重复、数字还用字符串存。MessagePack 把同样的数据编成紧凑的二进制,体积更小、解码更快,还能原生保留Date、二进制 buffer 这些类型(第二个坑顺手解决了):
import { encode, decode } from '@msgpack/msgpack';
const payload = { id: crypto.randomUUID(), when: new Date() };
const bytes = encode(payload); // Uint8Array,通常比 JSON 更小
const restored = decode(bytes) as typeof payload;
restored.when instanceof Date; // ✅ true,类型没丢!
代价是它不再是人眼可读的,浏览器 Network 面板里看到的是一坨二进制,调试得多一步解码。适合前后端都归你管、且传输量大的内部接口;对外的公开 API 一般还是老实用 JSON。
第 3 招 · 数据是流式来的、或大到装不下内存:流式解析。与其等整个字符串下载完再一次性 parse,不如边下边解、解一块处理一块,内存占用能压到很低的常数。这就像吃一整个大蛋糕,你不会整个塞嘴里,而是切一块吃一块。Node 端有现成的 stream-json:
const { chain } = require('stream-chain');
const { parser } = require('stream-json');
const { streamArray } = require('stream-json/streamers/StreamArray');
chain([
fs.createReadStream('huge.json'), // 从文件流一点点读
parser(), // 流式解析
streamArray(), // 一条一条吐出数组元素
({ value }) => processItem(value), // 每来一条处理一条,不囤在内存里
]);
浏览器端没这么顺手的现成轮子,但思路一致:配合 fetch 返回的 ReadableStream 做增量解析。
第 4 招 · 就是不想让主线程停:扔进 Web Worker。前面几招是"让解析变快",这一招是"让解析别挡路"——思路完全不同。把 JSON 字符串丢给一个 Worker 线程去解析,相当于给餐厅多雇了个后厨专职洗碗:主线程那个服务员照样点单、上菜、续水,洗完了后厨再把干净碗(解析结果)递回来。
// 主线程
const worker = new Worker('./parse.worker.js', { type: 'module' });
worker.onmessage = ({ data }) => renderDashboard(data); // 收到结果再渲染
fetchRaw().then(json => worker.postMessage(json)); // 把原始字符串丢过去
// parse.worker.js —— 独立线程,卡也卡在这儿,不影响 UI
self.onmessage = ({ data }) => {
self.postMessage(JSON.parse(data));
};
有个坑必须提醒:结果通过 postMessage 传回主线程时,会经历一次"结构化克隆"(相当于把碗又搬了一趟),数据特别大时这次搬运本身也有开销。所以 Worker 不是万能钥匙——它换来的是"UI 不冻结",不是"总耗时更短"。
第 5 招 · 数据已经在内存里,只是渲染扛不住:分片 + 让出主线程。有时候 parse 本身不慢,慢的是拿到几万条数据后一次性渲染上万个 DOM。这时用异步生成器切片,每渲一批就让浏览器"喘口气"跑一帧:
async function* inChunks<T>(list: T[], size = 500) {
for (let i = 0; i < list.length; i += size) {
yield list.slice(i, i + size);
await new Promise(requestAnimationFrame); // 把主线程还给浏览器一帧
}
}
for await (const chunk of inChunks(bigList, 1000)) {
renderRows(chunk); // 分批上屏,页面全程可交互
}
数据规模 | 主线程冻结(参考) | 推荐方案 |
|---|---|---|
< 100KB | 几乎无感 | 原生 JSON.parse(+ reviver 保类型) |
100KB – 5MB | 十几到几十 ms | MessagePack 二进制,或前端分片渲染 |
5MB – 50MB | 上百 ms,明显卡顿 | Web Worker 兜底 UI + 流式解析 |
> 50MB | 秒级,甚至爆内存 | 流式解析,边下边处理,绝不整块进内存 |
表里的耗时是数量级参考,真实数字取决于你的设备、数据嵌套深度和 JS 引擎,一切以自己 Performance 面板实测为准。
选的时候,脑子里过一遍这张图就够了:
你的 JSON 有多大?
│
┌──────────────┼──────────────┐
▼ ▼ ▼
< 100KB 100KB~5MB > 5MB
│ │ │
▼ ▼ ▼
JSON.parse 体积敏感? UI 不能卡?
够用,别折腾 ├─是→MsgPack ├─是→Web Worker
│ └─否→分片渲染 └─大到爆内存?
│ └─是→流式解析
└──────── 别忘了 reviver 保类型 ────────┘
如果你用 React,又免不了在某次交互后处理一大坨数据,useDeferredValue 能帮上忙——它让这次重计算"降级"到低优先级,先保证输入、点击这些高优先交互不掉帧:
function Report({ rawJson }: { rawJson: string }) {
const deferred = useDeferredValue(rawJson);
const data = useMemo(() => JSON.parse(deferred), [deferred]);
return <Dashboard data={data} />;
}
它不会让解析本身变快(该卡的还在主线程卡),但能把卡顿"藏"到不打扰用户的时机。真正的大块头,还是老老实实上 Worker 或流式。
Q:那我以后是不是都别用 JSON.parse 了?不是。100KB 以下它又快又稳,是最佳选择。它的问题只在"数据一大"时才暴露。工具没有好坏,用错场景才有。
Q:JSON.parse 和 .json() 有啥区别?response.json() 内部就是帮你 text() + JSON.parse 一条龙,卡顿的锅它也一样背。想上 Worker,可以先 .text() 拿到字符串,再把字符串丢进 Worker 里解析。
Q:try/catch 该不该包 JSON.parse?该。只要数据来源不完全可控(接口、localStorage、用户上传),字符串就可能是坏的、半截的,JSON.parse 遇到非法字符会直接抛错。裸奔一时爽,线上白屏火葬场。
Q:localStorage 存对象为什么也会慢?localStorage 的读写是同步的,getItem 之后你还得 JSON.parse 一次。要是你把一大坨对象塞了进去,每次进页面都会在首屏同步解析一遍,白白拖慢打开速度。大数据别硬塞 localStorage,考虑 IndexedDB。
说到底,JSON.parse 没有错,错在我们把一把削水果的小刀当成了砍骨头的斧子。它同步、会丢类型、对脏数据不设防——这三条只要心里有数,配上合适的解法,页面就不会再"咯噔"那一下了。
记住这条主线就行:先用 Performance 面板测出到底卡在哪,再按数据大小对号入座选招——小数据别折腾,大数据别硬扛。
互动一下: 你在项目里踩过最深的解析 / 序列化坑是哪个?是几十 MB 接口把页面干白屏了,是 Date 变字符串导致的线上 bug,还是往 localStorage 硬塞大对象拖慢了首屏?评论区聊聊你的"翻车现场",最有共鸣的那个,我下期专门拆一篇 👀