JSON

理解JSON文本与JavaScript值之间的边界,正确使用解析器、序列化器及其转换钩子,并识别精度、类型和安全陷阱。

难度 入门 时长 标准深度约14分钟
版本 Node 24
what

JSON是一种与语言无关的文本数据格式。JSON.parse()把JSON文本转换为JavaScript值,JSON.stringify()执行相反方向的转换。

trap

解析成功只证明语法有效,不证明数据符合业务契约。序列化还会丢弃或改写一部分JavaScript值,并且不能保留对象标识或原型。

fix

解析后验证数据形状,序列化前构造明确的公开表示。对大整数、日期和其他非JSON类型制定可逆的线格式,而不是依赖隐式转换。

是什么,为什么存在

JSON是JavaScript Object Notation的缩写。它是一种数据交换语法,不是JavaScript对象,也不是带方法的运行时值。符合JSON语法的一段Unicode文本称为 JSON文本(JSON text) ,它可以表示对象、数组、字符串、数字、布尔值或null。

JSON解决的是系统边界上的表示问题。内存中的对象不能直接穿过HTTP、消息队列、文件或Web Storage;把数据变成文本后,不同语言和进程就能按同一套语法交换它。把运行时值转换为这种表示的过程称为 序列化(serialization) ,反方向通常称为解析或反序列化。

你会在API响应、请求正文、配置文件、缓存项和日志中遇到JSON。JavaScript提供全局JSON对象,其中最常用的两个静态方法是JSON.parse()与JSON.stringify()。它们只负责语法与值之间的转换,不会替你检查字段是否存在、字符串是否真是日期,或当前用户是否有权使用某个字段。

JSON的数据模型有意保持很小。它没有undefined、BigInt、Date、Map、Set、函数、Symbol、类实例、共享引用或循环引用。跨边界需要这些概念时,发送方与接收方必须先约定如何把它们表示成JSON支持的值。

工作原理

JSON的两个方向可以看作一条边界管道。进入应用的数据先经过语法解析,再经过形状与业务验证;离开应用的数据先被投影成公开表示,再被序列化。

JSON的语法与值模型

JSON对象用花括号包围,成员名必须是双引号字符串。数组用方括号包围并保持元素顺序。对象与数组可以递归包含JSON支持的任意值,因此JSON表示的是一棵树。

字符串必须使用双引号,并用反斜杠转义双引号、反斜杠和控制字符。JSON没有单引号字符串、注释或尾随逗号。成员名不能像JavaScript对象字面量那样省略引号。

JSON数字支持十进制整数、小数和指数形式,但不支持NaN、Infinity、-Infinity、BigInt后缀n、前导加号或十六进制写法。语法中的数字范围并不等于JavaScript Number能精确表示的范围;解析器最终仍会生成Number。

顶层值不必是对象或数组。"ready"、42、false与null都是完整的JSON文本。若接口只接受对象,必须在解析后单独检查,不能把“是有效JSON”当作“是有效请求”。

对象成员名应当唯一。JSON.parse()遇到重复成员名时保留后一个值,但其他实现的可观察行为未必适合互操作。生产者不应依赖重复成员覆盖来传达含义。

JSON.parse()的处理过程

JSON.parse(text)必须消费完整输入。语法无效时,它会抛出SyntaxError,不会返回已经解析的前半段。语法有效时,它可以返回任意JSON值对应的JavaScript值,包括null与原始值。

可选的第二个参数是 恢复函数(reviver) 。解析器先构造普通JavaScript值,再从最深的子属性向根部调用恢复函数。回调接收字符串形式的key和当前value;根调用的键是空字符串""。

在Node 24中,恢复函数处理原始值时还会收到第三个context参数,其source属性保存该值对应的原始JSON片段。处理对象或数组时没有这个上下文。这个信息可以在已经生成不精确Number后,从原始数字文本重建精确整数。

恢复函数的返回值会替换当前属性。返回undefined会删除对象属性;数组元素则变成空槽,而不是让后续元素前移。根调用返回undefined时,整个JSON.parse()的结果也是undefined。

JSON.stringify()的处理过程

JSON.stringify(value)访问一个JavaScript值,并生成紧凑的JSON文本。第二个参数可以是 替换函数(replacer) 或属性名数组;第三个space参数控制缩进,数值最多按10个空格处理,字符串最多使用前10个字符。

对象若有toJSON()方法,序列化器会先调用它,再把结果交给替换函数。Date正是通过toJSON()变成ISO字符串,所以普通替换函数看到的通常已经不是Date实例。自定义类中的toJSON()也会改变对外表示,应当把它当作API契约审查。

替换函数从一个键为""的包装根开始,然后按序访问后代。它返回undefined时,对象属性会被省略;数组位置会写成null。属性名数组则会在对象的每一层筛选成员,并不会只筛选顶层。

默认序列化行为不是对所有JavaScript值的一一映射:

输入位置或类型JSON.stringify()的结果
对象属性中的undefined、函数或Symbol省略该属性
数组中的undefined、函数或Symbol写成null
NaN、Infinity或-Infinity写成null
BigInt抛出TypeError,除非先提供表示规则
Date通过toJSON()写成ISO字符串
循环引用抛出TypeError

普通对象只序列化自有、可枚举、字符串键属性。Symbol键不会出现,继承属性也不会出现。数组按从0到length - 1的位置序列化;其他自有属性不会成为数组JSON的成员。

边界上的职责

解析、验证和授权是三个不同步骤。解析器回答“文本是否符合JSON语法”,形状验证回答“值是否具有预期字段与类型”,业务验证回答“这些值是否满足当前操作的规则”。授权还要根据身份与资源判断操作是否允许。

发送数据时也应先定义线格式。构造只包含公开字段的新对象,比把领域对象直接传给JSON.stringify()再按黑名单删除秘密更可靠。新增内部字段时,允许列表不会自动把它泄露到响应中。

JSON.parse(JSON.stringify(value))只对JSON数据模型内的树状值近似往返。它不是通用深拷贝,也不会恢复类实例、原型、属性描述符或共享引用。内存复制与跨系统序列化是两个不同问题。

示例

下面四个示例依次展示语法解析、形状验证、显式序列化和带来源文本的恢复。所有输出都来自Node 24.14.0。

1. 解析完整JSON文本

parse-order.js
const source = `{
  "orderId": "A-17",
  "items": [{ "sku": "PEN", "quantity": 2 }],
  "paid": false
}`;

const order = JSON.parse(source);
console.log(order.orderId);
console.log(order.items[0].quantity);
console.log(typeof order.paid);

try {
  JSON.parse("{'orderId': 'A-17'}");
} catch (error) {
  console.log(error.name);
}
A-17
2
boolean
SyntaxError

第一次解析得到普通对象、数组、字符串、数字和布尔值。第二次输入使用单引号,因此不是JSON;它看起来像某些语言的对象字面量,并不会让JSON解析器放宽语法。

只在能处理失败的边界捕获SyntaxError。若调用方需要区分无正文、错误页和格式错误的JSON,就应保留这些不同状态,而不是统一返回空对象。

2. 解析后验证形状

validate-order.js
function parseOrder(source) {
  const value = JSON.parse(source);

  if (value === null || typeof value !== "object" || Array.isArray(value)) {
    throw new TypeError("order must be an object");
  }
  if (typeof value.orderId !== "string" || !Array.isArray(value.items)) {
    throw new TypeError("orderId and items are required");
  }
  if (!value.items.every((item) =>
    item !== null &&
    typeof item === "object" &&
    typeof item.sku === "string" &&
    Number.isInteger(item.quantity) &&
    item.quantity > 0
  )) {
    throw new TypeError("each item needs a SKU and positive quantity");
  }
  return value;
}

for (const source of [
  '{"orderId":"A-17","items":[{"sku":"PEN","quantity":2}]}',
  '{"orderId":"A-18","items":[{"sku":"PEN","quantity":0}]}',
  'null'
]) {
  try {
    console.log("accepted", parseOrder(source).orderId);
  } catch (error) {
    console.log("rejected", error.message);
  }
}
accepted A-17
rejected each item needs a SKU and positive quantity
rejected order must be an object

三个输入在语法上都有效,只有第一个满足订单契约。验证先排除null和数组,再读取对象属性,从而避免把typeof null === "object"误当作足够的对象检查。

真实边界通常使用经过审查的schema验证器,但验证顺序仍然相同:先解析不可信文本,再检查顶层类别、必需字段、嵌套结构与业务约束。不要通过TypeScript类型断言跳过运行时验证,因为类型在运行时不存在。

3. 构造公开表示后序列化

stringify-invoice.js
const invoice = {
  id: "INV-9",
  totalCents: 1250n,
  customer: { name: "Mira", passwordHash: "not-for-output" },
  exportedAt: new Date("2026-09-04T10:30:00.000Z")
};

// 先构造公开数据形状,比尝试用黑名单删除秘密更安全。
const publicInvoice = {
  id: invoice.id,
  totalCents: invoice.totalCents,
  customerName: invoice.customer.name,
  exportedAt: invoice.exportedAt
};

const json = JSON.stringify(
  publicInvoice,
  (key, value) => typeof value === "bigint" ? value.toString() : value,
  2
);

console.log(json);
{
  "id": "INV-9",
  "totalCents": "1250",
  "customerName": "Mira",
  "exportedAt": "2026-09-04T10:30:00.000Z"
}

公开对象按允许列表选择字段,所以passwordHash根本不会进入序列化器。替换函数把BigInt转成十进制字符串;接收方必须知道totalCents的字符串代表整数,而不是任意文本。

日期在替换函数运行前通过自己的toJSON()变成ISO字符串。若协议需要时区名称、日历日期或其他日期语义,就要把这些字段显式加入公开表示;一个Date本身只提供时间点。

4. 用恢复函数保留整数精度

revive-order.js
const source = `{
  "orderId": "A-17",
  "placedAt": "2026-09-04T10:30:00.000Z",
  "transactionId": 9007199254740993
}`;

const order = JSON.parse(source, (key, value, context) => {
  if (key === "placedAt" && typeof value === "string") {
    return new Date(value);
  }
  if (key === "transactionId" && typeof value === "number") {
    return BigInt(context.source);
  }
  return value;
});

console.log(order.placedAt instanceof Date);
console.log(order.placedAt.toISOString());
console.log(typeof order.transactionId);
console.log(order.transactionId.toString());
true
2026-09-04T10:30:00.000Z
bigint
9007199254740993

transactionId超出 安全整数(safe integer) 范围。回调中的value已经是不精确的Number,但Node 24提供的context.source仍保存原始数字字符,因此可以直接交给BigInt()。

这个转换只应用于协议明确指定的字段。按“像日期”或“数字很大”等启发式条件转换所有字符串,会悄悄改变合法业务文本;更通用的协议应使用经过验证的类型标签,并处理标签与用户字段冲突。

陷阱

深入 解析与序列化的深层边界

解析与序列化的深层边界

字符、数字与重复成员

JSON文本以Unicode定义,而JavaScript字符串是UTF-16码元序列。JSON转义语法也能写出孤立代理码元;当前JSON.stringify()会把这种码元写成转义序列,使输出仍是可编码的JSON文本。接收方如何处理这些码元取决于它的字符串模型;协议若要求Unicode规范化,必须另外规定并在两端执行。

JSON数字语法允许任意长度的十进制数字序列,ECMAScript解析结果却是Number。因此,语法有效不代表整数精确,也不代表其他语言能用相同数值类型接收。金额常用有单位的整数,但单位和可接受范围仍应写进schema。

重复对象成员没有形成历史记录。Node 24的JSON.parse('{"role":"user","role":"admin"}')得到最后一个值"admin"。接收边界若需要拒绝重复键,普通JSON.parse()的恢复函数已经看不到先前成员,需要在解析前使用能报告重复成员的专用机制。

恢复函数的后序遍历

恢复函数先访问叶子,再访问容器,最后以空键访问根值。对于{"a":[1]},调用顺序是数组索引"0"、属性"a"、根键""。因此,父级回调看到的是子级已经替换或删除后的值。

第三个context参数只随原始值调用。其source是当前原始值对应的精确JSON源片段,不是整个文档,也不是对象属性路径。使用它恢复大整数时,仍要同时验证字段身份与数字语法,避免把本应是Number的任意字段改成BigInt。

普通函数形式的恢复函数会把持有当前属性的对象作为this。箭头函数没有自己的this,所以需要检查兄弟字段或属性描述时不能机械替换成箭头函数。大多数纯值转换不需要依赖持有者,使用箭头函数反而更清楚。

返回undefined表示删除,而不是保留原值。数组删除后会留下空槽,length不变;根值被删除时,调用方拿到undefined。若undefined本身是转换结果的一部分,就不能用返回值区分“恢复成undefined”与“删除”。

toJSON()与替换函数的顺序

序列化从一个包装对象上的空键开始。每个值若提供toJSON(),该方法先决定中间表示,替换函数随后才看到它。对Date做value instanceof Date的普通替换条件通常不会命中,因为回调收到的是字符串;若必须访问原始属性,可以在普通函数中谨慎查看this[key],但显式构造线格式更容易审查。

替换函数可以改写标量、返回新对象或省略属性。它也可能触发getter,返回的对象还会继续被遍历。带副作用的getter、toJSON()或替换函数会让输出依赖访问过程,因此传输对象最好是无行为的普通数据。

空字符串既是根调用使用的键,也可能是真实对象的属性名。只用key === ""判断“这是根”会把这两种情况混在一起。确实需要区别时,应在闭包中记录第一次调用,而不是假定业务数据不会出现空键。

顶层的undefined、函数或Symbol会让JSON.stringify()返回JavaScript值undefined,而不是字符串"undefined"。这会让要求字符串的存储或传输API在更远处失败。调用边界应断言结果类型,或者先限制允许序列化的顶层值。

树不保留对象图

JavaScript值可以形成 对象图(object graph) :两个属性可以指向同一对象,节点也可以回指祖先。JSON只有嵌套树结构,没有对象标识或引用边。重复引用在往返后变成两个独立对象,循环则在默认序列化时抛出TypeError。

原型、类名、私有字段、getter、setter、不可枚举属性、Symbol键与属性描述符都不在JSON数据模型中。解析产生普通对象和数组,而不是原来的类实例。需要恢复领域类型时,应由验证后的字段显式构造实例,不能把输入中的类名直接用于动态构造。

JSON.stringify()使用稳定且定义好的属性访问顺序,但稳定不等于规范化。两个具有相同键值、却以不同顺序构造的对象可能输出不同文本。跨语言签名还会遇到数字格式、Unicode和重复键策略差异,所以必须先选择并实现一份共同的规范。

解析与序列化都是同步操作,并且默认处理完整文本。把巨大的输入切成字符串片段后分别调用JSON.parse()不能保持任意JSON结构,使用正则提取字段也不能实现JSON语法。需要流式处理时,应选择明确的记录分帧或经过验证的流式解析器,并另行规定资源上限。

嵌入与传输上下文

有效JSON只对JSON解析器安全,并不会自动适合HTML、JavaScript源码、SQL或日志。把同一段文本放进另一个语法时,必须按外层语法的规则编码,不能把JSON.stringify()当作通用转义函数。

例如,HTML解析器会把<script type="application/json">当作原始文本元素处理,其中的</script>仍可提前结束元素。服务端嵌入数据时应使用为该上下文设计的框架序列化功能,或至少按既定策略把<写成Unicode转义,再从元素的textContent解析。

  • HTTP入口先检查状态、媒体类型和已压缩与解压后的大小上限,再缓冲和解析正文。
  • HTML嵌入对外层标记上下文编码;不要把不可信JSON直接拼进标签或脚本。
  • Web Storage与文件中的长期数据带上格式版本,并为旧版本提供迁移或明确拒绝路径。
  • 日志输出在序列化前按允许列表选择字段;格式正确的JSON仍然可能包含令牌或个人数据。

Content-Type: application/json描述正文的媒体类型,却不会证明正文语法、schema或授权正确。客户端仍要处理错误状态、空正文和服务端返回HTML错误页的情况。

存储中的JSON还会比当前代码活得更久。字段改名、单位改变或标签格式升级时,版本字段能让读取方选择迁移器,而不是用一组猜测规则解释旧数据。

日志中的JSON也不是天然匿名。替换函数按键名做黑名单过滤容易漏掉别名和嵌套凭据,应先构造专用日志事件,再由记录器序列化。

这些外层边界与JSON语法本身分开审查。先问文本将进入哪一种解析器,再为那个上下文选择编码、大小限制、版本和失败策略。

延伸阅读

检查点

4个问题 · 2道输出预测题 · 1道找错题

下一篇 错误处理 Fetch api 即将上线 Web storage 即将上线 Date对象
复制为Markdown 面试题库 在GitHub上编辑 报告错误 讲清楚了吗?