JSON 和 JavaScript 中字符串化的怪象

前言

在我刚开始学习web开发时,JSON是看起来很简单的一个东西。因为JSON字符串看起来就像一个文本,JavaScript对象的的最小子集。在我职业生涯的早期,我从来没有花时间去好好研究这种数据格式。我仅仅只是使用JSON.stringifyJSON.parse,直到出现意外的错误。

在这篇文章中,我想:

  • 总结一下我在JavaScript中使用JSON(更确切的说是JSON.stringifyAPI)时遇到的怪事
  • 通过从头开始实现JSON.stringify的简化版本,来加深我对JSON的理解

什么是JSON

JSON是Douglas Crockford[1]发明的一种数据结构。你可能已经知道了这些。但是有意思的是,正如Crockford在他的书《JavaScript悟道》中写的那样,他承认:“关于JSON的最糟糕的事情就是名字。”

JSON表示JavaScript对象表示法(JavaScript Object Notation)。问题在于,这个名字误导人们认为它只适用于JavaScript。然而事实上,它的目的是允许不同语言编写的程序有效地沟通。

在类似的问题上,Crockford也坦言,JavaScript提供的两个内置API可以与JSON一起工作。它们是JSON.parseJSON.stringify ,同样的,命名也很糟糕。它们应该分别被称为JSON.decodeJSON.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' 字符串):

代码语言:javascript
复制
JSON.stringify(undefined) // undefined
JSON.stringify(Symbol('foo')) // undefined
JSON.stringify(() => {}) // undefined

对于其他内置对象类型(FunctionDate 除外),比如说Map, Set, WeakMap, WeakSet, Regex 等等,JSON.stringify 会返回一个空对象字面量的字符串,也就是'{}'

代码语言:javascript
复制
JSON.stringify(/foo/) // '{}'
JSON.stringify(new Map()) // '{}'
JSON.stringify(new Set()) //'{}'

当被序列化的值位于数组或对象中时,会发生更多不一致的行为。

对于不支持的导致undefined 的类型,也就是undefined, Symbol, Function ,当它们在数组中被发现时,会被转换为字符串'null' ;当它们在对象中被发现时,整个属性会从输出中省略:

代码语言:javascript
复制
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转换完毕后,都会变为空对象字面量的字符串,也就是'{}'

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

更多例外

对于最近添加的新类型BigIntJSON.stringify 会抛出一个TypeError错误 。另一种情况时,当传递循环对象时,JSON.stringify会抛出错误。大多数情况下,JSON.stringify是相当宽容的。它不会因为你违反了JSON的规则而使你的程序崩溃(除非是BigInt或循环对象)。

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

尽管是数字类型,NaNInfinity依然会被JSON.stringify转换为null。这个设计决定背后的原因是,正如Crockford在他的书《JavaScript悟道》中写到的,NaNInfinity的存在表明了一个错误。他通过使它们变成null来排除它们。

代码语言:javascript
复制
JSON.stringify(NaN) // 'null'
JSON.stringify(Infinity) // 'null'

通过JSON.stringifyDate 对象会被编码为ISO字符串,因为具有Date.prototype.toJSON

代码语言:javascript
复制
JSON.stringify(new Date()) // '"2022-06-01T14:22:51.307Z"'

JSON.stringify只处理可枚举的、非符号键的对象属性。符号键、非枚举属性会被忽略:

代码语言:javascript
复制
const foo = {}
foo[Symbol('p1')] = 'bar'
Object.defineProperty(foo, 'p2', {value: 'baz', enumerable: false})

JSON.stringify(foo) // '{}'

顺便说一下,希望你能明白为什么使用JSON.parseJSON.stringify来深克隆一个对象大多是一个坏主意。

归纳

我知道要记住的东西很多,所以我整理了一份小抄,供你参考。

cheatsheet.png

自定义编码

目前为止,我们所讨论的是,JavaScript如何通过JSON.stringify将值编码为JSON字符串的默认行为,有两种方式可以自行控制转换规则:

添加一个toJSON方法,到你传递给JSON.stringify的对象上。这也是为什么Date对象传递给JSON.stringify不会导致一个空对象字面量。因为Date对象会从它的原型上继承toJSON方法。

代码语言:javascript
复制
const foo = {
toJSON: () => 'bar',
}

JSON.stringify(foo) // 'bar'

JSON.stringify接收一个称为replacer的可选参数,它可以是一个函数或一个数组,来改变字符串化过程的默认行为。

简化版JSON.stringify

下面是简化版JSON.stringify的实现。为了简洁起见,这里省略了可选参数replacerspace

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