Saving and restoring objects in Javascript is hard, here I propose a solution, a practice to create savable Javascript object.

1. Avoid saving function, put them in prototype

Let says you have an object like this:

var alice = {
  name : "Alice",
  age : 10,
  greet : function(){
    return "Hello my name is "+this.name;
  }
}

If you pass it to JSON.stringify, it gives you something like this

{"name":"Alice","age":10}

Clearly something is missing.

The problem is, the default behavior of stringify ignores functions, even though (function(){}) instanceof Object == true. But it makes sense because functions are difficult to save. For example native function, Math.min itself is a anonymous native function, calling toString on it will result in function min() { [native code] } which doesn’t provide any information. Functions can also access outer scope, it is really hard to save everything the function will potentially access.

To make sure your objects are savable, data and functions should be separated. One good way is to store functions in the prototype object. This also reduce memory usage because every instance of the same class shares the same function.

var Human = function(name, age){
  this.name = name;
  this.age = age;
  console.log("Human created: "+name);
};

Human.prototype = {
  greet : function(){
    return "Hello my name is "+this.name;
  }
};

var alice = new Human("Alice", 10); // Human created: Alice
alice.greet(); // Hello my name is Alice.
JSON.stringify(alice); // {"name":"Alice", "age":10}

Now the generated JSON still doesn’t include the greet function, but it makes sense. The greet function doesn’t belongs to alice, it belongs to Human.prototype. So the problem now becomes How to save the prototype information. Well, we can create a special initializer key to store the prototype information.

Human.prototype.initializer = "Human"; // now every human instance knows they are human
alice.initializer // Human

The initializer is still in prototype so it won’t be added to JSON.

2. Abusing toJSON()

Luckily, we have a toJSON method that can be used to customize the JSON representation of an object. So we can add the initializer ourself.

Human.prototype.toJSON = function(){
  var result = this instanceof Array? [] : {};
  result.initializer = this.initializer;
  for (var key in this) {
    if (this.hasOwnProperty(key)) {
      result[key] = this[key];
    }
  }
  return result;
}

// So starting from now:
JSON.stringify(alice); // {"initializer":"Human","name":"Alice","age":10}
// it stores the initializer!

Restore it

We can definitely do something like this:

function restore(obj) {
	var initializer = window[obj.initializer]
	var restoredObj = new initializer(); // print Human created: undefined
	for (var key in obj) {
 	 restoredObj[key] = obj[key];
	}
  return restoredObj;
}
var obj = {"initializer":"Human","name":"Alice","age":10};
restore(obj);

While it works most of the time, you may notice that it prints "Human created: undefined" duration the restore process. It is because the restore function knows nothing about the initializer, so it could not restore correctly. In order to make the restore process more customizable, I think it would be better to create an optional restore function in the initializer.

function restore(obj) {
  var initializer = window[obj.initializer];
  // Use restore function if available
  if (typeof initializer.restore == "function") {
    return initializer.restore(obj);
  }

  var restoredObj = new initializer();
  for (var key in obj) {
    restoredObj[key] = obj[key];
  }
  return restoredObj;
}

Human.restore = function(savedObj){
  return new Human(savedObj.name, savedOBj.age); // prints Human created: Alice
};

Now it works as expected.

Customization

Subclass

Subclassing can be done like this:

Student = function(name, age, school){
  Human.call(this, name, age); // calling super class
  this.school = school;
}
Student.prototype = Object.create(Human.prototype);
Student.prototype.initializer = "Student";
Student.prototype.greet = function() {
  var superClassReturn = Human.prototype.greet.apply(this, arguments);
  return superClassReturn+" I study in "+this.school;
}
Student.restore = function(savedObj){
  return new Student(savedObj.name, savedObj.age, savedObj.school);
}

var alice = new Student(‘Alice’, 10, ‘HK School’);
var restored = restore(JSON.parse(JSON.stringify(alice)));
restored instanceof Student // true
restored.greet() // Hello my name is Alice. I study in HK School

Make these functions available to every class!

By abusing the fact that Object.prototype can be modified, we can add the above functions directly to the root of prototype chain, like this:

Object.defineProperty(Object.prototype,"toJSON", {
  enumerable:false,
  writable: true,
  value: function(){
    var initializer = window[obj.initializer];
    // Use restore function if available
    if (typeof initializer.restore == "function") {
      return initializer.restore(obj);
    }
    var restoredObj = new initializer();
    for (var key in obj) {
      restoredObj[key] = obj[key];
    }
    return restoredObj;
  }
});

Here is an enhanced version of restore, adding support for namespaced initializer like new Some.thing.here():

Object.defineProperty(Object.prototype,"restore",{
  enumerable:false,
  writable: true,
  value: function(){
    if( this.initializer ){
      var keys = this.initializer.split('.');
      var obj = window;
      keys.forEach(function (thisKey){
        obj = obj[thisKey];
      });
      
      if( typeof obj === 'function' ){
        if( obj.restore != Object.prototype.restore ){
          return obj.restore(this);
        }else{
          var restoredObj = new initializer();
          for (var key in obj) {
            restoredObj[key] = obj[key];
          }
          return restoredObj;
        }
      }
    }
    return this;
  }
});

Then we can restore objects like this:

var restored = {"initializer":"Human","name":"Alice","age":10}.restore();

Custom JSON behavior

Let says you have a custom object such as an Image.

var Book = function(coverURL){
  this.image = new Image();
  this.image.src = coverURL;
};
var myFirstBook = new Book("http://example.com/image.jpg");

The image object cannot be saved easily, because it is a circular referenced object (All DOM are circular referenced), and you cannot modify its prototype chain. However these problems can be solved by overriding the toJSON method.

Book.prototype.toJSON = function(){
  return {
    "initializer":"Book",
    "image" : this.image.src
	}
}
Book.restore = function(savedObj){
	return new Book(savedObj.image);
}

This is a brief introduction of my solution on making savable Javascript objects. Here at Novelsphere, we are using a promise-compatible version (So the restore process can be async) of the above structure to make our game engine, to make sure games save and load properly.

Blog Logo

b123400


Published