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, desplázate hasta la parte inferior de esta página ↓.

Cómo importar una app de listas de tareas existente

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

Incluimos una versión de la app 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.

Copia la carpeta todomvc en la carpeta del codelab

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

Reemplaza index.html

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

Nueva carpeta del proyecto

Los archivos destacados en azul pertenecen a la carpeta todomvc.

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

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

Abre la consola de Herramientas para desarrolladores (haz clic con el botón derecho > Inspeccionar elemento y, luego, selecciona la pestaña Consola). Verás un error sobre la negativa a ejecutar una secuencia de comandos intercalada:

App de Todo con error de registro de la consola de CSP

Para corregir este error, haz que la app cumpla con la Política de Seguridad de Contenido. Uno de los incumplimientos del CSP más comunes es causado por el código JavaScript intercalado. Los ejemplos de JavaScript intercalado incluyen controladores de eventos como atributos 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 código JavaScript intercalado y, en su lugar, incluye 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. Crea un archivo en la carpeta js llamado bootstrap.js. Mueve el código intercalado anterior para que esté en este archivo:

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

Si vuelves a cargar la app ahora, seguirá sin funcionar, pero estarás más cerca de hacerlo.

Convierte localStorage a 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 la falta de disponibilidad de window.localStorage:

App de Todo 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 subproceso único podría hacer que tu app deje de responder.

Las apps para Chrome tienen una API equivalente que puede almacenar objetos de forma asíncrona. Esto ayudará a evitar el proceso de serialización de objeto a cadena a objeto, que a veces es costoso.

Para abordar el mensaje de error en nuestra app, debes convertir localStorage en 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"],

Obtén 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. Una función de devolución de llamada opcional es el segundo parámetro. 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 de Datastore que deseas recuperar. Se puede pasar una sola clave como una cadena. Se pueden organizar varias claves en un array de cadenas o 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 quieres get() 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 los elementos almacenados de forma local con 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

Obtén una vista previa de los cambios obligatorios en la API

La mayoría de los pasos restantes para convertir la app de Todo son pequeños cambios en las llamadas a la API. Es necesario cambiar todos los lugares donde se usa localStorage actualmente, aunque esto requiera tiempo y sea 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 abarcamos la recuperación, el guardado y la eliminación de elementos de tareas pendientes en las siguientes secciones.

Cómo recuperar elementos de la lista de tareas pendientes

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

1. El método del constructor Store se encarga de inicializar la app de Todo con todos los elementos de tareas existentes del almacén de datos. Primero, el método verifica si existe el almacén de datos. De lo contrario, creará un array vacío de todos y lo guardará en el almacén de datos para que no haya errores de lectura del 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 tareas del modelo. Los resultados que se muestran cambian según si filtras por "Todos", "Activos" o "Completados".

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. Al igual que find(), findAll() tiene todas las tareas 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 lista de tareas pendientes, como "marcar todas las tareas pendientes como completadas", generará un riesgo de datos conocido como lectura después de la escritura. Este problema no ocurriría si usáramos un almacenamiento de datos más apropiado, como IndexedDB, pero intentamos minimizar el esfuerzo de conversión para este codelab.

Hay varias formas de solucionarlo, por lo que aprovecharemos esta oportunidad para refactorizar ligeramente save() tomando un array de IDs de todo para actualizarlos todos a la 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));
};

Cómo marcar elementos de la lista de tareas pendientes como completados

Ahora que la app funciona en arrays, debes cambiar la forma en que la app controla que un usuario haga clic en el botón Clear completed (#):

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. También borra la llamada a _filter(), ya que ajustarás el 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. 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 afuera.

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

Cómo descartar todos los elementos de tareas pendientes

Hay un método más en store.js que usa 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: Consulta chrome.storage.local.clear().

Inicia tu app de tareas pendientes terminada

Completaste el paso 2. Vuelve a cargar la app. Ahora deberías tener una versión empaquetada de Chrome de TodoMVC que funcione correctamente.

Más información

Para obtener información más detallada sobre algunas de las APIs que se presentan en este paso, consulta los siguientes recursos:

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