JavaScript-Web-应用高级教程-二-

75 阅读39分钟

JavaScript Web 应用高级教程(二)

原文:Pro JavaScript for Web Apps

协议:CC BY-NC-SA 4.0

四、使用 URL 路由

在这一章中,我将向你展示如何在你的 web 应用中添加另一个服务器端的概念:URL 路由。URL 路由背后的想法非常简单:我们将 JavaScript 函数与内部的 ?? URL 联系起来。内部 URL 是相对于当前文档的 URL,包含一个散列片段。事实上,它们通常只表示为散列片段本身,比如#summary

在正常情况下,当用户点击一个指向内部 URL 的链接时,浏览器会查看文档中是否有一个元素的id属性值与片段相匹配,如果有,就滚动以使该元素可见。

当我们使用 URL 路由时,我们通过执行 JavaScript 函数来响应这些导航变化。这些函数可以显示和隐藏元素,更改视图模型,或者执行应用中可能需要的其他任务。使用这种方法,我们可以为用户提供一种在应用中导航的机制。

当然,我们可以使用事件。问题还是在于规模。对于小型简单的 web 应用来说,处理由元素触发的事件是一种完全可行且可接受的方法。对于更大、更复杂的应用,我们需要更好的东西,URL 路由提供了一种简单、优雅、可伸缩的好方法。当我们使用 URL 作为导航机制时,向 web 应用添加新的功能区域,并为用户提供使用它们的方法,变得非常简单和健壮。

构建一个简单的路由 Web 应用

解释 URL 路由的最好方式是用一个简单的例子。清单 4-1 显示了一个依赖于路由的基本 web 应用。

清单 4-1。一个简单的路由 Web 应用

`

    Routing Example                              ` `                   

            $('div.catSelectors').buttonset();

             hasher.initialized.add(crossroads.parse, crossroads);              hasher.changed.add(crossroads.parse, crossroads);              hasher.init();

            crossroads.addRoute("select/Apple", function() {                  viewModel.selectedItem("Apple");              });             crossroads.addRoute("select/Orange", function() {                  viewModel.selectedItem("Orange");              });             crossroads.addRoute("select/Banana", function() {                  viewModel.selectedItem("Banana");              });         });     

         
        
            The selected item is:         
    
`

这是一个相对较短的列表,但是有很多内容,所以我将在接下来的部分中分解内容并解释活动的部分。

添加路由库

我将再次使用一个公开可用的库来获得我需要的效果。周围有一些 URL 路由库,但我最喜欢的一个叫做 Crossroads。它简单、可靠、易于使用。它有一个缺点,那就是它依赖于同一作者的另外两个库。我喜欢看到依赖关系被整合到一个单独的库中,但是这并不是一个普遍的偏好,这仅仅意味着我们必须下载一些额外的文件。表 4-1 列出了我们从下载档案中需要的项目和 JavaScript 文件,这些文件应该复制到 Node.js 服务器content目录中。(如果您不想单独下载这些文件,这三个文件都是本书源代码下载的一部分。可在 Apress.com 免费下载。)

Image

我使用script元素将 Crossroads、它的支持库和我的新cheeseutils.js文件添加到 HTML 文档中:

`...

**  **

`

通过对容器元素应用buttonset方法,我能够从子a元素创建一组按钮。我使用了buttonset,而不是button,这样 jQuery UI 将在一个连续的块中设计元素的样式。你可以在图 4-1 中看到这造成的效果。

Image

图 4-1。应用路由的基本应用

buttonset方法创建的按钮之间没有空间,按钮组的外边缘被很好地圆化了。您还可以在图中看到一个内容元素。这个想法是,点击其中一个按钮将允许用户显示相应的内容项。

应用 URL 路由

我几乎准备好了一切:一组导航控件和一组内容元素。我现在需要将它们连接在一起,这是通过应用 URL 路由来实现的:

`

    $(document).ready(function() {         ko.applyBindings(viewModel);

        $('div.catSelectors').buttonset();

**         hasher.initialized.add(crossroads.parse, crossroads);** **         hasher.changed.add(crossroads.parse, crossroads);** **         hasher.init();    **

**        crossroads.addRoute("select/Apple", function() {** **             viewModel.selectedItem("Apple");** **         });** **        crossroads.addRoute("select/Orange", function() {** **             viewModel.selectedItem("Orange");** **         });** **        crossroads.addRoute("select/Banana", function() {** **             viewModel.selectedItem("Banana");** **         });**     }); `

突出显示的前三条语句设置了 Hasher 库,以便它能与 Crossroads 一起工作。Hasher 通过location.hash browser 对象响应内部 URL 的变化,并在有变化时通知 Crossroads。

Crossroads 检查新的 URL,并将其与给定的每条路线进行比较。使用addRoute方法定义路线。该方法的第一个参数是我们感兴趣的 URL,第二个参数是用户导航到该 URL 时要执行的函数。因此,例如,如果用户导航到#select/Apple,那么将视图模型中的selectedItem可观察值设置为Apple的函数将被执行。

Image 提示我们在使用addRoute方法时不需要指定#字符,因为 Hasher 在通知 Crossroads 发生变化之前会删除它。

在这个例子中,我定义了三条路由,每条路由对应于我在a元素上使用formatAttr绑定创建的一个 URL。

这是 URL 路由的核心。您创建一组驱动 web 应用行为的 URL 路由,然后在文档中创建导航到这些 URL 的元素。图 4-2 显示了示例中这种导航的效果。

Image

图 4-2。浏览示例 web 应用

当用户点击一个按钮时,浏览器导航到由底层a元素的href属性指定的 URL。这种导航变化被路由系统检测到,从而触发对应于该 URL 的功能。该函数更改视图模型中可观察项目的值,并导致用户显示表示所选项目的元素。

需要理解的重要一点是,我们正在使用浏览器的导航机制。当用户单击其中一个导航元素时,浏览器移动到目标 URL 尽管 URL 位于同一文档中,但浏览器的历史记录和 URL 栏会更新,如图所示。

这给 web 应用带来了两个好处。首先是后退按钮的工作方式符合大多数用户的预期。第二,用户可以手动输入 URL 并导航到应用的特定部分。要查看这两种行为的运行情况,请按照以下步骤操作:

  1. Load the list in the browser.

  2. Enter cheeselux.com/#select/Banana in the address bar of the browser.

  3. Click the back button of the browser.

当您单击橙色按钮时,橙色项目被选中,并且该按钮被突出显示。当您输入 URL 时,香蕉商品也会发生类似的情况。这是因为应用的导航机制现在由浏览器来协调,这就是我们如何能够使用 URL 路由来分离应用的另一个方面。

在我看来,第一个好处是最有用的。当用户单击后退按钮时,浏览器会导航回上一次访问的 URL。这是一个导航更改,如果以前的 URL 在我们的文档中,新的 URL 将与应用定义的路由集匹配。这是一个将应用状态展开到上一步的机会,在示例应用中,上一步显示橙色按钮。对于用户来说,这是一种更自然的工作方式,特别是与使用常规事件相比,在常规事件中,点击后退按钮往往会导航到用户在应用之前访问的站点。

巩固路线

在前面的例子中,我分别定义了每条路由及其执行的功能。如果这是定义路由的唯一方式,那么复杂的 web 应用将会陷入路由和功能的泥沼,并且与常规事件处理相比没有任何优势。幸运的是,URL 路由非常灵活,我们可以轻松地合并我们的路由。在接下来的部分中,我将描述这方面可用的技术。

使用可变段

清单 4-4 显示了将之前演示的三条路线合并成一条路线是多么容易。

清单 4-4。合并路线

`

    $(document).ready(function() {         ko.applyBindings(viewModel);

        $('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);** **         });**     }); `

URL 的路径部分由段组成。比如 URL 路径select/Apple有两段,分别是selectApple。当我指定一条路线时,像这样:

/select/Apple

只有当两段完全匹配时,路由才会与 URL 匹配。在清单中,我已经能够通过添加一个变量段来合并我的路线。可变段允许路由匹配具有相应段的任何值的 URL。因此,为了明确起见,简单 web 应用中的所有导航 URL 都将匹配我的新路线:

select/Apple select/Orange select/Banana

第一段仍然是静态的*,这意味着只有第一段是select的 URL 才会匹配,但是我为第二段添加了一个通配符。*

*这样我就可以适当地响应 URL,变量段的内容作为参数传递给我的函数。我使用这个参数来更改视图模型中可观察的selectedItem的值,这意味着/select/Apple的 URL 会导致如下调用:

viewModel.selectedItem('Apple');

一个 URLselect/Cherry将导致这样一个调用:

viewModel.selectedItem('Cherry');

处理意外的段值

最后一个网址有问题。在我的 web 应用中没有一个名为 Cherry 的项目,将视图模型observable设置为这个值会为用户创建一个奇怪的效果,如图图 4-3 所示。

Image

图 4-3。意外变量段值的结果

URL 路由带来的灵活性也是一个问题。能够导航到应用的特定部分对用户来说是一个有用的工具,但是,对于用户提供输入的所有机会,我们必须防止意外的值。对于我的示例应用,验证变量段值的最简单方法是检查视图模型中数组的内容,如清单 4-5 所示。

清单 4-5。忽略意外的段值

... crossroads.addRoute("select/{item}", function(item) {     **if (viewModel.items.indexOf(item) > -1) {**         viewModel.selectedItem(item);     **}** }); ...

在这个清单中,我选择了阻力最小的方法,即简单地忽略意外值。有许多可供选择的方法。我本可以显示一条错误消息,或者如清单 4-6 所示,接受这个意外的值并将其添加到视图模型中。

清单 4-6。通过将意外值添加到视图模型中来处理它们

`["Apple", "Orange", "Banana"]),         selectedItem: ko.observable("Apple")     };

    $(document).ready(function() {         ko.applyBindings(viewModel);

        $('div.catSelectors').buttonset();

        hasher.initialized.add(crossroads.parse, crossroads);         hasher.changed.add(crossroads.parse, crossroads);         hasher.init();

        crossroads.addRoute("select/{item}", function(item) { **            if (viewModel.items.indexOf(item)== -1) {** **                viewModel.items.push(item);** **                $('div.catSelectors').buttonset();** **            }**             viewModel.selectedItem(item);         });     }); `

如果变量 segment 的值不是视图模型中的items数组中的值之一,那么我使用push方法添加新值。我改变了视图模型,所以使用ko.observableArray方法,items数组是一个可观察的项目。一个可观察数组就像一个常规的可观察数据项,除了像foreach这样的绑定会随着数组内容的改变而更新。使用可观察数组意味着添加一个项目会导致 Knockout 在文档中生成内容和导航元素。

这个过程的最后一步是再次调用 jQuery UI buttonset方法。KO 不知道应用于a元素来创建按钮的 jQuery UI 样式,必须重新应用这个方法才能获得正确的效果。在图 4-4 中可以看到导航到#select/Cherry的结果。

Image

图 4-4。将意外的段值合并到应用状态中

使用可选段

可变段的限制是 URL 必须包含一个段值来匹配路由。比如路由select/{item}会匹配任何一个第一段是select的两段式 URL,但是不会匹配select/Apple/Red(因为段太多)或者select(因为段太少)。

我们可以使用可选航段来增加路线的灵活性。清单 4-7 显示了该示例的可选段上的应用。

清单 4-7。使用路线中的可选路段

... crossroads.addRoute(**"select/:item:"**, function(item) {     **if (!item) {**         **item = "Apple";**     } else  if (viewModel.items.indexOf(item)== -1) {         viewModel.items.push(item);         $('div.catSelectors').buttonset();     }     viewModel.selectedItem(item); }); ...

为了创建一个可选的段,我简单地用冒号替换括号字符,这样{item}就变成了:item:。通过这一改变,路由将匹配具有一个或两个段并且第一个段是select的 URL。如果没有第二段,那么传递给函数的参数将为 null。在我的清单中,如果是这种情况,我默认使用Apple值。一条路线可以包含任意多的静态、变量和可选航段。在这个例子中,我将保持我的路线简单,但是您可以创建几乎任何您需要的组合。

添加默认路线

随着可选段的引入,我的路由将匹配一段和两段 URL。我想添加的最后一个路由是一个默认路由,它是一个当 URL 中根本没有段时将被调用的路由。这是完成对后退按钮的支持所必需的。要查看我正在解决的问题,请将清单加载到浏览器中,单击其中一个导航元素,然后单击 Back 按钮。你可以在图 4-5 中看到效果——或者说,没有效果。

Image

图 4-5。导航回应用起始点

单击“后退”按钮时,应用不会重置为其原始状态。只有当点击 Back 按钮将浏览器带回到 web 应用的基本 URL(在我的例子中是[cheeselux.com](http://cheeselux.com))时,才会发生这种情况。什么都不会发生,因为基本 URL 与应用定义的路由不匹配。清单 4-8 显示了增加一条新的路线来解决这个问题。

清单 4-8。为基本 URL 添加路由

`...

...`

此路由不包含任何类型的段,只匹配基本 URL。现在,单击 Back 按钮直到到达基本 URL 会使应用返回到其初始状态。(嗯,它回到了它的初始状态;在这一章的后面,我将解释这种方法中的一个小问题,并告诉你如何改进它。)

使事件驱动控件适应导航

并不总是能够限制文档中的元素,使得所有的导航都可以通过a元素来处理。当向路由的应用添加 JavaScript 事件时,我遵循一个简单的模式,它在 URL 路由和常规事件之间架起了一座桥梁,给了我许多路由的好处,也让我可以使用其他类型的元素。清单 4-9 展示了这种应用于其他元素类型的模式。

清单 4-9。URL 路由和 JavaScript 事件之间的桥接

`...

...`

这里的技术是向元素添加一个data-url属性,这些元素的事件将导致导航的改变。我使用 jQuery 来处理具有data-url属性的元素的changeclick事件。处理这两个事件让我能够迎合不同种类的input元素。我使用了live方法,这是一个简洁的 jQuery 特性,它依靠事件传播来确保在脚本执行后为添加到文档中的元素处理事件;当文档中的元素集可以根据视图模型的变化而改变时,这是非常重要的。这种方法允许我使用这样的元素:

`...

              **             
...`

该标记为视图模型items数组中的每个元素生成一组单选按钮。我用我的自定义formatAttr数据绑定为data-url属性创建值,我在前面已经描述过了。select元素需要一些特殊的处理,因为当select元素触发change事件时,关于哪个值被选中的信息是从子option元素获得的。下面是创建一个使用该模式的select元素的一些标记:

`...

                           
...`

目标 URL 的一部分在select元素的data-url属性中,其余部分取自option元素的value属性。包括select在内的一些元素会同时触发clickchange事件,所以在使用location.replace触发导航更改之前,我会检查目标 URL 是否不同于当前 URL。清单 4-10 显示了这种技术如何应用到select元素、按钮、单选按钮和复选框中。

清单 4-10。事件之间的桥接和不同类型元素的路由

`

    Routing Example                                                  

            $('div.catSelectors').buttonset();

            hasher.initialized.add(crossroads.parse, crossroads);             hasher.changed.add(crossroads.parse, crossroads);             hasher.init();

            crossroads.addRoute("select/:item:", function(item) {                 if (!item) {                     item = "Apple";                 } else  if (viewModel.items.indexOf(item)== -1) {                     viewModel.items.push(item); $('div.catSelectors').buttonset();                 }                 if (viewModel.selectedItem() != item) {                     viewModel.selectedItem(item);                 }             });

            crossroads.addRoute("", function() {                 viewModel.selectedItem("Apple");             })

**            ('[data-url]').live("change click", function(e) {** **                var target = (e.target).attr("data-url");** **                if (e.target.tagName == 'SELECT') {** **                    target += $(e.target).children("[selected]").val();** **                }                ** **                if (location.hash != target) {** **                    location.replace(target);** **                }** **            })**         });     

         
        
            The selected item is:         
    

**    

** **        ** **            ** **            ** **        ** **    
**

**    

        ** **    
**

**    

** **        ** **            ** **            ** **        ** **    
**

**    

** **        ** **            ** **            ** **        ** **    
**

`

我定义了另一个定制绑定来正确设置适当的option元素上的selected属性。我把这个绑定叫做selected(显然足够了),它被定义了,如清单 4-11 所示,在utils.js文件中。

清单 4-11。所选数据绑定

ko.bindingHandlers.selected = {     init: function(element, accessor) {         if (accessor()) {             $(element).siblings("[selected]").removeAttr("selected");             $(element).attr("selected", "selected");         }     },     update: function(element, accessor) {         if (accessor()) {             $(element).siblings("[selected]").removeAttr("selected");             $(element).attr("selected", "selected");         }     } }

您可能想简单地处理事件并直接触发应用更改。这是可行的,但是您将增加应用的复杂性,因为您需要承担开销或者创建和管理路由来跟踪哪些元素的哪些事件触发了不同的状态变化。我的建议是关注 URL 路由,并使用桥接,如这里所述,将事件从元素传递到路由系统。

使用 HTML5 历史 API

到目前为止,我在本章中使用的 Crossroads 库依赖于同一作者的 Hasher 库来接收 URL 更改时的通知。Hasher 库监控 URL,并在它改变时告诉 Crossroads,触发路由行为。

这种方法有一个弱点,那就是应用的状态不会作为浏览器历史的一部分保存下来。以下是演示该问题的一些步骤:

  1. Load the manifest into the browser.
  2. Click the orange button.
  3. Navigate directly to #select/Cherry.
  4. Click the banana button.
  5. Click the back button twice.

一切都开始得很好。当您导航到#select/Cherry URL 时,新项目被添加到视图模型并被正确选择。当您第一次单击后退按钮时,Cherry 项目再次被正确选择。当您第二次单击后退按钮时,问题出现了。所选项目正确地绕回橙色,但樱桃项目仍然在列表中。应用能够使用 URL 来选择正确的项目,但是当最初选择橙色项目时,视图模型中没有 Cherry 项目,但是它仍然显示给用户。

对于一些 web 应用来说,这没什么大不了的,对于这个简单的例子来说也是如此。毕竟,用户是否能够选择他们首先明确添加的项目并不重要。但是对于其他 web 应用,这是一个关键问题,确保视图模型正确地保存在浏览器历史中是至关重要的。我们可以使用 HTML5 历史 API 来解决这个问题,这使我们能够比 web 程序员更好地访问浏览器历史。我们通过windows.history或全局history对象访问历史 API。对于这种情况,我对历史 API 的两个方面感兴趣。

Image 注意除了维护应用状态之外,我不打算讨论 HTML5 API。我在的 HTML5 权威指南中提供了全部细节,该指南也由 Apress 出版。你可以在[dev.w3.org/html5/spec](http://dev.w3.org/html5/spec)阅读 W3C 规范(关于历史 API 的信息在 5.4 节,但这可能会改变,因为 HTML5 规范仍在草案中)。

history.replaceState方法允许您将一个状态对象与浏览器历史中当前文档的条目相关联。这种方法有三个论据:第一个是状态对象,第二个参数是历史中使用的标题,第三个是文档的 URL。第二个参数不被当前一代的浏览器使用,但是 URL 参数允许您有效地替换与当前文档相关联的历史中的 URL。本章中我感兴趣的部分是第一个参数,我将用它来存储历史中的viewModel.items数组的内容,这样当用户点击后退和前进按钮时,我可以正确地维护状态。

Image 提示你也可以使用history.pushState方法将新条目插入历史记录中。该方法采用与replaceState相同的参数,并且可以用于插入额外的状态信息。

每当激活的历史条目改变时,window浏览器对象触发一个popstate事件。如果条目有与之相关的状态信息(因为使用了replaceStatepushState方法),那么您可以通过history.state属性检索状态对象。

向示例应用添加历史状态

当使用历史 API 时,事情并不像你想的那么简单;它遇到了大多数 HTML5 APIs 共有的两个问题。第一个问题是,并不是所有的浏览器都支持历史 API。显然,HTML5 之前的浏览器不知道历史 API,但即使是一些支持其他 HTML5 特性的浏览器版本也没有实现历史 API。

第二个问题是那些实现 HTML5 API 的浏览器会引入不一致性,这需要一些仔细的测试。因此,即使历史 API 帮助我们解决了一个问题,我们也面临着其他问题。尽管如此,历史 API 是值得使用的,只要你承认它不是普遍支持的,并且需要一个后备。清单 4-12 展示了向简单示例 web 应用添加历史 API。

清单 4-12。使用 HTML5 历史 API 保存视图模型状态

`

    Routing Example                     **    **                              

           $('div.catSelectors').buttonset();

           crossroads.addRoute("select/:item:", function(item) {                if (!item) {                    item = "Apple";                } else  if (viewModel.items.indexOf(item)== -1) {                    viewModel.items.push(item);                }

               if (viewModel.selectedItem() != item) {                    viewModel.selectedItem(item);                }

**               $('div.catSelectors').buttonset();** **               if (Modernizr.history) {** **                   history.replaceState(viewModel.items(), document.title, location);** **               }**            });

           crossroads.addRoute("", function() {                viewModel.selectedItem("Apple");            })

**           if (Modernizr.history) {** **               (window).bind("popstate", function(event) {** **                   var state = history.state ? history.state** **                       : event.originalEvent.state;** **                   if (state) {            ** **                       viewModel.items.removeAll();** **                       .each(state, function(index, item) {** **                           viewModel.items.push(item);** **                       });** **                   }** **                   crossroads.parse(location.hash.slice(1));** **               });                  ** **           } else {** **               hasher.initialized.add(crossroads.parse, crossroads);** **               hasher.changed.add(crossroads.parse, crossroads);** **               hasher.init();            ** **           }**

       });   

         
        
            The selected item is:         
    
`
存储应用状态

当主应用路由匹配一个 URL 时,清单中的第一组更改存储应用状态。通过响应 URL 更改,我能够在用户单击导航元素或直接输入 URL 时保留状态。下面是存储状态的代码:

`...

... crossroads.addRoute("select/:item:", function(item) {     if (!item) {         item = "Apple";     } else  if (viewModel.items.indexOf(item)== -1) {         viewModel.items.push(item);     }

    if (viewModel.selectedItem() != item) {         viewModel.selectedItem(item);     }

    $('div.catSelectors').buttonset(); **    if (Modernizr.history) {** **        history.replaceState(viewModel.items(), document.title, location);** **    }** }); ...`

清单中新的script元素将 Modernizr 库添加到 web 应用中。Modernizr 是一个特性检测库,它包含了确定浏览器是否支持大量 HTML5 和 CSS3 特性的检查。您可以下载 Modernizr,并在[modernizr.com](http://modernizr.com)获得它可以检测到的功能的全部细节。

我不想调用历史 API 的方法,除非我确定浏览器实现了它,所以我检查了Modernizr.history属性的值。值true意味着已经检测到历史 API,值false意味着该 API 不存在。

如果您愿意,您可以编写自己的特性检测测试。作为一个例子,下面是测试背后的代码:

tests['history'] = function() {     return !!(window.history && history.pushState); };

Modernizr 只是检查history.pushState是否由浏览器定义。我更喜欢使用像 Modernizr 这样的库,因为它执行的测试是经过良好验证的,并且可以根据需要进行更新,此外,因为不是所有的测试都这么简单。

Image 提示像 Modernizr 这样的特性检测库不会对一个特性的实现做任何评估。history.pushState方法的出现表明 History API 的存在,但是它并没有提供任何对可能必须考虑的古怪行为的洞察。简而言之,特性检测库不能代替在一系列浏览器上彻底测试你的代码。

如果历史 API 存在,那么我调用replaceState方法将视图模型items数组的值与当前 URL 关联起来。如果历史 API 不可用,我可以不执行任何操作,因为在浏览器中没有存储状态的替代机制(尽管我可以使用poly fill;详见侧栏)。

使用历史聚合填充

polyfill 是一个 JavaScript 库,为老版本的浏览器提供 API 支持。Pollyfilla,这个名字的来源,是英国的 Spackle 家庭修复产品,其理念是 polyfill 库使开发前景变得平滑。多填充库还可以解决浏览器实现功能之间的差异。History API 似乎是 polyfill 的理想选择,但问题是浏览器没有提供任何存储状态对象的替代方法。最常见的解决方法是将状态表示为 URL 的一部分,这样我们可能会得到如下结果:

[cheeselux.com/#select/Banana?items=Apple,Orange,Banana,Cherry](http://cheeselux.com/#select/Banana?items=Apple,Orange,Banana,Cherry)

我不喜欢这种方法,因为我不喜欢看到复杂的数据类型以这种方式表达,我认为它会产生令人困惑的 URL。但是您可能会有不同的感觉,或者有状态历史特性可能对您的项目至关重要。如果是这样的话,那么我找到的最好的历史 API polyfill 叫做 History.js,位于[github.com/balupton/history.js](http://github.com/balupton/history.js)

还原应用状态

当然,存储应用状态是不够的。我还必须能够恢复它,这意味着当 URL 更改触发popstate事件时,要对其做出响应。下面是代码:

`... crossroads.addRoute("select/:item:", function(item) {

*    ...other statements removed for brevity...  *

**    if (Modernizr.history) {** **        (window).bind("popstate", function(event) {** **            var state = history.state ? history.state** **                : event.originalEvent.state;**` `**            if (state) {            ** **                viewModel.items.removeAll();** **                .each(state, function(index, item) {** **                    viewModel.items.push(item);** **                });** **            }** **            crossroads.parse(location.hash.slice(1));** **        });                  ** **    } else {** **        hasher.initialized.add(crossroads.parse, crossroads);** **        hasher.changed.add(crossroads.parse, crossroads);** **        hasher.init();            ** **    }** }); ...`

在使用 bind 方法为popstate事件注册一个处理函数之前,我已经使用了Modernizr.history来检查 API。严格来说,这不是必需的,因为如果 API 不存在,事件就不会被触发,但是我想让它变得明显,这段代码与历史 API 相关。

您可以在处理popstate事件的函数中看到一个迎合浏览器古怪性的例子。history.state属性应该返回与当前 URL 关联的状态对象,但 Google Chrome 不支持这一点,取而代之的是必须从Event对象的state属性中获取值。jQuery 规范化了Event对象,这意味着我必须使用originalEvent属性来访问浏览器生成的底层事件对象,如下所示:

var state = history.state ? history.state: **event.originalEvent.state**;

使用这种方法,我可以从history.state中获取state数据(如果可用的话),如果不可用则获取事件。可悲的是,使用 HTML5 APIs 通常需要这种变通方法,尽管我希望各种实现的一致性会随着时间的推移而提高。

我不能指望每次触发popstate事件时都有一个状态对象,因为不是浏览器历史中的所有条目都有与之相关的状态。

状态数据时,我使用removeAll方法清除视图模型中的items数组,然后使用 jQuery each函数用从状态数据中获得的项目填充它:

if (state) {     **viewModel.items.removeAll();**     **$.each(state, function(index, item) {**         **viewModel.items.push(item);**     **});** }

一旦设置了视图模型的内容,我通过调用parse方法通知 Crossroads URL 发生了变化。这是以前由 Hasher 库处理的函数,它在将 URL 传递到 Crossroads 之前从 URL 中删除了前导字符#。我做了同样的事情来保持与我之前定义的路由的兼容性:

crossroads.parse(**location.hash.slice(1)**);

我想保持兼容性,因为我不想假设用户有一个支持历史 API 的 HTML5 浏览器。为此,如果Modernizr.history属性是false,我就退回到使用 Hasher,这样 web 应用的基本功能仍然可以工作,即使我不能提供状态管理特性:

if (Modernizr.history) { *    ...History API code...* } else { **    hasher.initialized.add(crossroads.parse, crossroads);** **    hasher.changed.add(crossroads.parse, crossroads);** **    hasher.init();** }

有了这些改变,我能够在历史 API 可用时使用它来管理应用的状态,并在用户使用后退按钮时展开它。图 4-6 显示了本节开始时我让你执行的任务序列中的关键步骤。当用户在历史中向后移动时,Cherry项消失。

Image

图 4-6。使用历史 API 管理应用状态的变化

顺便说一下,我选择在每次 URL 改变时存储应用状态,因为它允许我支持前进按钮和后退按钮。从图中所示的状态,单击 Forward 按钮将 Cherry 项恢复到视图模型,这表明应用状态在两个方向上都得到了正确的保留和恢复。

向 CheeseLux Web 应用添加 URL 路由

在本章中,我切换到一个简单的例子,因为我不想用标记和数据绑定(可能很冗长)淹没路由代码(相当稀疏)。但是现在我已经解释了 URL 路由是如何工作的,是时候向 CheeseLux 演示介绍它了,如清单 4-13 所示。

清单 4-13。向 CheeseLux 示例添加路由

`

` `CheeseLux                                                                ('#buttonDiv input:submit').button().css("font-family", "Yanone");

            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);                 item.quantity.subscribe(function() {                     updateState();                 });             }, cheeseModel.products, "items");

            cheeseModel.total = ko.computed(function() {                 var total = 0;                 mapProducts(function(elem) {                     total += elem.subtotal();                 }, cheeseModel.products, "items");                 return total;             }); ('div.cheesegroup').not("#basket").css("width", "50%");             ('div.navSelectors').buttonset();

            ko.applyBindings(cheeseModel);

            $(window).bind("popstate", function(event) {                 var state = history.state ? history.state : event.originalEvent.state;                 restoreState(state);                 crossroads.parse(location.hash.slice(1));             });

            crossroads.addRoute("category/:newCat:", function(newCat) {                 cheeseModel.selectedCategory(newCat ?                     newCat : cheeseModel.products[0].category);                 updateState();             });

            crossroads.addRoute("remove/{id}", function(id) {                 mapProducts(function(item) {                     if (item.id == id) {                         item.quantity(0);                     }                 }, cheeseModel.products, "items");             });

            $('#basketTable a')                 .button({icons: {primary: "ui-icon-closethick"},text: false});

            function updateState() {                 var state = {                     category: cheeseModel.selectedCategory()                 };                 mapProducts(function(item) {                     if (item.quantity() > 0) {                         state[item.id] = item.quantity();                     }                 }, cheeseModel.products, "items");                 history.replaceState(state, "",                     "#select/" + cheeseModel.selectedCategory());             }

            function restoreState(state) {                 if (state) {                     mapProducts(function(item) {                         item.quantity(state[item.id] ? state[item.id] : 0);                     }, cheeseModel.products, "items");                     cheeseModel.selectedCategory(state.category);                 }             }         });     

    
                 Gourmet European Cheese     

    

             

    

        
Basket
        

            

                No products selected             

            

                                                                                                                                                                                            
CheeseSubtotal
<span data-bind="text: subtotal"></span></td>                             <td>                                 <a data-bind="formatAttr: {attr: 'href',                                     prefix: '#remove/', value: id}"></a>                             </td>                         </tr>                     <!-- /ko -->                 </tbody>                 <tfoot>                     <tr><td class="sumline" colspan=2></td></tr>                     <tr>                         <th>Total:</th><td>
        
        

        

                     
    

    

                 <div class="cheesegroup"              data-bind="fadeVisible: category == cheeseModel.selectedCategory()">             
            
                
                                                                       (<spandatabind="text:price"></span>)</label>                    <inputdatabind="attr:name:id,value:quantity"/>                    <spandatabind="visible:subtotal"class="subtotal">                        ((<span data-bind="text:price"></span>)</label>                     <input data-bind="attr: {name: id}, value: quantity"/>                     <span data-bind="visible: subtotal" class="subtotal">                         ()                                      
            
                      

`

我不打算逐行分解这个清单,因为大部分功能与前面的例子相似。然而,有一些值得学习的技术和我需要解释的一些变化,所有这些我将在接下来的章节中介绍。图 4-7 显示了 web 应用如何出现在浏览器中。

Image

图 4-7。向 CheeseLux 示例添加路由

移动地图产品功能

第一个变化,也是最基本的,是我将mapProducts函数移到了util.js文件中。在第九章中,我将向你展示如何更有效地打包这类函数,我不想在清单中重复使用相同的代码。当我移动这个函数时,我重写了它,使它可以在任何嵌套数组上工作。清单 4-14 显示了这个函数的新版本。

清单 4-14。修改后的 mapProducts 函数

function mapProducts(func, **data, indexer**) {     $.each(data, function(outerIndex, outerItem) {         $.each(outerItem[indexer], function(itemIndex, innerItem) {             func(innerItem, **outerItem**);         });     }); }

该函数的两个新参数是外部嵌套数组和内部数组的属性名。您可以看到我是如何在主清单中使用它的,因此参数分别是cheeseModel.productsitems

增强视图模型

我对视图模型做了两处修改。第一个是定义一个可观察的数据项,以捕捉选定的奶酪类别:

cheeseModel.selectedCategory = ko.observable(cheeseModel.products[0].category);

第二种更有趣。数据绑定不是将视图模型更改传播到 web 应用的方法。您还可以订阅一个可观察的数据项,并指定一个当值改变时将被执行的函数。这是我创建的订阅:

mapProducts(function(item) {     item.quantity = ko.observable(0);     item.subtotal = ko.computed(function() {         return this.quantity() * this.price;     }, item); **    item.quantity.subscribe(function() {** **        updateState();** **    });** }, cheeseModel.products, "items");

我订阅了每个奶酪产品上可观察到的数量。当数值改变时,将执行updateState功能。我将简要描述这个函数。订阅很像视图模型的事件;它们在很多情况下都很有用,当我想自动执行一些任务时,我经常会使用它们。

管理应用状态

我想在这个 web 应用中保留两种状态。第一个是选择的产品类别,第二个是购物篮的内容。我将状态信息存储在浏览器的历史记录中的updateState函数中,每当我的quantity订阅被触发或所选类别改变时,就会执行该函数。

Image 提示我在这里演示的技术在应用于购物篮时有点奇怪,因为网站通常会不遗余力地保存你的产品选择。如果您愿意,可以忽略这一点,将注意力集中在状态管理技术上,这是本节的真正目的。

function updateState() {     var state = {         category: cheeseModel.selectedCategory()     };     mapProducts(function(item) {         if (item.quantity() > 0) {             state[item.id] = item.quantity();         }     }, cheeseModel.products, "items");     **history.replaceState(state, "", "#select/" + cheeseModel.selectedCategory());** }

Image 提示这个清单需要 HTML5 历史 API,并且不像本章前面的例子,没有回退到 Hasher 库采用的 HTML4 兼容方法。

我创建了一个对象,它有一个包含所选类别名称的category属性,以及一个包含非零quantity值的每个奶酪的属性。我使用replaceState方法将其写入浏览器历史,我在清单中突出显示了这一点。

一些聪明的事情正在这里发生。为了解释我在做什么——以及为什么——我们必须从从购物篮中移除产品的导航元素的标记开始。以下是相关的 HTML:

<a data-bind="formatAttr: {attr: 'href', prefix: '#remove/', value: id}"></a>

当数据绑定被应用时,我得到了这样一个元素:

<a href="#/remove/stilton"></a>

在第三章的中,我通过处理这些元素的click事件从篮子中移除了项目。现在我使用 URL 路由,我必须定义一个路由,如下所示:

crossroads.addRoute("remove/{id}", function(id) {     mapProducts(function(item) {         if (item.id == id) {             item.quantity(0);         }     }, cheeseModel.products, "items"); });

我的路线匹配任何两段式 URL,其中第一段是remove。我使用第二段在视图模型中找到正确的项目,并将quantity属性的值更改为零。

在这一点上,我有个问题。我已经导航到一个 URL,我不希望用户能够导航回来,因为它将匹配的路线只是从篮子中删除项目,这并没有帮助我。

解决方案就在对history.replaceState方法的调用中。当quantity值改变时,我的订阅导致updateState函数被调用,该函数又调用history.replaceState。第三个论点很重要:

history.replaceState(state, "", **"#select/" + cheeseModel.selectedCategory()**);

此参数指定的 URL 用于替换用户导航到的 URL。当 URL 被更改时,浏览器不会导航到该 URL,但是当用户在浏览器历史中向后移动时,浏览器将使用替换的 URL。无论哪条路由与 URL 匹配,历史记录总是包含一条以#select/开头的路由。这样,我就可以使用 URL 路由,而不会向用户暴露我的 web 应用的内部工作方式。

总结

在这一章中,我已经向你展示了如何在你的 web 应用中添加 URL 路由。这是一种强大而灵活的技术,它将应用导航从 HTML 元素中分离出来,提供了一种更简洁、更具表现力的导航处理方式,以及一个更易测试和维护的代码库。习惯在客户端使用路由可能需要一段时间,但是投入时间和精力是值得的,尤其是对于大型复杂的项目。*

五、创建离线 Web 应用

HTML5 规范包括对应用缓存的支持,该缓存用于创建 web 应用,即使在没有网络连接的情况下,用户也可以使用。如果您的用户需要离线工作或在连接受限的环境中工作(例如在飞机上),这是非常理想的。

与所有更复杂的 HTML5 特性一样,使用应用缓存并不是一帆风顺的。浏览器之间的实现存在一些差异,您需要注意一些奇怪的地方。在这一章中,我将向你展示如何创建一个有效的离线 web 应用,以及如何避免各种陷阱。

Image 注意浏览器对离线存储的支持还处于初级阶段,有很多不一致的地方。我已经试图指出潜在的问题,但是因为每个浏览器版本都倾向于改进 HTML5 特性的实现,所以当你运行本章中的例子时,你应该期望看到一些变化。

重置示例

我将再次简化 CheeseLux 的例子,这样我就不会列出与其他章节相关的大量代码。清单 5-1 显示了修改后的文档。

清单 5-1。复位 CheeseLux 示例

`

    CheeseLux                                                                                       ('#buttonDiv input:submit').button();             $('div.navSelectors').buttonset();            

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

        });     

    
                 Gourmet European Cheese     

    

             
                

    

        
            <div class="cheesegroup"                  data-bind="fadeVisible: category == cheeseModel.selectedCategory()">                 
                                 
                                                                       (<spandatabind="text:price"></span>)</label>                    <inputdatabind="attr:name:id,value:quantity"/>                    <spandatabind="visible:subtotal"class="subtotal">                        ((<span data-bind="text:price"></span>)</label>                     <input data-bind="attr: {name: id}, value: quantity"/>                     <span data-bind="visible: subtotal" class="subtotal">                         ()                                      
                                 
                    Total:                                              $                                                          
            
                 
                     
    

`

这个例子建立在前面章节的视图模型和路由概念之上,但是我简化了一些功能。我在每一类奶酪的底部增加了一个total显示屏,而不是一个篮子。我已经将创建可观察视图模型项目的代码移到了utils.js文件中一个名为enhanceViewModel的函数中。清单中的其他内容应该是不言自明的。

使用 HTML5 应用缓存

使用应用缓存的起点是创建一个清单。这告诉浏览器脱机运行应用需要哪些文件,以便浏览器可以确保它们都存在于缓存中。清单文件的后缀是appcache,所以我将清单文件命名为cheeselux.appcache。你可以在清单 5-2 中看到这个文件的内容。

清单 5-2。一个简单的清单文件

`CACHE MANIFEST

HTML document

example.html offline.html

script files

jquery-1.7.1.js jquery-ui-1.8.16.custom.js knockout-2.0.0.js signals.js crossroads.js hasher.js utils.js

CSS files

styles.css jquery-ui-1.8.16.custom.css

images

#blackwave.png cheeselux.png img/ui-bg_flat_75_eb8f00_40x100.png img/ui-bg_flat_75_fbbe03_40x100.png img/ui-icons_ffffff_256x240.png img/ui-bg_flat_75_595959_40x100.png img/ui-bg_flat_65_fbbe03_40x100.png

fonts

fonts/YanoneKaffeesatz-Regular.ttf fonts/fanwood_italic-webfont.ttf fonts/ostrich-rounded-webfont.woff`

一个基本的清单文件以CACHE MANIFEST头开始,然后列出应用需要的所有文件,包括 HTML 文件,其html元素包含manifest属性(稍后讨论)。在清单中,我按照类型对文件进行了分类,并使用了注释(以#字符开始的行)来更容易地判断发生了什么。

Image 提示您会注意到我已经注释掉了blackwave.png文件的条目。我用这个文件来演示一个缓存应用的行为。

清单通过html元素的manifest属性添加到 HTML 文档中,如清单 5-3 所示。

清单 5-3。将清单添加到 HTML 文档

`

**              ...                   ...      `

当加载 HTML 文档时,浏览器检测到manifest属性,从 web 服务器请求指定的appcache文件,并开始加载和缓存清单文件中列出的每个文件。浏览器处理清单时下载的文件被称为离线内容。有些浏览器会提示用户是否允许存储脱机内容。

Image 注意创建清单时要小心。如果不能从服务器获得任何列出的项目,那么浏览器根本不会缓存该应用。

了解何时使用缓存内容

浏览器第一次加载离线内容时不会使用它。它将被缓存,以备下次用户加载或重新加载页面时使用。这个名字线下内容有误导性。一旦浏览器有了 web 应用的离线内容,无论用户何时访问 web 应用的 URL,都将使用它,即使有可用的网络连接。浏览器负责确保使用最新版本的离线内容,但正如您将了解到的,这是一个复杂的过程,需要一些程序员的干预。

我注释掉了清单中的blackwave.png文件,以演示浏览器如何处理离线内容。我使用blackwave.png作为 CheeseLux web 应用的背景图像,这给了我一个演示缓存 web 应用的基本行为的好方法。

首先,将manifest属性添加到清单 5-3 所示的示例中,并将文档加载到浏览器中。不同的浏览器以不同的方式处理缓存的应用。例如,Google Chrome 会悄悄地处理清单,并开始下载它指定的内容。Mozilla Firefox 通常会提示用户允许离线内容,如图图 5-1 所示。如果您使用的是 Firefox,请单击“允许”按钮启动浏览器处理清单。

Image

图 5-1。Firefox 提示用户允许网络应用在本地存储数据

Image 提示所有主流浏览器都允许用户禁用缓存应用,这意味着即使浏览器实现了这一功能,你也不能指望能够存储数据。在这种情况下,应用清单将被忽略。您可能需要更改浏览器的配置来缓存示例内容。

您应该会看到 CheeseLux web 应用的黑色背景。此时,浏览器有两个 web 应用的副本。第一个副本在常规浏览器缓存中,这是当前运行的版本。第二个副本位于应用缓存中,包含清单中指定的项。只需重新加载页面,切换到应用缓存版本。当你重新加载时,背景将是白色的,如图图 5-2 所示。

Image

图 5-2。切换到应用缓存

这种差异是由于清单中的文件blackwave.png被注释掉了。浏览器将应用缓存和常规缓存分开,这意味着即使它在常规缓存中有一个blackwave.png文件,它也不会将它用于缓存的应用。

Image 提示注意你没有对网络连接做任何事情。浏览器仍然在线,但是应用是单独使用离线内容加载的。这是我很快会谈到的。

接受对清单的更改

缓存应用在行为上最显著的变化是刷新网页不会导致应用内容被缓存。这个想法是,需要管理对缓存应用的更新,以避免不一致的更改。例如,取消清单中的blackwave.png行的注释并重新加载不会将背景改为黑色。

清单 5-4 显示了 web 应用支持更新所需的最少代码量。在本章的后面,我将向您展示如何使用更多的应用缓存 API,但是在我们进一步深入之前,我们需要这些更改。

清单 5-4。接受清单中的更改

`...

...`

HTML5 应用缓存 API 是通过window.applicationCache浏览器对象来表达的。这个对象触发事件来通知 web 应用缓存状态的变化。目前对我们来说最重要的是updateready事件,这意味着有更新的缓存数据可用。除了事件之外,applicationCache对象还定义了一些有用的方法和属性。同样,我将在本章后面回到这些,但是我现在关心的方法是swapCache,它将更新的清单及其内容应用到应用缓存。

现在,我已经准备好演示如何更新缓存的 web 应用了。但在此之前,我必须删除现有的缓存数据。我通过应用清单而没有添加对swapCache方法的调用,创建了一个僵尸 web 应用,我没有办法让更新生效。我需要清空缓存并重新开始。使用 JavaScript 无法清除缓存,浏览器有不同的机制来手动清除应用缓存数据。对于谷歌浏览器,你删除了定期浏览历史。对于 Mozilla Firefox,您必须选择高级Image网络选项选项卡,从列表中选择网站,然后单击删除按钮。

清除应用缓存后,重新加载清单以加载清单并缓存数据。再次重新加载页面,切换到应用的缓存版本(背景为白色)。

最后,您可以取消对cheeselux.appcache文件中的blackwave.png条目的注释。此时,您将需要重新加载网页两次。第一次使浏览器检查更新的清单,发现有新版本,并将更新的资源下载到缓存中。此时,updateready事件被触发,我的脚本调用swapCache方法,将更新应用到缓存中。这些更改要到下次加载 web 应用时才会生效,这就是为什么需要第二次重新加载的原因。这是一种笨拙的方法,但是我将很快向您展示如何改进它。在这一点上,缓存将被更新为包含blackwave.png文件的清单,web 应用背景将变成黑色。

Image 提示浏览器仅检查清单文件是否已更改。对单个资源(包括 HTML 和脚本文件)的更改将被忽略,除非清单也发生了更改。如果清单已经更改,那么浏览器将检查单个资源自上次下载以来是否已经更新(当然,将下载已经添加到清单中的任何资源)。

控制缓存更新过程

我带你绕远路更新,因为我想强调的方式,浏览器试图隔离我们不得不处理不一致的缓存。JavaScript web 应用在运行时没有标准的方法来响应缓存更改,因此 HTML5 应用缓存标准过于谨慎,只有在加载应用时才会应用缓存更新。

总有一天,您会开始出现奇怪的行为,并且您对清单或应用所做的任何更改都不会解决问题。发生这种情况时,最简单的方法就是清除浏览器历史记录和应用缓存内容,看看问题是否仍然存在。大多数时候,我发现行为的突然变化是由浏览器引起的,重新开始可以解决问题(尽管这有时需要使用文件浏览器直接从磁盘中清除文件,因为浏览器管理应用缓存的能力也会出错)。

我们可以使用applicationCache browser 对象以更优雅的方式管理缓存的应用。我们可以做的第一件事是监控缓存的状态,并为用户提供一些选项。清单 5-5 展示了如何做到这一点。

清单 5-5。主动控制应用缓存

`

    CheeseLux                                                                                       ('#buttonDiv input:submit').button();             $('div.navSelectors').buttonset();

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

            $(window.applicationCache).bind("checking noupdate downloading " +                     "progress cached updateready", function(e) {                         cheeseModel.cache.status(window.applicationCache.status);             });

            ('div.tagcontainer a').button().click(function(e) {**                 **e.preventDefault();**                 **if ((this).attr("data-action") == "update") {                     window.applicationCache.update();                 } else {                                         window.applicationCache.swapCache();                     window.location.reload(false);                                     }             });           });     

    <**div id="logobar">                  
            Gourmet European Cheese             
                Check for Updates                 Apply Update             
        
    **

    

             
                

    

        
            <div class="cheesegroup"                  data-bind="fadeVisible: category == cheeseModel.selectedCategory()">                 
                                 
                                                                       (<spandatabind="text:price"></span>)</label>                    <inputdatabind="attr:name:id,value:quantity"/>                    <spandatabind="visible:subtotal"class="subtotal">                        ((<span data-bind="text:price"></span>)</label>                     <input data-bind="attr: {name: id}, value: quantity"/>                     <span data-bind="visible: subtotal" class="subtotal">                         ()                                      
                                 
                    Total:                                              $                                                          
            
                 
                     
    

`

首先,我向视图模型添加了一个新的可观察数据项,它代表应用缓存的状态:

cache: {     status: ko.observable(window.applicationCache.status)     }

我使用视图模型是因为我想使用数据绑定将状态传播到 HTML 标记中。为了保持值是最新的,我订阅了一组由window.applicationCache对象触发的事件,如下所示:

$(window.applicationCache).bind("**checking noupdate downloading " +**     **"progress cached updateready"**, function(e) {         cheeseModel.cache.status(**window.applicationCache.status);** });

七个缓存事件可用。我已将它们列在表 5-1 中。我使用了bind方法来处理其中的六个,因为第七个方法obsolete仅在清单文件无法从 web 服务器获得时出现。

Image

当我收到一个应用缓存事件时,我更新了视图模型中的cache.status数据项。当前状态可从window.applicationCache.status属性获得,我已经在表 5-2 中描述了返回值的范围。

Image

如您所见,status值对应于一些应用缓存事件。对于这个例子,我只关心UPDATEREADY状态值,我用它来控制我添加到页面徽标区域的一些a元素的可见性:

`

    <a data-bind="visible: cheeseModel.cache.status() != 4"         data-action="update" class="cachelink">Check for Updates     <a data-bind="visible: cheeseModel.cache.status() == 4"         data-action="swapCache" class="cachelink">Apply Update

`

当缓存空闲时,我显示提示用户检查更新的元素,当有可用的更新时,我提示用户安装它。图 5-3 显示了这两个按钮的原位。

Image

图 5-3。添加按钮控制缓存

如图所示,我已经使用 jQuery UI 从a元素创建了按钮。我还使用 jQuery click方法为click事件注册了一个处理程序,如下所示:

$('div.tagcontainer a').button().click(function(e) {     **e.preventDefault();**     **if ($(this).attr("data-action") == "update") {**         **window.applicationCache.update();**     **} else {**                             **window.applicationCache.swapCache();**         **window.location.reload(false);**                         **}** });            

我使用常规的 JavaScript 事件来控制缓存,因为我希望用户能够反复检查更新。浏览器忽略导航到正在显示的同一内部 URL 的请求。你可以看到这种情况发生,如果你点击一个奶酪类别按钮。重复单击同一个按钮不会做任何事情,该按钮实际上是禁用的,直到选择另一个类别。如果我使用 URL 路由来处理缓存按钮,那么用户将能够检查一次更新,然后不能再次这样做,直到他们导航到另一个内部 URL(在这个例子中需要选择一个奶酪类别)。因此,我使用了 JavaScript 事件,每次点击按钮时都会触发这些事件,而不考虑应用的其他状态。

当单击任一缓存按钮时,我会读取data-action属性的值。如果属性值是update,那么我调用缓存update方法。这将导致浏览器检查服务器以查看清单是否已更改。如果是,那么缓存的状态将变为UPDATEREADY,并且 Apply Update 按钮将显示给用户。

当点击 Apply Update 按钮时,我调用swapCache方法将更新推入应用缓存。这些更新直到应用重新加载后才会生效,这是我通过调用window.location.reload方法强制实现的。这意味着更新会应用到缓存中,并立即用于响应用户的单个操作。测试这些添加的最简单的方法是在清单中切换blackwave.png映像的状态,并应用结果更新。如果您想测试更多实质性的更改,请参阅缓存控制标题上的信息。

应用缓存条目和缓存控制头

调用applicationCache方法并不总是导致浏览器联系服务器来查看清单是否已经改变。所有主流浏览器都支持 HTTP Cache-Control头,并且只有在清单到期时才会检查更新。

此外,即使清单已经改变,浏览器也认可单个清单项目的Cache-Control值。这可能会导致这样的情况:如果清单在受影响资源的Cache-Control生命周期内发生变化,则忽略对 HTML 或脚本文件的更新。

在生产中,这种行为是完全合理的。但在开发和测试期间,这是一个巨大的痛苦,因为对 HTML 和脚本文件内容的更改不会立即反映在更新中。为了解决这个问题,我在 Node.js 服务器提供的内容上设置了非常短的缓存寿命。您需要做一些类似于您的开发服务器的事情来获得相同的效果。

向清单添加网络和回退条目

常规清单条目告诉浏览器主动获取并缓存 web 应用所需的资源。此外,应用缓存支持另外两种清单条目类型:网络回退条目。网络条目,也称为白名单条目,指定浏览器不应该缓存的资源。当浏览器在线时,对这些资源的请求将总是导致对服务器的请求。这有助于确保用户始终收到文件的最新版本,即使应用的其余部分已被缓存。

回退条目告诉浏览器当浏览器离线并且用户请求网络条目时该做什么。回退条目允许您替换替代文件,而不是向用户显示错误。清单 5-6 展示了在cheeselux.appcache文件中两种条目的使用。

清单 5-6。使用应用清单中的网络条目

`CACHE MANIFEST

HTML document

example.html

script files

jquery-1.7.1.js jquery-ui-1.8.16.custom.js knockout-2.0.0.js signals.js crossroads.js hasher.js utils.js

CSS files

styles.css jquery-ui-1.8.16.custom.css

images

blackwave.png cheeselux.png img/ui-bg_flat_75_eb8f00_40x100.png img/ui-bg_flat_75_fbbe03_40x100.png img/ui-icons_ffffff_256x240.png img/ui-bg_flat_75_595959_40x100.png img/ui-bg_flat_65_fbbe03_40x100.png

fonts

fonts/YanoneKaffeesatz-Regular.ttf fonts/fanwood_italic-webfont.ttf fonts/ostrich-rounded-webfont.woff

NETWORK: news.html`

网络条目的前缀是单词NETWORK和冒号(:)。与常规条目一样,每个资源占用一行。在这个清单中,我为文件news.html创建了一个网络条目。我在example.html文件中创建了一个链接到该文件的按钮,如下所示:

`

         
        Gourmet European Cheese         
            <a data-bind="visible: cheeseModel.cache.status() != 4"                data-action="update" class="cachelink">Check for Updates             <a data-bind="visible: cheeseModel.cache.status() == 4"                data-action="swapCache" class="cachelink">Apply Update             News                         
    

`

当浏览器在线时,点击此链接显示news.html文件。你可以在图 5-4 中看到效果。

Image

图 5-4。链接到 news.html 页面

因为它在NETWORK部分,所以news.html文件永远不会被添加到应用缓存中。当我单击“新闻”按钮时,浏览器的行为与常规内容一样。它联系服务器,获取资源,并将它们添加到常规(非应用)缓存中,然后将它们显示给用户。我可以对news.html文件进行更改,即使应用缓存没有更新,这些更改也会显示给用户。

当浏览器离线时,无法获得不在应用缓存中的内容。这就是FALLBACK条目出现的地方。这些条目的格式与其他条目不同。

Image 警告浏览器对离线意味着什么有不同的看法。我将在本章后面的“监视脱机状态”一节中详细解释这一点。

第一部分指定资源的前缀,第二部分指定在浏览器脱机时请求与前缀匹配的资源时要使用的文件。因此,在清单 5-7 中,我已经设置了清单,因此任何对任何 URL(由/表示)的请求都应该被给予文件offline.html

清单 5-7。在应用清单中使用后备条目

`...

fonts

fonts/YanoneKaffeesatz-Regular.ttf fonts/fanwood_italic-webfont.ttf fonts/ostrich-rounded-webfont.woff

FALLBACK: / offline.html`

Image 提示浏览器对网络中资源的回退处理不一致。您不应该依赖回退部分来为网络部分中列出的 URL 提供替代内容,而应该只为清单主要部分中的 URL 提供替代内容。对单个文件提供回退的支持也是不一致的,这就是为什么我在本章的例子中使用了尽可能广泛的回退。我希望随着 HTML5 实现的稳定,这些特性的可靠性和一致性会提高。

当浏览器脱机时,单击“新闻”按钮会触发对浏览器无法从应用缓存提供服务的 URL 的请求,而使用回退条目。你可以在图 5-5 中看到结果。浏览器地址栏中的 URL 显示请求的 URL,但显示的内容来自后备资源。

Image

图 5-5。使用回退条目

HTML5 应用缓存规范支持更复杂的回退条目,包括每个 URL 的回退和通配符的使用。然而,在我写这篇文章的时候,Google Chrome 不支持这些条目,只有一个通用的后备选项,比如我在清单中展示的,才是可靠的。

对于浏览器是否应该使用常规内容缓存来满足对网络入口资源的请求,HTML5 应用缓存功能的规范并不明确。当然,采取了不同的方法。谷歌 Chrome 对该标准做了最字面的解释。当浏览器离线时,网络入口资源对 web 应用不可用。Mozilla Firefox 和 Opera 采取了一种更宽容的方法:如果浏览器离线时资源在主浏览器缓存中,它将可供 web 应用使用。当然,浏览器经常更新,所以当你读到这篇文章时,可能会有不同的行为。

Image 注意网络和回退功能的实现可能不一致。主流浏览器的实现有些奇怪,因此,我倾向于避免在缓存应用中使用这类条目。然而,常规缓存条目工作得很好,并且可以在那些支持应用缓存特性的浏览器中使用。

监控离线状态

HTML5 定义了确定浏览器是否在线的能力。离线意味着什么取决于平台和浏览器。对于移动设备,离线通常需要用户切换到飞行模式,或者以其他方式明确关闭网络。仅仅是不在覆盖范围内通常不会改变浏览器的状态。

大多数桌面浏览器也需要明确的用户操作。例如,Firefox 和 Opera 都有在在线和离线模式之间切换浏览器的菜单项。谷歌 Chrome 是一个例外,它可以监控底层网络连接,如果没有网络设备,它就会切换到离线状态。

Image 注意 Chrome 只有在没有启用网络连接的情况下才会进入离线模式。为了创建本节中的屏幕截图,我必须禁用我的主(无线)连接,手动禁用一个已启用但未插入任何东西的以太网端口,禁用一个由虚拟机包创建的连接。直到那时,Chrome 才决定是时候下线了。大多数用户不会有这个问题,但这是要记住的事情,特别是如果你没有得到你期望的离线行为。

主流浏览器的最新版本实现了一个 HTML5 特性,可以报告浏览器是在线还是离线。这对于向用户呈现有用的上下文界面以及管理 web 应用的内部操作都很有用。为了演示这个特性,我将更改示例 web 应用,以便仅当浏览器在线时才显示缓存控件和新闻按钮。清单 5-8 显示了对script元素的修改。

清单 5-8。检测网络状态

`         }     };

    (document).ready(function() {         ('#buttonDiv input:submit').button();         $('div.navSelectors').buttonset();

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

        $(window.applicationCache).bind("checking noupdate downloading " +                 "progress cached updateready", function(e) {                     cheeseModel.cache.status(window.applicationCache.status);

        });

        $(window).bind("online offline", function() {             cheeseModel.cache.online(window.navigator.onLine);         });

        ('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);             }         });     }); `

window浏览器对象支持浏览器状态改变时触发的onlineoffline事件。可以通过window.navigator.onLine属性获取当前状态,如果浏览器是online则返回 true,如果是offline则返回 false。注意onLine中的L是大写的。我已经向视图模型添加了一个online可观察数据项,我更新它以响应onlineoffline事件。这与我用于应用缓存状态的技术相同,它允许我使用视图模型将更改传播到我的标记。清单 5-9 显示了显示新闻和应用缓存控制按钮的 HTML 元素的变化。

清单 5-9。添加元素和绑定以响应浏览器在线状态

`

         
        Gourmet European Cheese         
                             <a data-bind="visible: cheeseModel.cache.status() != 4"                    data-action="update" class="cachelink">Check for Updates                 <a data-bind="visible: cheeseModel.cache.status() == 4"                    data-action="swapCache" class="cachelink">Apply Update                 News                                                                           (Offline)                                  
    

`

当浏览器联机时,将显示缓存控件和新闻按钮。当浏览器离线时,我用一个简单的占位符替换按钮。你可以在图 5-6 中看到效果。

Image 提示在让浏览器离线之前,你需要确保你拥有离线内容的正确版本。在运行此示例之前,您应该更改清单或清除浏览器的历史记录。

Image

图 5-6。响应浏览器在线状态

使用递归 AJAX 请求聚合填充

有一些 JavaScript polyfill 库可以使用定期的 Ajax 请求来替代navigator.onLine属性。每隔几分钟就会向服务器请求一个小文件,如果请求失败,则认为浏览器处于脱机状态。

我强烈建议避免这种方法。首先,它没有足够的响应能力。如果你想知道浏览器何时离线,在离线几分钟后发现是没有多大用处的。在两次测试之间,浏览器的状态是未知的,也是不可靠的。

第二,重复请求一个文件会消耗你和用户必须支付的带宽。如果您有一个流行的 web 应用,定期检查的带宽成本可能会很大。更重要的是,随着移动设备的无限数据计划变得越来越不常见,假设你可以免费使用你的用户的带宽是极其放肆的。我的建议是不要依赖这种聚合填充物。如果浏览器不支持通知,就不要通知。

了解 Ajax 和 POST 请求

应用缓存使得使用 Ajax 变得困难,更广泛地说,是发布表单。当浏览器离线时,情况会变得更糟,尽管可能不是你所期望的那样。在这一节中,我将向您展示这些问题以及处理这些问题的有限选择。然而,首先,我需要更新 CheeseLux web 应用,以便它依赖于 Ajax GET 请求来操作。清单 5-10 显示了对script元素的必要修改(本例中不需要修改标记)。

清单 5-10。添加一个 Ajax 请求请求

`...

...`

在这个清单中,我使用了 jQuery getJSON方法。这是一个方便的方法,它让 Ajax GET 请求第一个方法参数指定的 JSON 文件,在本例中是products.json。当 Ajax 请求完成时,jQuery 解析 JSON 数据以创建一个 JavaScript 对象,该对象被传递给第二个方法参数指定的函数。在我的清单中,该函数简单地获取 JavaScript 对象,并将其分配给视图模型的products属性。products.json文件包含我已经内联定义的数据的超集。定义了相同的类别、产品和价格,以及每种奶酪的附加描述。清单 5-11 显示了来自products.json的摘录。

清单 5-11。products.json 文件的摘录

... {"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."}, ...

在清单中,我用对success的调用链接了getJSON方法。success方法是 jQuery 支持 JavaScript 承诺的一部分,这使得使用和管理像 Ajax 请求这样的异步操作变得容易。传递给success方法的函数在getJSON方法完成之前不会被执行,确保我的视图模型在脚本的其余部分运行之前完成。

这种从 JSON 获取核心数据的方法很常见,尤其是当数据来源于 web 应用其他部分的不同系统时。而且,如果小心使用,它可以确保用户拥有最新的数据,但仍然具有缓存应用的好处。

了解默认的 Ajax GET 行为

浏览器以非常简单的方式处理 Ajax GET 请求。如果 Ajax 请求的资源不在清单中,即使浏览器在线,请求也会失败。

对于我的示例应用来说,这意味着数据从请求中返回,并且它死得很难看。我作为参数传递给getJSON方法的函数只有在 Ajax 请求成功时才会执行,传递给success方法的函数也是如此。因为两个函数都没有执行,所以我的script代码的主要部分没有执行,我让用户束手无策。更糟糕的是,因为应用缓存控制按钮从来没有设置过,所以我没有给用户一个更新应用来解决问题的方法。

我展示了这个场景,因为这是程序员第一次开始使用应用缓存时经常遇到的情况。我将很快向您展示如何使 Ajax 连接工作,但是首先,有几个重要的变化要做。

重构应用

第一个变化是构建应用,以便让用户摆脱困境的核心行为总是被执行。我最初的清单过于乐观,我需要将那些应该一直运行的代码部分分开。有很多不同的技术可以做到这一点,但是我发现最简单的是创建另一个依赖于 jQuery ready事件的函数。清单 5-12 显示了我需要对script元素进行的修改。

清单 5-12。重组脚本元素

`...

...`

我将所有与成功的 Ajax 请求无关的代码放在一起,放在传递给complete方法的函数中,我将它添加到方法调用链中。这个函数将在 Ajax 请求完成时执行,不管它是成功还是失败。

现在,即使 Ajax 请求失败,更新缓存和应用更改的控件也总是可用的。鉴于 Ajax 问题最有可能是客户端出错的原因,为用户提供一种应用更新的方式是至关重要的。否则,您必须提供每个浏览器的指令来清除缓存。这不是一个完美的解决方案,因为我无法应用我的数据绑定,所以我宁愿隐藏的元素是可见的。我可以使用 CSS display属性来隐藏其中的一些项目,但是我认为让用户能够下载和应用更新才是最重要的。在图 5-7 中可以看到重组前后的效果。

Image

图 5-7。重组应用的效果

处理 Ajax 错误

我需要做的另一个改变是添加某种错误处理程序,以应对 Ajax 请求失败的情况。这似乎是一个基本的技术,但是许多 web 应用都是为了成功而编写的,当连接失败时,一切都会崩溃。有很多方法可以处理 Ajax 错误,但是清单 5-13 中显示的方法使用了一些 jQuery 特性。

清单 5-13。增加对处理 Ajax 错误的支持

`

    .getJSON("products.json", function(data) {         cheeseModel.products = data;     }).success(function() {         (document).ready(function() {             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);             });         });     }).error(function() {         var dialogHTML = '

Try again later
';         (dialogHTML).dialog({             modal: true,             title: "Ajax Error",                         buttons: [{text: "OK", click: function() {(this).dialog("close")}}]         });     }).complete(function() {         (document).ready(function() {             ('#buttonDiv input:submit').button();             (div.navSelectors).buttonset();            ('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);                                     }             });                       });             }); `

jQuery 使得用error方法处理错误变得很容易。这是 Promises 特性的另一部分,如果请求有问题,传递给error方法的函数将被执行。在这个例子中,我创建了一个简单的 jQuery UI 对话框,告诉用户有问题。

将 Ajax URL 添加到主清单或回退部分

此时最糟糕的事情是将 Ajax URL 添加到清单的主要部分。浏览器会像对待任何其他资源一样对待 URL,在处理清单时下载并缓存内容。当客户端发出 Ajax 请求时,浏览器将从应用缓存中返回内容,直到清单更改触发缓存更新,数据才会更新。这样做的结果是,您的用户将使用陈旧的数据,这通常与最初发出 Ajax 请求的原因相反。

如果您将 URL 添加到FALLBACK部分,您会得到几乎相同的结果。每个请求,即使当浏览器在线时,也将由您设置为后备的任何内容来满足,并且不会向服务器发出任何请求。

将 Ajax URL 添加到清单网络部分

最好的方法(尽管远非理想)是将 Ajax URL 添加到清单的NETWORK部分。当浏览器在线时,Ajax 请求将被传递给服务器,最新的数据将呈现给用户。

当浏览器离线时,问题就出现了。在离线浏览器中处理 Ajax 请求有两种不同的方法。第一种方法,你可以在 Google Chrome 中看到,是 Ajax 请求会失败。您的 Ajax 错误处理程序将被调用,这是一个干净的失败。

另一种方法可以在 Firefox 中看到。当浏览器离线时,如果可能的话,将使用主浏览器缓存来处理 Ajax 请求。这就造成了一种奇怪的情况,如果在浏览器离线之前请求了同一个 URL,用户将得到陈旧的数据,如果这是第一次请求该 URL,将得到一个错误。

了解发布请求行为

POST 请求的处理方式比 GET 请求更加一致。如果浏览器在线,那么将向服务器发出 POST 请求。如果浏览器离线,那么请求将失败。对于使用常规 HTML 发出的 POST 请求和使用 Ajax 发出的 POST 请求来说都是如此。

这导致了用户的烦恼,因为发布表单通常是在他们一段时间的活动之后。在 CheeseLux 示例中,用户将翻阅类别并输入他们需要的每种产品的数量。当他们提交订单时,浏览器会显示一个错误页面。您甚至不能使用清单的FALLBACK部分来指定要显示的页面,而不是错误。

唯一明智的做法是拦截表单提交,并使用navigator.onLine属性和事件来监控浏览器状态,防止用户在浏览器离线时试图发布内容。在第六章中,我将向你展示一些保存用户努力结果的技巧,为浏览器重新上线做好准备。

总结

在本章中,我向你展示了如何使用 HTML5 应用缓存来创建离线应用。通过使用应用缓存,即使用户没有网络连接,您也可以创建可用的应用。尽管应用缓存的核心得到了很好的支持,但仍有一些异常,需要仔细的设计和测试才能得到可靠和健壮的结果。在下一章,我将向你展示如何使用一些相关的功能来帮助消除离线应用的一些粗糙边缘,并可以用来为用户创造更好的体验。