Étape 2: Importez une application Web existante

Au cours de cette étape, vous allez apprendre à:

  • Adapter une application Web existante à la plate-forme des applications Chrome
  • Rendre vos scripts d'application conformes à la stratégie de sécurité du contenu (CSP)
  • Comment implémenter le stockage local à l'aide de chrome.storage.local.

Durée estimée pour cette étape: 20 minutes.
Pour avoir un aperçu de ce que vous allez faire dans cette étape, descendez en bas de cette page ↓.

Importer une application de tâches à faire existante

Pour commencer, importez la version JavaScript standard de TodoMVC, une application de référence courante, dans votre projet.

Nous avons inclus une version de l'application TodoMVC dans le code de référence ZIP du dossier todomvc. Copiez tous les fichiers (y compris les dossiers) de todomvc dans le dossier de votre projet.

Copier le dossier todomvc dans le dossier de l'atelier de programmation

Vous serez invité à remplacer index.html. N'hésitez pas à accepter.

Remplacer "index.html"

La structure de fichiers suivante doit s'afficher dans le dossier de votre application:

Nouveau dossier de projet

Les fichiers en surbrillance en bleu proviennent du dossier todomvc.

Actualisez votre application maintenant (clic droit > Actualiser l'application). L'interface utilisateur de base devrait s'afficher, mais vous ne pourrez pas ajouter de tâches.

Rendre les scripts conformes à la Content Security Policy (CSP)

Ouvrez la console des outils pour les développeurs (clic droit > Inspecter l'élément, puis sélectionnez l'onglet Console). Une erreur s'affichera, indiquant que l'exécution d'un script intégré est refusée:

Application Todo avec erreur de journal de console CSP

Corrigez cette erreur en rendant l'application conforme à la stratégie de sécurité du contenu. L'une des non-conformités CSP les plus courantes est causée par le code JavaScript intégré. Les gestionnaires d'événements en tant qu'attributs DOM (par exemple, <button onclick=''>) et les balises <script> avec du contenu dans le code HTML sont des exemples de code JavaScript intégré.

La solution est simple: déplacez le contenu intégré vers un nouveau fichier.

1. Près du bas de index.html, supprimez le code JavaScript intégré et incluez à la place js/bootstrap.js:

<script src="bower_components/director/build/director.js"></script>
<script>
  // Bootstrap app data
  window.app = {};
</script>
<script src="js/bootstrap.js"></script>
<script src="js/helpers.js"></script>
<script src="js/store.js"></script>

2. Dans le dossier js, créez un fichier nommé bootstrap.js. Déplacez le code précédemment intégré dans ce fichier:

// Bootstrap app data
window.app = {};

Si vous actualisez l'application maintenant, votre application Todo ne fonctionnera toujours pas, mais vous vous en rapprochez.

Convertir localStorage en chrome.storage.local

Si vous ouvrez la console DevTools maintenant, l'erreur précédente devrait disparaître. Une nouvelle erreur indique toutefois que window.localStorage n'est pas disponible:

Application Todo avec erreur de journal de console localStorage

Les applications Chrome ne sont pas compatibles avec localStorage, car localStorage est synchrone. L'accès synchrone aux ressources bloquantes (E/S) dans un environnement d'exécution à thread unique peut empêcher votre application de répondre.

Les applications Chrome disposent d'une API équivalente qui peut stocker des objets de manière asynchrone. Cela permet d'éviter le processus de sérialisation objet->chaîne->objet parfois coûteux.

Pour résoudre le message d'erreur dans notre application, vous devez convertir localStorage en chrome.storage.local.

Mettre à jour les autorisations des applications

Pour utiliser chrome.storage.local, vous devez demander l'autorisation storage. Dans manifest.json, ajoutez "storage" au tableau permissions:

"permissions": ["storage"],

En savoir plus sur local.storage.set() et local.storage.get()

Pour enregistrer et récupérer des tâches, vous devez connaître les méthodes set() et get() de l'API chrome.storage.

La méthode set() accepte un objet de paires clé-valeur comme premier paramètre. Une fonction de rappel facultative est le deuxième paramètre. Exemple :

chrome.storage.local.set({secretMessage:'Psst!',timeSet:Date.now()}, function() {
  console.log("Secret message saved");
});

La méthode get() accepte un premier paramètre facultatif pour les clés de datastore que vous souhaitez récupérer. Une seule clé peut être transmise sous forme de chaîne. Plusieurs clés peuvent être organisées dans un tableau de chaînes ou un objet dictionnaire.

Le deuxième paramètre, qui est obligatoire, est une fonction de rappel. Dans l'objet renvoyé, utilisez les clés demandées dans le premier paramètre pour accéder aux valeurs stockées. Exemple :

chrome.storage.local.get(['secretMessage','timeSet'], function(data) {
  console.log("The secret message:", data.secretMessage, "saved at:", data.timeSet);
});

Si vous souhaitez get() tout ce qui se trouve actuellement dans chrome.storage.local, omettez le premier paramètre:

chrome.storage.local.get(function(data) {
  console.log(data);
});

Contrairement à localStorage, vous ne pourrez pas inspecter les éléments stockés localement à l'aide du panneau "Ressources" des outils pour les développeurs. Vous pouvez toutefois interagir avec chrome.storage depuis la console JavaScript comme suit:

Utiliser la console pour déboguer chrome.storage

Prévisualiser les modifications requises pour l'API

La plupart des étapes restantes de la conversion de l'application Todo consistent en de petites modifications apportées aux appels d'API. Vous devez modifier tous les emplacements où localStorage est actuellement utilisé, même si ce processus est chronophage et sujet aux erreurs.

Les principales différences entre localStorage et chrome.storage proviennent de la nature asynchrone de chrome.storage:

  • Au lieu d'écrire dans localStorage à l'aide d'une affectation simple, vous devez utiliser chrome.storage.local.set() avec des rappels facultatifs.

    var data = { todos: [] };
    localStorage[dbName] = JSON.stringify(data);
    

    contre

    var storage = {};
    storage[dbName] = { todos: [] };
    chrome.storage.local.set( storage, function() {
      // optional callback
    });
    
  • Au lieu d'accéder directement à localStorage[myStorageName], vous devez utiliser chrome.storage.local.get(myStorageName,function(storage){...}), puis analyser l'objet storage renvoyé dans la fonction de rappel.

    var todos = JSON.parse(localStorage[dbName]).todos;
    

    contre

    chrome.storage.local.get(dbName, function(storage) {
      var todos = storage[dbName].todos;
    });
    
  • La fonction .bind(this) est utilisée pour tous les rappels afin de s'assurer que this fait référence au this du prototype Store. (Pour en savoir plus sur les fonctions liées, consultez la documentation MDN : Function.prototype.bind().)

    function Store() {
      this.scope = 'inside Store';
      chrome.storage.local.set( {}, function() {
        console.log(this.scope); // outputs: 'undefined'
      });
    }
    new Store();
    

    contre

    function Store() {
      this.scope = 'inside Store';
      chrome.storage.local.set( {}, function() {
        console.log(this.scope); // outputs: 'inside Store'
      }.bind(this));
    }
    new Store();
    

Gardez ces différences clés à l'esprit lorsque nous aborderons la récupération, l'enregistrement et la suppression des tâches à faire dans les sections suivantes.

Récupérer les tâches

Mettons à jour l'application Todo afin de récupérer les tâches:

1. La méthode de constructeur Store s'occupe d'initialiser l'application Todo avec tous les éléments de la liste de tâches existants dans le datastore. La méthode vérifie d'abord si le datastore existe. Dans le cas contraire, il crée un tableau vide de todos et l'enregistre dans le datastore afin qu'il n'y ait pas d'erreurs de lecture d'exécution.

Dans js/store.js, remplacez l'utilisation de localStorage dans la méthode du constructeur par chrome.storage.local:

function Store(name, callback) {
  var data;
  var dbName;

  callback = callback || function () {};

  dbName = this._dbName = name;

  if (!localStorage[dbName]) {
    data = {
      todos: []
    };
    localStorage[dbName] = JSON.stringify(data);
  }
  callback.call(this, JSON.parse(localStorage[dbName]));

  chrome.storage.local.get(dbName, function(storage) {
    if ( dbName in storage ) {
      callback.call(this, storage[dbName].todos);
    } else {
      storage = {};
      storage[dbName] = { todos: [] };
      chrome.storage.local.set( storage, function() {
        callback.call(this, storage[dbName].todos);
      }.bind(this));
    }
  }.bind(this));
}

2. La méthode find() est utilisée lors de la lecture des tâches à faire à partir du modèle. Les résultats affichés changent selon que vous utilisez le filtre "Tous", "Actifs" ou "Terminés".

Convertissez find() pour utiliser chrome.storage.local:

Store.prototype.find = function (query, callback) {
  if (!callback) {
    return;
  }

  var todos = JSON.parse(localStorage[this._dbName]).todos;

  callback.call(this, todos.filter(function (todo) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var todos = storage[this._dbName].todos.filter(function (todo) {
      for (var q in query) {
         return query[q] === todo[q];
      }
      });
    callback.call(this, todos);
  }.bind(this));
  }));
};

3. Comme find(), findAll() récupère tous les éléments de la liste de tâches du modèle. Convertissez findAll() pour utiliser chrome.storage.local:

Store.prototype.findAll = function (callback) {
  callback = callback || function () {};
  callback.call(this, JSON.parse(localStorage[this._dbName]).todos);
  chrome.storage.local.get(this._dbName, function(storage) {
    var todos = storage[this._dbName] && storage[this._dbName].todos || [];
    callback.call(this, todos);
  }.bind(this));
};

Enregistrer les éléments des tâches

La méthode save() actuelle présente un défi. Elle dépend de deux opérations asynchrones (get et set) qui s'exécutent à chaque fois sur l'ensemble du stockage JSON monolithique. Toute mise à jour par lot sur plusieurs éléments de la liste de tâches, comme "marquer toutes les tâches comme terminées", entraînera un risque de données appelé lecture après écriture. Ce problème ne se produirait pas si nous utilisions un espace de stockage de données plus approprié, comme IndexedDB, mais nous essayons de minimiser l'effort de conversion pour cet atelier de programmation.

Il existe plusieurs façons de résoudre ce problème. Nous allons donc profiter de cette opportunité pour refactoriser légèrement save() en utilisant un tableau d'ID de tâches à mettre à jour en une seule fois:

1. Pour commencer, encapsulez tout ce qui se trouve déjà dans save() avec un rappel chrome.storage.local.get():

Store.prototype.save = function (id, updateData, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = JSON.parse(localStorage[this._dbName]);
    // ...
    if (typeof id !== 'object') {
      // ...
    }else {
      // ...
    }
  }.bind(this));
};

2. Convertissez toutes les instances localStorage avec chrome.storage.local:

Store.prototype.save = function (id, updateData, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = JSON.parse(localStorage[this._dbName]);
    var data = storage[this._dbName];
    var todos = data.todos;

    callback = callback || function () {};

    // If an ID was actually given, find the item and update each property
    if ( typeof id !== 'object' ) {
      // ...

      localStorage[this._dbName] = JSON.stringify(data);
      callback.call(this, JSON.parse(localStorage[this._dbName]).todos);
      chrome.storage.local.set(storage, function() {
        chrome.storage.local.get(this._dbName, function(storage) {
          callback.call(this, storage[this._dbName].todos);
        }.bind(this));
      }.bind(this));
    } else {
      callback = updateData;

      updateData = id;

      // Generate an ID
      updateData.id = new Date().getTime();

      localStorage[this._dbName] = JSON.stringify(data);
      callback.call(this, [updateData]);
      chrome.storage.local.set(storage, function() {
        callback.call(this, [updateData]);
      }.bind(this));
    }
  }.bind(this));
};

3. Modifiez ensuite la logique pour qu'elle s'applique à un tableau plutôt qu'à un seul élément:

Store.prototype.save = function (id, updateData, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = storage[this._dbName];
    var todos = data.todos;

    callback = callback || function () {};

    // If an ID was actually given, find the item and update each property
    if ( typeof id !== 'object' || Array.isArray(id) ) {
      var ids = [].concat( id );
      ids.forEach(function(id) {
        for (var i = 0; i < todos.length; i++) {
          if (todos[i].id == id) {
            for (var x in updateData) {
              todos[i][x] = updateData[x];
            }
          }
        }
      });

      chrome.storage.local.set(storage, function() {
        chrome.storage.local.get(this._dbName, function(storage) {
          callback.call(this, storage[this._dbName].todos);
        }.bind(this));
      }.bind(this));
    } else {
      callback = updateData;

      updateData = id;

      // Generate an ID
      updateData.id = new Date().getTime();

      todos.push(updateData);
      chrome.storage.local.set(storage, function() {
        callback.call(this, [updateData]);
      }.bind(this));
    }
  }.bind(this));
};

Marquer des tâches comme terminées

Maintenant que l'application fonctionne sur des tableaux, vous devez modifier la façon dont elle gère un utilisateur qui clique sur le bouton Effacer les tâches terminées (#):

1. Dans controller.js, mettez à jour toggleAll() pour appeler toggleComplete() une seule fois avec un tableau de tâches au lieu de marquer une tâche comme terminée une par une. Supprimez également l'appel à _filter(), car vous allez ajuster le toggleComplete _filter().

Controller.prototype.toggleAll = function (e) {
  var completed = e.target.checked ? 1 : 0;
  var query = 0;
  if (completed === 0) {
    query = 1;
  }
  this.model.read({ completed: query }, function (data) {
    var ids = [];
    data.forEach(function (item) {
      this.toggleComplete(item.id, e.target, true);
      ids.push(item.id);
    }.bind(this));
    this.toggleComplete(ids, e.target, false);
  }.bind(this));

  this._filter();
};

2. Mettez à jour toggleComplete() pour qu'il accepte à la fois une seule tâche ou un tableau de tâches. Cela inclut le déplacement de filter() pour qu'il soit à l'intérieur de update(), plutôt qu'à l'extérieur.

Controller.prototype.toggleComplete = function (ids, checkbox, silent) {
  var completed = checkbox.checked ? 1 : 0;
  this.model.update(ids, { completed: completed }, function () {
    if ( ids.constructor != Array ) {
      ids = [ ids ];
    }
    ids.forEach( function(id) {
      var listItem = $$('[data-id="' + id + '"]');
      
      if (!listItem) {
        return;
      }
      
      listItem.className = completed ? 'completed' : '';
      
      // In case it was toggled from an event and not by clicking the checkbox
      listItem.querySelector('input').checked = completed;
    });

    if (!silent) {
      this._filter();
    }

  }.bind(this));
};

Count todo items

After switching to async storage, there is a minor bug that shows up when getting the number of todos. You'll need to wrap the count operation in a callback function:

1. In model.js, update getCount() to accept a callback:

  Model.prototype.getCount = function (callback) {
  var todos = {
    active: 0,
    completed: 0,
    total: 0
  };
  this.storage.findAll(function (data) {
    data.each(function (todo) {
      if (todo.completed === 1) {
        todos.completed++;
      } else {
        todos.active++;
      }
      todos.total++;
    });
    if (callback) callback(todos);
  });
  return todos;
};

2. Back in controller.js, update _updateCount() to use the async getCount() you edited in the previous step:

Controller.prototype._updateCount = function () {
  var todos = this.model.getCount();
  this.model.getCount(function(todos) {
    this.$todoItemCounter.innerHTML = this.view.itemCounter(todos.active);

    this.$clearCompleted.innerHTML = this.view.clearCompletedButton(todos.completed);
    this.$clearCompleted.style.display = todos.completed > 0 ? 'block' : 'none';

    this.$toggleAll.checked = todos.completed === todos.total;

    this._toggleFrame(todos);
  }.bind(this));

};

You are almost there! If you reload the app now, you will be able to insert new todos without any console errors.

Remove todos items

Now that the app can save todo items, you're close to being done! You still get errors when you attempt to remove todo items:

Todo app with localStorage console log error

1. In store.js, convert all the localStorage instances to use chrome.storage.local:

a) To start off, wrap everything already inside remove() with a get() callback:

Store.prototype.remove = function (id, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = JSON.parse(localStorage[this._dbName]);
    var todos = data.todos;

    for (var i = 0; i < todos.length; i++) {
      if (todos[i].id == id) {
        todos.splice(i, 1);
        break;
      }
    }

    localStorage[this._dbName] = JSON.stringify(data);
    callback.call(this, JSON.parse(localStorage[this._dbName]).todos);
  }.bind(this));
};

b) Then convert the contents within the get() callback:

Store.prototype.remove = function (id, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = JSON.parse(localStorage[this._dbName]);
    var data = storage[this._dbName];
    var todos = data.todos;

    for (var i = 0; i < todos.length; i++) {
      if (todos[i].id == id) {
        todos.splice(i, 1);
        break;
      }
    }

    localStorage[this._dbName] = JSON.stringify(data);
    callback.call(this, JSON.parse(localStorage[this._dbName]).todos);
    chrome.storage.local.set(storage, function() {
      callback.call(this, todos);
    }.bind(this));
  }.bind(this));
};

2. The same Read-After-Write data hazard issue previously present in the save() method is also present when removing items so you will need to update a few more places to allow for batch operations on a list of todo IDs.

a) Still in store.js, update remove():

Store.prototype.remove = function (id, callback) {
  chrome.storage.local.get(this._dbName, function(storage) {
    var data = storage[this._dbName];
    var todos = data.todos;

    var ids = [].concat(id);
    ids.forEach( function(id) {
      for (var i = 0; i < todos.length; i++) {
        if (todos[i].id == id) {
          todos.splice(i, 1);
          break;
        }
      }
    });

    chrome.storage.local.set(storage, function() {
      callback.call(this, todos);
    }.bind(this));
  }.bind(this));
};

b) In controller.js, change removeCompletedItems() to make it call removeItem() on all IDs at once:

Controller.prototype.removeCompletedItems = function () {
  this.model.read({ completed: 1 }, function (data) {
    var ids = [];
    data.forEach(function (item) {
      this.removeItem(item.id);
      ids.push(item.id);
    }.bind(this));
    this.removeItem(ids);
  }.bind(this));

  this._filter();
};

c) Finally, still in controller.js, change the removeItem() to support removing multiple items from the DOM at once, and move the _filter() call to be inside the callback:

Controller.prototype.removeItem = function (id) {
  this.model.remove(id, function () {
    var ids = [].concat(id);
    ids.forEach( function(id) {
      this.$todoList.removeChild($$('[data-id="' + id + '"]'));
    }.bind(this));
    this._filter();
  }.bind(this));
  this._filter();
};

Supprimer tous les éléments de la liste de tâches

Il existe une autre méthode dans store.js utilisant localStorage:

Store.prototype.drop = function (callback) {
  localStorage[this._dbName] = JSON.stringify({todos: []});
  callback.call(this, JSON.parse(localStorage[this._dbName]).todos);
};

Cette méthode n'est pas appelée dans l'application actuelle. Si vous souhaitez relever un défi supplémentaire, essayez de l'implémenter vous-même. Indice: Consultez chrome.storage.local.clear().

Lancer l'application Todo terminée

Vous avez terminé l'étape 2. Actualisez votre application. Vous devriez maintenant disposer d'une version empaquetée Chrome entièrement fonctionnelle de TodoMVC.

Pour en savoir plus

Pour en savoir plus sur certaines des API présentées dans cette étape, consultez les ressources suivantes:

Prêt à passer à l'étape suivante ? Passez à l'étape 3 : Ajouter des alarmes et des notifications.