第 2 步:导入现有 Web 应用

在此步骤中,您将了解以下内容:

  • 如何将现有 Web 应用改造成适用于 Chrome 应用平台的应用。
  • 如何使应用脚本符合内容安全政策 (CSP) 要求。
  • 如何使用 chrome.storage.local 实现本地存储空间。

完成此步骤预计需要 20 分钟。
如需预览您将在此步骤中完成的内容,请向下滚动到此页面底部 ↓

导入现有的 Todo 应用

首先,将常见基准应用 TodoMVC纯 JavaScript 版本导入到您的项目中。

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

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

系统会提示您替换 index.html。请直接接受邀请。

替换 index.html

您的应用文件夹中现在应具有以下文件结构:

新建项目文件夹

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

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

使脚本符合内容安全政策 (CSP) 要求

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

待办事项应用包含 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 = {};

如果您现在重新加载该应用,Todo 应用仍无法正常运行,但已经离正常运行更近了。

将 localStorage 转换为 chrome.storage.local

如果您现在打开 DevTools 控制台,之前的错误应该已消失。不过,系统出现了关于 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 不同,您无法使用开发者工具“资源”面板检查本地存储的内容。不过,您可以通过 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);
    

    vs.

    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;
    

    vs.

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

    vs.

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

保存待办事项

当前的 save() 方法存在一个问题。这取决于两项异步操作(get 和 set),这些操作每次都在整个单体式 JSON 存储空间中执行。对多个待办事项进行的任何批量更新(例如“将所有待办事项标记为已完成”)都会导致被称为“写入后读取”的数据危害。如果我们使用更合适的数据存储(例如 IndexedDB),就不会出现此问题,但我们会尽量减少此 Codelab 的转换工作量。

有几种方法可以解决此问题,因此我们将借此机会稍微重构 save(),通过一次性获取要更新的一系列待办事项 ID 来实现此目的:

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 步!重新加载您的应用,现在,您应该拥有一个完全正常运行的 Chrome 打包版 TodoMVC。

了解详情

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

准备好继续执行下一步了吗?前往第 3 步 - 添加闹钟和通知 »