第 2 步:导入现有 Web 应用

在此步骤中,您将学习以下内容:

  • 如何针对 Chrome 应用平台调整现有的 Web 应用。
  • 如何确保您的应用脚本符合内容安全政策 (CSP)。
  • 如何使用 chrome.storage.local 实现本地存储。

完成此步骤的预计用时:20 分钟。
如需预览您将在此步骤中完成的内容,请跳至本页面底部 ↓

导入现有的待办事项应用

首先,将通用的基准应用 TodoMVC原始 JavaScript 版本导入您的项目。

我们在 todomvc 文件夹内的参考代码 ZIP 中添加了 TodoMVC 应用的一个版本。将 todomvc 中的所有文件(包括文件夹)复制到您的项目文件夹中。

将 todomvc 文件夹复制到 Codelab 文件夹中

您需要替换 index.html。开始接受吧。

替换 index.html

现在,您的应用文件夹中的文件结构应如下所示:

新建项目文件夹

以蓝色突出显示的文件来自 todomvc 文件夹。

立即重新加载您的应用(右键点击 > 重新加载应用)。您应该会看到基本界面,但无法添加待办事项。

让脚本符合内容安全政策 (CSP)

打开开发者工具控制台(右键点击 > 检查元素,然后选择 Console 标签页)。您将看到一条关于拒绝执行内联脚本的错误:

出现 CSP 控制台日志错误的待办事项应用

让我们使该应用符合内容安全政策规定,以便解决此错误。最常见的 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 = {};

如果您现在重新加载该应用,但距离该应用越来越近了,它仍会正常运行。

将 localStorage 转换为 chrome.storage.local

如果现在打开开发者工具控制台,之前的错误应该就会消失。不过,出现一个关于 window.localStorage 不可用的新错误:

待办事项应用显示 localStorage 控制台日志错误

Chrome 应用不支持 localStorage,因为 localStorage 是同步的。在单线程运行时中同步访问阻塞资源 (I/O) 可能会导致应用无响应。

Chrome 应用具有可异步存储对象的等效 API。这有助于避免有时开销很高的对象->字符串->对象序列化过程。

若要解决应用中的错误消息,您需要将 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 不同,您将无法使用开发者工具的“Resources”面板检查本地存储的项。不过,您可以从 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
    });
    
  • 您不能直接访问 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;
    });
    
  • 函数 .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 构造函数方法负责使用数据存储区中的所有现有待办事项项初始化待办事项应用。该方法首先检查数据存储区是否存在。如果没有,则将创建一个空的 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() 方法带来了质询。它依赖于两个每次都在整个单体式 JSON 存储空间上运行的异步操作(get 和 set)。对多个待办事项进行任何批量更新(例如“将所有待办事项标记为已完成”)都会导致名为“写后读取”的数据危险。如果我们使用的是更合适的数据存储(例如 IndexedDB),则不会出现此问题,但我们会尝试尽可能减少此 Codelab 的转换工作。

有多种方法可以解决此问题,因此我们将借此机会通过一次性更新所有待办事项 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:

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 步!重新加载您的应用,您现在应该得到一个正常运行的 Chrome 封装版 TodoMVC。

更多信息

如需详细了解此步骤中引入的一些 API,请参阅:

准备好继续下一步了吗?转到第 3 步 - 添加闹钟和通知 »