ステップ 2: 既存のウェブアプリをインポートする

このステップでは、次のことを学習します。

  • 既存のウェブ アプリケーションを Chrome アプリ プラットフォームに適応させる方法
  • アプリ スクリプトをコンテンツ セキュリティ ポリシー(CSP)に準拠させる方法
  • chrome.storage.local を使用してローカル ストレージを実装する方法

このステップの推定所要時間は 20 分です。
このページの一番下に移動 ↓ すると、この手順を完了できます。

既存の Todo アプリをインポートする

出発点として、一般的なベンチマーク アプリである TodoMVC標準の JavaScript バージョンをプロジェクトにインポートします。

TodoMVC アプリのバージョンは、todomvc フォルダのリファレンス コード zip に含まれています。todomvc からプロジェクト フォルダにすべてのファイル(フォルダを含む)をコピーします。

todomvc フォルダを Codelab フォルダにコピーします

index.html を置き換えるように求められます。同意してください。

index.html を置き換える

これで、アプリケーション フォルダ内のファイル構造は次のようになります。

新しいプロジェクト フォルダ

青色でハイライト表示されたファイルは、todomvc フォルダ内のファイルです。

今すぐアプリを再読み込みします(右クリック > [アプリを再読み込み])。基本的な UI が表示されますが、To-Do を追加することはできません。

スクリプトをコンテンツ セキュリティ ポリシー(CSP)に準拠させる

DevTools コンソールを開きます(右クリック > [要素を検証] > [コンソール] タブを選択します)。インライン スクリプトの実行拒否に関するエラーが表示されます。

CSP コンソール ログエラーがある ToDo アプリのログ

アプリをコンテンツ セキュリティ ポリシーに準拠させることで、このエラーを修正しましょう。CSP のコンプライアンス違反の最も一般的な原因の一つは、インライン JavaScript です。インライン JavaScript の例として、DOM 属性としてのイベント ハンドラ(例: <button onclick=''>)や、HTML 内にコンテンツを含む <script> タグなどがあります。

解決策は簡単です。インライン コンテンツを新しいファイルに移動します。

1. index.html の下部で、インライン JavaScript を削除し、代わりに 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. js フォルダに bootstrap.js という名前のファイルを作成します。以前のインライン コードをこのファイルに移動します。

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

今すぐアプリを再読み込みしても、Todo アプリは正常に機能していない状態です。

localStorage を chrome.storage.local に変換する

DevTools コンソールを開くと、以前のエラーは消えています。ただし、window.localStorage を使用できないという新しいエラーが発生します。

localStorage コンソールのログエラーを含む ToDo アプリのログ

Chrome アプリでは localStorage が同期的であるため、localStorage をサポートしていません。シングルスレッドのランタイムでブロッキング リソース(I/O)に同期アクセスすると、アプリが応答しなくなる可能性があります。

Chrome アプリには、オブジェクトを非同期に保存できる同等の API があります。これにより、オブジェクト -> 文字列 -> オブジェクトのシリアル化のプロセスを避けやすくなります。

アプリでエラー メッセージに対処するには、localStoragechrome.storage.local に変換する必要があります。

アプリの権限の更新

chrome.storage.local を使用するには、storage 権限をリクエストする必要があります。manifest.json で、"storage"permissions 配列に追加します。

"permissions": ["storage"],

local.storage.set() と local.storage.get() の詳細

ToDo アイテムの保存と取得を行うには、chrome.storage API の set() メソッドと get() メソッドを理解する必要があります。

set() メソッドは、最初のパラメータとして Key-Value ペアのオブジェクトを受け取ります。オプションのコールバック関数は、2 つ目のパラメータです。例:

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

get() メソッドは、取得するデータストア キーの省略可能な最初のパラメータを受け入れます。単一のキーを文字列として渡すことができます。複数のキーを、文字列の配列または辞書オブジェクトに配置できます。

2 つ目の必須パラメータはコールバック関数です。返されたオブジェクトで、最初のパラメータでリクエストされたキーを使用して、保存されている値にアクセスします。例:

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

現在 chrome.storage.local にあるすべての要素を get() する場合は、最初のパラメータを省略します。

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

localStorage とは異なり、DevTools の [リソース] パネルを使用して、ローカルに保存されているアイテムを検査することはできません。ただし、次のように JavaScript コンソールから chrome.storage を操作することはできます。

コンソールを使用して chrome.storage をデバッグする

必要な API の変更をプレビューする

Todo アプリを変換する残りの手順のほとんどは、API 呼び出しへの小さな変更です。localStorage が現在使用されている場所をすべて変更するには、時間がかかり、エラーが発生しやすい場所を変更する必要があります。

localStoragechrome.storage の主な違いは、chrome.storage の非同期という特性にあります。

  • 単純な割り当てを使用して localStorage に書き込むのではなく、オプションのコールバックで chrome.storage.local.set() を使用する必要があります。

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

    var storage = {};
    storage[dbName] = { todos: [] };
    chrome.storage.local.set( storage, function() {
      // optional callback
    });
    
  • localStorage[myStorageName] に直接アクセスする代わりに、chrome.storage.local.get(myStorageName,function(storage){...}) を使用して、コールバック関数で返された storage オブジェクトを解析する必要があります。

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

    chrome.storage.local.get(dbName, function(storage) {
      var todos = storage[dbName].todos;
    });
    
  • thisStore プロトタイプの this を参照するように、すべてのコールバックで関数 .bind(this) が使用されます。(バインドされた関数の詳細については、MDN のドキュメント: Function.prototype.bind() をご覧ください)。

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

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

以降のセクションでは、ToDo アイテムの取得、保存、削除について説明します。この違いにご注意ください。

ToDo アイテムを取得する

ToDo アイテムを取得するために、Todo アプリを更新しましょう。

1. Store コンストラクタ メソッドは、データストアの既存の ToDo アイテムすべてで Todo アプリを初期化します。このメソッドは、まずデータストアが存在するかどうかを確認します。存在しない場合は、todos の空の配列が作成され、データストアに保存されます。これにより、ランタイム読み取りエラーは発生しません。

js/store.js では、コンストラクタ メソッドでの localStorage の使用を、代わりに 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. find() メソッドは、モデルから Todo を読み取るときに使用されます。返される結果は、[すべて]、[有効]、[完了] でフィルタしているかどうかによって異なります。

chrome.storage.local を使用するように find() を変換します。

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. find() と同様に、findAll() はモデルからすべてのタスクを取得します。chrome.storage.local を使用するように findAll() を変換します。

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

ToDo リストを保存する

現在の save() メソッドには課題があります。毎回モノリシック JSON ストレージ全体を操作する 2 つの非同期オペレーション(get と set)に依存します。「すべての ToDo を完了としてマークする」など、複数の ToDo アイテムをバッチ アップデートすると、Read-After-Write と呼ばれるデータ危険が発生します。IndexedDB などのより適切なデータ ストレージを使用している場合、この問題は発生しませんが、この Codelab では変換作業を最小限に抑えることを目指しています。

修正方法はいくつかありますが、ここでは save() を少しリファクタリングするために、Todo ID の配列を一度に更新できるようにします。

1. まず、すでに save() 内にあるすべてを 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. すべての localStorage インスタンスを 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. 次に、ロジックを更新して、単一のアイテムではなく配列を操作するようにします。

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

ToDo アイテムを完了としてマーク

アプリが配列で動作しているので、ユーザーが [Clear completed (#)] ボタンをクリックした場合のアプリの処理方法を変更する必要があります。

1. controller.jstoggleAll() を更新し、Todo を 1 つずつ完了としてマークするのではなく、Todo の配列で toggleComplete() を 1 回だけ呼び出します。また、toggleComplete _filter() を調整するため、_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. 次に、toggleComplete() を更新して、単一の Todo と ToDo の配列の両方を受け入れるようにします。これには、filter()update() の外部ではなく内部に移動することも含まれます。

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

すべての ToDo アイテムをドロップ

store.js には、localStorage を使用するもう 1 つのメソッドがあります。

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

このメソッドは現在のアプリでは呼び出されないため、追加のチャレンジが必要な場合は、独自に実装してみてください。ヒント: chrome.storage.local.clear() をご覧ください。

完成した ToDo アプリを起動する

ステップ 2 は完了です。アプリを再読み込みすると、TodoMVC の Chrome パッケージ化バージョンが完全に機能するはずです。

自然言語処理についてや、

この手順で導入する API の詳細については、以下をご覧ください。

次のステップに進む準備はできましたか?ステップ 3 - アラームや通知を追加する » に進みます。