JavaScript Web 应用高级教程(三)
六、在浏览器中存储数据
客户端数据存储是对离线应用的自然补充。HTML5 定义了一些用于在浏览器中存储数据的有用的 JavaScript APIs,从简单的名称/值对到使用 JavaScript 对象数据库。在本章中,我将向您展示如何构建依赖持久存储数据的应用,包括如何在离线 web 应用中使用这些数据的细节。
注意浏览器对数据存储的支持喜忧参半。您应该使用 Google Chrome 运行本章中的示例,但 IndexedDB 部分的示例除外,它们只能在 Mozilla Firefox 中运行。
使用本地存储
在浏览器中存储数据最简单的方法是使用 HTML5 本地存储特性。这允许您存储简单的名称/值对,并在以后检索或修改它们。数据会永久存储,但不能保证永远存储。如果需要空间(或者如果数据很长时间没有被访问),浏览器可以自由删除你的数据,当然,用户可以随时清除数据存储,即使你的 web 应用正在运行。其结果是数据是广泛持久的,但不是无限持久的。使用本地存储非常类似于使用常规的 JavaScript 数组,如清单 6-1 所示。
清单 6-1。使用本地存储
`
Local Storage Example ` `$('div.catSelectors').buttonset();
hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init();
crossroads.addRoute("select/{item}", function(item) { viewModel.selectedItem(item); ** localStorage["selection"] = item;** }); ** viewModel.selectedItem(localStorage["selection"] || viewModel.items[0]);** });
为了演示本地存储,我使用了第四章中的简单例子,它允许我专注于存储技术,而不会妨碍其他章节的特性。如清单所示,开始使用本地存储非常简单。全局localStorage对象就像一个数组。当用户在这个简单的 web 应用中做出选择时,我使用数组样式的符号存储所选项,如下所示:
**localStorage["selection"]** = item;
提示键区分大小写(因此
selection和Selection将代表不同的数据项),给已经存在的键赋值会覆盖先前定义的值。
这条语句创建了一个新的本地存储条目,我可以使用相同的数组样式符号读回它,如下所示:
viewModel.selectedItem**(localStorage["selection"]** || viewModel.items[0]);
将这两条语句添加到示例中的效果是为用户的选择创建简单的持久性。当加载 web 应用时,我检查是否有数据存储在selection键下,如果有,在视图模型中设置相应的数据项,这将恢复用户在早期会话中的选择。
用户可以查看和编辑本地存储的内容,这意味着您存储的任何内容都不是秘密的,任何内容都可以更改。不要存储任何你不想公开传播的内容,也不要依赖本地存储来给你的 web 应用提供特权访问。
从那时起,每当我的路线被一个 URL 改变匹配时,我就更新与selection键相关的值。我包含了一个默认选择的后备选项,以应对本地存储数据被删除的可能性(或者这是用户第一次加载 web 应用)。要测试这个特性,加载示例 web 应用,选择其中一个选项,然后重新加载 web 页面。浏览器将重新加载文档,重新执行 JavaScript 代码,并恢复您的选择。
存储 JSON 数据
本地存储的规范要求键和值都是字符串,就像前面的例子一样。能够存储名称/值对的列表并不总是那么有用,但是我们可以建立对字符串的支持来使用 JSON 数据的本地存储,如清单 6-2 所示。
清单 6-2。为 JSON 数据使用本地存储
`...
...`
我在script元素中定义了两个新函数来支持存储 JSON。每当用户做出选择时,就会调用storeViewModelData函数。JSON 只能存储数据值,而不能存储 JavaScript 函数,所以我从视图模型中提取数据值,并用它们来创建一个新对象。我将这个对象传递给JSON.stringify方法,该方法返回一个 JSON 字符串,如下所示:
{"items":["Apple","Orange","Banana"], "selectedItem":"Banana"}
我通过将这个字符串与本地存储中的viewModelData键相关联来存储它。对应的功能是loadViewModelData。当 jQuery ready事件被触发时,我调用这个函数,并使用它来完成视图模型。
提示本地存储的持久性意味着,如果您重复使用一个密钥来存储不同类型的数据,您将面临遇到以前会话中存储的旧格式的风险。在开发中处理这个问题的最简单的方法是清除浏览器的缓存。在生产中,您必须能够检测旧数据并处理它,或者至少能够在不产生任何错误的情况下丢弃它。
如果有与viewModelData键相关联的本地存储数据,我加载 JSON 字符串并使用JSON.parse方法创建一个 JavaScript 对象。然后我可以读取对象的属性来填充视图模型。当然,我不能依赖现有的数据,所以如果需要的话,我会使用一些合理的默认值。
存储对象数据
在我的简单示例中,从包含数据的对象中分离数据并不难,但是在复杂的 web 应用中,这可能要困难得多。您可能想通过直接存储对象来简化这个过程,而不是将数据映射到字符串。不要这样;这只会给你带来麻烦。下面是一段代码,展示了对象使用的本地存储:
`...
...`
这种技术行不通。当您存储对象时,浏览器不会抱怨,如果您在同一个会话中读回值,一切看起来都很好。但是浏览器会序列化该对象,以便为将来的会话存储它。对于大多数 JavaScript 对象,存储的值将是[object Object],这是调用toString方法得到的结果。当用户再次访问 web 应用时,本地存储中的值不是有效的 JavaScript 对象,无法解析。这是应该在测试过程中发现的问题,但是我经常看到这个问题,尤其是因为即使是认真对待测试的项目通常也不会在多个会话中重新访问应用。
存储表单数据
本地存储非常适合使表单数据持久化。键/值映射非常适合表单元素的本质,而且不费吹灰之力,就可以创建会话间持久的表单,如清单 6-3 所示。
清单 6-3。使用本地存储创建持久表单
`
Local Storage Example .each(viewModel.personalDetails, function(index, item) {** ** item.value(localStorage[item.name] || "");** ** item.value.subscribe(function(newValue) {** ** localStorage[item.name] = newValue;** ** }); });**
ko.applyBindings(viewModel);
$('#buttonDiv input').button().click(function(e) { ** localStorage.clear();** }); });
我在这个例子中定义了一个简单的三字段form元素,你可以在图 6-1 中看到。该表单捕获用户的姓名、城市和国家,并被发送到服务器上的/formecho URL,服务器简单地响应所提交数据的细节。
图 6-1。对表单元素使用本地存储
我使用了一个视图模型作为input元素和本地存储之间的媒介。当用户向input元素之一输入值时,值数据绑定更新视图模型中相应的可观察数据项。我使用subscribe函数接收这些更改的通知,并将更新写入本地存储,如下所示:
$.each(viewModel.personalDetails, function(index, item) { item.value(localStorage[item.name] || ""); ** item.value.subscribe(function(newValue) {** ** localStorage[item.name] = newValue;** ** });** });
我通过枚举视图模型中的项目来设置订阅。如果有可用的数据,我利用这个机会从本地存储设置视图模型中的初始值,如下所示:
item.value(localStorage[item.name] || "");
当我设置初始值时,来自本地存储的值通过视图模型传播到input元素,保持所有内容都是最新的。
一旦提交了表单或者当用户单击重置按钮时,继续存储表单数据是没有意义的。当单击提交或重置按钮时,我从本地存储中删除数据,如下所示:
$('#buttonDiv input').button().click(function(e) { ** localStorage.clear();** });
clear方法删除本地存储中 web 应用的所有数据(但不删除其他 web 应用的数据;只有用户或浏览器本身可以影响跨 web 应用的存储)。我没有阻止这两个按钮的默认操作,这意味着表单将由 submit 按钮提交,表单将由 reset 按钮重置。
提示严格来说,我不需要处理 reset 按钮的
click事件,因为视图模型会导致空值被写入本地存储。在这种情况下,我倾向于两次清理数据,以获得更简单的 JavaScript 代码。
这个小 web 应用的效果是表单数据是持久的,直到用户提交表单。如果用户在提交表单之前导航离开表单,他们在导航离开之前输入的数据将在下次加载 web 应用时恢复。
在文档间同步视图模型数据
本地存储中的数据是基于每个原点存储的,这意味着每个原点都有自己单独的本地存储区域。这意味着你不必担心与其他人的 web 应用发生键冲突。这也意味着我们可以使用 web 存储来同步同一域内不同文档之间的视图模型。
以这种方式使用本地存储时,我希望在另一个文档修改存储的数据值时得到通知。我可以通过处理由window浏览器对象发出的storage事件来接收这样的通知。为了让这个事件更容易使用,我创建了一种新的可观察数据项,它自动将自身保存到本地存储中,并加载更改后的值来响应storage事件。我将这个新功能添加到了utils.js文件中,如清单 6-4 中的所示。
清单 6-4。创建持久可观察数据项
`... ko.persistentObservable = function(keyName, initialValue) { var obItem = ko.observable(localStorage[keyName] || initialValue);
$(window).bind("storage", function(e) { if (e.originalEvent.key == keyName) { obItem(e.originalEvent.newValue); } }); obItem.subscribe(function(newValue) { localStorage[keyName] = newValue; }); return obItem; } ...`
这段代码是标准可观察数据项、本地存储数据数组和storage事件的包装器。调用该函数时使用的键名引用了本地存储中的数据项。当函数被调用时,我使用键来检查本地存储中是否已经有指定键的数据,如果有,就设置可观察值的初始值。如果没有默认值,我使用initialValue函数参数:
var obItem = ko.observable(localStorage[keyName] || initialValue);
我使用 jQuery 绑定到window对象上的storage事件。jQuery 将事件规范化,用一个特定于 jQuery 的替代来包装元素发出的事件对象。我需要获得底层的事件对象,因为它包含有关本地存储中的变化的信息;我通过originalEvent房产做这件事。当处理storage事件时,originalEvent属性返回一个StorageEvent对象,其最有用的属性在表 6-1 中描述。
在示例中,我使用key属性来确定这是否是我正在监视的数据项的事件,如果是,则使用newValue属性来更新常规的可观察数据项:
$(window).bind("storage", function(e) {
if (**e.originalEvent.key** == keyName) { obItem(**e.originalEvent.newValue**); } });
最后,我使用 KO subscribe方法来更新本地存储值,以响应视图模型的变化:
obItem.subscribe(function(newValue) { localStorage[keyName] = newValue; });
只需几行代码,我就能够为我的视图模型创建一个持久的可观察数据项。
我不必采取任何特殊的预防措施来防止事件-更新-订阅-事件的无限循环发生。这有两个原因。首先,我的代码包装的 KO observable 数据项足够智能,只有在更新的值与现有值不同时才发布更新。
第二,浏览器只在同一来源的其他文档中触发storage事件,而在而不是进行了更改的文档中。我一直认为这有点奇怪,但这确实意味着我的代码比其他情况下要简单。
为了展示我新的持久化数据项,我定义了一个名为embedded.html的新文档,其内容如清单 6-5 所示。
清单 6-5。使用持久可观察数据项的新文档
`
Embedded Storage Example ` `这个文档复制了主示例中的input元素,但是没有form和button元素。但是,它有一个使用persistentObservable数据项的视图模型,这意味着对本文档中input元素值的更改将反映在本地存储中,同样,本地存储中的更改将反映在input元素中。我没有为持久可观察项提供默认值;如果没有本地存储值,那么我希望初始值默认为null,这是通过不向persistentObservable函数提供第二个参数来实现的。
剩下的就是修改主文档了。为了简单起见,我将一个文档嵌入到另一个文档中,但是本地存储由来自相同来源的任何文档共享,这意味着当这些文档在不同的浏览器选项卡或窗口中时,这种技术将会工作。清单 6-6 显示了对example.html的修改,包括嵌入embedded.html文档。
清单 6-6。修改主示例文档
`
Local Storage Exampleko.applyBindings(viewModel);
$('#buttonDiv input').button().click(function(e) { localStorage.clear();
});
});
** **
`
在定义视图模型时,我为persistentObservable函数使用了相同的键,并添加了一个嵌入其他 HTML 文档的iframe元素。由于两者都是从相同的原点加载的,因此浏览器在它们之间共享相同的本地存储。通过本地存储和两个视图模型,更改一个文档中的input元素的值将触发另一个文档中相应的更改。
注意如果两个文档的更新同时写入本地存储器,浏览器不提供任何关于数据项完整性的保证。很难考虑这种可能性(我从未见过这种情况发生),但谨慎的做法是假设如果您共享本地存储,可能会发生数据损坏。
使用会话存储
本地存储的补充是会话存储,它通过sessionStorage对象访问。sessionStorage和localStorage对象以相同的方式使用,并发出相同的storage事件。不同之处在于,当文档在浏览器中关闭时,数据被删除(更具体地说,当顶级浏览上下文被破坏时,数据被删除,但这通常是一回事)。
会话存储最常见的用途是在重新加载文档时保留数据。这是一种有用的技术,尽管我不得不承认我倾向于使用本地存储来实现相同的效果。会话存储的主要好处是性能,因为数据通常保存在内存中,不需要写入磁盘。也就是说,如果你关心这提供的边际性能增益,那么你可能需要考虑浏览器是否是你的应用的最佳环境。清单 6-7 展示了我如何在utils.js中为我的可观察数据项添加会话持久性支持。
清单 6-7。使用会话存储定义半持久可观察数据项
`ko.persistentObservable = function(keyName, initialValue, useSession) { ** var storageObject = useSession ? sessionStorage : localStorage** var obItem = ko.observable(storageObject[keyName] || initialValue);
$(window).bind("storage", function(e) { if (e.originalEvent.key == keyName) { obItem(e.originalEvent.newValue); } }); obItem.subscribe(function(newValue) { storageObject[keyName] = newValue; }); return obItem; }`
由于sessionStorage和localStorage对象暴露了相同的特性并使用了相同的事件,所以我能够很容易地修改我的本地存储可观察项来添加对会话存储的支持。我在函数中添加了一个参数,如果true,就会切换到会话存储。如果参数没有提供或者是false,我使用本地存储。清单 6-8 展示了我如何将会话存储应用于示例视图模型中的两个可观察数据项。
清单 6-8。使用会话存储
... var viewModel = { personalDetails: [ {name: "name", label: "Name", value: ko.persistentObservable("name")}, ** {name: "city", label: "City",** ** value: ko.persistentObservable("city", null, true)},** ** {name: "country", label: "Country",** ** value: ko.persistentObservable("country", null, true)}** ] }; ...
使用会话存储处理City和Country元素的值,而Name元素保留在本地存储中。如果您将示例加载到浏览器中,您会发现重新加载文档不会清除您输入的任何值。但是,如果您关闭并重新打开文档,只有Name值保留。
对离线 Web 应用使用本地存储
使用本地存储的部分好处是它可以脱机使用。这意味着当浏览器离线时,我们可以使用本地数据来解决 Ajax GET 请求引起的问题。清单 6-9 显示了上一章的缓存 CheeseLux web 应用,更新后可以利用本地存储。
清单 6-9。为使用 Ajax 的离线 Web 应用使用本地存储
`
CheeseLux (document).ready(function() { ** if (cheeseModel.products) {** enhanceViewModel(); ko.applyBindings(cheeseModel);hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init();
crossroads.addRoute("category/:cat:", function(cat) { cheeseModel.selectedCategory(cat ||
cheeseModel.products[0].category);
});
('#buttonDiv input:submit').button(); ('div.navSelectors').buttonset(); $(window).bind("online offline", function() { cheeseModel.cache.online(window.navigator.onLine); });
$(window.applicationCache).bind("checking noupdate downloading " + "progress cached updateready", function(e) { cheeseModel.cache.status(window.applicationCache.status); });
('div.tagcontainer a').button().filter(':not([href])') .click(function(e) { e.preventDefault(); if ((this).attr("data-action") == "update") { window.applicationCache.update(); } else {
window.applicationCache.swapCache(); window.location.reload(false); } }); ** } else {** ** var dialogHTML = '
** });** ** }** });
在这个清单中,当 Ajax 请求成功时,我使用JSON.stringify方法存储视图模型数据的副本:
$.getJSON("products.json", function(data) { cheeseModel.products = data; ** localStorage["jsondata"] = JSON.stringify(data);**
})
我在这个 web 应用的清单的NETWORK部分添加了products.json URL,所以我有理由相信数据是可用的,Ajax 请求会成功。
但是,如果请求失败(如果浏览器脱机,这种情况肯定会发生),那么我会尝试从本地存储中定位并恢复序列化数据,如下所示:
}).error(function() { ** if (localStorage["jsondata"]) {** ** cheeseModel.products = JSON.parse(localStorage["jsondata"]);** ** }** })
假设初始请求有效,如果后续请求失败,我将有一个很好的后备位置。这种技术产生的效果类似于 Firefox 在浏览器离线时处理 Ajax 请求的方式,因为我最终使用了我能够从服务器获得的最新版本的数据。
请注意,我已经重新构建了代码,因此 web 应用设置的其余部分发生在complete处理函数中,它的触发与 Ajax 请求的结果无关。Ajax 的成败不再决定我如何处理它;现在,关键是我是否有数据,无论是从服务器上获得的还是从本地存储中恢复的。
对脱机表单使用本地存储
我在第五章中提到,在缓存的应用中处理 POST 请求的唯一方法是防止用户在浏览器离线时发起请求。这仍然是正确的,但是您可以通过使用本地存储来创建持久值,从而改善您向用户提供的体验。为了演示这种方法,我首先需要更新utils.js文件中的enhanceViewModel函数,以使用本地存储来保存表单值,如清单 6-10 所示。
清单 6-10。更新 enhanceViewModel 函数以使用本地存储
`... function enhanceViewModel() {
cheeseModel.selectedCategory ** = ko.persistentObservable("selectedCategory", cheeseModel.products[0].category);**
mapProducts(function(item) { ** item.quantity = ko.persistentObservable(item.id + "_quantity", 0);** item.subtotal = ko.computed(function() { return this.quantity() * this.price; }, item); }, cheeseModel.products, "items");
cheeseModel.total = ko.computed(function() {
var total = 0;
mapProducts(function(elem) {
total += elem.subtotal();
}, cheeseModel.products, "items");
return total;
}); };
...`
这是一个非常简单的改变,但是有几点需要注意。我想让每个奶酪产品的视图模型quantity属性持久化,所以我使用 item id属性的值来避免本地存储中的键冲突:
item.quantity = ko.persistentObservable(**item.id + "_quantity",** 0);
要注意的第二点是,当我从本地存储中加载值时,我将在视图模型中放置字符串,而不是数字。然而,JavaScript 足够聪明,可以在执行乘法运算时转换字符串,就像这样:
return this.quantity() * this.price;
一切都如我所愿。然而,JavaScript 使用相同的符号来表示字符串连接和数字相加,因此如果我试图对视图模型中的值求和,我将不得不采取额外的步骤来解析值,如下所示:
return Number(this.quantity()) + someOtherValue;
在离线应用中使用持久性
现在我已经修改了视图模型,我可以更改主文档来改进浏览器离线时处理form元素的方式。清单 6-11 展示了 HTML 标记的变化。
清单 6-11。添加当浏览器离线时处理表单的按钮
`...
我在文档中添加了一个“保存以备后用”按钮,该按钮在浏览器脱机时可见。我还修改了提交按钮,这样它只有在浏览器在线时才可见。清单 6-12 显示了对script元素的相应变化。
清单 6-12。更改脚本元素以支持离线表单
`
.getJSON("products.json", function(data) { cheeseModel.products = data; localStorage["jsondata"] = JSON.stringify(data); }).error(function() { if (localStorage["jsondata"]) { cheeseModel.products = JSON.parse(localStorage["jsondata"]); } }).complete(function() { (document).ready(function() { if (cheeseModel.products) { enhanceViewModel(); ko.applyBindings(cheeseModel);
hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init();
crossroads.addRoute("category/:cat:", function(cat) { cheeseModel.selectedCategory(cat || cheeseModel.products[0].category); });
** $('#buttonDiv input').button().click(function(e) {**
** if (e.target.type == "button") {**
** createDialog("Basket Saved for Later");**
** } else {**
** localStorage.clear();**
** } });**
(window).bind("online offline", function() { cheeseModel.cache.online(window.navigator.onLine); });
$(window.applicationCache).bind("checking noupdate downloading " + "progress cached updateready", function(e) { cheeseModel.cache.status(window.applicationCache.status); });
('div.tagcontainer a').button().filter(':not([href])') .click(function(e) { e.preventDefault(); if ((this).attr("data-action") == "update") { window.applicationCache.update(); } else { window.applicationCache.swapCache(); window.location.reload(false); } }); } else { createDialog("Try again later"); } }); }); `
这是一个简单的改变,你会很快意识到我正在做一些轻微的误导。当浏览器在线时,用户可以正常提交表单,本地存储中的所有数据都将被清除。当浏览器离线,用户点击保存以备后用按钮时,就会出现误导。我所做的就是调用createDialog函数,告诉用户表单数据已经保存。然而,我实际上不需要保存数据,因为我在视图模型中使用了持久可观察的数据项。用户不需要知道这些;他们只是获得了持久性的好处和来自 web 应用的明确信号,即表单数据尚未提交。当浏览器再次联机时,用户可以提交数据。始终使用本地存储意味着,如果用户在将表单提交给服务器之前关闭并重新加载应用,他们不会丢失数据。为了完整起见,清单 6-13 显示了createDialog函数,我在utils.js文件中定义了它。这与我在原始示例中创建错误对话框的方法相同,我将代码移到了一个函数中,因为我需要在应用的多个位置创建相同类型的对话框。
清单 6-13。createDialog 函数
function createDialog(message) { $('<div>' + message + '</div>').dialog({ modal: true, title: "Message", buttons: [{text: "OK", click: function() {$(this).dialog("close")}}] }); };
我采用了一种非常简单直接的方法在浏览器离线时处理表单数据,但是您可以很容易地看到如何创建一种更复杂的方法。例如,您可以通过提示用户提交数据来响应online事件,或者甚至使用 Ajax 自动提交数据。无论你采取什么方法,你必须确保用户理解并认可你的 web 应用正在做的事情。
存储复杂数据
存储名称/值对非常适合存储表单数据,但是对于任何更复杂的东西,这样一个简单的方法就开始失效了。还有另一个浏览器特性,叫做 IndexedDB ,可以用来存储和处理更复杂的数据。
注意 IndexedDB 只是在浏览器中存储复杂数据的两个竞争标准之一。另一个是 WebSQL。在我写这篇文章的时候,W3C 正在支持 IndexedDB,但是 WebSQL 完全有可能卷土重来,或者至少成为事实上的标准。我没有在本章中包括 WebSQL,因为目前对它的支持是有限的,但是这是一个功能性的领域,还远没有解决,在您的项目中采用其中一个标准之前,您应该查看对这两个标准的支持。
IndexedDB 仍处于早期阶段,在我撰写本文时,该功能只能通过供应商指定的前缀获得,这意味着浏览器实现仍处于试验阶段,可能会偏离 W3C 规范。目前,最符合 W3C 规范的浏览器是 Mozilla Firefox,所以这是我用来演示 IndexedDB 的浏览器。
注意本章中的例子可能不适用于 Firefox 以外的浏览器。事实上,除了我在本章中使用的版本(版本 10)之外,它们甚至不能在其他版本的 Firefox 上运行。也就是说,即使规范或实现发生了变化,您仍然应该能够对 IndexedDB 的工作原理有一个坚实的理解。
IndexedDB 特性是围绕数据库组织的,这些数据库与本地和会话存储一样,都是基于每个来源隔离的,因此它们可以在来自相同来源的应用之间共享。IndexedDB 不遵循关系数据库中常见的基于 SQL 的表结构。IndexedDB 数据库由对象存储库组成,其中可以包含 JavaScript 对象。您可以将 JavaScript 对象添加到对象存储中,并且可以用不同的方式查询这些存储,其中一些我将很快演示。
这种方法的结果是一种更符合 JavaScript 语言风格的存储机制,但最终使用起来有点笨拙。IndexedDB 中的几乎所有操作都是作为异步请求执行的,函数可以附加到这些请求上,以便在操作完成时执行它们。为了演示 IndexedDB 如何工作,我将创建一个奶酪查找器应用。我将奶酪产品数据放入 IndexedDB 数据库,并为用户提供一些不同的方法来搜索他们可能喜欢的奶酪数据。图 6-2 展示了完成的 web 应用,为后面的代码提供一些上下文。
图 6-2。使用 IndexedDB 查询产品数据
图中显示了搜索正在使用的每种产品的描述的选项。我搜索过 cow 这个词,页面底部列出了那些描述中包含这个词的产品。(有几个匹配,因为很多描述都解释说奶酪是用牛奶做的。)
创建索引数据库和对象存储
这个例子的代码被分割在utils.js文件和主example.html文档之间。我将在这些文件之间跳转,展示 IndexedDB 提供的核心特性。首先,我在utils.js中定义了一个DBO对象和setupDatabase函数,如清单 6-14 中的所示。
清单 6-14。建立索引数据库
`var DBO = { dbVersion: 31 }
function setupDatabase(data, callback) { var indexDB = window.indexedDB || window.mozIndexedDB; var req = indexDB.open("CheeseDB", DBO.dbVersion);
req.onupgradeneeded = function(e) { var db = req.result;
var existingStores = db.objectStoreNames; for (var i = 0; i < existingStores.length; i++) { db.deleteObjectStore(existingStores[i]); }
var objectStore = db.createObjectStore("products", {keyPath: "id"}); objectStore.createIndex("category", "category", {unique: false});
.each(data, function(index, item) { var currentCategory = item.category; .each(item.items, function(index, item) { item.category = currentCategory; objectStore.add(item); }); }); };
req.onsuccess = function(e) { DBO.db = this.result; callback(); }; };`
我定义了一个名为DBO的对象,它执行两个重要的任务。首先,它定义了我期望使用的数据库的版本。每次我对数据库模式进行更改时,我都会增加dbVersion属性的值,正如您所看到的,我花了 31 次更改才得到本例中我想要的结果。这主要是因为当前的规范草案和 Firefox 中的实现之间的差异。
提示版本号是一个重要的机制,可以确保我为我的应用使用正确的模式版本。稍后,我将向您展示如何检查模式版本,如果需要的话,如何升级模式。
在setupDatabase函数中,我首先定位充当 IndexedDB 数据库网关的对象,如下所示:
var indexDB = window.indexedDB || **window.mozIndexedDB;**
IndexedDB 特性目前只能通过window.mozIndexedDB对象在 Firefox 中使用,但是一旦实现收敛到最终规范,它将变为window.indexedDB。为了给你最大的机会让本章这一部分的例子发挥作用,我试着首先使用“官方的”IndexedDB 对象,如果它不可用,就使用厂商前缀的替代对象。下一步是打开数据库:
var req = **indexDB.open("CheeseDB", DBO.dbVersion);**
两个参数是数据库的名称和预期的模式版本。如果指定的数据库已经存在,IndexedDB 将打开它,如果不存在,将创建它。来自open方法的结果是一个表示打开数据库请求的对象。要在 IndexedDB 中完成任何事情,您必须为请求的一个或多个可能结果提供处理函数。
响应需要升级的结果
当我打开数据库时,我关心两种可能的结果。首先,如果数据库已经存在,并且模式版本与我期望的版本不匹配,我希望得到通知。当这种情况发生时,我想删除数据库中的对象存储并重新开始。我通过onupgradeneeded属性注册一个函数来接收模式不匹配的通知:
`req.onupgradeneeded = function(e) { var db = req.result;
var existingStores = db.objectStoreNames; for (var i = 0; i < existingStores.length; i++) { db.deleteObjectStore(existingStores[i]); }
var objectStore = db.createObjectStore("products", {keyPath: "id"}); objectStore.createIndex("category", "category", {unique: false});
.each(data, function(index, item) { var currentCategory = item.category; .each(item.items, function(index, item) { item.category = currentCategory; objectStore.add(item); }); }); };`
数据库对象可通过由open方法返回的请求的result属性获得。我通过objectStoreNames属性获得现有对象存储的列表,并使用deleteObjectStore方法依次删除每个对象存储。在删除对象存储时,我也删除了它们包含的数据。对于这样一个简单的 web 应用来说,这很好,因为所有的数据都来自服务器,很容易替换,但是如果您的数据库包含用户操作生成的数据,您可能需要采用更复杂的方法。
注意分配给
onupgradeneeded属性的函数是您修改数据库模式的唯一机会。如果您尝试在其他地方添加或删除对象存储,浏览器将会生成错误。
一旦现有的对象存储不碍事了,我可以使用createObject store 方法创建一些新的。此方法的参数是新存储的名称和一个可选对象,该对象包含要应用于新存储的配置设置。我使用了keyPath配置选项,它允许我为添加到存储中的对象设置一个默认键。我已经指定了id属性作为键。我还使用createIndex方法在新创建的对象存储上创建了一个索引。索引允许我使用除键以外的属性在对象存储中执行搜索,在本例中,属性是 category。我将很快向您展示如何使用索引。
最后,我将对象添加到数据存储中。当我在主文档中使用这个函数时,我将使用从 Ajax 请求中获得的数据来处理products.json文件。这与我在本书中使用的数据格式相同。我使用 jQuery each函数来枚举每个类别及其包含的项目。我已经为每个商品添加了一个category属性,这样我可以更容易地找到属于同一类别的所有产品。
提示使用 HTML5 结构化克隆技术克隆你添加到对象库中的对象。这是一种比 JSON 更全面的序列化技术,浏览器通常可以处理复杂的对象,只要这些属性都不是函数或 DOM API 对象。
对成功结果的回应
我关心的第二个结果是成功,我通过为打开数据库的请求的onsuccess属性分配一个函数来处理它,如下所示:
req.onsuccess = function(e) { DBO.db = this.result; callback(); };
该函数中的第一条语句将打开的数据库分配给DBO对象的db属性。这只是保持数据库句柄的一种便捷方式,这样我就可以在其他函数中使用它,稍后我将演示这一点。
第二条语句调用作为第二个参数传递给setupDatabase函数的回调函数。在执行onsuccess函数之前假设数据库是打开的是不安全的,这意味着我需要某种机制来通知函数调用方数据库已经成功打开,可以开始与数据相关的操作。
提示 IndexedDB 请求有一个对应的结果属性叫做
onerror。我不会在这些例子中做任何错误处理,因为在我写这篇文章的时候,试图处理 IndexedDB 错误导致的问题比它解决的问题还多。理想情况下,当你阅读本章时,这种情况会有所改善,你将能够编写更健壮的代码。
将数据库整合到 Web 应用中
清单 6-15 显示了示例应用的标记和内联 JavaScript。除了特定于数据库的函数之外,本例中的所有内容都依赖于前面章节中介绍的主题。
清单 6-15。消耗数据库的网络应用
`
CheeseLux Cheese Finder** function handleSearchResults(resultData) {**
** if (resultData) {**
** viewModel.selectedItems.removeAll();**
** if ($.isArray(resultData)) {**
** for (var i = 0; i < resultData.length; i++) {**
** viewModel.selectedItems.push(resultData[i]);**
** }**
** } else {**
** viewModel.selectedItems.push(resultData);**
** }**
** } }**
.getJSON("products.json", function(data) { ** setupDatabase(data, function() {** (document).ready(function() {
hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init();
crossroads.addRoute("mode/:mode:", function(mode) { viewModel.selectedMode(mode || viewModel.searchModes[0]); viewModel.selectedItems.removeAll(); $('#textsearch').val(""); }); crossroads.parse(location.hash.slice(1));
ko.applyBindings(viewModel); ('div.groupcontent a').button().click(function() { var sText = $('#textsearch').val(); switch (viewModel.selectedMode()) { case "ID": ** getProductByID(sText, handleSearchResults)** break; case "Description": ** getProductsByDescription(sText, handleSearchResults);** break; case "Category": ** getProductsByCategory(sText, handleSearchResults);** break; }; }); }); }); });
| Name | Price | Description |
|---|---|---|
正如您现在所期望的,我已经使用了一个视图模型来将应用的状态绑定到 HTML 标记。文档的大部分都用来定义和控制提供给用户的视图,并支持用户交互。
当用户点击搜索按钮时,根据所选择的搜索模式,调用utils.js文件中的三个函数之一。如果用户选择了按产品 ID 搜索,则调用getProductByID函数。当用户想要搜索产品描述时使用getProductsByDescription功能,而getProductsByCategory功能用于查找特定类别中的所有产品。这些函数中的每一个都有两个参数:要搜索的文本和结果应该发送到的回调函数(即使搜索对象存储也是使用 IndexedDB 的异步操作)。所有三种搜索模式的回调函数都是相同的:handleSearchResults。搜索功能的结果将是单个产品对象或一组对象。handleSearchResults函数的作用是清除视图模型中selectedItems 可观察数组的内容,并用新的结果替换它们;这将导致元素被更新,并将结果显示给用户。
注意,我将大部分代码语句放在了setupDatabase函数回调函数内的内联script元素中。这是数据库成功打开时调用的函数。
通过按键定位物体
第一个搜索函数是getProductByID,它根据id属性的值定位一个对象。您可能还记得,在创建数据库时,我将该属性指定为对象存储的键:
var objectStore = db.createObjectStore("products", **{keyPath: "id"}**);
使用对象的键获取对象非常简单。清单 6-16 显示了我在utils.js文件中定义的getProductByID函数。
清单 6-16。使用对象的键定位对象
function getProductByID(id, callback) { var transaction = DBO.db.transaction(["products"]); var objectStore = transaction.objectStore("products"); var req = objectStore.get(id); req.onsuccess = function(e) { callback(this.result); }; }
这个函数展示了查询数据库中对象存储的基本模式。首先,您必须使用transaction方法创建一个事务,声明您想要使用的对象存储。只有这样,您才能在刚刚创建的事务上使用objectStore方法打开一个对象存储。
提示您不需要显式关闭对象存储或事务;当它们超出范围时,浏览器会为您关闭它们。试图显式强制关闭商店或事务没有任何好处。
我使用get方法获得具有指定键的对象,它最多匹配一个对象(如果有多个对象具有相同的键,那么匹配第一个匹配的对象)。该方法返回一个请求,我必须为onsuccess属性提供一个函数,以便在搜索完成时得到通知。匹配的对象在请求的result属性中可用,我通过调用传递给getProductByID函数的回调函数将它传递回 web 应用的主要部分(您应该记得,它是handleSearchResults函数)。
来自get方法的(最终)结果是一个 JavaScript 对象,或者,如果没有匹配,则是null。我不必担心从数据库存储的序列化数据中重新创建对象,也不必使用任何类型的对象关系映射层。IndexedDB 数据库始终处理 JavaScript 对象,这是一个很好的特性。
每当你想执行一个简单的操作时,都不得不使用回调,这有点令人沮丧,但这很快就变成了习惯。其结果是一种非常适合 JavaScript 世界的存储机制,当执行长时间操作时,它不会占用执行的主线程,但需要仔细思考和应用设计才能正确使用。
用光标定位物体
当用户想通过描述搜索产品时,我必须采取不同的方法。描述在我的对象存储中不是一个键,我希望能够查找部分匹配(否则用户将不得不准确地输入所有描述来进行匹配)。清单 6-17 显示了在utils.js中定义的getProductsByDescription函数。
清单 6-17。使用光标定位对象
function getProductsByDescription(text, callback) { var searchTerm = text.toLowerCase(); var results = []; var transaction = DBO.db.transaction(["products"]); var objectStore = transaction.objectStore("products"); ** objectStore.openCursor().onsuccess = function(e) {** ** var cursor = this.result;** ** if (cursor) {** ** if (cursor.value.description.toLowerCase().indexOf(searchTerm) > -1) {** ** results.push(cursor.value);** ** }** ** cursor.continue();** ** } else {** ** callback(results);** ** }** ** };** };
我在这里的技术是使用一个光标来枚举对象存储中的所有对象,并寻找那些其products属性包含用户提供的搜索词的对象。游标只是在我枚举一系列数据库对象时记录我的进度。
IndexedDB 没有文本搜索功能,所以我必须自己处理。在对象存储上调用openCursor方法会创建一个请求,当光标打开时会执行该请求的onsuccess回调。光标本身可以通过this上下文对象的 result 属性获得。(它也应该可以通过传递给函数的事件的result属性获得,但是当前的实现并不总是可靠地设置它。)
如果光标不是null,那么在value属性中有一个可用的对象。我检查对象的description属性是否包含我要寻找的术语,如果包含,我将对象push放入一个局部数组。为了将光标移动到下一个对象,我调用了continue方法,该方法再次执行onsuccess函数。
当我读取了对象存储中的所有对象时,光标是null。此时,我的本地数组包含所有匹配我的搜索的对象,我使用回调将它们传递回 web 应用的主要部分,回调作为第二个参数提供给getProductsByDescription函数。
使用索引定位对象
枚举对象存储中的所有对象并不是查找对象的有效方式,这就是为什么我在设置对象存储时为category属性创建了一个索引:
objectStore.createIndex("category", "category", {unique: false});
createIndex方法的参数是索引的名称、将被索引的对象中的属性以及一个配置对象,我用它来告诉 indexed db,category属性的值不是惟一的。
在清单 6-18 中显示的getProductsByCategory函数使用索引来缩小光标所列举的对象。
清单 6-18。使用 IndexedDB 索引
function getProductsByCategory(searchCat, callback) { var results = []; var transaction = DBO.db.transaction(["products"]); var objectStore = transaction.objectStore("products"); ** var keyRange = IDBKeyRange.only(searchCat);** ** var index = objectStore.index("category");** index.openCursor**(keyRange)**.onsuccess = function(e) { var cursor = this.result; if (cursor) { results.push(cursor.value); cursor.continue(); } else { callback(results); } }; };
IDBKeyRange对象有许多方法来约束键值,这些键值将匹配对象存储中的对象。我已经使用了only方法来指定我只想要精确的匹配。
我通过调用对象存储上的index方法打开索引,并在打开光标时将IDBKeyRange对象作为参数传入。这就缩小了通过游标可用的对象集,意味着我通过回调传递的结果只包含指定类别中的奶酪产品。在这个例子中没有部分匹配;用户必须输入整个类别名称,例如法国奶酪。
总结
在本章中,我向您展示了如何使用本地存储在浏览器中持久地存储名称/值对,以及如何在离线 web 应用中使用该功能来处理 HTML 表单。我还向您展示了 IndexedDB 的特性,它远没有那么成熟,但是作为使用自然 JavaScript 对象和语言习惯用法存储和查询更复杂数据的基础,它显示了很大的潜力。
IndexedDB 还不能用于生产,但是我发现本地存储在很多情况下都非常健壮和有用。我发现它特别有助于使表单更有用、更少烦人,就像我在本章中演示的那样。本地存储特性非常易于使用,尤其是当它嵌入到应用视图模型中时。
在下一章中,我将向您展示如何创建响应性 web 应用,这些应用能够适应并响应运行它们的设备的功能。
七、创建响应式 Web 应用
有两种方法可以让一个 web 应用面向多个平台。首先是为你想要瞄准的每种设备创建不同版本的应用:台式机、智能手机、平板电脑等等。我会在第八章中给你一些如何做到这一点的例子。
另一种方法,也是本章的主题,是创建一个响应式 web 应用,这仅仅意味着 web 应用适应运行它的设备的功能。我喜欢这种方法,因为它没有在移动设备和“普通”设备之间划出明显的界限。
这很重要,因为智能手机、平板电脑和台式机的功能混淆在一起。许多移动浏览器已经有很好的 HTML5 支持,带触摸屏的台式机也越来越普遍。在这一章中,我将向你展示可以用来创建灵活多变的 web 应用的技术。
设置视口
我需要解决一个特定于智能手机和平板电脑上运行的浏览器的问题(我将开始称之为移动浏览器)。移动浏览器通常从一个假设开始,即网站是为大屏幕桌面设备设计的,因此,用户需要一些帮助才能浏览它。这是通过视窗完成的,它缩小了网页,这样用户就能对整个页面结构有所了解。用户然后放大到页面的特定区域,以便阅读或使用它。你可以在图 7-1 中看到效果。
图 7-1。手机浏览器中默认视口的效果
注图 7-1 中的截图是 Opera 手机模拟器的,可以从
[www.opera.com/developer/tools/mobile](http://www.opera.com/developer/tools/mobile)获取。虽然它有一些怪癖,但这个模拟器相当忠实于真实的 Opera Mobile,后者广泛用于移动设备。我喜欢它,因为它允许我创建屏幕大小从小型智能手机到大型平板电脑的模拟器,并选择是否支持触摸事件。另外,您可以使用标准的 Opera 开发工具调试和检查您的 web 应用。仿真器不能代替在一系列真实硬件设备上的测试,但是在开发的早期阶段非常方便。
这是一个明智的功能,但你需要禁用它的网络应用;否则,内容和控件的显示尺寸太小,无法使用。清单 7-1 展示了如何使用 HTML meta标签禁用这个特性,我已经将它应用到一个简化版的 CheeseLux web 应用中,这将是本章的基础示例。
清单 7-1。使用 meta 标签控制 CheeseLux Web 应用中的视窗
`
CheeseLux ** ** (document).ready(function() { ('#buttonDiv input:submit').button().css("font-family", "Yanone"); ('div.cheesegroup').not("#basket").css("width", "50%"); $('div.navSelectors').buttonset();enhanceViewModel(); ko.applyBindings(cheeseModel);
hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads);
hasher.init();
crossroads.addRoute("category/:newCat:", function(newCat) { cheeseModel.selectedCategory(newCat ? newCat : cheeseModel.products[0].category); }); crossroads.parse(location.hash.slice(1)); }); });
Gourmet European Cheese
| Cheese | Subtotal | |
|---|---|---|
| $ | ||
| Total: | $ | |
<div class="cheesegroup" data-bind="fadeVisible: category == cheeseModel.selectedCategory()">
将高亮显示的meta元素添加到文档中会禁用缩放功能。你可以在图 7-2 中看到效果。这个特殊的meta标签告诉浏览器使用显示器的实际宽度显示 HTML 文档,而不进行任何放大。当然,web 应用仍然是一团糟,但它是以正确的大小显示的,这是向响应性应用迈出的第一步。在本章的其余部分,我将向您展示如何响应不同的设备特性和功能。
图 7-2。禁用 web 应用的视窗的效果
响应屏幕大小
媒体查询是根据设备功能定制 CSS 样式的有效方法。从响应性 web 应用的角度来看,设备最重要的特征可能是屏幕大小,CSS 媒体查询很好地解决了这个问题。如图 7-2 所示,CheeseLux 标志在小屏幕上占据了很大的空间,我可以使用 CSS 媒体查询来确保它只在更大的显示器上显示。清单 7-2 显示了我添加到styles.css文件中的一个简单的媒体查询。
清单 7-2。简单的媒体查询
@media screen AND (max-width:500px) { *.largeScreenOnly { display: none; } }
提示 Opera Mobile 大肆缓存 CSS 和 JavaScript 文件。当试验媒体查询时,最好的技术是在主 HTML 文档中定义 CSS 和脚本代码,当您对结果满意时,将它移到外部文件中。否则,您需要清除缓存(或重新启动模拟器)以确保应用您的更改。
标签告诉浏览器这是一个媒体查询。我已经指定只有当设备是屏幕(相对于投影仪或印刷材料)并且宽度不大于 500 像素时,才应该应用该查询中包含的largeScreenOnly样式。
提示在这一章中,我将把世界分为两类显示器。小型显示器将是那些宽度不超过 500 像素的显示器,而大型显示器将是其他所有的显示器。这是简单而随意的,你可能需要设计更多的类别来获得你的 web 应用所需要的效果。我将完全忽略显示器的高度。我的简单分类将使本章中的例子易于管理,尽管是以牺牲粒度为代价的。
如果满足这些条件,那么定义一个样式,将分配给largeScreenOnly类的任何元素的 CSS display属性设置为none,这将隐藏该元素。添加到样式表后,我可以通过对我的标记应用largeScreenOnly类来确保 CheeseLux 标识只在大显示器上显示,如清单 7-3 所示。
清单 7-3。使用 CSS 媒体查询来响应屏幕尺寸
`...
Gourmet European Cheese
CSS 媒体查询是实时的,这意味着如果调整浏览器窗口的大小,屏幕大小的类别会改变。这在移动设备上用处不大,但这意味着一个响应迅速的 web 应用即使在桌面平台上也能适应显示尺寸。你可以在图 7-3 中看到布局是如何变化的。
图 7-3。使用媒体查询来管理元素的可见性
通过 JavaScript 使用媒体查询
为了正确地将媒体查询集成到 web 应用中,我们需要使用 W3C CSS 对象模型规范的视图模块,它将 JavaScript 媒体查询支持引入到浏览器中。使用window.matchMedia方法在 JavaScript 中评估媒体查询,如清单 7-4 所示。我在utils.js文件中定义了detectDeviceFeatures函数;目前,它只检测屏幕大小,但我稍后会检测一些附加功能。清单中有很多内容,所以我将在接下来的部分中对其进行分解并解释各个部分。
清单 7-4。在 JavaScript 中使用媒体查询
function detectDeviceFeatures(callback) { var deviceConfig = {}; Modernizr.load({ test: window.matchMedia,
` nope: 'matchMedia.js',
complete: function() {
var screenQuery = window.matchMedia('screen AND (max-width:500px)');
deviceConfig.smallScreen = ko.observable(screenQuery.matches);
if (screenQuery.addListener) {
screenQuery.addListener(function(mq) {
deviceConfig.smallScreen(mq.matches);
});
}
deviceConfig.largeScreen = ko.computed(function() { return !deviceConfig.smallScreen(); });
setInterval(function() { deviceConfig.smallScreen(window.innerWidth <= 500); }, 500);
callback(deviceConfig); } }); };`
装载聚合填料
我需要使用 polyfill 来确保我可以使用matchMedia方法。桌面浏览器对这一特性的支持很好,但在移动世界却不尽如人意。我使用的 polyfill 叫做matchMedia.js,可以从[github.com/paulirish/matchMedia.js](http://github.com/paulirish/matchMedia.js)买到。
我想仅在浏览器本身不支持matchMedia功能时加载聚合填充。为此,我使用了Modernizr.load方法,这是一个灵活的资源加载器。我向load方法传递一个对象,该对象的属性告诉 Modernizr 该做什么。
提示
Modernizr.load特性仅在您创建自定义 Modernizr 构建时可用;它不包含在 Modernizr 库的未压缩开发版本中。Modernizr load 方法是一个名为 YepNope 的库的包装器,可在[yepnopejs.com](http://yepnopejs.com)获得。如果出于任何原因不想使用压缩的 Modernizr 构建,可以直接使用 YepNope。[yepnopejs.com](http://yepnopejs.com)网站还包含所有装载机功能的详细信息;当这个库包含在 Modernizr 中时,语法不会改变。在外部 JavaScript 文件中使用资源加载器时要小心。可能会出现严重的问题,我在第九章中描述了这些问题。您将在 Modernizr 网页上看到一个创建自定义下载的链接。对于我在本章中使用的定制构建,我简单地检查了所有选项,以便在下载中包含尽可能多的 Modernizr 功能。
test属性,顾名思义,指定了我希望 Modernizr 计算的表达式。在这种情况下,我想看看window.matchMedia方法是否由浏览器定义。您可以使用任何带有test属性的 JavaScript 表达式,包括 Modernizr 特性检测检查。
属性告诉 Modernizr,如果test对false求值,我想加载什么资源。在本例中,我指定了包含多填充代码的matchMedia.js文件。有一个相应的属性yep,它告诉 Modernizr 如果test是true需要什么资源,但是我不需要在这个例子中使用它,因为如果test是true,我将依赖于对matchMedia的内置支持。complete属性指定了一个函数,当yep或nope属性指定的资源都已被加载和执行时,该函数将被执行。
Modernizr.load异步获取并执行 JavaScript 脚本,这就是为什么detectDeviceFeatures函数将回调函数作为参数。我在complete函数的末尾调用这个回调函数,传入一个包含已检测到的特性细节的对象。
检测屏幕尺寸
我现在可以开始计算这款设备的屏幕属于我的大类还是小类。为此,我向matchMedia方法传递一个媒体查询,就像我在 CSS 中使用的一样,如下所示:
var screenQuery = **window.matchMedia('screen AND (max-width:500px)');**
我通过读取从matchMedia返回的对象的matches属性来确定我的媒体查询是否匹配。如果matches是true,那么我正在处理的屏幕属于我的小类(500 像素及更小)。如果是false,那么我有一个大屏幕。我将结果赋给对象中的一个可观察数据项,并将其传递给回调函数:
var deviceConfig = { smallScreen: ko.observable(screenQuery.matches) };
如果浏览器实现了matchMedia特性,那么我可以使用addListener方法在媒体查询的状态改变时得到通知,如下所示:
if (screenQuery.addListener) { screenQuery.addListener(function(mq) { deviceConfig.smallScreen(mq.matches); }); }
当介质查询包含的条件之一改变时,介质查询的状态也会改变。我的查询中的两个条件是,我们正在一个屏幕上工作,它的最大宽度为 500 像素。因此,改变通知指示显示的宽度已经改变。这意味着浏览器窗口的大小已被调整或屏幕方向已被改变(详见本章后面的“响应屏幕方向”一节)。
matchMedia.js polyfill 不支持变更通知,所以在使用之前,我必须测试一下addListener方法是否存在。当媒体查询的状态改变并且我更新可观察数据项的值时,我的函数被执行。我做的最后一件事是创建一个计算的可观察数据项,就像这样:
deviceConfig.largeScreen = ko.computed(function() { return !deviceConfig.smallScreen(); });
这只是为了帮助整理我的语法,当我想在我的 web 应用的其余部分引用屏幕尺寸时,这样我就可以引用smalllScreen和largeScreen来弄清楚我正在处理什么,而不是smallScreen和!smallScreen。这是一件小事,但我这样做可以减少打字错误。
一些浏览器在处理媒体查询中状态变化的方式上不一致。例如,当我写这篇文章的时候,谷歌浏览器的最新版本并不总是在屏幕尺寸改变的时候更新媒体查询。作为一项严格的措施,我添加了一个对屏幕尺寸的简单检查,这是使用setInterval函数设置的:
setInterval(function() { deviceConfig.smallScreen(window.innerWidth <= 500); }, 500);
该函数每 500 毫秒执行一次,并更新视图模型中的屏幕尺寸项目。这并不理想,但重要的是一个响应迅速的 web 应用能够适应设备的变化,这可能意味着采取一些不可取的预防措施,包括轮询状态变化。
提示注意,我使用了
window.innerWidth属性来计算屏幕的大小。我正在解决的问题是,媒体查询不能在所有浏览器中正常工作,所以我需要找到一种替代机制来评估屏幕大小。
将能力检测集成到 Web 应用中
我想在 web 应用中做任何事情之前检测设备的功能,这就是我为什么添加了一个对detectDeviceFeatures函数的回调。在清单 7-5 中,你可以看到我是如何将这个函数的使用集成到 web app script元素中的。
清单 7-5。从内联脚本元素调用 detectDeviceFeatures 函数
`
** detectDeviceFeatures(function(deviceConfig) {** ** cheeseModel.device = deviceConfig;** .getJSON("products.json", function(data) { cheeseModel.products = data; }).success(function() { (document).ready(function() { ('#buttonDiv input:submit').button().css("font-family", "Yanone"); ('div.cheesegroup').not("#basket").css("width", "50%"); $('div.navSelectors').buttonset();
enhanceViewModel(); ko.applyBindings(cheeseModel);
hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init();`
crossroads.addRoute("category/:newCat:", function(newCat) { cheeseModel.selectedCategory(newCat ? newCat : cheeseModel.products[0].category); }); }); ** });** }); </script>
我将detectDeviceFeatures函数传递给回调的对象分配给视图模型中的设备属性。通过使用可观察的数据项,当媒体查询改变时,我将改变从视图模型传播到应用中。
最后一步是利用 web 应用标记中对视图模型的增强。清单 7-6 显示了我如何通过数据绑定来控制 CheeseLux 标志的可见性。
清单 7-6。基于通过视图模型表达的屏幕能力控制元素可见性
`...
Gourmet European Cheese
结果是重新创建了在 JavaScript 中使用 CSS 媒体查询的效果。CheeseLux 徽标仅在大屏幕上可见。你可能会奇怪,为什么我要花这么大力气用 JavaScript 重新创建一个简单而优雅的 CSS 技术。原因很简单:通过我的 web 应用视图模型推送关于设备功能的信息给了我创建响应性 web 应用的基础,这些应用比单独使用 CSS 要强大和灵活得多。下一节给出了一个例子。
推迟图像加载
简单隐藏一个img元素的问题是浏览器仍然会加载它;它只是从来没有向用户展示过。这是一个荒谬的情况,因为它花费了我和的用户带宽来下载一个永远不会在小屏幕设备上显示的资源。为了解决这个问题,我在utils.js文件中定义了一个名为ifAttr的新数据绑定,如清单 7-7 中的所示。这种绑定基于对条件的评估来添加和移除属性。
清单 7-7。用于有条件地设置元素属性的数据绑定
ko.bindingHandlers.ifAttr = { update: function(element, accessor) { if (accessor().test) { $(element).attr(accessor().attr, accessor().value); } else { $(element).removeAttr(accessor().attr); } } }
这个绑定期望一个包含三个属性的数据对象:attr属性指定我想要应用哪个属性,test属性确定该属性是否被添加到元素中,value属性指定如果test是true将被分配给该属性的值。清单 7-8 显示了如何将这个绑定应用到我的 CheeseLux 徽标标记上,以推迟加载图像,直到需要它的时候。
清单 7-8。使用 ifAttr 绑定防止图像加载
`
当img元素没有src属性时,浏览器无法加载图像。为了利用这一点,我在largeScreen视图模型项中使用了ifAttr属性,这样src属性只在图像显示时才被设置。通过这种方式,我能够阻止图像加载,除非它将被显示。这是一个非常简单的技巧,但是展示了在创建一个响应式 web 应用时应该寻求的灵活性。
提示将你不想马上使用的资源和你根本不太可能想要的资源区分开来是很重要的。如果你有一个合理的预期,用户在正常使用你的应用时会需要一个图像,那么你应该让浏览器下载它,以便在需要的时候可以立即得到。如果用户不太可能需要资源,使用
ifAttr技术来避免浪费下载。
调整网络应用布局
从现在开始,我只需要根据我感兴趣的两类屏幕来调整 web 应用的每个部分。清单 7-9 显示了所需的变更。
提示在应用了清单 7-10 中的更改之前,不要试图在浏览器中加载这个清单。如果这样做,您将得到一个错误,因为视图模型数据和数据绑定不同步。
清单 7-9。使网络应用适应大小屏幕
`
CheeseLux ` ` (document).ready(function() {** function performScreenSetup(smallScreen) {** ** $('div.cheesegroup').not("#basket")** ** .css("width", smallScreen ? "" : "50%");** ** };**
** cheeseModel.device.smallScreen.subscribe(performScreenSetup);** ** performScreenSetup(cheeseModel.device.smallScreen());**
('div.navSelectors').buttonset();
enhanceViewModel(); ko.applyBindings(cheeseModel);
hasher.initialized.add(crossroads.parse, crossroads); hasher.changed.add(crossroads.parse, crossroads); hasher.init();
crossroads.addRoute("category/:newCat:", function(newCat) { cheeseModel.selectedCategory(newCat ? newCat : cheeseModel.products[0].category); }); crossroads.parse(location.hash.slice(1)); }); }); });
<div id="basket" class="cheesegroup basket" ** data-bind="visible: cheeseModel.device.largeScreen()">**
| Cheese | Subtotal | |
|---|---|---|
| $ | ||
| Total: | $ | |
这种方法的乐趣在于,只需要很少的改变就能让 web 应用响应屏幕大小(以及这些改变是多么简单)。也就是说,有少量的变化需要解释,我将在下面的部分中提供。你可以在图 7-4 中看到我的响应式 web app 是如何出现在大小屏幕上的。
图 7-4。在大小屏幕上显示相同的网络应用
这些微小的变化会产生很大的影响,而且在很大程度上,这些变化只是表面上的。我的 web 应用的基本功能和结构保持不变。我不必为了支持一个更小屏幕的设备而放弃我的视图模型或路由。
调整源数据
类别按钮在小屏幕上是一个问题,所以我想向用户显示一些有意义但需要较少屏幕空间的东西。为此,我在products.json文件中添加了一些内容,以便在空间有限的情况下,每个类别都包含一个名称。清单 7-10 显示了其中一个类别的添加。
清单 7-10。向产品数据添加屏幕特定信息
... {"category": "British Cheese", ** "shortName": "British",** "items" : [ {"id": "stilton", "name": "Stilton", "price": 9, "description": "A semi-soft blue cow's milk cheese produced in the Nottinghamshire region. A strong cheese with a distinctive smell and taste and crumbly texture."}, ...
我已经对products.json文件中的所有其他类别进行了类似的修改。我可以通过在空格字符上拆分类别值字符串来获得短名称,但是我想指出的是,不仅仅是 web 应用中的脚本和标记可以响应;您还可以在驱动应用的数据中支持这一概念。
在[清单 7-9 中,我修改了导航按钮的数据绑定,以利用更短的类别名称,如下所示:
`
对于formatAttr绑定,我仍然使用完整的类别名称。这使我可以使用同一组导航路线,而不管屏幕大小如何(参见第四章了解在网络应用中使用路线的详细信息)。
应用条件 jQuery UI 样式
在大屏幕布局中,我调整了产品列表元素的大小,以便为购物篮腾出空间。在小屏幕布局中,我在每个部分的末尾用一行合计替换专用的购物篮。如果可以的话,我喜欢利用matchMedia.addListener功能,这意味着我必须能够根据需要在大小屏幕布局之间切换。为了适应这种情况,我将那些在自己的函数中驱动单个布局的脚本语句视为视图模型中更改的订阅者:
function performScreenSetup(smallScreen) { ** $('div.cheesegroup').not("#basket").css("width", smallScreen ? "" : "50%"); ** }; cheeseModel.device.smallScreen.subscribe(performScreenSetup);
只有当值发生变化时,才会调用该函数,所以我显式调用该函数,以便在文档首次加载时获得正确的行为,如下所示:
performScreenSetup(cheeseModel.device.smallScreen());
实际上,我根据屏幕的大小切换了cheesegroup类中div元素的 CSS width属性。你可以忽略这种方法,让布局保持初始状态,但是我认为这是一个为桌面用户提供良好体验的机会。
从文档中删除元素
在大多数情况下,我只是根据屏幕的大小隐藏和显示文档中的元素。但是,有时需要使用if和ifnot绑定来确保元素完全从文档中移除。在清单中可以看到一个简单的例子,我使用了if绑定来获得一行总摘要:
`
我在这里使用了if绑定,因为隐藏在styles.css文件中的是一个应用圆角的 CSS 样式:
div.groupcontent:last-child { border-bottom-left-radius: 8px; border-bottom-right-radius: 8px; }
浏览器在判断哪个元素是其父元素的最后一个子元素时,不会考虑元素的可见性。如果我使用了visible绑定,那么我在大屏幕布局中没有得到我想要的圆角。if绑定通过完全移除元素来强制我想要的行为,确保圆角被正确应用。
响应屏幕方向
许多移动设备通过在横向和纵向模式之间改变屏幕方向来响应用户握持设备的方式。保持对显示模式的了解是相当棘手的,但是确保你的 web 应用在方向改变时做出适当的响应是值得的。有几种方法可以解决这个问题。
一些设备支持一个window.orientation属性和一个orientationchange事件,以便更容易地跟踪屏幕方向,但这个特性并不通用,即使实现了,事件也往往在不应该被触发的时候被触发(在应该被触发的时候没有被触发)。
其他设备支持将orientation作为媒体查询的一部分。如果作为matchMedia的一部分支持addListener功能,这是很有用的,但是大多数移动浏览器不支持这个功能,而这些设备的方向最有可能改变。
几乎所有的浏览器都支持一个resize事件,当调整窗口大小或改变方向时,就会触发这个事件。然而,一些实现在方向改变和事件被触发之间引入了延迟,这使得 web 应用响应缓慢,并且可能在用户已经开始以新的方向进行交互之后改变其布局或行为。
最后一种方法是定期检查屏幕尺寸,并手动确定方向。这是一种粗糙但有效的方法,只有当检查的频率足够高,可以快速响应,但又足够低,不会让设备不堪重负时,这种方法才有效。
确保检测到方向变化的唯一可靠方法是应用所有四种技术。清单 7-11 显示了对detectDeviceFeatures函数的必要添加。
清单 7-11。检测屏幕方向变化
`function detectDeviceFeatures(callback) { var deviceConfig = {};
** deviceConfig.landscape = ko.observable();** ** deviceConfig.portrait = ko.computed(function() {** ** return !deviceConfig.landscape();** ** });**
** var setOrientation = function() {** ** deviceConfig.landscape(window.innerWidth > window.innerHeight);** ** }** ** setOrientation();**
** $(window).bind("orientationchange resize", function() {** ** setOrientation();** ** });**
** setInterval(setOrientation, 500);**
** if (window.matchMedia) {** ** var orientQuery = window.matchMedia('screen AND (orientation:landscape)')** ** if (orientQuery.addListener) {** ** orientQuery.addListener(setOrientation);** ** }** ** }**
Modernizr.load({
test: window.matchMedia,
nope: 'matchMedia.js',
complete: function() {
var screenQuery = window.matchMedia('screen AND (max-width:500px)');
deviceConfig.smallScreen = ko.observable(screenQuery.matches);
if (screenQuery.addListener) { screenQuery.addListener(function(mq) {
deviceConfig.smallScreen(mq.matches);
});
}
deviceConfig.largeScreen = ko.computed(function() {
return !deviceConfig.smallScreen();
});
setInterval(function() { deviceConfig.smallScreen(window.innerWidth <= 500); }, 500);
callback(deviceConfig); } }); };`
我已经建立了两个视图模型数据项,landscape和portrait,遵循我用于smallScreen和largeScreen的相同模式。我不想重复我的代码来测试设备的方向,所以我创建了一个简单的名为setOrientation的内嵌函数来设置landscape数据项的值:
var setOrientation = function() { deviceConfig.landscape(**window.innerWidth > window.innerHeight**); }
我发现比较window对象的innerWidth和innerHeight值是确定屏幕方向最可靠的方法。screen.width和screen.height值应该起作用,但是一些浏览器在设备重定向时不会改变这些值。属性提供了很好的信息,但是它并没有被普遍实现。这无疑是一种妥协,我建议您在目标设备上测试这种方法的有效性。
其余的添加实现了调用setOrientation的各种方法:通过orientationchange和resize事件,通过媒体查询,以及通过轮询。判断轮询方向的正确频率很困难,但我通常使用 500 毫秒。它并不总是像我希望的那样响应迅速,但它达到了合理的平衡。
提示我本可以使用一个单独的
setInterval调用来轮询屏幕大小和方向,但是我更喜欢将代码功能的区域尽可能分开。
将屏幕方向整合到网络应用中
我可以让 web 应用响应屏幕方向,因为视图模型已经有了portrait和landscape项。为了演示这一点,我将解决一个问题:web 应用目前需要用户向下滚动,才能在小屏幕设备上以横向模式查看所有元素。图 7-5 显示了我修改 web 应用布局后的问题和结果。
图 7-5。响应小屏幕上的横向方向
为了适应小屏幕的这种定位,我移除了类别导航元素,用左右按钮来替换它们。这不是最优雅的方法,但它很好地利用了有限的屏幕空间,同时保留了 web 应用的基本特性。清单 7-12 显示了添加数据绑定来控制导航项目的可见性。
清单 7-12。绑定元素对屏幕大小和方向的可见性
`<div class="cheesegroup" ** data-bind="ifnot: cheeseModel.device.smallScreen() &&** ** cheeseModel.device.landscape()">**
`如果设备的屏幕很小,并且是横向的,我会从 DOM 中删除这些元素。我添加的按钮如下:
`
`元素本身并不有趣,但是处理单击时出现的导航的代码值得一看:
... function performScreenSetup(smallScreen) { $('div.cheesegroup').not("#basket") .css("width", smallScreen ? "" : "50%"); ** $('button#left').button({icons:** ** {primary: "ui-icon-circle-triangle-w"},text: false});** ** $('button#right').button({icons:** ** {primary: "ui-icon-circle-triangle-e"},text: false});** ** $('button#left, button#right').click(function(e) {** ** e.preventDefault();** ** advanceCategory(e, this.id);** ** });** }; ...
这是一个使用路由导航不起作用的例子。我希望用户能够重复点击这些按钮,正如我已经提到的,浏览器不会响应试图导航到已经显示的相同 URL。考虑到这一点,我使用 jQuery click方法通过调用advanceCategory函数来处理常规的 JavaScript 事件。我在utils.js中定义了这个函数,它显示在清单 7-13 中。
清单 7-13。高级分类功能
function advanceCategory(e, dir) { var cIndex = -1; for (var i = 0; i < cheeseModel.products.length; i++) { if (cheeseModel.products[i].category == cheeseModel.selectedCategory()) { cIndex = i; break; } } cIndex = (dir == "left" ? cIndex - 1 : cIndex + 1) % (cheeseModel.products.length); if (cIndex < 0) { cIndex = cheeseModel.products.length -1; } cheeseModel.selectedCategory(cheeseModel.products[cIndex].category) }
视图模型中的类别没有整齐的顺序,所以我通过数据枚举来查找当前所选类别的索引,并根据所单击的按钮来增加或减少值。结果是更紧凑的布局,更适合小屏幕横向。我对设备进行分类的方式相当粗糙,我建议您在实际项目中采用更细粒度的方法,但它可以演示您需要的技术,以便对屏幕方向做出响应。
对触摸做出反应
响应式 web 应用需要处理的最后一个特性是触摸支持。基于触摸的交互理念在智能手机和平板电脑市场已经根深蒂固,但它也正在向桌面进军,主要是通过微软 Windows 8。
为了支持触摸交互,我们需要两样东西:触摸屏和发出触摸事件的浏览器。这两者并不总是走到一起;例如,将支持触摸的显示器插入台式机并不会自动在浏览器中启用触摸。同样,你不应该假设如果一个设备支持触摸,这将是唯一的交互模式。许多设备将支持鼠标和键盘交互以及触摸,用户应该能够在使用 web 应用时选择适合他们的模式,并在它们之间自由切换。
没有常规鼠标和键盘的设备合成诸如click的事件以响应触摸事件。这意味着您不需要对您的 web 应用进行更改来支持基本的触摸交互。然而,要创建一个真正响应的 web 应用,你应该考虑支持触摸设备上常见的导航手势,比如滑动。我将很快演示如何做到这一点。
检测触摸支持
触摸事件有一个 W3C 规范,但它是低级的,需要做大量的工作来弄清楚用户正在做什么手势。正如我以前说过的,web 应用开发的部分乐趣在于高质量 JavaScript 库的可用性,它使开发变得更加简单。一个这样的例子是 touchSwipe ,它建立在 jQuery 之上,将低级别的触摸事件转换成表示手势的事件。我将 touchSwipe 库包含在本书附带的源代码下载中,可以从 Apress.com 获得。图书馆的网址是[labs.skinkers.com/touchSwipe](http://labs.skinkers.com/touchSwipe)。
检测触摸支持最简单、最可靠的方法是依靠 Modernizr 测试。清单 7-14 显示了添加到utils.js文件中的detectDeviceFeatures函数,用于检测和报告触摸支持,并显示了使用 touchSwipe 来响应触摸事件。
清单 7-14。检测对触摸事件的支持
`function detectDeviceFeatures(callback) { var deviceConfig = {};
deviceConfig.landscape = ko.observable(); deviceConfig.portrait = ko.computed(function() { return !deviceConfig.landscape(); });
var setOrientation = function() { deviceConfig.landscape(window.innerWidth > window.innerHeight); } setOrientation();
$(window).bind("orientationchange resize", function() { setOrientation(); });
setInterval(setOrientation, 500);
if (window.matchMedia) {
var orientQuery = window.matchMedia('screen AND (orientation:landscape)')
if (orientQuery.addListener) {
orientQuery.addListener(setOrientation);
} }
Modernizr.load([{ test: window.matchMedia, nope: 'matchMedia.js', complete: function() { var screenQuery = window.matchMedia('screen AND (max-width:500px)'); deviceConfig.smallScreen = ko.observable(screenQuery.matches); if (screenQuery.addListener) { screenQuery.addListener(function(mq) { deviceConfig.smallScreen(mq.matches); }); } deviceConfig.largeScreen = ko.computed(function() { return !deviceConfig.smallScreen(); }); } }, { ** test: Modernizr.touch,** ** yep: 'jquery.touchSwipe-1.2.5.js',** ** callback: function() {** ** $('html').swipe({** ** swipeLeft: advanceCategory,** ** swipeRight: advanceCategory** ** });** ** }** },{ complete: function() { callback(deviceConfig); } }]); };`
当您将一个对象数组传递给Modernizr.load方法时,会依次执行每个测试。我已经添加了一个使用Modernizr.touch检查的测试,如果存在触摸支持,它将加载 touchSwipe 库。
提示如果你下载了你自己版本的 Modernizr,确保你包含了触摸测试。本章源代码中的版本 I 包含了所有可用的测试。
注意,我使用了callback属性来设置对处理刷卡的支持。使用callback属性设置的函数在加载指定的资源时执行,而使用complete指定的函数在测试结束时执行,不管测试结果如何。我想只有在已经加载 touchSwipe 的情况下才处理 swipe 事件(这本身表明存在触摸支持),所以我使用了callback来赋予 Modernizr 我的功能。
使用swipe方法应用 touchSwipe 库。在这个例子中,我选择了html元素作为检测滑动手势的目标。一些浏览器限制了body元素的大小,这样当内容小于可用空间时就不会填满整个窗口。这通常不是问题,但在处理手势时,它会在屏幕上产生盲点,因为手势可能不是针对单个元素的。解决这个问题最简单的方法是处理html元素。
touchSwipe 库能够区分不同种类的触摸事件和在一系列方向上的滑动。在这个例子中,我只关心左右滑动,这就是为什么我在传递给swipe方法的对象中为swipeLeft和swipeRight属性定义了一个函数。在这两种情况下,我都指定了advanceCategory函数,这个函数就是我之前用来更改所选类别的函数。结果是向左滑动移动到上一个类别,向右滑动进入下一个类别。关于这个清单需要注意的最后一点是传递给Modernizr.load方法的数组中的最后一项:
{ complete: function() { callback(deviceConfig); } }
我不想调用回调函数,除非我已经在将被添加到视图模型的结果对象中设置了所有的设备细节。确保这一点的最简单方法是创建一个额外的测试,只包含一个complete函数。Modernizr 不会执行这个函数,直到所有其他的测试都已执行,所需的资源都已加载,并且前面所有测试的callback和complete函数都已执行。
使用触摸浏览网络应用历史
在前面的例子中,我通过循环浏览可用的产品类别来响应滑动手势。在这一节中,我将向您展示如何以更有效的方式回应这些手势。
诱惑是使用浏览器的历史来响应滑动。问题是,没有办法查看历史记录中的上一个或下一个条目,看它是否属于 web 应用。如果不是,那么你最终会让用户离开你的 web 应用,潜在地导航到一个他们无意访问的 URL。清单 7-15 显示了对utils.js文件中的enhanceViewModel函数所需的更改,以建立跟踪用户类别选择的基本支持。
提示你可以选择使用本地存储,让刷卡相关的历史持久化。我不喜欢这样做,因为我认为将历史记录限制在 web 应用的当前生命周期更有意义。
清单 7-15。使用会话存储添加特定于应用的历史记录
`function enhanceViewModel() {
cheeseModel.selectedCategory = ko.observable(cheeseModel.products[0].category);
mapProducts(function(item) {
item.quantity = ko.observable(0);
item.subtotal = ko.computed(function() {
return this.quantity() * this.price; }, item);
}, cheeseModel.products, "items");
cheeseModel.total = ko.computed(function() { var total = 0; mapProducts(function(elem) { total += elem.subtotal(); }, cheeseModel.products, "items"); return total; });
** var history = cheeseModel.history = {};** ** history.index = 0;** ** history.categories = [cheeseModel.selectedCategory()];** ** cheeseModel.selectedCategory.subscribe(function(newValue) {** ** if (newValue != history.categories[history.index]) { ** ** history.index++;** ** history.categories.push(newValue);** ** }** ** })** };`
添加很简单。我已经在视图模型中添加了一个索引和一个数组,并订阅了selectedCategory observable 数据项,这样我就可以在用户改变类别时建立他们的历史记录。我不担心管理阵列的大小,因为我认为不太可能进行足够多的类别更改来导致容量问题。清单 7-16 展示了广告的变化。
清单 7-16。利用特定于应用的历史记录
`function advanceCategory(e, dir) { ** if (cheeseModel.device.smallScreen() && cheeseModel.device.landscape()) {** var cIndex = -1; for (var i = 0; i < cheeseModel.products.length; i++) { if (cheeseModel.products[i].category == cheeseModel.selectedCategory()) { cIndex = i; break; } } cIndex = (dir == "left" ? cIndex-1 : cIndex + 1) % (cheeseModel.products.length); if (cIndex < 0) { cIndex = cheeseModel.products.length -1; } cheeseModel.selectedCategory(cheeseModel.products[cIndex].category)
** } else {**
** var history = cheeseModel.history;**
** if (dir == "left" && history.index > 0) {**
** cheeseModel.selectedCategory(history.categories[--history.index]);**
** } else if (dir == "right" && history.index < history.categories.length -1) {**
** cheeseModel.selectedCategory(history.categories[++history.index]);**
** } }**
}`
当 web 应用以横向显示在小屏幕上时,我必须小心不要应用滑动历史。我删除了这个设备配置中的类别按钮,这意味着用户无法生成历史记录供我浏览。在所有其他设备配置中,我可以通过更改索引值和选择相应的历史类别来响应滑动。结果是,用户可以使用导航按钮在类别之间导航,并且在最近的选择中向后或向前滑动。
结合应用途径
我想做的最后一个调整是通过 web 应用的 URL 路由来响应滑动事件。在上一个清单中,我采取了直接更改可观察数据项的捷径,但这意味着我将绕过因 URL 更改而生成的任何代码,包括与 HTML5 History API 的集成(我在第四章的中对此进行了描述)。这些变化如清单 7-17 所示。
清单 7-17。通过应用路由响应刷卡事件
`function advanceCategory(e, dir) { if (cheeseModel.device.smallScreen() && cheeseModel.device.landscape()) { var cIndex = -1; for (var i = 0; i < cheeseModel.products.length; i++) { if (cheeseModel.products[i].category == cheeseModel.selectedCategory()) { cIndex = i; break; } } cIndex = (dir == "left" ? cIndex-1 : cIndex + 1) % (cheeseModel.products.length); if (cIndex < 0) { cIndex = cheeseModel.products.length -1; } cheeseModel.selectedCategory(cheeseModel.products[cIndex].category)
} else { var history = cheeseModel.history; if (dir == "left" && history.index > 0) { ** location.href = "#category/" + history.categories[--history.index];** } else if (dir == "right" && history.index < history.categories.length -1) { ** location.href = "#category/" + history.categories[++history.index];** } } }`
我使用了 browser location对象来改变浏览器显示的 URL。因为我已经指定了相对 URL,所以浏览器不会离开 web 应用,并且我的路线将能够匹配这些 URL。通过这样做,我确保了我对滑动事件的响应与其他形式的导航一致。
总结
在这一章中,我已经向你展示了为了创建一个响应式 web 应用,你必须适应的三个特征:屏幕尺寸、屏幕方向和触摸交互。通过检测和适应不同的设备配置,您可以创建一个 web 应用,该应用可以无缝、优雅地调整其布局和交互模型,以适应用户的设备。当你考虑到智能手机和平板电脑的激增以及这些设备和台式机之间的界限模糊时,这种方法的优势是显而易见的。在下一章中,我将向您展示支持不同类型设备的不同方法:创建特定于平台的 web 应用。