天天看點

有個朋友因為 JSON.stringify 差點丢了獎金

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

翻譯 | 楊小愛

有個朋友因為 JSON.stringify 差點丢了獎金

這是發生在我朋友身上的真實故事,他的綽号叫胖頭。由于 JSON.stringify 的錯誤使用,他負責的其中一個業務子產品上線後出現了 bug,導緻某個頁面無法使用,進而影響使用者體驗,差點讓他失去年終獎。

在這篇文章中,我将分享這個悲傷的故事。然後我們還将讨論 JSON.stringify 的各種功能,以幫助您避免将來也犯同樣的錯誤。

我們現在開始

故事是這樣的。

他所在的公司,有一位同僚離開了,然後胖頭被要求接受離開同僚的工作内容。

沒想到,在他接手這部分業務後不久,項目中就出現了一個bug。

當時,公司的交流群裡,很多人都在讨論這個問題。

産品經理先是抱怨:項目中有一個bug,使用者無法送出表單,客戶抱怨這個。請開發組盡快修複。

然後測試工程師說:我之前測試過這個頁面,為什麼上線後就不行了?

而後端開發者說:前端發送的資料缺少value字段,導緻服務端接口出錯。

找到同僚抱怨後,問題出在他負責的子產品上,我的朋友胖頭真的很頭疼。

經過一番檢查,我的朋友終于找到了這個錯誤。

事情就是這樣。

發現頁面上有一個表單允許使用者送出資料,然後前端應該從表單中解析資料并将資料發送到伺服器。

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

有個朋友因為 JSON.stringify 差點丢了獎金

這些字段是可選的。

通常,資料應如下所示:

let data = {
  signInfo: [
    {
      "fieldId": 539,
      "value": "silver card"
    },
    {
      "fieldId": 540,
      "value": "2021-03-01"
    },
    {
      "fieldId": 546,
      "value": "10:30"
    }
  ]
}           

然後它們應該轉換為:

有個朋友因為 JSON.stringify 差點丢了獎金

但問題是,這些字段是可選的。如果使用者沒有填寫某些字段,那麼資料會變成這樣:

let data = {
  signInfo: [
    {
      "fieldId": 539,
      "value": undefined
    },
    {
      "fieldId": 540,
      "value": undefined
    },
    {
      "fieldId": 546,
      "value": undefined
    }
  ]
}           

他們将變成這樣:

有個朋友因為 JSON.stringify 差點丢了獎金

JSON.stringify 在轉換過程中忽略其值為undefined的字段。

是以,此類資料上傳到伺服器後,伺服器無法解析 value 字段,進而導緻錯誤。

一旦發現問題,解決方案就很簡單,為了在資料轉換為 JSON 字元串後保留 value 字段,我們可以這樣做:

有個朋友因為 JSON.stringify 差點丢了獎金
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 差點丢了獎金

同時,JSON.stringify 有以下規則。

1、如果目标對象有toJSON()方法,它負責定義哪些資料将被序列化。

有個朋友因為 JSON.stringify 差點丢了獎金

2、 Boolean、Number、String 對象在字元串化過程中被轉換為對應的原始值,符合傳統的轉換語義。

有個朋友因為 JSON.stringify 差點丢了獎金

3、 undefined、Functions 和 Symbols 不是有效的 JSON 值。如果在轉換過程中遇到任何此類值,則它們要麼被忽略(在對象中找到),要麼被更改為 null(當在數組中找到時)。

有個朋友因為 JSON.stringify 差點丢了獎金
有個朋友因為 JSON.stringify 差點丢了獎金

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

有個朋友因為 JSON.stringify 差點丢了獎金

5、 Date的執行個體通過傳回一個字元串來實作toJSON()函數(與date.toISOString()相同)。是以,它們被視為字元串。

有個朋友因為 JSON.stringify 差點丢了獎金

6、 數字 Infinity 和 NaN 以及 null 值都被認為是 null。

有個朋友因為 JSON.stringify 差點丢了獎金

7、 所有其他 Object 執行個體(包括 Map、Set、WeakMap 和 WeakSet)将僅序列化其可枚舉的屬性。

有個朋友因為 JSON.stringify 差點丢了獎金

8、找到循環引用時抛出TypeError(“循環對象值”)異常。

有個朋友因為 JSON.stringify 差點丢了獎金

9、 嘗試對 BigInt 值進行字元串化時抛出 TypeError(“BigInt 值無法在 JSON 中序列化”)。

有個朋友因為 JSON.stringify 差點丢了獎金

自己實作 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 的特性并自己實作了它。

今天我與你分享這個故事,是希望你以後遇到這個問題,知道怎麼處理,不要也犯同樣的錯誤。

如果你覺得有用的話,請點贊我,關注我,最後,感謝你的閱讀,程式設計愉快!