javascript

Очередной сериализатор для JavaScript, но есть нюанс…

  • четверг, 3 апреля 2025 г. в 00:00:07
https://habr.com/ru/articles/896606/

Задача

Пару лет назад начал разрабатывать редактор текстовых квестов на JavaScript и обратил внимание на то, что неплохо было бы добавить в JSON-сериализатор поддержку ссылок на объекты. Чтобы можно было одним методом сохранить и загрузить состояние объекта, не нарушая его целостность и связь с внешним миром. Что-то подобное есть в PHP при работе метода serialize.

Спустя год начал разрабатывать пошаговую стратегию, в которой такой метод был бы идеальным для реализации сохранений и сетевого режима (пересылка сохранений от игрока к игроку, как это реализовано в Heroes of Might&Magic 3). Имея такой метод, можно было бы не заботиться о сохранении/загрузке объектов игрового мира при их изменениях. Например, добавим лучнику привязку его стрел к конкретному типу дерева. Или в морском пароме создадим массив перевозимых юнитов. При обычной тактике обработки данных это создало бы немало проблем для организации сохранения ссылок.

В итоге, кроме банальной организации внутренних ссылок, идея разрослась амбициозными планами, а именно:

  • Сохранять цепочку прототипов объекта со всеми их значениями;

  • Организовать связь с внешними статичными объектами (чтобы не тянуть их в сериализацию);

  • Сохранять методы объекта;

  • Сделать так, чтобы один объект, размещённый в нескольких местах, так и оставался одним объектом.

Этими идеями данный сериализатор отличается от имеющихся аналогов.

Решение

В итоге была разработана следующая утилита

https://github.com/nerd220/JSONext

Она содержит два метода - toLinkedJSON и fromLinkedJSON для запаковки и распаковки объекта в JSON.

fromLinkedJSON производит распаковку объекта из сериализации.

toLinkedJSON принимает три параметра:

  1. Объект. Если нужно вставить не объект, необходимо обернуть его в объект, например {myData: array1, elseData: text1}.

  2. Массив ссылок на внешние объекты и способ их восстановления, в формате
    [ [объект, 'способ восстановления'], [объект2, 'способ 2']... ]

    Пример:
    [ [document.body, 'document.body'], [canvas1, 'document.getElementById("canvas1")'] ]

    Если в исходном объекте будет найдена ссылка на document.body, она будет заменена скриптом, который при распаковке объекта присоединит ему ссылку на document.body.

  3. Массив конструкторов (прототипов), объекты которых нужно пересоздать при распаковке.

    Пример: ['employers', 'workers']

Примеры

Простой пример:

var a={x: 1};
var b={y: 2, z: a};
var c={a: a, b: b};
var json=toLinkedJSON(c);
var x=fromLinkedJSON(json);
console.log(x.a == x.b.z); // true, потому что все ссылки были сохранены

Более сложный пример:

// создаём функции конструкторы

function constructAProto(){
	this.i=1;
	this.protoMethod=function(){
		this.i+=2;
	}
}

function constructA(){
	// странный, но рабочий метод наследования
	this.__proto__=new constructAProto();
	this.constructor=constructA; // нужно указать конструктор объекта
	// данные
	this.body=document.body;
}

function constructB(link){
	this.someMethod=function(){
		this.l.i++;
	}
	this.l=link;
}

// создаём объекты и заполняем их данные
 
var a=new constructA();
var b=new constructB(a);
var c={linkA: a, linkB: b, method: (o)=>console.log(o.l.i)};
var o={a:a, b:b, c:c};

// сериализуем объект

var json=toLinkedJSON(o,[[document.body,'document.body']],['constructA','constructB']);

// десериализуем объект

var x=fromLinkedJSON(json);

// тестируем результат

x.b.someMethod(); // проверяем метод объекта B, увеличиваем i у связанной с ним A
x.a.protoMethod(); // вызываем метод прототипа объекта А, прибавляем к А ещё 2
x.c.method(x.b); // используя сериализованный метод объекта C, выводим A = 4
console.log(x.c.linkA === x.b.l); // true
console.log(x.a.body); // вернёт наш текущий document.body

Этот пример демонстрирует бесшовный перенос сложного связанного объекта в строку и обратно.

Принцип работы

При сохранении всё довольно просто - метод рекурсивно обходит объект, собирая информацию об его содержимом (ссылки на внутренние и внешние объекты, принадлежность объектов к тем или иным прототипам), затем записывает в JSON формат с собственными добавлениями.

При этом исходный объект меняется. В связанные с ним объекты добавляются идентификаторы, которые удаляются после завершения работы метода.

При распаковке метод делает три вещи:

  1. Восстанавливает ссылки на внутренние объекты, для этого осуществляется поиск точек монтирования внутреннего объекта;

  2. Восстанавливает ссылки на внешние объекты, используя указанные пользователем директивы;

  3. Пересоздаёт объекты так, чтобы они вновь принадлежали нужному прототипу, при этом вызывается функция-конструктор, в которую подбираются входные параметры исходя из данных объекта. После создания объекта в него загружаются сохранённые данные, а также восстанавливаются ссылки на него во всех точках монтирования внутри основного объекта;

  4. Распаковывает методы объектов, не принадлежащих указанным прототипам.

Минусы подхода

  1. Метод может нагружать процессор. Это связано с необходимостью переобхода объекта в поиске точек монтирования. Было бы значительно проще, если бы JavaScript позволял осуществлять доступ к внутреннему ID объекта;

  2. Использование eval для пересоднания объектов гипотетически небезопасно (например, если пользователь имеет доступ к сериализации);

  3. Необходимо использовать специфический способ наследования объектов (как в указанном примере);

  4. Если какие-то внешние объекты ссылались на внутренние объекты нашей сериализации, эти связи необходимо обновлять самостоятельно.

Заключение

Данный метод позволит разработчикам экономить время на разработке алгоритмов сохранения и загрузки данных. Фактически, можно сохранить всё приложение как есть, а затем развернуть его из строки без потери связности и функциональности.

Метод создаёт компактную сериализацию за счёт игнорирования содержимого указанных прототипов, однако восстанавливает значения не только самого объекта, но и всех его прототипов (как в указанном выше примере с значением i).

В данный момент метод успешно работает для сохранений в сетевых играх до 10мб (сектора поля, юниты, события и т.д.)