产品经理反馈:项目中有一个bug,用户无法提交表单,导致客户投诉,请开发组尽快修复。
然后测试工程师说:我之前测试过这个页面,为什么上线后就不行了?
而后端开发者说:前端发送的数据缺少value字段,导致服务端接口出错。
经过一番检查,我终于找到了这个错误。
事情就是这样。
发现页面上有一个表单允许用户提交数据,然后前端应该从表单中解析数据并将数据发送到服务器。
表格是这样的:(下面是我的示例)
这些字段是可选的。并且在通常情况下,数据应如下所示:
let data1 = {
signInfo: [
{
"fieldId": 539,
"value": "silver card"
},
{
"fieldId": 540,
"value": "2021-03-01"
},
{
"fieldId": 546,
"value": "10:30"
}
]
}
然后它们应该转换为:
但问题是,这些字段是可选的。如果用户没有填写某些字段,那么数据会变成这样:
let data2 = {
signInfo: [
{
"fieldId": 539,
"value": undefined
},
{
"fieldId": 540,
"value": undefined
},
{
"fieldId": 546,
"value": undefined
}
]
}
他们将变成这样:
JSON.stringify 在转换过程中忽略其值为undefined的字段。
因此,此类数据上传到服务器后,服务器无法解析 value 字段,进而导致错误。
一旦发现问题,解决方案就很简单,为了在数据转换为 JSON 字符串后保留 value 字段,我们可以这样做:
let signInfo = [
{
fieldId: 539,
value: undefined
},
{
fieldId: 540,
value: undefined
},
{
fieldId: 546,
value: undefined
},
]
let newSignInfo = signInfo.map((it) => {
const value = typeof it.value === 'undefined' ? '' : it.value
return {
...it,
value
}
})
console.log(JSON.stringify(newSignInfo))
// '[{"fieldId":539,"value":""},{"fieldId":540,"value":""},{"fieldId":546,"value":""}]'
如果发现某个字段的值为undefined,我们将该字段的值更改为空字符串。
虽然问题已经解决了,但是,我们还需要思考这个问题是怎么产生的。
本来这是一个已经上线好几天的页面,为什么突然出现这个问题?仔细排查,原来是产品经理之前提出了一个小的优化点,然后,我对代码做了一点改动。但是对 JSON.stringify 的特性并不熟悉,同时,认为改动比较小,所以没有进行足够的测试,最终导致项目出现 bug。
接下来,让我们一起来了解一下 JSON.stringify,它为啥那么“厉害”。
2、重新了解一下 JSON.stringify
其实,这个bug主要是因为我对JSON.stringify不熟悉造成的,所以,这里我们就一起来分析一下这个内置函数的一些特点。
基本上,JSON.stringify() 方法将 JavaScript 对象或值转换为 JSON 字符串:
同时,JSON.stringify 有以下规则。
(1)如果目标对象有toJSON()方法,它负责定义哪些数据将被序列化。
(2)Boolean、Number、String 对象在字符串化过程中被转换为对应的原始值,符合传统的转换语义。
(3)undefined、Functions 和 Symbols 不是有效的 JSON 值。如果在转换过程中遇到任何此类值,则它们要么被忽略(在对象中找到),要么被更改为 null(当在数组中找到时)。
(4)所有 Symbol-keyed 属性将被完全忽略
(5)Date的实例通过返回一个字符串来实现toJSON()函数(与date.toISOString()相同)。因此,它们被视为字符串。
JSON.stringify(new Date())
""2022-06-14T06:55:19.924Z""
(6)数字 Infinity 和 NaN 以及 null 值都被认为是 null。
(7)所有其他 Object 实例(包括 Map、Set、WeakMap 和 WeakSet)将仅序列化其可枚举的属性。
(8)找到循环引用时抛出TypeError(“循环对象值”)异常。
(9)尝试对 BigInt 值进行字符串化时抛出 TypeError(“BigInt 值无法在 JSON 中序列化”)。
3、自己实现 JSON.stringify
理解一个函数的最好方法是自己实现它。下面我写了一个模拟 JSON.stringify 的简单函数。
const jsonstringify = (data) => {
// Check if an object has a circular reference
const isCyclic = (obj) => {
// Use a Set to store the detected objects
let stackSet = new Set()
let detected = false
const detect = (obj) => {
// If it is not an object, we can skip it directly
if (obj && typeof obj != 'object') {
return
}
// When the object to be checked already exists in the stackSet,
// it means that there is a circular reference
if (stackSet.has(obj)) {
return detected = true
}
// save current obj to stackSet
stackSet.add(obj)
for (let key in obj) {
// check all property of `obj`
if (obj.hasOwnProperty(key)) {
detect(obj[key])
}
}
// After the detection of the same level is completed,
// the current object should be deleted to prevent misjudgment
/*
For example: different properties of an object may point to the same reference,
which will be considered a circular reference if not deleted
let tempObj = {
name: 'bytefish'
}
let obj4 = {
obj1: tempObj,
obj2: tempObj
}
*/
stackSet.delete(obj)
}
detect(obj)
return detected
}
// Throws a TypeError ("cyclic object value") exception when a circular reference is found.
if (isCyclic(data)) {
throw new TypeError('Converting circular structure to JSON')
}
// Throws a TypeError when trying to stringify a BigInt value.
if (typeof data === 'bigint') {
throw new TypeError('Do not know how to serialize a BigInt')
}
const type = typeof data
const commonKeys1 = ['undefined', 'function', 'symbol']
const getType = (s) => {
return Object.prototype.toString.call(s).replace(/\[object (.*?)\]/, '$1').toLowerCase()
}
if (type !== 'object' || data === null) {
let result = data
// The numbers Infinity and NaN, as well as the value null, are all considered null.
if ([NaN, Infinity, null].includes(data)) {
result = 'null'
// undefined, arbitrary functions, and symbol values are converted individually and return undefined
} else if (commonKeys1.includes(type)) {
return undefined
} else if (type === 'string') {
result = '"' + data + '"'
}
return String(result)
} else if (type === 'object') {
// If the target object has a toJSON() method, it's responsible to define what data will be serialized.
// The instances of Date implement the toJSON() function by returning a string (the same as date.toISOString()). Thus, they are treated as strings.
if (typeof data.toJSON === 'function') {
return jsonstringify(data.toJSON())
} else if (Array.isArray(data)) {
let result = data.map((it) => {
// 3# undefined, Functions, and Symbols are not valid JSON values. If any such values are encountered during conversion they are either omitted (when found in an object) or changed to null (when found in an array).
return commonKeys1.includes(typeof it) ? 'null' : jsonstringify(it)
})
return `[${result}]`.replace(/'/g, '"')
} else {
// 2# Boolean, Number, and String objects are converted to the corresponding primitive values during stringification, in accord with the traditional conversion semantics.
if (['boolean', 'number'].includes(getType(data))) {
return String(data)
} else if (getType(data) === 'string') {
return '"' + data + '"'
} else {
let result = []
// 7# All the other Object instances (including Map, Set, WeakMap, and WeakSet) will have only their enumerable properties serialized.
Object.keys(data).forEach((key) => {
// 4# All Symbol-keyed properties will be completely ignored
if (typeof key !== 'symbol') {
const value = data[key]
// 3# undefined, Functions, and Symbols are not valid JSON values. If any such values are encountered during conversion they are either omitted (when found in an object) or changed to null (when found in an array).
if (!commonKeys1.includes(typeof value)) {
result.push(`"${key}":${jsonstringify(value)}`)
}
}
})
return `{${result}}`.replace(/'/, '"')
}
}
}
}
从一个 bug 开始,探究了这个bug出现的原因,并讨论了 JSON.stringify 的特性,顺便自己再次实现了它。