步驟 2:匯入現有的網頁應用程式

在這個步驟中,您將瞭解:

  • 如何針對 Chrome 應用程式平台調整現有的網頁應用程式。
  • 如何讓應用程式指令碼符合內容安全政策 (CSP) 規定。
  • 如何使用 chrome.storage.local 實作本機儲存空間。

預計完成這個步驟的時間:20 分鐘。
如要預覽這個步驟中要完成的內容,請向下跳到本頁底部 ↓

匯入現有的待辦事項應用程式

一開始,請將通用基準應用程式 TodoMVC香草 JavaScript 版本匯入您的專案。

我們在 todomvc 資料夾的參照程式碼 zip 中加入了 TodoMVC 應用程式版本。將 todomvc 中的所有檔案 (包括資料夾) 複製到專案資料夾。

將 todomvc 資料夾複製到程式碼研究室資料夾

系統會請您取代 index.html。現在就開始接受

取代 index.html

現在應用程式資料夾中應該會有下列檔案結構:

新增專案資料夾

以藍色醒目顯示的檔案來自「todomvc」資料夾。

立即重新載入應用程式 (按一下滑鼠右鍵 >「重新載入應用程式」)。您應該會看到基本 UI,但無法新增待辦事項。

確保指令碼內容安全政策 (CSP) 符合規範

開啟開發人員工具控制台 (按一下滑鼠右鍵並依序選取「檢查元素」 >「控制台」分頁標籤)。您將會看到有關拒絕執行內嵌指令碼的錯誤:

發生 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

如果現在開啟開發人員工具控制台,先前的錯誤應已消失。不過,發生新的 window.localStorage 錯誤無法使用:

Todo 應用程式發生 localStorage 主控台記錄錯誤

Chrome 應用程式不支援 localStorage,因為 localStorage 是同步處理作業。在單一執行緒執行階段中封鎖資源 (I/O) 的同步存取權可能會導致應用程式沒有回應。

Chrome 應用程式提供對等的 API,能以非同步方式儲存物件。這有助於避免耗費大量成本的 object->string-> 物件序列化程序。

如要解決應用程式中的錯誤訊息,您必須將 localStorage 轉換為 chrome.storage.local

更新應用程式權限

如要使用 chrome.storage.local,您必須要求 storage 權限。在 manifest.json 中,將 "storage" 新增至 permissions 陣列:

"permissions": ["storage"],

瞭解 local.storage.set() 和 local.storage.get()

如要儲存及擷取待辦事項,您必須瞭解 chrome.storage API 的 set()get() 方法。

set() 方法接受鍵/值組合的物件做為第一個參數。第二個參數是選用的回呼函式。例如:

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

get() 方法接受選用的第一個參數,以擷取您要擷取的資料儲存庫金鑰。單一鍵可用字串的形式傳遞;可將多個鍵排列為字串陣列或字典物件。

第二個參數 (必要) 為回呼函式。在傳回的物件中,使用第一個參數中要求的鍵來存取儲存的值。例如:

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

如要 get() 目前在 chrome.storage.local 中的所有項目,請省略第一個參數:

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

localStorage 不同,您將無法使用開發人員工具面板檢查本機儲存的項目,但您可以透過 JavaScript 控制台與 chrome.storage 互動,如下所示:

使用控制台對 chrome.storage 進行偵錯

預覽必要的 API 變更

轉換 Todo 應用程式的其餘步驟,大多對 API 呼叫進行小幅變更。變更目前使用 localStorage 的所有位置,但需要很耗時又容易出錯。

localStoragechrome.storage 的主要差異,在於 chrome.storage 的非同步性質:

  • 您需要將 chrome.storage.local.set() 與選用的回呼搭配使用,而不是使用簡單的指派寫入 localStorage

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

    相較於

    var storage = {};
    storage[dbName] = { todos: [] };
    chrome.storage.local.set( storage, function() {
      // optional callback
    });
    
  • 您需要使用 chrome.storage.local.get(myStorageName,function(storage){...}),然後在回呼函式中剖析傳回的 storage 物件,而非直接存取 localStorage[myStorageName]

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

    相較於

    chrome.storage.local.get(dbName, function(storage) {
      var todos = storage[dbName].todos;
    });
    
  • 所有回呼都使用 .bind(this) 函式,確保 this 參照 Store 原型的 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();
    

我們會在以下各節探討擷取、儲存和移除待辦事項時,請注意這些主要差異。

擷取待辦事項

請更新待辦事項應用程式,以便擷取待辦事項:

1. Store 建構函式方法會使用資料儲存庫中所有現有的待辦事項,處理 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() 方法。傳回的結果會根據您按「全部」、「有效」或「已完成」篩選。

轉換 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() 可以從模型中獲得所有待辦事項。轉換 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() 方法存在挑戰。它取決於兩項非同步作業 (get 和 set),每次都會在整個單體 JSON 儲存空間上運作。如果對多個待辦事項項目進行批次更新,例如「將所有待辦事項標示為完成」,皆會產生資料危害,稱為「讀取後寫入作業」。如果我們使用更合適的資料儲存空間 (例如 IndexedDB),就不會發生這個問題,但正試圖盡量減少在本程式碼研究室中發生的轉換作業。

修正方式有很多種,因此我們會把握這個機會,利用陣列一次更新全部的待辦事項 ID 來稍微重構 save()

1. 首先,請使用 chrome.storage.local.get() 回呼,納入 save() 中的所有既有項目:

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. 使用 chrome.storage.local 轉換所有 localStorage 例項:

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 中,更新 toggleAll() 以便使用待辦事項陣列僅呼叫 toggleComplete() 一次,而不會將待辦事項逐一標示為已完成。此外,由於您必須調整 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(),以便接受單一待辦事項或一系列待辦事項。包括將 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();
};

捨棄所有待辦事項

store.js 還有另一個使用 localStorage 的方法:

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

目前的應用程式不會呼叫這個方法,因此如需額外的驗證問題,請自行實作。提示:請查看 chrome.storage.local.clear()

啟動已完成的待辦事項應用程式

您已完成步驟 2!重新載入應用程式,您現在應該擁有可完整運作的 TodoMVC 的 Chrome 封裝版本。

瞭解詳情

如要進一步瞭解這個步驟介紹的某些 API,請參閱:

準備好繼續進行下一步了嗎?請參閱步驟 3 - 新增鬧鐘和通知 »