前言
開發要對線上環境有一顆敬畏之心,任何一個點都有可能導致線上故障,也有可能讓你的年終獎泡湯(⊙︿⊙)。比如使用了JSON.stringify
,這個無比熟悉但又無比陌生的API。
看完本文您可以收穫:
- 瞭解一個差點讓我年終獎打水漂的悲傷的故事o(╥﹏╥)o
- 學習JSON.stringify的9大特性和轉換規則 (重點)
- 瞭解如何判斷一個物件是否存在迴圈引用 (重點)
- 從零開始手寫一個JSON.stringify (重點)
- 等等
說一個悲傷的故事
最近組內有個小夥伴離職了,他所負責的一塊業務由我去維護,結果剛接手,程式碼還沒捂熱乎,差點背上p0的鍋。請讓我花一點時間和你說清楚來龍去脈。
悲傷伊始
這一天胖頭魚正徜徉在程式碼的海洋不可自拔,突然被拉進了一個線上問題排查群,群裡不可謂不熱鬧。
產品同學在訴苦:線上使用者不能提交表單了,帶來了好多客訴,估計會是p0故障,希望儘快解決。
測試同學在納悶:這個場景測試和預發環境明明驗過的,怎麼線上就不行了。
後端同學在講原因:介面缺少了value欄位,導致出錯了。
就是木有人說問題怎麼解決!!!
就是木有人說問題怎麼解決!!!
就是木有人說問題怎麼解決!!!
這樣的場景不知道你是不是也似曾相識呢?o(╥﹏╥)o,不管咋說第一要務還是先把線上問題解決掉,減少持續影響,趕緊把交接的程式碼翻出來,開始了排查過程。
問題原因
如下圖:有這樣一個動態表單蒐集頁面,使用者選擇或者填寫了資訊之後(各欄位非必填情況下也可以直接提交
),接著前端把資料傳送給後端,結束,看起來沒有多複雜的邏輯。
直接錯誤原因
非必填情況下,signInfo欄位中經過JSON.stringify
後的字串物件缺少value
key,導致後端parse之後無法正確讀取value值,進而報介面系統異常,使用者無法進行下一步動作。
// 異常入引數據,陣列字串中沒有value key
{
signInfo: '[{"fieldId":539},{"fieldId":540},{"fieldId":546,"value":"10:30"}]'
}
// 正常入引數據
{
signInfo: '[{"fieldId":539,"value":"銀卡"},{"fieldId":540,"value":"2021-03-01"},{"fieldId":546,"value":"10:30"}]'
}
異常資料是如何產生的
// 預設情況下資料是這樣的
let signInfo = [
{
fieldId: 539,
value: undefined
},
{
fieldId: 540,
value: undefined
},
{
fieldId: 546,
value: undefined
},
]
// 經過JSON.stringify之後的資料,少了value key,導致後端無法讀取value值進行報錯
// 具體原因是`undefined`、`任意的函式`以及`symbol值`,出現在`非陣列物件`的屬性值中時在序列化過程中會被忽略
console.log(JSON.stringify(signInfo))
// '[{"fieldId":539},{"fieldId":540},{"fieldId":546}]'
解決方案
問題的原因找到了,解決方式 (這裡只講前端的解決方案,當然也可以由後端解決) 也很簡單,將value值為undefined的項轉化為空字串再提交即可。
方案一:新開一個物件處理
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":""}]'
方案二:利用JSON.stringify
第二個引數,直接處理
方案一的缺陷是需要新開一個物件進行一頓操作才能解決,不夠優雅
let signInfo = [
{
fieldId: 539,
value: undefined
},
{
fieldId: 540,
value: undefined
},
{
fieldId: 546,
value: undefined
},
]
// 判斷到value為undefined,返回空字串即可
JSON.stringify(signInfo, (key, value) => typeof value === 'undefined' ? '' : value)
// '[{"fieldId":539,"value":""},{"fieldId":540,"value":""},{"fieldId":546,"value":""}]'
故事後續
原本這是一個已經上線有一段時間的頁面,為何會突然出現這個問題,之前卻沒有呢?仔細詢問下,原來是中途產品同學提了一個小的優化點,離職的小夥伴感覺點比較小直接就改了程式碼上線了,未曾想出現了線上問題。
後面針對這件事從產品到測試、到後端、到前端單獨做了一個完整的覆盤,細節就不再展開說了。
因為從發現問題到解決問題速度較快、影響使用者數較少,還未達到問責程度,俺的年終獎可算是保住了o(╥﹏╥)o。
重學JSON.stringify
經過這件事情,我覺得有必要重新審視一下JSON.stringify
這個方法,徹底搞清楚轉換規則,並嘗試手寫實現一個JSON.stringify
如果你曾遇到和我一樣的問題,歡迎一起來重新學習一次,一定會有不一樣的收穫噢!
學透JSON.stringify
JSON.stringify()
方法將一個JavaScript 物件
或值
轉換為 JSON 字串,如果指定了一個 replacer 函式,則可以選擇性地替換值,或者指定的 replacer 是陣列,則可選擇性地僅包含陣列指定的屬性。
以下資訊來自MDN
語法
JSON.stringify(value[, replacer [, space]])
value
將要序列化成 一個 JSON 字串的值。
replacer
可選- 如果該引數是一個函式,則在序列化過程中,被序列化的值的每個屬性都會經過該函式的轉換和處理;
- 如果該引數是一個陣列,則只有包含在這個陣列中的屬性名才會被序列化到最終的 JSON 字串中;
- 如果該引數為 null 或者未提供,則物件所有的屬性都會被序列化。
space
可選- 指定縮排用的空白字串,用於美化輸出(pretty-print);
- 如果引數是個數字,它代表有多少的空格;上限為10。
- 該值若小於1,則意味著沒有空格;
- 如果該引數為字串(當字串長度超過10個字母,取其前10個字母),該字串將被作為空格;
- 如果該引數沒有提供(或者為 null),將沒有空格。
返回值
一個表示給定值的JSON字串。
- 當在迴圈引用時會丟擲異常
TypeError
("cyclic object value")(迴圈物件值) - 當嘗試去轉換
BigInt
型別的值會丟擲TypeError
("BigInt value can't be serialized in JSON")(BigInt值不能JSON序列化).
基本使用
注意
- JSON.stringify可以轉換物件或者值(平常用的更多的是轉換物件)
- 可以指定
replacer
為函式選擇性的地替換 - 也可以指定
replacer
為陣列,可轉換指定的屬性
這裡僅僅是NDN上關於JSON.stringify
其中最基礎的說明,我們們先打個碼試試這幾個特性
// 1. 轉換物件
console.log(JSON.stringify({ name: '前端胖頭魚', sex: 'boy' })) // '{"name":"前端胖頭魚","sex":"boy"}'
// 2. 轉換普通值
console.log(JSON.stringify('前端胖頭魚')) // "前端胖頭魚"
console.log(JSON.stringify(1)) // "1"
console.log(JSON.stringify(true)) // "true"
console.log(JSON.stringify(null)) // "null"
// 3. 指定replacer函式
console.log(JSON.stringify({ name: '前端胖頭魚', sex: 'boy', age: 100 }, (key, value) => {
return typeof value === 'number' ? undefined : value
}))
// '{"name":"前端胖頭魚","sex":"boy"}'
// 4. 指定陣列
console.log(JSON.stringify({ name: '前端胖頭魚', sex: 'boy', age: 100 }, [ 'name' ]))
// '{"name":"前端胖頭魚"}'
// 5. 指定space(美化輸出)
console.log(JSON.stringify({ name: '前端胖頭魚', sex: 'boy', age: 100 }))
// '{"name":"前端胖頭魚","sex":"boy","age":100}'
console.log(JSON.stringify({ name: '前端胖頭魚', sex: 'boy', age: 100 }, null , 2))
/*
{
"name": "前端胖頭魚",
"sex": "boy",
"age": 100
}
*/
9大特性要記住
以前僅僅是使用了這個方法,卻沒有詳細瞭解他的轉換規則,居然有9個之多。
特性一
undefined
、任意的函式
以及symbol值
,出現在非陣列物件
的屬性值中時在序列化過程中會被忽略undefined
、任意的函式
以及symbol值
出現在陣列
中時會被轉換成null
。undefined
、任意的函式
以及symbol值
被單獨轉換
時,會返回 undefined
// 1. 物件中存在這三種值會被忽略
console.log(JSON.stringify({
name: '前端胖頭魚',
sex: 'boy',
// 函式會被忽略
showName () {
console.log('前端胖頭魚')
},
// undefined會被忽略
age: undefined,
// Symbol會被忽略
symbolName: Symbol('前端胖頭魚')
}))
// '{"name":"前端胖頭魚","sex":"boy"}'
// 2. 陣列中存在著三種值會被轉化為null
console.log(JSON.stringify([
'前端胖頭魚',
'boy',
// 函式會被轉化為null
function showName () {
console.log('前端胖頭魚')
},
//undefined會被轉化為null
undefined,
//Symbol會被轉化為null
Symbol('前端胖頭魚')
]))
// '["前端胖頭魚","boy",null,null,null]'
// 3.單獨轉換會返回undefined
console.log(JSON.stringify(
function showName () {
console.log('前端胖頭魚')
}
)) // undefined
console.log(JSON.stringify(undefined)) // undefined
console.log(JSON.stringify(Symbol('前端胖頭魚'))) // undefined
特性二
布林值
、數字
、字串
的包裝物件在序列化過程中會自動轉換成對應的原始值。
console.log(JSON.stringify([new Number(1), new String("前端胖頭魚"), new Boolean(false)]))
// '[1,"前端胖頭魚",false]'
特性三
所有以symbol
為屬性鍵的屬性都會被完全忽略掉,即便replacer
引數中強制指定包含了它們。
console.log(JSON.stringify({
name: Symbol('前端胖頭魚'),
}))
// '{}'
console.log(JSON.stringify({
[ Symbol('前端胖頭魚') ]: '前端胖頭魚',
}, (key, value) => {
if (typeof key === 'symbol') {
return value
}
}))
// undefined
特性四
NaN 和 Infinity 格式的數值及 null 都會被當做 null。
console.log(JSON.stringify({
age: NaN,
age2: Infinity,
name: null
}))
// '{"age":null,"age2":null,"name":null}'
特性五
轉換值如果有 toJSON() 方法,該方法定義什麼值將被序列化。
const toJSONObj = {
name: '前端胖頭魚',
toJSON () {
return 'JSON.stringify'
}
}
console.log(JSON.stringify(toJSONObj))
// "JSON.stringify"
特性六
Date 日期呼叫了 toJSON() 將其轉換為了 string 字串(同Date.toISOString()),因此會被當做字串處理。
const d = new Date()
console.log(d.toJSON()) // 2021-10-05T14:01:23.932Z
console.log(JSON.stringify(d)) // "2021-10-05T14:01:23.932Z"
特性七
對包含迴圈引用的物件(物件之間相互引用,形成無限迴圈)執行此方法,會丟擲錯誤。
let cyclicObj = {
name: '前端胖頭魚',
}
cyclicObj.obj = cyclicObj
console.log(JSON.stringify(cyclicObj))
// Converting circular structure to JSON
特性八
其他型別的物件,包括 Map/Set/WeakMap/WeakSet,僅會序列化可列舉的屬性
let enumerableObj = {}
Object.defineProperties(enumerableObj, {
name: {
value: '前端胖頭魚',
enumerable: true
},
sex: {
value: 'boy',
enumerable: false
},
})
console.log(JSON.stringify(enumerableObj))
// '{"name":"前端胖頭魚"}'
特性九
當嘗試去轉換 BigInt
型別的值會丟擲錯誤
const alsoHuge = BigInt(9007199254740991)
console.log(JSON.stringify(alsoHuge))
// TypeError: Do not know how to serialize a BigInt
手寫一個JSON.stringify
終於重新學完JSON.stringify
的眾多特性啦!我們們根據這些特性來手寫一個簡單版本的吧(無replacer函式和space)
原始碼實現
const jsonstringify = (data) => {
// 確認一個物件是否存在迴圈引用
const isCyclic = (obj) => {
// 使用Set資料型別來儲存已經檢測過的物件
let stackSet = new Set()
let detected = false
const detect = (obj) => {
// 不是物件型別的話,可以直接跳過
if (obj && typeof obj != 'object') {
return
}
// 當要檢查的物件已經存在於stackSet中時,表示存在迴圈引用
if (stackSet.has(obj)) {
return detected = true
}
// 將當前obj存如stackSet
stackSet.add(obj)
for (let key in obj) {
// 對obj下的屬性進行挨個檢測
if (obj.hasOwnProperty(key)) {
detect(obj[key])
}
}
// 平級檢測完成之後,將當前物件刪除,防止誤判
/*
例如:物件的屬性指向同一引用,如果不刪除的話,會被認為是迴圈引用
let tempObj = {
name: '前端胖頭魚'
}
let obj4 = {
obj1: tempObj,
obj2: tempObj
}
*/
stackSet.delete(obj)
}
detect(obj)
return detected
}
// 特性七:
// 對包含迴圈引用的物件(物件之間相互引用,形成無限迴圈)執行此方法,會丟擲錯誤。
if (isCyclic(data)) {
throw new TypeError('Converting circular structure to JSON')
}
// 特性九:
// 當嘗試去轉換 BigInt 型別的值會丟擲錯誤
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
// 特性四:
// NaN 和 Infinity 格式的數值及 null 都會被當做 null。
if ([NaN, Infinity, null].includes(data)) {
result = 'null'
// 特性一:
// `undefined`、`任意的函式`以及`symbol值`被`單獨轉換`時,會返回 undefined
} else if (commonKeys1.includes(type)) {
// 直接得到undefined,並不是一個字串'undefined'
return undefined
} else if (type === 'string') {
result = '"' + data + '"'
}
return String(result)
} else if (type === 'object') {
// 特性五:
// 轉換值如果有 toJSON() 方法,該方法定義什麼值將被序列化
// 特性六:
// Date 日期呼叫了 toJSON() 將其轉換為了 string 字串(同Date.toISOString()),因此會被當做字串處理。
if (typeof data.toJSON === 'function') {
return jsonstringify(data.toJSON())
} else if (Array.isArray(data)) {
let result = data.map((it) => {
// 特性一:
// `undefined`、`任意的函式`以及`symbol值`出現在`陣列`中時會被轉換成 `null`
return commonKeys1.includes(typeof it) ? 'null' : jsonstringify(it)
})
return `[${result}]`.replace(/'/g, '"')
} else {
// 特性二:
// 布林值、數字、字串的包裝物件在序列化過程中會自動轉換成對應的原始值。
if (['boolean', 'number'].includes(getType(data))) {
return String(data)
} else if (getType(data) === 'string') {
return '"' + data + '"'
} else {
let result = []
// 特性八
// 其他型別的物件,包括 Map/Set/WeakMap/WeakSet,僅會序列化可列舉的屬性
Object.keys(data).forEach((key) => {
// 特性三:
// 所有以symbol為屬性鍵的屬性都會被完全忽略掉,即便 replacer 引數中強制指定包含了它們。
if (typeof key !== 'symbol') {
const value = data[key]
// 特性一
// `undefined`、`任意的函式`以及`symbol值`,出現在`非陣列物件`的屬性值中時在序列化過程中會被忽略
if (!commonKeys1.includes(typeof value)) {
result.push(`"${key}":${jsonstringify(value)}`)
}
}
})
return `{${result}}`.replace(/'/, '"')
}
}
}
}
測試一把
// 1. 測試一下基本輸出
console.log(jsonstringify(undefined)) // undefined
console.log(jsonstringify(() => { })) // undefined
console.log(jsonstringify(Symbol('前端胖頭魚'))) // undefined
console.log(jsonstringify((NaN))) // null
console.log(jsonstringify((Infinity))) // null
console.log(jsonstringify((null))) // null
console.log(jsonstringify({
name: '前端胖頭魚',
toJSON() {
return {
name: '前端胖頭魚2',
sex: 'boy'
}
}
}))
// {"name":"前端胖頭魚2","sex":"boy"}
// 2. 和原生的JSON.stringify轉換進行比較
console.log(jsonstringify(null) === JSON.stringify(null));
// true
console.log(jsonstringify(undefined) === JSON.stringify(undefined));
// true
console.log(jsonstringify(false) === JSON.stringify(false));
// true
console.log(jsonstringify(NaN) === JSON.stringify(NaN));
// true
console.log(jsonstringify(Infinity) === JSON.stringify(Infinity));
// true
let str = "前端胖頭魚";
console.log(jsonstringify(str) === JSON.stringify(str));
// true
let reg = new RegExp("\w");
console.log(jsonstringify(reg) === JSON.stringify(reg));
// true
let date = new Date();
console.log(jsonstringify(date) === JSON.stringify(date));
// true
let sym = Symbol('前端胖頭魚');
console.log(jsonstringify(sym) === JSON.stringify(sym));
// true
let array = [1, 2, 3];
console.log(jsonstringify(array) === JSON.stringify(array));
// true
let obj = {
name: '前端胖頭魚',
age: 18,
attr: ['coding', 123],
date: new Date(),
uni: Symbol(2),
sayHi: function () {
console.log("hello world")
},
info: {
age: 16,
intro: {
money: undefined,
job: null
}
},
pakingObj: {
boolean: new Boolean(false),
string: new String('前端胖頭魚'),
number: new Number(1),
}
}
console.log(jsonstringify(obj) === JSON.stringify(obj))
// true
console.log((jsonstringify(obj)))
// {"name":"前端胖頭魚","age":18,"attr":["coding",123],"date":"2021-10-06T14:59:58.306Z","info":{"age":16,"intro":{"job":null}},"pakingObj":{"boolean":false,"string":"前端胖頭魚","number":1}}
console.log(JSON.stringify(obj))
// {"name":"前端胖頭魚","age":18,"attr":["coding",123],"date":"2021-10-06T14:59:58.306Z","info":{"age":16,"intro":{"job":null}},"pakingObj":{"boolean":false,"string":"前端胖頭魚","number":1}}
// 3. 測試可遍歷物件
let enumerableObj = {}
Object.defineProperties(enumerableObj, {
name: {
value: '前端胖頭魚',
enumerable: true
},
sex: {
value: 'boy',
enumerable: false
},
})
console.log(jsonstringify(enumerableObj))
// {"name":"前端胖頭魚"}
// 4. 測試迴圈引用和Bigint
let obj1 = { a: 'aa' }
let obj2 = { name: '前端胖頭魚', a: obj1, b: obj1 }
obj2.obj = obj2
console.log(jsonstringify(obj2))
// TypeError: Converting circular structure to JSON
console.log(jsonStringify(BigInt(1)))
// TypeError: Do not know how to serialize a BigInt
通過上面測試可以看出,jsonstringify
基本和JSON.stringify
表現一致,(也有可能測試用例不夠全面,歡迎提出一起學習)
結尾
因為一個BUG,重學了JSON.stringify
,瞭解到原來它還有這麼多平時沒有注意到特性,前端娛樂圈水太深了,願大家都被溫柔以待,少些bug,多些關懷。晚安