Назад в Центр знаний
Отпечатки

structuredClone против JSON: что сохраняет глубокая копия

Запустите structuredClone и обход через JSON на датах, картах, NaN, BigInt, циклах и общих ссылках, чтобы увидеть, что каждая копия хранит, меняет, отвергает или тихо теряет.

BotBrowser Team

Документация

Нужна структурированная документация по теме Отпечатки?

Эта статья относится к редакционной библиотеке. Для пошаговой настройки, справки и постоянных обновлений переходите сразу в соответствующий раздел docs.

BotBrowser поддерживает повтор разрешенного fixture на том же origin под объявленным профилем и сохраняет ограниченные наблюдения о том, как страница копирует объект, но он не меняет то, как среда реализует алгоритм структурного клонирования, не может сделать функцию или символ клонируемыми и не превращает результат копии в признак личности. Этот разбор запускает structuredClone и обход через JSON на одних и тех же значениях, а затем фиксирует, какие типы хранит каждый, какие числа меняет, какие значения отвергает или тихо теряет, и как ведут себя общие ссылки, циклы и прототипы.

Кратко

  • structuredClone сохраняет Date, Map, Set и RegExp как эти типы; обход через JSON превращает Date в строку, а Map, Set или RegExp в {}.
  • Обход через JSON превращает NaN и Infinity в null, а -0 в 0; structuredClone сохраняет все три, включая знак -0.
  • structuredClone бросает DataCloneError на функцию или символ; JSON тихо теряет свойство-функцию.
  • structuredClone сохраняет BigInt и, с опцией transfer, перемещает ArrayBuffer; JSON.stringify бросает на BigInt.
  • structuredClone сохраняет общие ссылки и циклы; обход через JSON дублирует общую ссылку и бросает на цикле.
  • Ни один не сохраняет прототип класса, поэтому клон теряет методы, если прототип не восстановить вручную.

Содержание

Нейтральная схема: исходное значение питает structuredClone и обход через JSON; structuredClone сохраняет тип и общие ссылки и бросает на функциях, а путь JSON превращает Map и Set в пустые объекты и меняет особые числа.

Типы, что выживают в одной копии и не в другой

Два пути копируют разные части значения. structuredClone реализует алгоритм структурного клонирования из HTML, который имеет явные случаи для платформенных типов ниже; обход через JSON умеет только сериализовать то, что поддерживает JSON.stringify, и восстановить то, что возвращает JSON.parse:

structuredClone({d:new Date(0)}) -> {d: Date(1970-01-01T00:00:00.000Z)}
JSON round trip of Date -> {d: "1970-01-01T00:00:00.000Z"}
structuredClone({m:new Map([[1,"a"]])}) -> {m: Map(1=>"a")}
JSON round trip of Map -> {m: {}}
structuredClone({s:new Set([1,2])}) -> {s: Set(1, 2)}
JSON round trip of Set -> {s: {}}
structuredClone(/ab+/gi) -> RegExp(/ab+/gi)
JSON round trip of RegExp -> {}

Четыре различия в четырех строках. Date становится строкой через JSON, так что значение, бывшее Date до обхода, после него string, и любой вызов .getTime() падает. Map становится пустым объектом, так что каждая запись исчезает без ошибки. Set становится пустым объектом, так что каждый член исчезает без ошибки. RegExp становится пустым объектом, теряя шаблон и флаги.

Молчание это опасная часть. Каждый из этих четырех результатов JSON это корректное значение, которое по типу объект, так что код, проверяющий только typeof, не видит проблемы. Потеря появляется позже, когда код вызывает метод Map на {} или делает арифметику со строкой даты. Поэтому глубокую копию надо выбирать по тому, что содержит значение, а не по удобству: обход через JSON корректен только для значений из собственных типов JSON.

Типизированные массивы показывают тот же шаблон с более запутанным результатом, потому что JSON дает объект, который выглядит как массив, но им не является:

structuredClone typed array kept: true values=1,2,3
JSON typed array kept: false shape={"0":1,"1":2,"2":3}

structuredClone возвращает настоящий Uint8Array с теми же тремя значениями, так что instanceof проходит и каждый метод типизированного массива работает. Версия JSON это простой объект с числовыми ключами, так что instanceof Uint8Array это false, byteLength это undefined, а цикл, пишущий по индексу, назначает простые свойства. Кто сериализует двоичные данные через JSON и читает их обратно, получает структуру, которая логируется одинаково под JSON.stringify и ведет себя совершенно иначе под любой арифметикой, так что сравнение сериализованного вывода не обнаружит изменение. Только проверка типа или длины на результате его покажет.

Форма объекта сохраняется только для значений, которые JSON уже понимает. Простые объекты, массивы, строки, конечные числа, булевы и null проходят обход с нетронутой структурой, и это ровно тот поднабор, для которого создан JSON. Все остальное либо преобразуется, либо теряется, либо бросает. Поэтому схема, перечисляющая разрешенные типы, это самый дешевый способ выбрать метод копии: если схема содержит только типы JSON, обход безопасен и предсказуем; если она называет Date, Map или типизированный массив, structuredClone это единственный путь, который его сохраняет.

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

clone independent: list=true nested=true date=true

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

Числа и BigInt

Правила чисел в JSON уже, чем в JavaScript, и обход тихо меняет три значения, которые выглядят представимыми:

structuredClone({n:NaN,i:Infinity,z:-0}) -> {n: NaN, i: Infinity, z: -0}
JSON round trip of NaN/Infinity/-0 -> {n: null, i: null, z: 0}
Object.is(JSON -0, -0) -> false
Object.is(structuredClone -0, -0) -> true

NaN и Infinity становятся null через JSON, потому что у JSON нет литерала для них, и кто делает обход со значением с любым из них, получает null без предупреждения. Случай -0 тоньше: результат JSON численно равен -0 под ===, так что наивная проверка равенства проходит, но Object.is различает знак и сообщает false, а structuredClone сохраняет знак и сообщает true. Код, хранящий отрицательный ноль, чтобы обозначить что-то (знаковый ноль в вычислении, например), теряет этот сигнал через JSON без ошибки.

BigInt разделяет два пути еще резче. structuredClone его обрабатывает, а JSON.stringify на нем бросает:

structuredClone(10n) -> BigInt(10)
JSON.stringify(10n) -> THREW TypeError

TypeError от JSON.stringify это единственный громкий отказ во всем сравнении, и его стоит отметить, потому что это исключение. Значение с BigInt вообще не может пересечь границу JSON, а через structuredClone проходит без изменений. Команда, перешедшая на идентификаторы BigInt и сохранившая глубокую копию на JSON, столкнется с этим в первый раз при копировании такого идентификатора.

Числовые изменения выше относятся к той же категории проблемы, которую разбирает Точность JavaScript Number и переносимость между средами: значение может быть численно равно под === и все же различаться под Object.is, а путь копии, который сериализует, это место, где это различие становится постоянным.

Значения, которые отвергают или тихо теряют

Для значений без представления копии два пути расходятся худшим образом: один отвергает, другой теряет:

structuredClone(()=>1) -> THREW DataCloneError
structuredClone(Symbol("x")) -> THREW DataCloneError
JSON round trip of function property -> {v: 1}
structuredClone({u:undefined}) -> {u: undefined}
JSON round trip of {u:undefined} -> {}
JSON round trip of [1,,3] -> [1, null, 3]

structuredClone бросает DataCloneError на функцию или символ, так что отказ немедленный и в точке копии. Обход через JSON тихо теряет свойство-функцию: результат имеет v: 1 и не имеет f, так что кто ожидал, что метод выживет, получает простой объект без метода. Свойство undefined сохраняет structuredClone и теряет JSON, а дыры массива заменяются на null через JSON.

Общую форму надо записать как правило: structuredClone громко падает на значении, которое не может представить, а JSON тихо падает, теряя или подменяя. Для пути валидации громкий отказ легче поймать и исправить. Для пути, через который значение всегда должно пройти, тихая потеря это опасность, потому что копия выглядит успешной, пока данные потеряны.

Общие ссылки и циклы

Глубокая копия не только о типах; она и о графе. Когда один объект появляется дважды в источнике, две копии ведут себя по-разному:

structuredClone shared reference identical: true
JSON shared reference identical: false

structuredClone сохраняет ссылку: если a и b указывают на один объект до копии, они указывают на тот же (новый) объект после, так что форма графа сохраняется. Обход через JSON дублирует общий объект, потому что сериализация пишет его дважды, а парсинг строит два отдельных объекта, так что a === b становится false. Это различие важно для любого значения, чья семантика зависит от алиасинга, например узел, общий для двух родителей, и копия JSON тихо ломает алиас.

Циклы это более острый случай. structuredClone обрабатывает самоссылающийся объект и сохраняет петлю нетронутой, а обход через JSON бросает:

structuredClone(cycle) -> ok, self===clone: true
JSON round trip of cycle -> THREW TypeError

self клона указывает на клон, так что цикл выживает. JSON.stringify бросает на циклической структуре, потому что не может представить бесконечную вложенность, и ошибка это TypeError. Так что значение с циклом копируется structuredClone и вообще не копируется JSON, что противоположно случаю общей ссылки, где JSON копирует, но меняет форму.

Результат цикла также несет урок о инструментах, которые инспектируют клон. Рекурсивная функция describe, обходящая каждое свойство циклического объекта без учета уже посещенного, будет рекурсировать до переполнения стека, так что fixture, печатающий клонированный циклический граф, должен останавливаться на множестве посещенных, а не следовать каждой ссылке слепо. Сам клон в порядке; падает код, который сообщает. Это различие важно, когда тест сообщает RangeError на циклическом значении: ошибка может прийти от вспомогательного кода проверки, а не от копии, и исправление принадлежит помощнику, а не вызову копии.

Прототипы, передаваемые объекты и что еще требует внимания

Ни один путь не сохраняет прототип класса. Клон теряет свои методы и после этого простой объект:

structuredClone prototype is Point: false
structuredClone has double method: undefined

Клонированное значение имеет поля экземпляра, но не прототип, так что instanceof Point это false, а вызов double() падает. Команда, клонирующая доменные объекты через structuredClone, все еще должна восстановить прототип, обычно создавая новый экземпляр из клонированных полей или явно задавая прототип. Копия структурная, а не семантическая.

Опция transfer это то, что делает structuredClone больше, чем глубокой копией. Передача буфера в список transfer перемещает его, а не копирует, и исходный буфер отсоединяется:

transfer: source byteLength=0 clone byteLength=8

Источник сообщает длину ноль, потому что владение перешло к клону, так что источник больше нельзя использовать. Это намеренная оптимизация для большого буфера, который передается из одного места в другое, и это также ловушка: кто передает буфер и затем читает из источника, получает пустое представление, без ошибки. Показания копии должны записывать, была ли передача, потому что передача меняет источник так же, как производит клон.

Правило передачи также решает, копия ли это вообще. Без списка transfer буфер дублируется как любое другое значение, так что источник сохраняет свои байты, а клон получает свои, ценой удвоения памяти на время копии. Со списком transfer владение перемещается, и общая память остается плоской, что важно для буфера, достаточно большого, чтобы быть причиной медленной копии. Так что один и тот же вызов имеет два очень разных профиля в зависимости от одной опции, и fixture, измеряющий только клон, не может сказать, что произошло; он должен записать длину источника после.

Сообщения worker используют тот же алгоритм структурного клонирования, так что каждый результат этой статьи переносится на данные, отправленные в worker и прочитанные в worker. Разница в том, что граница worker также применяет правила передачи автоматически для буфера, указанного как передаваемый, а буфер, отправленный дважды без передачи, копируется дважды. Команда, перемещающая большой буфер между страницей и worker, а затем читающая со стороны страницы, обнаружит пустой буфер, точно как в передаче в памяти выше, и отказ выглядит как поврежденные данные, а не как передача, опустошившая источник.

Есть еще одна асимметрия, которую стоит записать, потому что она ломает код сравнения. structuredClone сохраняет свойства undefined, а JSON их теряет, так что два объекта, которые выглядят равными под сравнением JSON, могут различаться под структурным клоном:

structuredClone({u:undefined}) -> {u: undefined}
JSON round trip of {u:undefined} -> {}

Тест, сравнивающий два значения, сначала прогоняя оба через JSON, будет считать объект с u: undefined и объект без u равными, потому что оба сериализуются в {}. Тот же тест на структурных клонах увидит объект с ключом и объект без него. Так что помощник сравнения, построенный на JSON, строже к одним типам и мягче к другим, чем позволяет модель значений, и fixture должен сравнивать клонированные значения напрямую, а не прогонять их через JSON для сравнения.

Записывайте показания копии

Показания копии, выдерживающие ревью, говорят, что копия хранит и что теряет. Шесть полей покрывают наблюдаемую часть:

  1. Метод копии и среду, чтобы читатель знал, алгоритм это структурное клонирование или JSON.
  2. Список типов значений в источнике и какие из них остаются тем же типом после копии.
  3. Особые числовые значения до и после, включая NaN, Infinity и знак -0.
  4. Была ли функция, символ или BigInt, и отвергла их копия или потеряла.
  5. Были ли у источника общие ссылки или циклы, и выжили ли они.
  6. Для буфера, был ли он передан, и длина источника после передачи.

Чего такие показания не могут показать, так это ничего о копии через границу worker или в постоянное хранилище. Значение может чисто клонироваться в памяти и все равно не сериализоваться в базу данных, а сообщение worker использует тот же алгоритм структурного клонирования, но со своими правилами передачи. О более широком вопросе, как модель данных страницы взаимодействует с тем, что она сообщает, читайте модель хранилища браузера и разбор URL и search params, где описан еще один случай, когда значение не проходит наивный обход.

Две привычки сохраняют честность чтения во времени. Первое, записывайте список типов и результат общей ссылки, а не только вернула ли копия без броска, потому что копия, которая успешна, но теряет Map, это отказ, который проверка на бросок упускает. Второе, включайте хотя бы один -0, одну общую ссылку и один цикл в fixture, который полагается на глубокую копию, чтобы тихие подмены и разрыв алиаса не прошли зеленый набор тестов.

Публичные источники

Итоги

Выбирайте копию по тому, что содержит значение: используйте structuredClone для Date, Map, Set, RegExp, BigInt, общих ссылок и циклов, а обход через JSON только для значений из собственных типов JSON. Ожидайте, что structuredClone громко падает на функции или символе, а JSON тихо их теряет, и что JSON превращает NaN и Infinity в null, а -0 в 0. Восстанавливайте прототип класса вручную после любой структурной копии и записывайте, был ли буфер передан, чтобы опустевший источник не был неожиданностью.

#structuredClone#JSON#Глубокая Копия#DataCloneError#Передаваемые Объекты

Эта статья входит в руководство: Что такое отпечаток браузера?

Переведите BotBrowser из исследований в продакшн

Используйте эти руководства, чтобы понять модель, а затем перейти к кроссплатформенной валидации, изолированным контекстам и масштабируемому браузерному развертыванию.