我的朋友因为 JSON.stringify 差点丢了奖金

英文 | https://medium.com/frontend-canteen/my-friend-almost-lost-his-year-end-bonus-because-of-json-stringify-9da86961eb9e

翻译 | 杨小爱


这是发生在我朋友身上的真实故事,他的绰号叫胖头。由于JSON.stringify的错误使用,他负责的其中一个业务模块上线后出现了bug,导致某个页面无法使用,进而影响用户体验,差点让他失去年终奖。

在这篇文章中,我将分享这个悲伤的故事。然后我们还将讨论 JSON.stringify 的各种功能,以帮助您避免将来也犯同样的错误。

我们现在开始

故事是这样的。

他所在的公司,有一位同事离开了,然后胖头被要求接受离开同事的工作内容。

没想到,在他接手这部分业务后不久,项目中就出现了一个bug。

当时,公司的交流群里,很多人都在讨论这个问题。

产品经理先是抱怨:项目中有一个bug,用户无法提交表单,客户抱怨这个。请开发组尽快修复。

然后测试工程师说:我之前测试过这个页面,为什么上线后就不行了?

而后端开发者说:前端发送的数据缺少value字段,导致服务端接口出错。

找到同事抱怨后,问题出在他负责的模块上,我的朋友胖头真的很头疼。

经过一番检查,我的朋友终于找到了这个错误。

事情就是这样。

发现页面上有一个表单允许用户提交数据,然后前端应该从表单中解析数据并将数据发送到服务器。

表格是这样的:(下面是我的模拟)



这些字段是可选的。

通常,数据应如下所示:

let data = {
  signInfo: [
    {
      "fieldId": 539,
      "value": "silver card"
    },
    {
      "fieldId": 540,
      "value": "2021-03-01"
    },
    {
      "fieldId": 546,
      "value": "10:30"
    }
  ]
}
然后它们应该转换为:



但问题是,这些字段是可选的。如果用户没有填写某些字段,那么数据会变成这样:

let data = {
  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。

好在他发现问题后,很快就解决了问题。这个bug影响的用户少,所以老板没有责怪他,我的朋友奖金没有丢掉,不然,影响大的话,估计奖金真的就没有了,甚至还会让他直接离开。

接着,我们一起来了解一下 JSON.stringify,它为啥那么“厉害”,差点把我朋友的奖金都给弄丢了。

了解一下 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()相同)。因此,它们被视为字符串。



6、 数字 Infinity 和 NaN 以及 null 值都被认为是 null。



7、 所有其他 Object 实例(包括 Map、Set、WeakMap 和 WeakSet)将仅序列化其可枚举的属性。



8、找到循环引用时抛出TypeError(“循环对象值”)异常。



9、 尝试对 BigInt 值进行字符串化时抛出 TypeError(“BigInt 值无法在 JSON 中序列化”)。



自己实现 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 开始,我们讨论了 JSON.stringify 的特性并自己实现了它。

今天我与你分享这个故事,是希望你以后遇到这个问题,知道怎么处理,不要也犯同样的错误。

如果你觉得有用的话,请点赞我,关注我,最后,感谢你的阅读,编程愉快!

作者:前端Q


欢迎关注微信公众号 :前端Q