首页
学习
活动
专区
圈层
工具
发布
社区首页 >专栏 >为什么页面一加载数据就卡?JSON.parse 的 3 个致命缺陷

为什么页面一加载数据就卡?JSON.parse 的 3 个致命缺陷

作者头像
前端达人
发布2026-07-02 13:32:31
发布2026-07-02 13:32:31
930
举报
文章被收录于专栏:前端达人前端达人

上周帮同事看一个"莫名其妙"的卡顿。用户点开报表页,Loading 转圈转得好好的,数据一到,整个页面"咯噔"一下白屏小半秒,连点击都没反应。

同事第一反应是渲染慢,围着 React.memo 和虚拟列表折腾了一下午,卡顿一点没少。我让他打开浏览器的 Performance 面板录一段——那根长长的黄色任务条,写着两个字:Parse。一次 JSON.parse,把主线程堵了将近 300 毫秒。

JSON.parse 大概是前端用得最顺手、也最不设防的一个 API 了。一行代码,字符串进去、对象出来,谁会怀疑它?可一旦数据量上来,它就从"厨房里削水果的小刀"变成了埋在主线程里的一颗雷。

这篇咱们不堆术语,从头把这件事讲明白:序列化到底在干嘛、JSON.parse 藏了哪三个坑、怎么自己动手排查、以及数据大了该换什么招。哪怕你才写前端没多久,也能跟下来。

先补个地基:序列化、反序列化到底是啥

别被这两个词唬住。

你电脑内存里的一个 JavaScript 对象,比如 { name: '小明', age: 18 },它是"活的"、有结构的。但网络传输也好、存到 localStorage 也好,它们只认一串字符,不认"对象"这种东西。

所以就有了两个动作:

  • 序列化(JSON.stringify:把活的对象"压扁"成一串字符串,好塞进网线、塞进硬盘。就像你把一件立体的羽绒服真空压缩成一块板砖,方便快递。
  • 反序列化(JSON.parse:把这串字符串"还原"回一个能用的对象。收到快递,撕开真空袋,羽绒服"噗"地弹回原样。
代码语言:javascript
复制
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 等数据回来这段是异步的,但真正把字符串"还原"成对象那一下,是同步的、跑在主线程上的、而且中途不能打断。

代码语言:javascript
复制
const raw = await fetch('/api/report').then(r => r.text());
const data = JSON.parse(raw); // ← 执行这一行的这几百毫秒里,页面完全冻结

什么叫"主线程"?你可以把浏览器的主线程想成餐厅里唯一的一个服务员。他既要给客人点单(响应点击)、又要上菜(渲染画面)、又要续水(跑动画)。平时手脚麻利看不出问题。可一旦你派他去后厨洗一大盆碗(JSON.parse 一大坨数据),在他洗完之前——点单没人理、菜端不上来、水也没人续。客人只会觉得:这店咋卡住了?

用一条时间线看更直观:

代码语言:javascript
复制
主线程时间轴(一格 ≈ 16ms,也就是一帧的预算)
│
├─[点击]─[响应✓]─[响应✓]─┐
│                          │ JSON.parse 开始(服务员进后厨洗碗)
│                          ▼
│   ╳────────────────────────────────────╳   ← 页面卡死区间
│   ↑ 点了没反应   ↑ 动画掉帧   ↑ 输入框打字延迟
│                                            │ 解析完成(服务员回来了)
│                                            ▼
├──────────────────────────────[恢复响应]───┘

人眼能感知到的卡顿门槛,大约在 50ms 以上。也就是说,只要解析超过 3 帧的时间,用户就能"看"出来页面顿了一下。几百 KB 一般无所谓,但几十 MB 的接口——分页没做好、后端一股脑全返回、或者地图/图表那种天生的大数据——堵个几百毫秒非常常见。

哪些场景最容易踩? 给你一份"高危名单":

  • 后端偷懒,一个接口把上万条数据全返回,前端一次 parse
  • 地图、可视化大屏、基因/金融那种天生的大 JSON;
  • 把一大坨对象 JSON.stringify 后塞进 localStorage,下次进页面同步 getItem + parse,直接拖慢首屏;
  • 导入功能:用户上传一个几十 MB 的 JSON 文件,你 FileReader 读完一把梭解析。

缺陷二:它会悄悄"吃掉"你的类型

第二个坑很阴,因为它不报错,等你发现时往往已经在线上了。

JSON 这个格式本身很"穷",它只认识几样东西:字符串、数字、布尔、null、数组、对象。它压根没有"日期""正则""undefined"这些概念。 所以你把一个带 Date 的对象压扁再还原,日期是回不来的:

代码语言:javascript
复制
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 和函数直接凭空消失、NaNInfinity 变成了 null。收快递的人不知道,还当它们好好的,一用才发现坏了。

这类 bug 的可怕之处在于:代码不报错,值"看起来"也对"2008-05-20" 看着不就是个日期嘛),直到某天有人调了一下 .getFullYear(),当场翻车。

好在 JSON.parse 留了个后门——第二个参数 reviver(直译"复活器",名字挺形象)。它是一个在还原过程中、逐个字段回调的函数,你可以趁这个机会把想要的类型"接住、复活":

代码语言:javascript
复制
// 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
复制
// 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__ 这个特殊的键上。先看代码:

代码语言:javascript
复制
const evil = '{"__proto__": {"isAdmin": true}}';
const obj = JSON.parse(evil);

好消息是:JSON.parse 本身对这一手是有防御的,上面这行不会真的去改原型链,__proto__ 只会作为一个普通属性存在。所以别被一些老文章吓到,说"parse 就能注入",那不准确。

真正的危险在下一步——当你把这个不可信的对象,丢进一个不设防的"深合并"里:

代码语言:javascript
复制
// 很多人手写的、或者老版本库里的深合并,没防这一手
merge(myConfig, obj);

// 污染就顺着原型链扩散了:
({}).isAdmin; // 可能变成了 true —— 凭空多出来一个人人都有的属性

打个比方:__proto__ 就像小区所有住户共用的一份"公共通知栏"。正常改自己家门牌没事,但坏人趁你不注意,往公共通知栏上贴了一张"所有人今天免物业费"——结果每一户打开门都看到了这条假通知。原型链就是那块公共通知栏,一旦被写脏,影响的是所有对象。

对于来自用户输入、第三方接口这类不可信来源的数据,这是一条真实存在的攻击面。防御其实不难:

  • 需要当"纯数据字典"用时,改用 Object.create(null) 创建没有原型的对象(相当于拆掉那块公共通知栏);
  • 自己写合并逻辑时,显式跳过 __proto__constructorprototype 这三个危险键;
  • 或者干脆用维护良好的成熟库(比如新版 lodash 的 merge 已经内置了防护)。

教你一招:怎么自己确认是不是 parse 在卡

讲了半天,你怎么知道自己项目里到底是不是这个问题?别猜,三步实测:

  1. 打开 Chrome DevTools,切到 Performance 面板;
  2. 点左上角小圆点开始录制,然后在页面上复现那个卡顿操作(比如点开报表);
  3. 停止录制,在时间线上找那根特别长的黄色任务条(黄色代表 Scripting/脚本执行),点它,展开调用栈。

如果栈里赫然写着 ParseJSON.parse 或者你调 .json() 的那一行,占了大几十甚至上百毫秒——实锤了,就是它在堵主线程。这时候再去优化渲染纯属南辕北辙,得从解析下手。

顺便,如果那根长条写的是别的(比如某个 render、某个 for 循环),那说明卡在别处,别冤枉了 JSON.parse先测量,再优化,这是排查性能问题的铁律。

数据大了,到底该换什么招

三个坑里,类型和安全都能靠 reviver、无原型对象补上;唯独"堵主线程"没法靠一个参数解决——它是同步 API 的原罪。真要扛大数据,得换思路。下面几招,按数据量从小到大排,对号入座就行,别一把梭。

第 1 招 · 数据不大,只是想保住类型:reviver 就够了。几百 KB 以内,JSON.parse 本身够快,你唯一要操心的是类型别丢,上面那段 reviver 就是全部。别过度设计——不是所有数据都值得上 Worker,杀鸡不用牛刀。

第 2 招 · 想传得更小更快:换掉 JSON,用二进制格式 MessagePack。JSON 是"给人看的",机器读它其实挺浪费——字段名一遍遍重复、数字还用字符串存。MessagePack 把同样的数据编成紧凑的二进制,体积更小、解码更快,还能原生保留Date、二进制 buffer 这些类型(第二个坑顺手解决了):

代码语言:javascript
复制
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

代码语言:javascript
复制
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 线程去解析,相当于给餐厅多雇了个后厨专职洗碗:主线程那个服务员照样点单、上菜、续水,洗完了后厨再把干净碗(解析结果)递回来。

代码语言:javascript
复制
// 主线程
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。这时用异步生成器切片,每渲一批就让浏览器"喘口气"跑一帧:

代码语言:javascript
复制
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 面板实测为准。

选的时候,脑子里过一遍这张图就够了:

代码语言:javascript
复制
                你的 JSON 有多大?
                       │
        ┌──────────────┼──────────────┐
        ▼              ▼              ▼
     < 100KB       100KB~5MB        > 5MB
        │              │              │
        ▼              ▼              ▼
   JSON.parse     体积敏感?      UI 不能卡?
   够用,别折腾   ├─是→MsgPack   ├─是→Web Worker
        │        └─否→分片渲染   └─大到爆内存?
        │                           └─是→流式解析
        └──────── 别忘了 reviver 保类型 ────────┘

给 React 同学一个小便利

如果你用 React,又免不了在某次交互后处理一大坨数据,useDeferredValue 能帮上忙——它让这次重计算"降级"到低优先级,先保证输入、点击这些高优先交互不掉帧

代码语言:javascript
复制
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 硬塞大对象拖慢了首屏?评论区聊聊你的"翻车现场",最有共鸣的那个,我下期专门拆一篇 👀

本文参与 腾讯云自媒体同步曝光计划,分享自微信公众号。
原始发表:2026-07-01,如有侵权请联系 cloudcommunity@tencent.com 删除

本文分享自 前端达人 微信公众号,前往查看

如有侵权,请联系 cloudcommunity@tencent.com 删除。

本文参与 腾讯云自媒体同步曝光计划  ,欢迎热爱写作的你一起参与!

评论
登录后参与评论
0 条评论
热度
最新
推荐阅读
目录
  • 先补个地基:序列化、反序列化到底是啥
  • 缺陷一:它是"同步"的,会把整个页面冻住
  • 缺陷二:它会悄悄"吃掉"你的类型
  • 缺陷三:解析别人给的数据,可能被"投毒"
  • 教你一招:怎么自己确认是不是 parse 在卡
  • 数据大了,到底该换什么招
  • 一张表看清怎么选
  • 给 React 同学一个小便利
  • 几个新手常问的问题
领券
问题归档专栏文章快讯文章归档关键词归档开发者手册归档开发者手册 Section 归档