Archives de Tag: javascript

Html5 Backbone.js et localStorage sont dans un bateau

Backbone.js

Toujours dans le cadre de mon travail chez Novelys, nous avons utilisé pour le projet d’application web pour iPad pouvant fonctionner en mode déconnecté.

c’est quoi ?

Backbone.js

Backbone supplies structure to JavaScript-heavy applications by providing models with key-value binding and custom events, collections with a rich API of enumerable functions, views with declarative event handling, and connects it all to your existing application over a RESTful JSON interface.

Backbone is an open-source component of DocumentCloud.

Directement copié de la homepage du projet disponible ici  : http://documentcloud.github.com/backbone/

Pourquoi ce choix ?

  1. concis
  2. souple, surtout pour la partie Vue.
  3. structurant
  4. exploite underscore.js
  5. s’intègre avec jQuery
  6. pattern MVC bien connu
  7. une documentation claire
  8. le logo est chouette (hum…)

La couche model selon Backbone.js

La couche modèle de Backbone est constituée de 2 composants :

  • le model
  • la collection

Bonjour Model

Comme d’habitude le Model va intégrer toutes les logiques métiers propre à votre donnée : validation, calcul et autre truc…

Voici un exemple :

var Craft = Backbone.Model.extend({
    has_children: function(){
        return _.isEmpty(this.attributes.child_ids) == false
    },

    children : function() {
        var ary
        if(this.has_children) {
            ary = _.map(this.attributes.child_ids, function(id){
                return crafts.get(id);
            });
            ary = _.uniq(ary);
        } else {
            ary = [];
        }
        return new Crafts(ary);
    },

    users : function() {
        var ary = [];
        var w;
        if(this.attributes.wastes_ids) {
            _.each(this.attributes.users_ids, function(user_id){
                w = user.get(waste_id);
                ary.push(w);
            });
        }
        return new Users(ary);
    }
});

La class Craft représente des métiers qui sont organisés sous la forme d’arbre permettant ainsi d’avoir des sous métiers puis des sous sous métiers puis des … etc… etc… et jusqu’à l’infini et au delà 🙂

La méthode children retourne donc la collection des sous métiers et users la collection des utilisateurs étant affecté à ce métier.

Voici quelques exemples d’utilisation :

//init
var craft = new Craft({ title: "Métier du bois" }); 

//recupère l'attribut title
craft.get('title'); 

//tous les attributes
craft.attibutes

//est enregistré ou pas ?
craft.isNew();

//sauvegarde
craft.save();

Sauf que dans l’état du code, le dernier exemple plante. Parce que le Model a besoin d’une collection. Nous allons voir celà toute de suite 🙂

Bonjour Collection

La collection représente donc une … liste ou collection d’un model données, mais pas seulement. C’est cette dernière qui va faire la liaison avec le support de stockage.

Voici un exemple

var Crafts = Backbone.Collection.extend({
    model: Craft,
    url: 'crafts',

    roots : function() {
        var ary = _.select(this.models, function(m){
            return _.isEmpty(m.attributes.parent_ids);
        });

        return new Crafts(ary);
    }
});
var crafts = new Crafts();

De base la collection doit définir deux attributs :

  • l’url de base de la ressource, nous approfondirons celà après.
  • le model forcément 🙂

La méthode roots permet, toujours dans nos histoires d’arbre des métiers, de récupérer les métiers racines.  C’est un peu le principe des scope avec Mongoid.

La synchronisation des données selon Backbone.js

Par défaut, le stockage des données se fait au travers d’une API Rest JSON exposée par un serveur.

Voici le code original

// Map from CRUD to HTTP for our default `Backbone.sync` implementation.
  var methodMap = {
    'create': 'POST',
    'update': 'PUT',
    'delete': 'DELETE',
    'read'  : 'GET'
  };

  // Backbone.sync
  // -------------

  // Override this function to change the manner in which Backbone persists
  // models to the server. You will be passed the type of request, and the
  // model in question. By default, uses jQuery to make a RESTful Ajax request
  // to the model's `url()`. Some possible customizations could be:
  //
  // * Use `setTimeout` to batch rapid-fire updates into a single request.
  // * Send up the models as XML instead of JSON.
  // * Persist models via WebSockets instead of Ajax.
  //
  Backbone.sync = function(method, model, success, error) {
    $.ajax({
      url       : getUrl(model),
      type      : methodMap[method],
      data      : {model : JSON.stringify(model)},
      dataType  : 'json',
      success   : success,
      error     : error
    });
  };

Si on couple celà avec une application Rails et les routes classiques et quelques respond_to format JSON l’affaire est dans le sac. Amoureux de Django c’est tout aussi facile 😉

Super ! Sauf que…

Y’a comme une couille dans la Matrix

Mes besoins

Nous avons besoin de faire la persistance dans le localStorage et non pas sur le serveur, ceci afin d’avoir une application dans le navigateur complètement fonctionnelle sans connexion.

Sauf que comme on le lit dans le code (et la documentation), on peut surcharger comme on le veut Backbone.sync.

On peut donc très aisément connecter le backend qu’on veut !

localStorage à la sauce Rest en 4 actions

Notre implémentation

function getIndexName(collection_name) {
    return "index_"+collection_name;
}

function readIndex(collection_name) {
    var index = localStorage[getIndexName(collection_name)];

    if(index == undefined || index === null) {
        index = [];
    } else {
        index = JSON.parse( index );
    }

    return index;
}

function writeIndex(collection_name, index) {
     localStorage[getIndexName(collection_name)] = JSON.stringify(index);
     return index;
}

function addToIndex(collection_name, model_id) {
    var index = readIndex(collection_name);

    if(_.include(index, model_id) === false) {
        index.push(model_id);
    }

    return writeIndex(collection_name, index);
}

function removeFromIndex(collection_name, model_id) {
    var index = readIndex(collection_name);
    var odds = _.reject(index, function(item){ return item == model_id; });
    return writeIndex(collection_name, odds);
}

Backbone.sync = function(method, model, success, error) {
    /**
     * model.url use the id and the url attributes of the collection
      */

    if(method == 'create') {
        model.id = Math.uuid(16);
        localStorage.setItem(model.url(), JSON.stringify(model));
        addToIndex(model.collection.url, model.id);
        success({model:model});
    }

    if(method == 'update') {
        localStorage.removeItem(model.url(), JSON.stringify(model));//Workaround of Error QUOTA_EXCEEDED_ERR DOM Exception 22
        localStorage.setItem(model.url(), JSON.stringify(model));
        addToIndex(model.collection.url, model.id);
        success({model:model});
    }

    if(method == 'read') {
        var json;
        if(_.isFunction(model.url)) {
            json = localStorage[model.url()];
            if(json == undefined || json === null) {
                error({model:null});
            } else {
                success({model:JSON.parse(json)});
            }
        } else {
            var index = readIndex(model.url);
            var models = [];
            var key = '';
            var obj = {}
            json = null;
            _.each(index, function(model_id) {
                key = model.url+'/'+model_id;
                json = localStorage[key];
                if(json != undefined && json !== null) {
                    obj = JSON.parse(json);
                    obj.id = model_id;
                    models.push(obj);
                }
            });

            success({models:models});
        }
    }

    if(method == 'delete') {
        localStorage.removeItem(model.url());
        removeFromIndex(model.collection.url, model.id);
        success();
    }
};

Nous avons fait le choix d’optimiser les accès au localStorage en constituant un index. Nos données ne sont pas très volumineuses mais se présentent souvent sous la forme de liste complète. La lecture de l’index des ressources a donc besoin d’être « optimisée ».

bug iPad iOS 3.2 lors de l’update

Revenons rapidement sur le traitement de la mise à jour (update).

    if(method == 'update') {
        localStorage.removeItem(model.url(), JSON.stringify(model));//Workaround of Error QUOTA_EXCEEDED_ERR DOM Exception 22
        localStorage.setItem(model.url(), JSON.stringify(model));
        addToIndex(model.collection.url, model.id);
        success({model:model});
    }

Il faut impérativement supprimer l’élément avant de vouloir l’écraser… Aucune explication pour l’instant des gens de la pomme. Sans celà au délà de la centaine de clés enregistrées dans le localStorage on se mange une vilaine exception. Sympathique…

Et si je veux pas créer le model ?

L’API des collections de Backbone.js ne fournit pas une méthode build ce qui peut être génant. Car comme nous l’avons vu, il faut fournir une collection au Model.

En réalité pour sauvegarder nous avons besoin d’une url pour générer l’endroit où sera fait « l’écriture ».

Voici deux moyen de faire :

var craft = new Craft({title:"mon métier"});
craft.collection = new Crafts(); // ou tout autre instance de crafts
craft.save();

var craft2 = new Craft({title:"mon métier"});
craft2.url = 'crafts';

J’ai une préférence pour la première 😉 DRY toussa toussa.

Et les données elles viennent d’où ?

Dans notre cas, nous avons surtout des référentiels à stocker. Nous avons donc simplement mis en place une API Rest qui exposent aux format JSON les données de la base de données du serveur.

Toujours sur l’exemple de l’arbre des métiers :

function crafts_sync() {
  $.ajax({
    url: '/api/crafts.json',
    dataType: 'json',
    success: function(data) {
      crafts.refresh(data);
      crafts.each(function(elem) {
          elem.id = elem.attributes._id;
          elem.save();
      });
    },
    error: function() {
      alert("ouink erreur");
    }
  });
}

Nous hydratons la collection Crafts, ce qui a pour effet de générer autant de models à partir du JSON. Comme nous travaillons avec une application Rails 3, avec mongodb et mongoid, l’attribut id n’existe pas. Nous devons donc pour chaque instance du model Craft forcer l’id. Ainsi s’il existe l’entrée du localStorage est mis à jour et dans l’autre cas elle est rajoutée.

Pour les petits curieux voici le model de notre appli Rails :

class Craft
  include Mongoid::Document
  include Mongoid::Timestamps
  include Mongoid::Acts::Tree #see mongoid_tree gem

  field :title
  validates_presence_of :title

  def ancestors
    self.class.where(:_id.in => self.parent_ids)
  end
end

Voilà, Backbone rend cette partie du travail facile et vraiment plaisante à exploiter. Amusez vous bien !

Le prochaine épisode sera la présentation de la partie vue de Backbone, toujours dans notre cadre d’application iPad.

À très vite.