Paso 2: Importa una app web existente

En este paso, aprenderás lo siguiente:

  • Cómo adaptar una aplicación web existente para la plataforma de Apps de Chrome
  • Cómo hacer que las secuencias de comandos de tu app cumplan con la Política de Seguridad del Contenido (CSP)
  • Cómo implementar el almacenamiento local con chrome.storage.local

Tiempo estimado para completar este paso: 20 minutos
Para obtener una vista previa de lo que completarás en este paso, ve hacia la parte inferior de esta página ↓.

Importa una app de tareas pendientes existente

Como punto de partida, importa la versión clásica de JavaScript de TodoMVC, una app de comparativas común, a tu proyecto.

Incluimos una versión de la app de TodoMVC en el código postal de referencia de la carpeta todomvc. Copia todos los archivos (incluidas las carpetas) de todomvc en la carpeta de tu proyecto.

Copiar la carpeta todomvc en la carpeta del codelab

Se te pedirá que reemplaces index.html. Adelante.

Reemplaza index.html

Ahora, deberías tener la siguiente estructura de archivos en la carpeta de tu aplicación:

Nueva carpeta de proyecto

Los archivos destacados en azul pertenecen a la carpeta todomvc.

Vuelve a cargar la app ahora (hacer clic con el botón derecho > Volver a cargar la app). Deberías ver la IU básica, pero no podrás agregar tareas pendientes.

Hacer que las secuencias de comandos cumplan con la Política de Seguridad del Contenido (CSP)

Abra la consola de Herramientas para desarrolladores (haga clic con el botón derecho > Inspeccionar elemento y, luego, seleccione la pestaña Consola). Verás un error sobre el rechazo de la ejecución de una secuencia de comandos intercalada:

App de tareas pendientes con un error de registro de la consola de CSP

Para corregir este error, asegúrate de que la app cumpla con la Política de Seguridad del Contenido. Uno de los incumplimientos de CSP más comunes se debe al JavaScript intercalado. Los ejemplos de JavaScript intercalado incluyen controladores de eventos como atributos del DOM (p.ej., <button onclick=''>) y etiquetas <script> con contenido dentro del HTML.

La solución es simple: mueve el contenido intercalado a un archivo nuevo.

1. Cerca de la parte inferior de index.html, quita el JavaScript intercalado y, en su lugar, incluye js/boot.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. Crea un archivo en la carpeta js con el nombre Boot.js. Mueve el código intercalado anteriormente para que esté en este archivo:

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

Seguirás teniendo una app de tareas pendientes que no funcionará si la vuelves a cargar ahora, pero te estás acercando más.

Convierte localStorage en chrome.storage.local

Si abres la consola de Herramientas para desarrolladores ahora, el error anterior debería desaparecer. Sin embargo, hay un error nuevo sobre window.localStorage no está disponible:

App de tarea pendiente con error de registro de la consola de localStorage

Las Apps de Chrome no admiten localStorage, ya que localStorage es síncrono. El acceso síncrono a recursos de bloqueo (E/S) en un entorno de ejecución de un solo subproceso podría hacer que tu app no responda.

Las Apps de Chrome tienen una API equivalente que puede almacenar objetos de forma asíncrona. Esto ayudará a evitar el proceso de serialización objeto->string->objeto a veces costoso.

Para solucionar el mensaje de error en nuestra app, debes convertir localStorage a chrome.storage.local.

Actualiza los permisos de la app

Para usar chrome.storage.local, debes solicitar el permiso storage. En manifest.json, agrega "storage" al array permissions:

"permissions": ["storage"],

Más información sobre local.storage.set() y local.storage.get()

Para guardar y recuperar elementos de tareas pendientes, debes conocer los métodos set() y get() de la API de chrome.storage.

El método set() acepta un objeto de pares clave-valor como primer parámetro. El segundo parámetro es una función de devolución de llamada opcional. Por ejemplo:

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

El método get() acepta un primer parámetro opcional para las claves del almacén de datos que deseas recuperar. Se puede pasar una sola clave como una cadena. Se pueden organizar varias claves en un array de cadenas o en un objeto de diccionario.

El segundo parámetro, que es obligatorio, es una función de devolución de llamada. En el objeto que se muestra, usa las claves solicitadas en el primer parámetro para acceder a los valores almacenados. Por ejemplo:

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

Si deseas aplicar get() a todo lo que está actualmente en chrome.storage.local, omite el primer parámetro:

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

A diferencia de localStorage, no podrás inspeccionar elementos almacenados de forma local en el panel Recursos de Herramientas para desarrolladores. Sin embargo, puedes interactuar con chrome.storage desde la Consola de JavaScript de la siguiente manera:

Usa la consola para depurar chrome.storage

Vista previa de los cambios necesarios en la API

La mayoría de los pasos restantes para convertir la app de Tareas pendientes son pequeños cambios en las llamadas a la API. Es obligatorio cambiar todos los lugares en los que se usa localStorage en la actualidad, aunque demanda mucho tiempo y es propenso a errores.

Las diferencias clave entre localStorage y chrome.storage provienen de la naturaleza asíncrona de chrome.storage:

  • En lugar de escribir en localStorage con una asignación simple, debes usar chrome.storage.local.set() con devoluciones de llamada opcionales.

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

    versus

    var storage = {};
    storage[dbName] = { todos: [] };
    chrome.storage.local.set( storage, function() {
      // optional callback
    });
    
  • En lugar de acceder a localStorage[myStorageName] directamente, debes usar chrome.storage.local.get(myStorageName,function(storage){...}) y, luego, analizar el objeto storage que se muestra en la función de devolución de llamada.

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

    versus

    chrome.storage.local.get(dbName, function(storage) {
      var todos = storage[dbName].todos;
    });
    
  • La función .bind(this) se usa en todas las devoluciones de llamada para garantizar que this haga referencia al this del prototipo Store. (Puedes encontrar más información sobre las funciones vinculadas en los documentos de MDN: Function.prototype.bind()).

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

    versus

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

Ten en cuenta estas diferencias clave cuando analizaremos la recuperación, el guardado y la eliminación de elementos de tareas pendientes en las siguientes secciones.

Recuperar elementos de tareas pendientes

Actualicemos la app de tareas pendientes para recuperar los elementos de tareas pendientes:

1. El método de constructor Store se encarga de inicializar la app de tareas pendientes con todos los elementos pendientes existentes del almacén de datos. El método primero comprueba si el almacén de datos existe. De lo contrario, creará un arreglo vacío de todos y lo guardará en el almacén de datos para que no haya errores de lectura en el tiempo de ejecución.

En js/store.js, convierte el uso de localStorage en el método del constructor para usar 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. El método find() se usa cuando se leen todas las tareas del modelo. Los resultados que se muestran cambian en función de si filtras por "Todos", "Activo" o "Completado".

Convierte find() para usar 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. De manera similar a find(), findAll() obtiene todas las tareas pendientes del modelo. Convierte findAll() para usar 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));
};

Guardar todos los elementos

El método save() actual presenta un desafío. Depende de dos operaciones asíncronas (get y set) que operan en todo el almacenamiento JSON monolítico cada vez. Cualquier actualización por lotes en más de un elemento de tareas pendientes, como “marcar todas las tareas como completadas”, generará un peligro de datos conocido como lectura-después-escritura. Este problema no ocurriría si estuviéramos usando un almacenamiento de datos más adecuado, como IndexedDB, pero estamos tratando de minimizar el esfuerzo de conversión para este codelab.

Existen varias formas de solucionarlo, por lo que aprovecharemos esta oportunidad para refactorizar ligeramente save() mediante la actualización de un array de IDs de tareas pendientes de una sola vez:

1. Para comenzar, une todo lo que ya está dentro de save() con una devolución de llamada 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. Convierte todas las instancias de localStorage con 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. Luego, actualiza la lógica para que opere en un array en lugar de en un solo elemento:

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));
};

Marcar las tareas pendientes como completadas

Ahora que la app funciona en arrays, debes cambiar la manera en que la app controla al usuario que hace clic en el botón Borrar elementos completados (#):

1. En controller.js, actualiza toggleAll() para llamar a toggleComplete() solo una vez con un array de tareas pendientes, en lugar de marcar una tarea pendiente como completada una por una. Además, borra la llamada a _filter(), ya que ajustarás el _filter() de toggleComplete.

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. Ahora actualiza toggleComplete() para que acepte una sola tarea pendiente o un array de tareas pendientes. Esto incluye mover filter() para que esté dentro de update(), en lugar de fuera.

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();
};

Descartar todas las tareas pendientes

Hay un método más en store.js con localStorage:

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

No se llama a este método en la app actual, por lo que, si quieres un desafío adicional, intenta implementarlo por tu cuenta. Sugerencia: Revisa chrome.storage.local.clear().

Inicia tu app de tareas pendientes terminada

Has terminado el paso 2. Vuelve a cargar tu app. Ahora deberías tener una versión de TodoMVC empaquetada en Chrome por completo.

Más información

Para obtener información más detallada sobre algunas de las APIs presentadas en este paso, consulta lo siguiente:

¿Todo listo para continuar con el siguiente paso? Ve al Paso 3: Agrega alarmas y notificaciones »