我的朋友因?yàn)?JSON.stringify 差點(diǎn)丟了獎(jiǎng)金

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

翻譯 | 楊小愛(ài)


這是發(fā)生在我朋友身上的真實(shí)故事,他的綽號(hào)叫胖頭。由于JSON.stringify的錯(cuò)誤使用,他負(fù)責(zé)的其中一個(gè)業(yè)務(wù)模塊上線后出現(xiàn)了bug,導(dǎo)致某個(gè)頁(yè)面無(wú)法使用,進(jìn)而影響用戶(hù)體驗(yàn),差點(diǎn)讓他失去年終獎(jiǎng)。

在這篇文章中,我將分享這個(gè)悲傷的故事。然后我們還將討論 JSON.stringify 的各種功能,以幫助您避免將來(lái)也犯同樣的錯(cuò)誤。

我們現(xiàn)在開(kāi)始

故事是這樣的。

他所在的公司,有一位同事離開(kāi)了,然后胖頭被要求接受離開(kāi)同事的工作內(nèi)容。

沒(méi)想到,在他接手這部分業(yè)務(wù)后不久,項(xiàng)目中就出現(xiàn)了一個(gè)bug。

當(dāng)時(shí),公司的交流群里,很多人都在討論這個(gè)問(wèn)題。

產(chǎn)品經(jīng)理先是抱怨:項(xiàng)目中有一個(gè)bug,用戶(hù)無(wú)法提交表單,客戶(hù)抱怨這個(gè)。請(qǐng)開(kāi)發(fā)組盡快修復(fù)。

然后測(cè)試工程師說(shuō):我之前測(cè)試過(guò)這個(gè)頁(yè)面,為什么上線后就不行了?

而后端開(kāi)發(fā)者說(shuō):前端發(fā)送的數(shù)據(jù)缺少value字段,導(dǎo)致服務(wù)端接口出錯(cuò)。

找到同事抱怨后,問(wèn)題出在他負(fù)責(zé)的模塊上,我的朋友胖頭真的很頭疼。

經(jīng)過(guò)一番檢查,我的朋友終于找到了這個(gè)錯(cuò)誤。

事情就是這樣。

發(fā)現(xiàn)頁(yè)面上有一個(gè)表單允許用戶(hù)提交數(shù)據(jù),然后前端應(yīng)該從表單中解析數(shù)據(jù)并將數(shù)據(jù)發(fā)送到服務(wù)器。

表格是這樣的:(下面是我的模擬)



這些字段是可選的。

通常,數(shù)據(jù)應(yīng)如下所示:

let data = {
  signInfo: [
    {
      "fieldId": 539,
      "value": "silver card"
    },
    {
      "fieldId": 540,
      "value": "2021-03-01"
    },
    {
      "fieldId": 546,
      "value": "10:30"
    }
  ]
}
然后它們應(yīng)該轉(zhuǎn)換為:



但問(wèn)題是,這些字段是可選的。如果用戶(hù)沒(méi)有填寫(xiě)某些字段,那么數(shù)據(jù)會(huì)變成這樣:

let data = {
  signInfo: [
    {
      "fieldId": 539,
      "value": undefined
    },
    {
      "fieldId": 540,
      "value": undefined
    },
    {
      "fieldId": 546,
      "value": undefined
    }
  ]
}
他們將變成這樣:



JSON.stringify 在轉(zhuǎn)換過(guò)程中忽略其值為undefined的字段。

因此,此類(lèi)數(shù)據(jù)上傳到服務(wù)器后,服務(wù)器無(wú)法解析 value 字段,進(jìn)而導(dǎo)致錯(cuò)誤。

一旦發(fā)現(xiàn)問(wèn)題,解決方案就很簡(jiǎn)單,為了在數(shù)據(jù)轉(zhuǎn)換為 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":""}]'
如果發(fā)現(xiàn)某個(gè)字段的值為undefined,我們將該字段的值更改為空字符串。

雖然問(wèn)題已經(jīng)解決了,但是,我們還需要思考這個(gè)問(wèn)題是怎么產(chǎn)生的。

本來(lái)這是一個(gè)已經(jīng)上線好幾天的頁(yè)面,為什么突然出現(xiàn)這個(gè)問(wèn)題?仔細(xì)排查,原來(lái)是產(chǎn)品經(jīng)理之前提出了一個(gè)小的優(yōu)化點(diǎn),然后,胖頭對(duì)代碼做了一點(diǎn)改動(dòng)。但是胖頭對(duì) JSON.stringify 的特性并不熟悉,同時(shí),他認(rèn)為改動(dòng)比較小,所以沒(méi)有進(jìn)行足夠的測(cè)試,最終導(dǎo)致項(xiàng)目出現(xiàn) bug。

好在他發(fā)現(xiàn)問(wèn)題后,很快就解決了問(wèn)題。這個(gè)bug影響的用戶(hù)少,所以老板沒(méi)有責(zé)怪他,我的朋友獎(jiǎng)金沒(méi)有丟掉,不然,影響大的話,估計(jì)獎(jiǎng)金真的就沒(méi)有了,甚至還會(huì)讓他直接離開(kāi)。

接著,我們一起來(lái)了解一下 JSON.stringify,它為啥那么“厲害”,差點(diǎn)把我朋友的獎(jiǎng)金都給弄丟了。

了解一下 JSON.stringify

其實(shí),這個(gè)bug主要是因?yàn)榕诸^對(duì)JSON.stringify不熟悉造成的,所以,這里我們就一起來(lái)分析一下這個(gè)內(nèi)置函數(shù)的一些特點(diǎn)。

基本上,JSON.stringify() 方法將 JavaScript 對(duì)象或值轉(zhuǎn)換為 JSON 字符串:



同時(shí),JSON.stringify 有以下規(guī)則。

1、如果目標(biāo)對(duì)象有toJSON()方法,它負(fù)責(zé)定義哪些數(shù)據(jù)將被序列化。



2、 Boolean、Number、String 對(duì)象在字符串化過(guò)程中被轉(zhuǎn)換為對(duì)應(yīng)的原始值,符合傳統(tǒng)的轉(zhuǎn)換語(yǔ)義。



3、 undefined、Functions 和 Symbols 不是有效的 JSON 值。如果在轉(zhuǎn)換過(guò)程中遇到任何此類(lèi)值,則它們要么被忽略(在對(duì)象中找到),要么被更改為 null(當(dāng)在數(shù)組中找到時(shí))。




4、 所有 Symbol-keyed 屬性將被完全忽略








5、 Date的實(shí)例通過(guò)返回一個(gè)字符串來(lái)實(shí)現(xiàn)toJSON()函數(shù)(與date.toISOString()相同)。因此,它們被視為字符串。



6、 數(shù)字 Infinity 和 NaN 以及 null 值都被認(rèn)為是 null。



7、 所有其他 Object 實(shí)例(包括 Map、Set、WeakMap 和 WeakSet)將僅序列化其可枚舉的屬性。



8、找到循環(huán)引用時(shí)拋出TypeError(“循環(huán)對(duì)象值”)異常。



9、 嘗試對(duì) BigInt 值進(jìn)行字符串化時(shí)拋出 TypeError(“BigInt 值無(wú)法在 JSON 中序列化”)。



自己實(shí)現(xiàn) JSON.stringify

理解一個(gè)函數(shù)的最好方法是自己實(shí)現(xiàn)它。下面我寫(xiě)了一個(gè)模擬 JSON.stringify 的簡(jiǎn)單函數(shù)。

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(/'/, '"')
      }
    }
  }
}
寫(xiě)在最后

從一個(gè) bug 開(kāi)始,我們討論了 JSON.stringify 的特性并自己實(shí)現(xiàn)了它。

今天我與你分享這個(gè)故事,是希望你以后遇到這個(gè)問(wèn)題,知道怎么處理,不要也犯同樣的錯(cuò)誤。

如果你覺(jué)得有用的話,請(qǐng)點(diǎn)贊我,關(guān)注我,最后,感謝你的閱讀,編程愉快!

作者:前端Q


歡迎關(guān)注微信公眾號(hào) :前端Q