在這個步驟中,您將瞭解:
- 如何將現有的網頁應用程式改編為 Chrome 應用程式平台。
- 如何讓應用程式指令碼符合內容安全政策 (CSP) 規定。
- 如何使用 chrome.storage.local 實作本機儲存空間。
完成這個步驟所需的時間:20 分鐘。
如要預覽您將在這個步驟完成的內容,請向下捲動到本頁底部 ↓。
匯入現有的 Todo 應用程式
首先,請將 TodoMVC 的 純 JavaScript 版本 (常見的基準應用程式) 匯入專案。
我們已在 todomvc 資料夾的 參考程式碼 zip 檔案中,加入 TodoMVC 應用程式的版本。將 todomvc 中的所有檔案 (包括資料夾) 複製到專案資料夾。
系統會要求您取代 index.html。請接受。
應用程式資料夾現在應該會顯示下列檔案結構:
以藍色標示的檔案來自 todomvc 資料夾。
立即重新載入應用程式 (按一下滑鼠右鍵 >「重新載入 App」)。您應該會看到基本 UI,但無法新增待辦事項。
讓指令碼符合內容安全政策 (CSP) 規定
開啟開發人員工具控制台 (按一下滑鼠右鍵 > 檢查元素,然後選取「Console」分頁標籤)。您會看到拒絕執行內嵌指令碼的錯誤訊息:
讓我們修正這個錯誤,讓應用程式符合「內容安全政策」規定。最常見的 CSP 不符規定原因之一,就是內嵌 JavaScript。內嵌 JavaScript 的範例包括事件處理常式 (做為 DOM 屬性,例如 <button onclick=''>
) 和 <script>
標記 (含有 HTML 內的內容)。
解決方法很簡單:將內嵌內容移至新檔案。
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
無法使用的新錯誤:
Chrome 應用程式不支援 localStorage
,因為 localStorage
是同步的。在單執行緒執行階段中,同步存取封鎖資源 (I/O) 可能會導致應用程式沒有回應。
Chrome 應用程式也有類似的 API,可異步儲存物件。這有助於避免物件 > 字串 > 物件序列化程序,因為這個程序有時會耗費大量資源。
如要解決應用程式中的錯誤訊息,您需要將 localStorage
轉換為 chrome.storage.local。
更新應用程式權限
您必須要求 storage
權限,才能使用 chrome.storage.local
。在 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
互動,如下所示:
預覽必要的 API 變更
轉換 Todo 應用程式的步驟中,大部分剩餘步驟都只是對 API 呼叫進行小幅變更。您必須變更目前使用 localStorage
的所有位置,雖然這項作業耗時且容易出錯,但仍是必要的。
localStorage
和 chrome.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 });
您需要使用
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();
請務必留意這些主要差異,因為我們將說明如何擷取、儲存及移除待辦事項。
擷取待辦事項
讓我們更新 Todo 應用程式,以便擷取待辦事項項目:
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));
};
儲存 ToDo 項目
目前的 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()
,而非逐一將待辦事項標示為已完成。您也要刪除對 _filter()
的呼叫,因為您會調整 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. 現在更新 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:
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()
。
啟動完成的 Todo 應用程式
您已經完成步驟 2!重新載入應用程式,您現在應該會看到完整運作的 Chrome 封裝版 TodoMVC。
瞭解詳情
如要進一步瞭解本步驟中介紹的部分 API,請參閱:
- 內容安全性政策 ↑
- 宣告權限 ↑
- chrome.storage ↑
- chrome.storage.local.get() ↑
- chrome.storage.local.set() ↑
- chrome.storage.local.remove() ↑
- chrome.storage.local.clear() ↑
準備繼續進行下一個步驟了嗎?請參閱步驟 3 - 新增鬧鐘和通知 »