尧图网站设计 尧图网站设计YAOTU DESIGN
ARTICLE DETAIL

资讯详情

深耕网站设计与一线实操的经验洞察。

JSON数组元素可以不同类型吗?规范允许但实战需谨慎

JSON数组元素可以不同类型吗?规范允许但实战需谨慎 我经常在技术群里看到同一个问题JSON 数组里的元素是不是必须类型一样每次都要解释半天。这里直接给结论按 JSON 规范数组元素可以完全不同类型。[hello, 42, true, null, {name: xiaoyu}, [1, 2, 3]]这种写法完全合法。但问题没这么简单——规范允许不代表你在实际项目里可以无脑使用。很多语言的强类型解析、数据库表格映射、API 设计规范都会默认“数组元素同构”一旦你塞了异构数组等着你的可能就是解析报错、数据丢失或者前后端联调翻车。这篇文章就从规范、实战、踩坑三个角度把这个话题说透顺便聊聊什么时候该用异构数组、什么时候应该老老实实改成同构。1. 先给结论JSON 数组元素完全可以不同1.1 从 JSON 规范看数组定义JSON 标准RFC 8259里数组被定义为零个或多个值的有序集合。这里有几个关键词值得拆开看一是“零个或多个”意味着空数组[]是合法数组二是“有序”数组里的元素顺序是有意义的第一个元素和第二个元素调换位置JSON 层面不会报错但语义可能就变了三是“值”而这个值可以是任意合法的 JSON 类型——对象、数组、数字、字符串、布尔值、null 都可以。也就是说数组本身并不承诺“元素必须是同一类型”。比如[1, a, true, null, {x: 1}, [2, 3]]这个结构在 JSON 解析器眼里没有任何问题。你甚至可以把数组嵌在数组里形成[[1, 2], [a, b], [true]]这依然是合法的 JSON。JSON 的“值”是一种递归定义数组里的每个元素都可以是任何 JSON 值这种设计天然支持异构。有人会问“不是说 JSON 是 JavaScript Object Notation 吗JavaScript 数组不是可以混放类型吗”对JSON 的数组语法正是继承了这种 JavaScript 式的宽松。但 JSON 现在已经是一种独立于语言的文本格式几乎所有语言都能解析。所以“数组元素能否不同类型”这个问题的答案在规范层面从来都很明确能而且这是设计使然。1.2 为什么“数组必须同类型”的误解这么普遍既然规范明确允许那为什么还是有那么多人觉得数组元素必须一致我总结下来主要有四个原因。第一个原因是编程语言的惯性。学过 C、Java、Go 这类静态类型语言的人脑海里对“数组”的默认印象就是int[]、String[]所有元素类型必须一致否则编译都过不了。这种思维惯性很强大容易让人想当然地以为所有数组都该这样。第二个原因是数据场景的统计规律。在真实业务里JSON 数组绝大多数表达的是“同一类事物的集合”。比如用户列表是对象数组、标签列表是字符串数组、订单 ID 列表是数字数组。见得多了自然形成“数组就应该同构”的直觉。这个直觉在大多数时候是对的但作为规则来记就容易出错。第三个原因是表格型工具的强制约束。当你把 JSON 转成 CSV、Excel、SQL 表时每一列必须有固定类型。数组一旦异构表格型工具无法直接映射于是一些转换工具干脆报错或者强制截断反过来让人觉得“异构非法”。第四个原因是不少 JSON 库和接口文档默认数组是同构的。比如 OpenAPI 里定义数组时items通常只给一个 schema意味着所有元素都要匹配它。这种契约层面的简化让很多开发者误以为这是 JSON 本身的要求。实际上JSON Schema 老版本里items也可以给一个 schema 数组用来描述每个位置不同类型也就是元组式数组新版本里改成了prefixItems。这四种原因叠加在一起导致一个很常见的场景明明 JSON 里能写异构数组到了应用层却因为“解析器不配合”而失败。所以下面我们先说异构数组到底能用来做什么再说怎么解析。2. 异构数组能解决什么问题三个典型场景2.1 场景一混合信息一次性传递很多内部接口、日志上报、消息队列的数据并不想为了几个字段单独定义一个大对象于是用数组把一组混合信息打包传走。举个例子[INFO, 2026-06-01T10:00:00Z, 200, {url: /api/users, cost_ms: 12}]这条数组表示日志级别是INFO时间是一个字符串HTTP 状态码是数字 200附带一个包含请求路径和耗时的对象。接收方只要按索引约定解析第一个取字符串、第三个取数字就能把日志拼装出来。这种写法的好处是轻量、紧凑传输体积比对象小——特别是字段名重复出现时数组可以省掉键名压缩效果明显。缺点是依赖“位置即语义”的约定一旦有人往中间插入一个字段后面所有索引都会错位。所以这种用法更适合内部系统、短生命周期消息不适合对外接口。2.2 场景二元组式数据的天然表达编程语言里有“元组”tuple的概念比如 Python 里(张三, 28, True)它把不同类型的数据按顺序组合成一个整体。JSON 数组其实可以扮演类似的角色。比如一个简单的用户记录[张三, 28, true]三个元素分别是姓名、年龄、是否为 VIP。这里类型完全不同但它是一个有序记录数组顺序承载了字段位置。如果硬要改成同构大概会变成[{name: 张三}, {age: 28}, {is_vip: true}]这样改完数组里的每个元素确实都是对象但反而丢了“它们属于同一条记录”的语义而且读取时还要多一层对象包装。所以当数据天然是“多个不同类型但固定顺序的值”时异构数组是一个合理的表达方式。要注意的是这里和对象方案{name: 张三, age: 28, is_vip: true}相比数组方案省了键名但可读性更差。我的经验是元组式数组适合在数据管道中作为中间表示或者用于极简传输如果这个数据要长期留存、被多个团队复用对象几乎是必然选择。2.3 场景三内部 DSL 与动态配置我们写规则引擎、命令系统时经常用数组来表示一条指令。比如[set, volume, 80] [echo, hello] [delay, 500]第二条命令的第二个元素是字符串第三条命令的第二个元素是数字。数组第一个元素是命令名后面参数的类型取决于具体命令。这种“多态指令”用异构数组表达非常自然解析方先读第一个元素决定命令类型再按命令规则处理后续元素。再比如一个简单的富文本节点列表[text, 这是一段普通文本] [image, https://example.com/a.png, {width: 200}] [link, https://example.com, 点击跳转]每个节点都是一个异构数组节点类型不同后面跟随的参数类型也不同。这种结构在内部 DSL 里很常用灵活且省流量。但这类 DSL 必须做好文档和校验。我见过一个线上事故有人给[set, volume, 80]多加了一个参数解析代码没做参数数量检查直接把第三个元素当成了 int结果字符串解析失败整个配置加载中断。所以内部 DSL 的校验逻辑要写得非常严格先判断数组长度再校验每个位置的类型最后才执行。3. 实操多语言解析异构数组的正确姿势附代码示例3.1 三个最常用的踩坑点先说实话绝大多数异构数组报错不是 JSON 本身的问题而是你在用“强类型语言 强类型声明”去解析一个“弱类型结构”。第一个坑是 Java 的 Jackson。你写了一个 DTOpublic class RequestDTO { private ListString items; }结果 JSON 是[abc, 123]Jackson 在反序列化时会直接抛MismatchedInputException因为它尝试把第二个元素 123 塞进String类型塞不进去。第二个坑是 Go 的标准库encoding/json。你用var arr []string接收[a, 1]同样会报cannot unmarshal number into Go value of type string。Go 这边如果改成var arr []interface{}每个元素还需要再手动做类型断言。第三个坑是 C# 的System.Text.Json。如果你声明ListPerson persons去解析一个混合了 Person 对象和其他类型的 JSON 数组默认策略下同样会抛JsonException。这些问题本质上都源于“类型系统无法同时表示多种类型”。3.2 Python / Java / Go / JavaScript 解析对照Python 的json模块天然支持异构数组因为 Python 列表本身就可以装任意对象。直接 load 就能用import json raw [张三, 28, true, null, {city: 上海}] arr json.loads(raw) for item in arr: print(item, type(item))输出结果里可以看到28是inttrue是boolnull是NoneType对象是dict。Python 这边最大的问题是“太自由”你拿到的 list 里类型不确定后续逻辑要自己判断类型否则容易写出item[city]这种在字符串元素上崩掉的代码。Java 这边如果不想强类型映射我一般用 Jackson 的JsonNode或ListObjectObjectMapper mapper new ObjectMapper(); JsonNode node mapper.readTree(raw); if (node.isArray()) { for (JsonNode item : node) { if (item.isTextual()) { System.out.println(字符串: item.asText()); } else if (item.isNumber()) { System.out.println(数字: item.asDouble()); } else if (item.isBoolean()) { System.out.println(布尔: item.asBoolean()); } else if (item.isNull()) { System.out.println(null); } else if (item.isObject()) { System.out.println(对象字段: item.fieldNames()); } } }JsonNode的方式比较安全因为每个元素都能拿到自己的实际类型不需要提前声明成某一种具体类。Go 这边推荐用[]interface{}然后做类型断言var arr []interface{} err : json.Unmarshal([]byte(raw), arr) if err ! nil { log.Fatal(err) } for _, item : range arr { switch v : item.(type) { case string: fmt.Println(字符串:, v) case float64: fmt.Println(数字:, v) case bool: fmt.Println(布尔:, v) case nil: fmt.Println(null) case map[string]interface{}: fmt.Println(对象:, v) default: fmt.Println(未知类型) } }这里要注意Go 把 JSON 里的数字统一解析成float64所以整数28拿到的类型也是float64做类型断言时不能写成case int否则永远匹配不上。JavaScript 原生 JSON 解析后数组就是普通数组元素类型多种多样配合typeof判断即可const arr JSON.parse([张三, 28, true, null, {city: 上海}]); arr.forEach(item { if (item null) { console.log(null); } else if (typeof item string) { console.log(字符串:, item); } else if (typeof item number) { console.log(数字:, item); } else if (typeof item boolean) { console.log(布尔:, item); } else if (typeof item object) { console.log(对象:, item); } });JavaScript 踩坑点集中在typeof null object这个历史遗留问题上所以判断 null 要放在最前面用item null单独判断。3.3 从一条具体报错看强类型与异构数组的矛盾很多人搜过这样一条报错json parse error: cannot deserialize value of type java.util.Date from String。它不一定是数组引起的但当数组里混着字符串日期和数字时间戳时问题会特别明显。比如这样一个数组[2026-05-01 10:00:00, 1780000000]第一个元素是字符串格式的日期第二个元素是数字时间戳。Java 端如果写ListDate dates mapper.readValue(raw, new TypeReferenceListDate() {});Jackson 默认对字符串日期有格式要求第一个元素2026-05-01 10:00:00不是它认识的默认格式于是抛出cannot deserialize value of type java.util.Date from String之类的错误。数字时间戳在某些配置下倒是可以转成 Date但字符串格式不行最终整个解析失败。这种“同一个数组里放了两种都能表示日期但类型完全不同的值”就是典型的异构数组碰上强类型目标。解决思路有两种一是把数据改成同构比如全部用 ISO 8601 字符串或全部用时间戳二是自定义反序列化器逐个判断元素是字符串还是数字分别转换。从工程维护角度看第一种永远比第二种稳妥因为自定义反序列化器写起来容易维护起来难时间格式一变又是坑。4. 设计决策什么时候该用异构数组什么时候老老实实同构4.1 先问一句这是集合还是记录我在设计数据结构时先问自己一个问题这个数组表达的是“集合”还是“记录”集合是指多个同类型、地位平等的元素比如一批订单、一组标签记录是指固定顺序、可能不同类型、组合在一起才有完整意义的元素比如一个坐标点、一条日志的多个字段。如果是集合数组必须同构这没得商量。你不可能在订单列表里混一个字符串进去否则调用方遍历时没法统一处理。如果是记录异构数组在技术上合法但你要掂量一下是不是用对象更好我的判断标准有三个如果这个数据要跨团队、跨系统传递优先用对象因为字段名本身就是文档。如果这个数据只是在程序内部临时组装生命周期很短可以用异构数组节省序列化开销。如果顺序本身就是语义的一部分而且元素数量固定比如二维坐标、颜色 RGBA即使所有元素都是数字也适合用数组表达因为[x, y]天然是位置语义。大多数情况下用户列表、标签列表、商品列表这些“集合”不用考虑异构。真正需要决策的往往是“这个数组到底是不是一个记录”的边界场景。4.2 用 JSON Schema 显式声明“元组数组”如果你决定用异构数组作为对外接口的数据格式我强烈建议你用 JSON Schema 把每个位置的类型写清楚而不是一句话“数组里元素可能有不同类型”糊弄过去。JSON Schema 里描述元组数组有两种写法。老版本用items数组{ type: array, items: [ { type: string }, { type: integer }, { type: boolean } ] }这表示数组第一个元素必须是字符串第二个必须是整数第三个必须是布尔值对应[张三, 28, true]。新版本里这种用法被prefixItems取代{ type: array, prefixItems: [ { type: string }, { type: integer }, { type: boolean } ] }如果数组允许有多余元素你还可以加一个items字段描述剩余位置元素的通用类型。这样的 schema 写出来之后调用方可以通过工具自动生成文档、生成校验逻辑不会拿着你的接口文档再去猜“第三个字段到底是字符串还是数字”。4.3 我的数组类型设计建议结合我自己的项目经验给你几条可以直接用的建议。第一对外 API 默认同构。除非有特别强烈的理由不要在 REST API 的响应里返回异构数组。调用方不是只有你一个别人拿到一个“有时字符串有时对象”的数组第一反应就是骂人。第二内部传输如果要用异构数组至少加一个“类型标记”。最稳的做法是像判别联合discriminated union那样用对象包一层[ { type: text, value: 普通文本 }, { type: image, url: https://example.com/a.png } ]这样每个元素都是对象数组层面是同构的对象内部通过type区分不同形态。解析方只需要先读type再按对应字段取值比“猜位置”可靠得多。第三如果要传输真正的异构数组比如日志、配置项记得同时传递一份 schema 或者类型模板让接收方知道每个位置应该是什么。宁可多写几个字段也不要让下游靠猜。第四空数组[]要特别处理。空数组没有类型信息强类型语言解析空数组到ListA和解析到ListB都可能成功等于把这个“薛定谔的类型”留给了后续逻辑。所以接口文档里要明确空数组的语义比如“为空时表示没有权限资源”而不是让调用方假设成某一种类型。5. 常见问题排查解析报错、去重排序、边界场景5.1 常见问题速查表我在不同项目里反复遇到过下面这些和异构数组相关的问题整理成一张表遇到类似情况可以直接照着查。问题原因解决方案Java Jackson 用ListString解析含数字的数组报错目标类型声明过于严格改用ListObject或JsonNode逐元素处理Go 用[]string解析含数字的数组报错Go 静态类型不允许隐式转换改用[]interface{}做类型断言JavaScript 用typeof判断 null 得到objectJS 历史遗留问题先判断item null再判断其他类型用Set对异构数组去重结果不对1和1在 Set 中是两个元素对象去重比较引用先做类型归一化或用JSON.stringify后去重sort()排序时数字和字符串混排结果混乱默认按字符串比较传入自定义比较函数异构数组导出 CSV 时数据错位CSV 每列类型固定先按列类型规整或把数组展开为多列数据库把 JSON 数组展开成多行时类型冲突数据库列类型单一展开前统一类型或使用 JSONB 原生存储这里重点说两个最常见的。1和1的差异在 JavaScript 去重场景里最容易踩。比如数组[1, 1, true]你用new Set(arr)结果是 3 个元素因为字符串、数字、布尔在比较时不是同一个值。这符合预期。但如果你随手用某个数组去重工具函数它内部用宽松比较1 1成立去重后可能就只剩 2 个元素。所以处理异构数组去重首先要明确“相等”的业务定义再选择严格相等还是按类型归一化。排序也是一样。默认arr.sort()会把所有元素转成字符串再比较所以数字10会排到字符串2前面因为10 2按字典序成立。如果你要在混合类型数组里排序必须自己写比较函数并且约定不同类型谁大谁小否则排序结果就是玄学。5.2 三个容易混淆的边界场景第一个是空数组[]。它既不是同构也不是异构因为它没有元素也就没有类型信息。很多人问“空数组应该定义成什么类型”答案是看上下文在 API 响应里它可能表示“没有数据”在 JSON Schema 里你需要用items指定未来元素的类型。空数组本身不会导致解析报错强类型语言里它几乎可以转成任何 List 类型这种灵活性反而容易隐藏问题。第二个是嵌套数组[[a, 1], [b, 2]]。外层数组的每个元素都是一个数组所以外层的元素类型是“数组”从这个角度看外层是同构的但内层数组里一个字符串一个数字内层是异构的。这种结构非常常见Excel 导出的二维表就是这种形态。解析时要注意外层可以用强类型ListList...吗不能因为内层是异构的所以里层还是要用ListObject或JsonNode。第三个是“对象数组但对象结构不同”。比如[{name: a}, {name: b, age: 1}]数组的每个元素都是 JSON 对象类型上是同构的但对象内部字段不一致第二个元素多了一个age。这种问题比类型异构更隐蔽因为你的解析代码可能用了getInt(age)第一个元素没有这个字段直接崩了。所以判断数组是否需要强类型映射不能只看元素是不是对象还要看对象结构是否一致。5.3 异构数组去重、排序时的小心机最后分享几个实操中的小技巧这些都不是官方文档会写的但实际用得上。JavaScript 对异构数组去重如果只是想消除“完全相同的字面量”可以用JSON.stringify作为比较键const arr [1, 1, {a: 1}, {a: 1}, null, [1, 2], [1, 2]]; const seen new Set(); const result arr.filter(item { const key JSON.stringify(item); if (seen.has(key)) { return false; } seen.add(key); return true; });注意对象{a: 1}和{a: 1}本身是两个不同引用但 stringify 后得到相同字符串所以可以按字面量去重。缺点是如果 JSON 对象很大stringify 的性能会变差而且对象键顺序不同会导致 stringify 结果不同比如{a:1, b:2}和{b:2, a:1}。如果字段顺序不固定可以先排序键再 stringify。Python 里对包含 dict/list 的数组用set(arr)会直接抛unhashable type错误因为 dict 和 list 不可哈希。要按字面量去重可以用import json arr [1, 1, {a: 1}, {a: 1}, [1, 2], [1, 2]] seen set() result [] for item in arr: key json.dumps(item, sort_keysTrue) if key not in seen: seen.add(key) result.append(item)排序方面如果数组中混了字符串和数字并且你想让数字排在前面、字符串排在后面可以写一个明确的比较器。JavaScript 里这样arr.sort((a, b) { if (typeof a number typeof b number) return a - b; if (typeof a number typeof b ! number) return -1; if (typeof a ! number typeof b number) return 1; return String(a).localeCompare(String(b)); });核心思想是异构数组的排序没有“正确答案”只有“你定义的顺序”。如果你不定义框架就按默认规则排结果通常不是你要的。我个人实际用下来的体会是JSON 数组元素能不能不同这个问题回答“能”只需要一秒但设计一个让团队能长期维护的数组结构需要想很久。现在我在写接口文档时只要数组可能异构就一定会写清每个位置的类型或者干脆用type字段包一层。JSON 的灵活是它能流行的原因但灵活从来都是双刃剑。系统里能省则省的是步骤不能省的是类型信息。希望这篇内容能帮你少踩几个解析的坑。
返回列表