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

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

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

このステップの所要時間: 20 分
このステップで完了する内容をプレビューするには、このページの下部に移動 ↓してください。

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

最初に、一般的なベンチマーク アプリである TodoMVCバニラ JavaScript バージョンをプロジェクトにインポートします。

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

todomvc フォルダを codelab フォルダにコピーする

index.html の置き換えを求められます。承諾してください。

index.html を置き換える

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

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

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

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

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

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

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

アプリをコンテンツ セキュリティ ポリシーに準拠させて、このエラーを修正しましょう。CSP に準拠していない最も一般的な原因の 1 つは、インライン 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 が使用できないという新しいエラーが発生します。

ToDo アプリに localStorage コンソールのログエラーが表示されます

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

Chrome アプリには、オブジェクトを非同期で保存できる同等の API があります。これにより、オブジェクトから文字列、オブジェクトへのシリアル化プロセスが不要になる場合があります。

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

アプリの権限の更新

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

"permissions": ["storage"],

local.storage.set() と local.storage.get() について

タスクリストを保存して取得するには、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 の [Resources] パネルを使用して、ローカルに保存されているアイテムを検査することはできません。ただし、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 アプリを更新しましょう。

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 を読み取るときに使用されます。返される結果は、[すべて]、[有効]、[完了] のいずれでフィルタするかによって異なります。

find() を変換して 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. find() と同様に、findAll() はモデルからすべての ToDo を取得します。findAll() を変換して 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));
};

タスクリストのアイテムを保存する

現在の save() メソッドには課題があります。これは、モノリシックな JSON ストレージ全体に対して毎回実行される 2 つの非同期オペレーション(get と set)に依存します。「すべての ToDo を完了済みとしてマークする」など、複数の ToDo アイテムに対してバッチ更新を行うと、書き込み後読み取りと呼ばれるデータハザードが発生します。IndexedDB などの適切なデータ ストレージを使用すれば、この問題は発生しませんが、この Codelab では変換作業を最小限に抑えようとしています。

修正方法はいくつかあるため、この機会に ToDo ID の配列を一度に更新して、save() を少しリファクタリングします。

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

やることリストのアイテムを完了としてマークする

アプリが配列で動作するようにしたので、ユーザーが [Clear completed (#)] ボタンをクリックしたときにアプリが処理する方法を変える必要があります。

1. controller.js で、todo を 1 つずつ完了としてマークするのではなく、todo の配列で toggleComplete() を 1 回だけ呼び出すように toggleAll() を更新します。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() を更新して、1 つの 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 - アラートと通知を追加する » に進みます。