Это реальная история, которая произошла со мной. Неправильное использование JSON.stringify ломало работу сайта. Это серьезно повлияло на пользовательский опыт, я чуть не лишился годового бонуса.

В этой статье я поделюсь с вами историей, а затем мы также поговорим об особенностях JSON.stringify.

История

Ушел коллега, я взял на себя часть его работы. Неожиданно, вскоре после его ухода, в проекте появился баг.
Команда начала обсуждать как можно решить эту проблему:

  • Продакт менеджер: пользователи больше не могут отправить форму, это вызывает много жалоб.

  • QA: я тестировал эту страницу! Как она могла сломаться без новых релизов?

  • Backend Developer: frontend не отправляет данные в поле value. Это приводит к ошибке.

Ой, кажется, эта ошибка происходит в моем модуле. Я начал ломать голову над решением проблемы.

Спустя время, я нашел причину.
На странице была форма. После ввода данных, пользователь нажимал кнопку отправки. Frontend собирал данные из формы и отправлял на backend.

Форма выглядела примерно так:

Эти поля опциональные.

При нормальном стечении обстоятельств, данные должны выглядеть вот так:

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

C помощью JSON.stringify это трансформируется в:

Но, как я уже сказал — эти поля опциональные. Поэтому, если пользователь их не заполнит, то результат может выглядеть таким образом:

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

При использовании JSON.stringify это трансформируется в:

JSON.stringify игнорирует поля, значение которых не определено в процессе преобразования. Когда эти данные отправляются, backend не может их обработать. Это приводит к ошибке.

Причина проблемы найдена, и решение тоже очень простое, преобразовать элемент, значение которого не определено, в пустую строку.

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

Проблема возникла из-за того, что я не до конца понимал как работает JSON.stringify, поэтому давайте посмотрим на возможности этой встроенной функции.

По сути, JSON.stringify преобразует объект в строку JSON:

В то же время JSON.stringify имеет следующие правила:

  1. Если у значения есть toJSON()метод, он отвечает за определение того, как данные будут сериализованы.

  2. BooleanNumber, и Stringобъекты преобразуются в соответствующие примитивные значения.

  3. undefinedFunction, и Symbolнедопустимые значения JSON. Если какие-либо такие значения встречаются во время преобразования, они либо отбрасываются (если они находится в объекте), либо заменяются на null(если они находятся в массиве). 

  4. Все Symbolсвойства с ключом будут полностью игнорироваться

  5. Dateреализует toJSON()функцию, возвращая строку (то же, что и date.toISOString()). Таким образом, результатом будет строка:

  6. Значения Infinityи NaN преобразуются в null

  7. Все остальные объекты (включая MapSetWeakMap и WeakSet) будут иметь только перечисляемые свойства:

  8. Если объект содержит циклическую ссылку, то будет вызвана ошибка TypeError ("cyclic object value"):

  9. Выдается ошибка при попытке преобразовать значение типа BigInt:

Реализуем 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(/'/, '"')
      }
    }
  }
}

Заключение

Из-за бага мне пришлось разобраться в особенностях работы JSON.stringifyи даже написать свою реализацию этой функции.

Надеюсь, эта статья поможет вам не допустить моей ошибки в будущем.

Комментарии (11)


  1. aamonster
    10.03.2022 13:36
    +11

    То есть автор оригинала (или кто-то из его коллег) написал код, который требовал наличия в JSON value? Зная о её опциональности в данных? При том, что вообще-то принято не различать ситуации, когда поле в объекте undefined и когда его нет вообще (хотя бы потому, что различить их – это отдельные усилия: если a1 = {value:undefined} и a2 = {}, то a1.value === undefined и a2.value === undefined).
    Интересно – у них там свой парсер JSON, что ли, был?


  1. nrjshka Автор
    10.03.2022 13:39
    -4

    Привет! Как мне кажется, автор придумал эту история для того, чтобы рассказать про подводные камни в работе с JSON.stringify. Это нарратив, который позволяет легче воспринимать техническую информацию, которая идет в конце статьи.


  1. Geobot
    10.03.2022 13:40

    Я бы посоветовал бежать из компании где можно лишиться бонуса из за JSON.stringify :)


    1. AjnaGame
      10.03.2022 13:45

      И всей командой обсуждают такие мощные баги)


  1. mayorovp
    10.03.2022 13:44
    +3

    Незаполненное поле имеет в качестве значения пустую строку. Зачем было писать логику, которая заменяла пустые строки на undefined только для того чтобы сломать JSON.stringify?


    1. Shaco
      10.03.2022 14:48
      +1

      Ставлю на то, что там модель хранится в JS и форма отрисовывается из модели. В этом случае возникает соблазн сэкономить и не инициализировать каждое поле пустой строкой, для целей шаблонизации пустая строка и undefined равнозначны, а если форма большая и изначально пустая - можно не писать много строчек. Возможно, про эту "небольшую оптимизацию" речь в тексте и идёт, хотя какое дело продакту до таких штук - загадка.

      Ну а потом, понятно, вместо сериализации формы используется модель, ведь в ней "то же самое" и вообще источник правды - модель, а не разметка. А вся проблема лечится копеечными unit-тестами, и лучше бы продакт про них думал, а не про оптимизации.


  1. aliencash
    10.03.2022 13:51
    +3

    Что это за бэкэнд, который ломается, если с фронта пришла какая-то фигня? Тут похоже бэкэндера нужно премии лишать.


    1. Shaco
      10.03.2022 14:54

      Если формат данных был оговорен и зафиксирован в типах на бекенде, то в чём тут его вина? Приходит запрос с фигнёй, не соответствующей формату, бекенд отказывается это обрабатывать и честно отвечает кодом 400 Bad Request. Для пользователя выглядит как очередное "ой, что-то пошло не так", форма не отправляется.


  1. Senpasi
    10.03.2022 14:05

    Какая-то странная логика получилась, сам входящий массив передаваемый в функцию из-за undefined выглядит неестественным. Либо поле существует и тогда оно уже заполнено пустой строкой, либо его нет, но тогда и никаких fieldId у него не должно быть.


    1. nrjshka Автор
      10.03.2022 14:20

      Согласен. Такое поведение зависит от логики работы со структурой данных.


  1. Mnemonik
    10.03.2022 14:33

    Как-то странно называть багом спецификацию JSON которая прямо говорит о том что undefined это невозможное значение и поля с таким значением просто отбрасываются. Так работает JSON, и это логично - undefined значит что поле неопределено, это аналогично тому, что поля не существует. Совершенно стандартное, документированное и конкретное поведение JSON.stringify. Нужно просто как бы знать спецификацию, а не писать целую статью о своих открытиях... А то так можно накатать и статью о том как я открыл для себя различие в поведение "==" и "===", вот удивление-то было!

    Ну и конечно всё что написал до этого, валиться на бэкенде из-за отсутствия опционального поля это конечно моща.