前言
在我刚开始学习web开发时,JSON是看起来很简单的一个东西。因为JSON字符串看起来就像一个文本,JavaScript对象的的最小子集。在我职业生涯的早期,我从来没有花时间去好好研究这种数据格式。我仅仅只是使用JSON.stringify
和JSON.parse
,直到出现意外的错误。
在这篇文章中,我想:
- 总结一下我在JavaScript中使用JSON(更确切的说是
JSON.stringify
API)时遇到的怪事 - 通过从头开始实现JSON.stringify的简化版本,来加深我对JSON的理解
什么是JSON
JSON是Douglas Crockford[1]发明的一种数据结构。你可能已经知道了这些。但是有意思的是,正如Crockford在他的书《JavaScript悟道》中写的那样,他承认:“关于JSON的最糟糕的事情就是名字。”
JSON表示JavaScript对象表示法(JavaScript Object Notation)。问题在于,这个名字误导人们认为它只适用于JavaScript。然而事实上,它的目的是允许不同语言编写的程序有效地沟通。
在类似的问题上,Crockford也坦言,JavaScript提供的两个内置API可以与JSON一起工作。它们是JSON.parse
和JSON.stringify
,同样的,命名也很糟糕。它们应该分别被称为JSON.decode
和JSON.encode
,因为JSON.parse
需要一个JSON文本并将其「解码」为JavaScript值,而JSON.stringify
需要一个JavaScript值并将其「编码」为JSON文本/字符串。
说完了命名,让我们看看JSON支持哪些数据类型,以及当一个不兼容的JSON值被JSON.stringify
字符串化时会发生什么。
JSON支持哪些数据格式
JSON有一个官方网站[2],你可以在上面查看所有支持的数据类型,但是说实话,对于我来说,页面上的图有点难以理解。所以我更喜欢下面的类型注释:
代码语言:javascript复制type Json =
| null
| boolean
| number
| string
| Json[]
| {[key: string]: Json}
对于任何不属于上述Json
联合类型的数据类型,比如说undefined
, Symbol
, BigInt
,以及其他内置对象,比如说Function
, Map
, Set
, Regex
,它们不被JSON支持,注释也一样不被支持。
下一个合乎逻辑的问题是,在JavaScript的上下文中,当我们说一个数据类型不被JSON支持时,到底是什么意思?
JSON.stringify的怪异行为
在JavaScript中,通过JSON.stringify
将值转换为JSON字符串。
对于JSON支持的类型的值,它们会被转换为预期的字符串:
代码语言:javascript复制JSON.stringify(1) // '1'
JSON.stringify(null) // 'null'
JSON.stringify('foo') // '"foo"'
JSON.stringify({foo: 'bar'}) // '{"foo":"bar"}'
JSON.stringify(['foo', 'bar']) // '["foo","bar"]'
但在字符串化/编码过程中,如果涉及到不支持的类型,事情会变得棘手起来。
当直接传递不支持的类型undefined
, Symbol
, 和 Function
时,JSON.stringify
会输出undefined
(不是'undefined'
字符串):
JSON.stringify(undefined) // undefined
JSON.stringify(Symbol('foo')) // undefined
JSON.stringify(() => {}) // undefined
对于其他内置对象类型(Function
和 Date
除外),比如说Map
, Set
, WeakMap
, WeakSet
, Regex
等等,JSON.stringify
会返回一个空对象字面量的字符串,也就是'{}'
:
JSON.stringify(/foo/) // '{}'
JSON.stringify(new Map()) // '{}'
JSON.stringify(new Set()) //'{}'
当被序列化的值位于数组或对象中时,会发生更多不一致的行为。
对于不支持的导致undefined
的类型,也就是undefined
, Symbol
, Function
,当它们在数组中被发现时,会被转换为字符串'null'
;当它们在对象中被发现时,整个属性会从输出中省略:
JSON.stringify([undefined]) // '[null]'
JSON.stringify({foo: undefined}) // '{}'
JSON.stringify([Symbol()]) // '[null]'
JSON.stringify({foo: Symbol()}) // '{}'
JSON.stringify([() => {}]) // '[null]'
JSON.stringify({foo: () => {}}) // '{}'
另一方面,对于其他内置对象类型,诸如Map
, Set
, Regex
等,存在于数组或对象中时,被JSON.stringify
转换完毕后,都会变为空对象字面量的字符串,也就是'{}'
:
JSON.stringify([/foo/]) // '[{}]'
JSON.stringify({foo: /foo/}) // '{"foo":{}}'
JSON.stringify([new Set()]) // '[{}]'
JSON.stringify({foo: new Set()}) // '{"foo":{}}'
JSON.stringify([new Map()]) // '[{}]'
JSON.stringify({foo: new Map()}) // '{"foo":{}}'
更多例外
对于最近添加的新类型BigInt
,JSON.stringify
会抛出一个TypeError
错误 。另一种情况时,当传递循环对象时,JSON.stringify
会抛出错误。大多数情况下,JSON.stringify
是相当宽容的。它不会因为你违反了JSON的规则而使你的程序崩溃(除非是BigInt或循环对象)。
const foo = {}
foo.a = foo
JSON.stringify(foo) // ❌ Uncaught TypeError: Converting circular structure to JSON
JSON.stringify(BigInt(1234567890)) // ❌ Uncaught TypeError: Do not know how to serialize a BigInt
尽管是数字类型,NaN
和Infinity
依然会被JSON.stringify
转换为null
。这个设计决定背后的原因是,正如Crockford在他的书《JavaScript悟道》中写到的,NaN
和Infinity
的存在表明了一个错误。他通过使它们变成null
来排除它们。
JSON.stringify(NaN) // 'null'
JSON.stringify(Infinity) // 'null'
通过JSON.stringify
,Date
对象会被编码为ISO字符串,因为具有Date.prototype.toJSON
。
JSON.stringify(new Date()) // '"2022-06-01T14:22:51.307Z"'
JSON.stringify
只处理可枚举的、非符号键的对象属性。符号键、非枚举属性会被忽略:
const foo = {}
foo[Symbol('p1')] = 'bar'
Object.defineProperty(foo, 'p2', {value: 'baz', enumerable: false})
JSON.stringify(foo) // '{}'
顺便说一下,希望你能明白为什么使用JSON.parse
和JSON.stringify
来深克隆一个对象大多是一个坏主意。
归纳
我知道要记住的东西很多,所以我整理了一份小抄,供你参考。
cheatsheet.png
自定义编码
目前为止,我们所讨论的是,JavaScript如何通过JSON.stringify
将值编码为JSON字符串的默认行为,有两种方式可以自行控制转换规则:
添加一个toJSON
方法,到你传递给JSON.stringify
的对象上。这也是为什么Date
对象传递给JSON.stringify
不会导致一个空对象字面量。因为Date
对象会从它的原型上继承toJSON
方法。
const foo = {
toJSON: () => 'bar',
}
JSON.stringify(foo) // 'bar'
JSON.stringify
接收一个称为replacer
的可选参数,它可以是一个函数或一个数组,来改变字符串化过程的默认行为。
简化版JSON.stringify
下面是简化版JSON.stringify
的实现。为了简洁起见,这里省略了可选参数replacer
和 space
。
const isCyclic = (input) => {
let seen = new Set()
const dfs = (obj) => {
if (typeof obj !== 'object' || obj === null) return false
seen.add(obj)
return Object.entries(obj).some(([key, value]) => {
const result = seen.has(value) ? true : isCyclic(value)
seen.delete(value)
return result
})
}
return dfs(input)
}
function jsonStringify(data) {
if (isCyclic(data))
throw new TypeError('Converting circular structure to JSON')
if (typeof data === 'bigint')
throw new TypeError('Do not know how to serialize a BigInt')
if (data === null) {
// get rid of null first because the type of null is 'object'
return 'null'
}
const type = typeof data
if (type !== 'object') {
let result = data
if (Number.isNaN(data) || data === Infinity) {
// for NaN and Infinity we return 'null'
result = 'null'
} else if (
type === 'function' ||
type === 'undefined' ||
type === 'symbol'
) {
return undefined
} else if (type === 'string') {
result = '"' data '"'
}
return String(result)
}
if (type === 'object') {
if (typeof data.toJSON === 'function') {
return jsonStringify(data.toJSON())
}
if (data instanceof Array) {
let result = []
data.forEach((item, index) => {
if (
typeof item === 'undefined' ||
typeof item === 'function' ||
typeof item === 'symbol'
) {
result[index] = 'null'
} else {
result[index] = jsonStringify(item)
}
})
result = '[' result ']'
return result.replace(/'/g, '"')
} else {
let result = []
Object.keys(data).forEach((item) => {
if (typeof item !== 'symbol') {
if (
data[item] !== undefined &&
typeof data[item] !== 'function' &&
typeof data[item] !== 'symbol'
) {
result.push('"' item '"' ':' jsonStringify(data[item]))
}
}
})
return ('{' result '}').replace(/'/g, '"')
}
}
}
总结
本文解释了什么是JSON,以及使用JSON.stringify
来字符串化JavaScript值时的怪异行为,最后实现了简易版的JSON.stringify
,希望对你有所帮助。
原文链接:https://www.zhenghao.io/posts/json-oddities[3] 作者:zhenghao
参考资料
[1]
Douglas Crockford: https://www.crockford.com/about.html
[2]
官方网站: https://www.json.org/json-en.html
[3]
https://www.zhenghao.io/posts/json-oddities: https://www.zhenghao.io/posts/json-oddities