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

JavaScript Web 应用高级教程(全)

原文:Pro JavaScript for Web Apps

协议:CC BY-NC-SA 4.0

一、做好准备

客户端 web 应用开发一直是服务器端编码的穷亲戚。这是因为浏览器和运行它们的设备没有企业级服务器强大。为了提供任何一种严肃的 web 应用功能,服务器必须为浏览器做所有繁重的工作,相比之下,这是相当愚蠢和简单的。

在过去的几年里,浏览器变得更加智能,功能更加强大,并且在实现 web 技术和标准方面更加一致。过去创造独特功能的斗争已经变成了创造最快和最兼容浏览器的斗争。智能手机和平板电脑的激增为高质量的 web 应用创造了一个巨大的市场,HTML5 的逐渐采用为 web 应用开发人员提供了构建丰富和流畅的客户端体验的坚实基础。

可悲的是,虽然客户端技术已经赶上了服务器端,但客户端程序员使用的技术仍然落后。客户端 web 应用的复杂性已经达到了一个临界点,规模、优雅和可维护性是必不可少的,快速解决方案的时代已经过去了。在本书中,我将公平竞争,向您展示如何加快您的客户端开发,以接受来自服务器端世界的最佳技术,并将它们与最新的 HTML5 特性相结合。

关于这本书

这是我关于技术的第 15 本书,为了纪念这一点,Apress 要求我做一些不同的事情:分享我用来创建复杂的客户端 web 应用的工具、技巧和技术。其结果是比我的常规工作更加个人化、非正式和不拘一格。我将向您展示如何从服务器端开发中获取工业级的开发概念,并将它们应用到浏览器中。通过使用这些技术,您可以构建更容易编写、更容易维护的 web 应用,并为您的用户提供更好、更丰富的功能。

你是谁?

你是一个经验丰富的 web 开发人员,你的项目已经开始失控。JavaScript 代码中的错误越来越多,找到并修复每个错误需要更长的时间。您的目标是越来越广泛的设备,包括台式机、平板电脑和智能手机,保持所有这些设备正常工作变得越来越困难。你的工作日变长了,但是你花在新特性上的时间却变少了,因为维护你已经拥有的代码消耗了你大量的时间。

来自工作的兴奋已经消退,你已经忘记了编码的一天是什么感觉。你知道出了问题,你知道你正在失去控制,你知道你需要找到一种不同的方法。如果这听起来很熟悉,那么是我的目标读者。

在你阅读这本书之前,你需要知道什么?

这是一本高级的书,你需要是一个有经验的 web 程序员才能理解内容。您需要 HTML 的工作知识,您需要知道如何编写 JavaScript,并且您已经使用这两者创建了客户端 web 应用。您需要理解浏览器是如何工作的,HTTP 是如何适应这种情况的,什么是 Ajax 请求,以及为什么您应该关心它们。

如果你没有这样的经历呢?

你可能仍然会从这本书里得到一些好处,但是你必须自己弄清楚一些基本的东西。我已经写了几本其他的书,作为这本书的入门,你可能会觉得有用。如果你是 HTML 新手,那么请阅读 HTML5 的权威指南。这解释了创建常规 web 内容和基本 web 应用所需的一切。我解释了如何使用 HTML 标记和 CSS3(包括新的 HTML5 元素),以及如何使用 DOM API 和 HTML5 APIs(如果您不熟悉这门语言,还包括一个 JavaScript 初级读本)。我在本书中大量使用了 jQuery。我提供了每个主题所需的所有信息,但是如果你想更好地了解 jQuery 如何工作以及它与 DOM API 的关系,那么请阅读 Pro jQuery 。这两本书都是出版社出版的。

除了书籍,通过阅读 W3C 在[www.w3.org](http://www.w3.org)发布的规范,你可以学到很多关于 HTML 和浏览器 API 的知识。这些规范是权威的,但可能很难做到,也不总是那么清晰。一个更容易获得的资源是位于[developer.mozilla.org](http://developer.mozilla.org)的 Mozilla 开发者网络。从 HTML 到 JavaScript,这是一个极好的信息来源。人们普遍偏向于 Firefox,但这通常不是问题,因为主流浏览器在实现 web 标准的方式上通常是兼容且一致的。

这是一本关于 HTML5 的书吗?

不,尽管我确实谈到了一些新的 HTML5 JavaScript APIs。这本书的大部分内容都是关于技术的,其中大部分都适用于 HTML4,就像它适用于 HTML5 一样。有些章节纯粹是基于 HTML5 APIs 构建的(比如第五章和第六章,它们向你展示了如何创建离线工作的 web 应用,以及如何在浏览器中存储数据),但其他章节并没有绑定到任何特定的 HTML 版本。我没有详细介绍 HTML5 中描述的新元素。这是一本关于编程的书,新元素对 JavaScript 编程没有太大影响。

这本书的结构是怎样的?

在第二章的中,我为一个虚构的奶酪零售商 CheeseLux 构建了一个简单的 web 应用,它基于我在本章后面介绍的基本示例。我遵循一些非常标准的方法来创建这个 web 应用,并在本书的剩余部分向您展示如何应用工业级技术来改进不同的方面。我试图将每一章合理地分开,但这是一本相当非正式的书,我确实在几章中逐步介绍了一些概念。每章都建立在前面章节介绍的技术之上。如果可以的话,你应该按章节顺序读这本书。以下部分总结了本书的章节。

第一章:做好准备

除了描述这本书,我还介绍了 CheeseLux 示例的静态 HTML 版本,我在这本书里一直使用它。我还列出了您需要的软件,如果您想自己重新创建示例或尝试本书附带的源代码下载中的清单(可从 Apress.com 免费获得)。

第二章:入门

在这一章中,我使用一些基本的技术来创建一个更动态的 CheeseLux 示例版本,从一个网站转移到一个 web 应用。我以此为契机,介绍本书剩余部分需要的一些工具和概念,并提供一个背景,以便我可以在后面的章节中展示更好的技术。

第三章:添加视图模型

我描述的第一个高级技术是在 web 应用中引入客户端视图模型。视图模型是设计模式中的关键组件,如模型视图控制器(MVC)和模型-视图-视图模型。如果你只采用这本书里的一种技术,那就选这一种;它将对您的开发实践产生最大的影响。

第四章:使用 URL 路由

URL 路由允许您扩展 web 应用中的导航机制。您可能没有意识到您有一个导航问题,但是当您看到 URL 路由如何在客户端工作时,您将会看到它是一种多么强大和灵活的技术。

第五章:创建离线网络应用

在这一章中,我将向您展示如何使用一些新的 HTML5 JavaScript APIs 来创建即使在用户离线时也能工作的 web 应用。这是一项强大的技术,随着智能手机和平板电脑进入市场,这项技术变得越来越重要。永远在线的网络连接的想法正在改变,能够适应离线工作对于许多 web 应用来说是必不可少的。

第六章:存储数据

除非你还能访问存储的数据,否则离线运行 web 应用没有多大用处。在本章中,我将向您展示可用于存储不同类型数据的不同 HTML5 APIs,从简单的名称/值对到持久化 JavaScript 对象的可搜索层次结构。

第七章:创建响应式网络应用

在传统的桌面和移动设备分类之外,还有许多网络设备类别。应对不同设备类型激增的一种方法是创建 web 应用,这些应用能够动态适应它们所使用的设备的功能,根据需要定制它们的外观、功能和交互模型。在这一章中,我将向你展示如何发现你关心的能力并对它们做出反应。

第八章:创建移动网络应用

创建响应式 web 应用的另一种方法是创建一个针对特定设备的单独版本。在本章中,我将向您展示如何使用 jQuery Mobile 来创建这样一个 web 应用,以及如何将 URL 路由等高级功能整合到移动 web 应用中。

第九章:写更好的 JavaScript

本书的最后一章是关于改进代码的——不是指更好地使用 JavaScript,而是指创建易于维护的代码模块,这些模块更易于在自己的项目中使用,也更易于与他人共享。我将向您展示一些基于约定的方法,并介绍异步模块定义,当外部库依赖于其他功能时,异步模块定义可以解决一些复杂的问题。我还将向您展示如何轻松地对客户端代码应用单元测试,包括如何对复杂的 HTML 转换进行单元测试。

你描述过设计模式吗?

我没有。这不是那种书。这是一本关于获得结果的书,我不会花太多时间讨论支撑我描述的每种技术的设计模式。如果你正在读这本书,那么你希望看到那些结果,并得到它们提供的好处现在。我的建议是解决你眼前的问题,然后开始研究理论。有很多关于设计模式和相关理论的有用信息。维基百科是一个很好的起点。一些读者可能会对维基百科作为编程信息来源的想法感到惊讶,但它提供了大量平衡且编写良好的内容。

我喜欢设计模式。我认为它们是重要的、有用的,并且是交流复杂问题的一般解决方案的有价值的机制。可悲的是,它们经常被用作一种宗教,在这种宗教中,模式的每个方面都必须完全按照指定的方式应用,并且关于竞争模式的优点和适用性的长期而令人讨厌的冲突就会爆发。

我的建议是将设计模式视为开发技术的基础。混合搭配不同的设计模式以适应您的项目,并挑选出能解决您所面临问题的部分。不要让任何人决定你使用模式的方式,并且始终专注于为真实的用户解决真实项目中的真实问题。你开始争论理论问题的解决方案的那一天,就是你走向黑暗面的那一天。要坚强。保持专注。抵制模式狂热者。

你会谈论平面设计和布局吗?

不。这也不是那种书。示例 web 应用的布局非常简单。这有几个原因。首先,这是一本关于编程的书,虽然我花了很多时间向您展示动态管理标记的技术,但实际的视觉效果是一个很大的副作用。

第二个原因是我有柠檬的艺术才能。我不画画,不作画,也没有副业在当地画廊卖我的布面油画作品。事实上,当我还是个孩子的时候,我被免除了艺术课,因为我完全没有天赋。我是一个相当好的程序员,但是我的设计技巧很烂。在这本书里,我坚持我所知道的,也就是重载编程。

如果你不喜欢我描述的技术或工具怎么办?

然后你调整这些技术,直到你确实喜欢它们,并找到以你喜欢的方式工作的替代工具。本书中的关键信息是,你可以应用重型服务器端技术来创建更好的 web 应用。精细的实现细节并不重要。我喜欢的工具和技术对我来说很好,如果你像我一样思考代码,它们也会对你很好。但是,如果你的思维以不同的方式工作,改变我的方法中不适合的部分,丢弃那些不起作用的部分,并使用剩下的部分作为你自己方法的基础。只要你最终开发出可伸缩性更好的 web 应用,让你的编码更有趣,并减轻维护负担,我们都会领先。

这本书的代码多吗?

是的。事实上,代码太多了,我都装不下。书籍有一个页面预算,在项目开始时就设定好了。页面预算影响图书的进度、生产成本和图书的最终售价。坚持页面预算是一件大事,每当我的编辑认为我要花很长时间(嗨,本!).我必须做一些编辑,以适应所有我想包含的代码。所以,当我引入一个新主题或者一口气做了很多改动的时候,我会给你看一个完整的 HTML 文档或者 JavaScript 代码文件,就像清单 1-1 中显示的那样。

清单 1-1。一个完整的 HTML 文档

`

    CheeseLux                             

    
                                              Would you like to use our mobile web app?                  
                                          
    
`

这个列表基于第八章中的一个。完整的清单给你一个更广阔的背景,让你了解手头的技术如何适应 web 应用世界。当我展示一个小的变化或者强调一个特定的代码区域时,我会展示一个类似于清单 1-2 中的代码片段。

清单 1-2。一段代码片段

`...

<title>CheeseLux</title> <script src="jquery-1.7.1.js" type="text/javascript"></script>     <script src="jquery.mobile-1.0.1.js" type="text/javascript"></script> <link rel="stylesheet" type="text/css" href="jquery.mobile-1.0.1.css"/> <link rel="stylesheet" type="text/css" href="styles.mobile.css"/> **<meta name="viewport" content="width=device-width, initial-scale=1">** <script> ...`

这些片段被累积应用到最后一个完整的列表中,这样,列表 1-2 中的片段显示一个meta元素被添加到列表 1-1 的head部分。如果您想对示例进行实验,您不必自己应用这些更改。相反,你可以从 Apress.com 下载本书中所有代码的完整列表。这个免费下载还包括我在本章后面提到的服务器端代码,并在本书中用来创建 web 应用的不同方面。

这本书需要什么软件?

如果你想重现书中的例子,你需要一些软件。每种类型都有很多选择,我用的都是免费的。我在接下来的章节中描述了每一种工具,以及我在每一类中的首选工具。

获取源代码

你需要下载这本书的源代码,Apress.com 免费提供。源代码下载包含按章节组织的所有清单和所有支持资源,比如图像和样式表。如果您想要完全重新创建任何示例,您将需要该下载的内容。

获得一个 HTML 编辑器

几乎任何编辑器都可以用来处理 HTML。我不依赖这本书的任何特性,所以使用任何适合你的编辑器。我在活动状态下使用 Komodo Edit。它免费且简单,对 HTML、JavaScript、jQuery 和 Node.js 都有很好的支持。可以从[activestate.com](http://activestate.com)获得 Komodo Edit,有 Windows、Mac、Linux 的版本。

获得桌面网络浏览器

任何现代主流桌面浏览器都会运行本书中的例子。我喜欢谷歌 Chrome 我发现它很快,我喜欢简单的用户界面,开发者工具也很不错。这本书里的大部分截图都是谷歌 Chrome 的,虽然有时候我会用 Firefox,因为 Chrome 没有完全实现 HTML5 的功能。(在我写这篇文章时,对 HTML5 APIs 的支持有点复杂,但每个浏览器版本都会改善这种情况。)

获得移动浏览器模拟器

在第七章和第八章中,我谈到了针对不同类型的设备。在开发的早期阶段,处理真实设备可能是一项缓慢而令人沮丧的工作,所以我使用一个移动浏览器模拟器来开始并将主要功能放在一起。直到我有了一些实用的、可靠的东西,我才开始在真正的移动设备上测试。

我喜欢 Opera 手机模拟器,从[www.opera.com/developer/tools/mobile](http://www.opera.com/developer/tools/mobile)开始可以免费获得;有适用于 Windows、Mac 和 Linux 的版本。该模拟器使用与真实的、广泛使用的 Opera Mobile 相同的代码库,虽然有一些奇怪的地方,但体验相当忠实于原作。我喜欢这个包,因为它让我可以为从小屏幕智能手机到高清平板电脑的不同屏幕尺寸创建模拟器。支持模拟触摸事件和改变设备的方向。你可以在任何浏览器中运行第七章和第八章中的例子,但是这些章节的部分重点是优雅地检测移动设备,并且你将通过使用仿真器获得最佳结果,即使它不是用于 Opera 的。

获取 JavaScript 库

我不相信重新创建一个写得很好、公开可用的 JavaScript 库中的功能。为此,我在每一章中都使用了一些库。有些是众所周知的,如 jQuery、jQuery UI 和 jQuery Mobile,但也有一些提供了一些特殊功能或弥补了没有实现某些 HTML5 APIs 的浏览器的空白。在我介绍时,我会告诉您如何获得每个库,它们都可以在 Apress.com 的源代码下载中找到。为了使用我讨论的技术,您不需要使用我喜欢的库,但是您将需要它们来重新创建示例。

获取 Web 服务器

本书中的例子集中在客户端 web 应用上,但是有些技术需要服务器的某些行为。大多数例子都适用于任何 web 服务器提供的内容,但是如果您想要重新创建本书中的每个例子,您将需要使用 Node.js。

我选择 Node.js 的原因是它是用 JavaScript 编写的,并且在许多平台上都得到支持。这意味着本书的任何读者都能够设置服务器,阅读并理解驱动服务器的代码。

服务器端代码包含在从 Apress.com 下载的源代码中,在一个名为server.js的文件中。我不打算详细介绍这段代码,也不打算列出它。它没有做任何特别的事情;它只是提供内容,并有几个特殊的 URL,允许我从示例 web 应用中发布数据,并获得定制的响应。还有一些其他的 URL 会产生特殊的效果,比如给一些请求增加延迟。如果你想知道里面有什么,可以看一看server.js,但是你不需要理解(甚至不需要看)服务器端代码就可以从这本书里得到最好的东西。

但是,您需要安装和设置 Node.js,以便它可以在您的网络上运行。在接下来的部分中,我提供了启动和运行的说明。

获取和准备 Node.js

可以从[nodejs.org](http://nodejs.org)下载 Node.js。安装包可用于 Windows、Mac 和 Linux,如果您想为不同的平台编译,源代码也是可用的。设置 Node 的说明经常变化,最好的入门方法是阅读 Felix Geisendö rfer 的 Node 初学者指南,你可以在[nodeguide.com/beginner.html](http://nodeguide.com/beginner.html)找到。

我依赖于一些第三方模块,所以在安装 Node.js 包之后运行以下命令:

npm install node-static jqtpl

这个命令下载并安装我在示例中用来交付静态和模板化内容的node-staticjqtpl包。该命令将生成类似如下的输出(但是您可能会看到一些额外的警告,这些警告可以忽略):


npm http GET https://registry.npmjs.org/node-static npm http GET https://registry.npmjs.org/jqtpl npm http 200 https://registry.npmjs.org/jqtpl npm http 200 https://registry.npmjs.org/node-static node-static@0.5.9 ./node_modules/node-static jqtpl@1.0.9 ./node_modules/jqtpl


源代码下载是按章节组织的。您需要在 Node.js 目录中创建一个名为content的目录,并将章节内容复制到其中。content目录没有多少结构;为了简单起见,几乎所有的资源和清单都在同一个目录中。

Image 注意章节之间的资源文件有变化,所以在章节内容之间移动时,一定要清除浏览器的历史记录。

您还需要将源代码下载中的server.js文件复制到 Node.js 目录中。这个节点脚本只为书中的例子服务;不要依赖它做任何其他用途,当然也不要用它来托管真正的项目。一旦一切就绪,只需运行以下命令:


node server.js


您将看到下面的输出(或与之非常接近的内容):


The "sys" module is now called "util". It should have a similar interface. Ready on port 80


如果您使用的是 Windows,可能会提示您允许 Node 通过 Windows 防火墙进行通信,您应该这样做。这样,您的服务器就可以正常运行了。该脚本监听端口 80 上的请求。如果您需要更改这一点,请在server.js文件中查找以下行:

http.createServer(handleRequest).listen(80);

Image 注意 Node.js 非常不稳定,经常发布新版本。我在这本书里使用的版本是 0.6.6,但是当你读到这本书的时候,它已经被取代了。我坚持使用更稳定的节点 API,但是您可能需要做一些小的调整来让一切正常工作。

介绍 CheeseLux 示例

本书中的大多数例子都基于一个虚构的奶酪零售商 CheeseLux 的 web 应用。我想把重点放在本书中的个别技术上,所以我尽可能保持 web 应用的简单性。首先,我创建了一个静态网站,向用户提供有限的产品。站点的入口点是example.html文件。我用example.html来表示这本书里几乎所有的列表。清单 1-3 显示了example.html的初始静态版本。

清单 1-3。静态 example.html

`

    CheeseLux     


        
            Gourmet European Cheese
    


            
French Cheese


                
                
            


                
                
            


                
                
            

        


            
        

    

`

我从一些基本的东西开始。web 应用的静态版本有四个页面,尽管在后面的章节中我倾向于只关注前两个页面的功能。这些是产品列表和显示用户选择的购物篮(在静态版本中由basket.html处理)。在图 1-1 中可以看到example.htmlbasket.html是如何在浏览器中显示的。

Image

图 1-1。浏览器中显示的 example.html 和 basket.html 文件

您不需要对静态文件做任何事情,但是如果您查看basket.html的内容,例如,您会看到我使用模板来生成基于通过 HTML 表单提交的数据的内容,如清单 1-4 所示。

清单 1-4。使用模板生成内容

`

<head>     <title>CheeseLux</title>     <link rel="stylesheet" type="text/css" href="styles.css"/> </head> <body>     <div id="logobar">         <img src="cheeselux.png">         <span id="tagline">Gourmet European Cheese</span>     </div>     <form action="/shipping" method="post">         <div class="cheesegroup">             <div class="grouptitle">Your Basket</div>             <table class="basketTable" border=0>                 <thead>                     <tr><th>Cheese</th><th>Quantity</th><th>Subtotal</th></tr>` `                    <tr><td class="sumline" colspan=3></td></tr>                 </thead>                 <tbody>                     **{{each properties}}**                         **{{if $value.propVal > 0}}**                         **<tr>**                             **<td>${$data.getProp($value.propName, "name")}</td>**                             **<td>${$value.propVal}</td>**                             **<td>**                                 **$${$data.getSubtotal($value.propName, $value.propVal)}**                             **</td>**                         **</tr>**                         **{{/if}}**                     **{{/each}}**                 </tbody>                 <tfoot>                     <tr><td class="sumline" colspan=3></td></tr>                     <tr><th colspan=2>Total:</th><td>$${$data.total}</td>                 </tfoot>             </table>             <div class="cornerplaceholder"></div>         </div>         <div id="buttonDiv">             <input type="submit" />         </div>         **{{each properties}}**             **<input type="hidden" name="${$value.propName}" value="${$value.propVal}"/>**         **{{/each}}**     </form> </body> </html>`

这些模板由您为 Node.js 下载的jqtpl模块处理。这个模块是一个简单模板库的节点兼容版本,广泛用于 jQuery 库。我在客户端示例中不使用这种风格的模板,但是我想解释一下这些标记的含义,以防您想偷看静态内容。

在下一章中,我将使用一些基本的 JavaScript 技术来创建这个简单应用的更动态的版本,然后在本书的剩余部分向您展示更高级的技术,您可以使用这些技术来为您自己的项目创建更好、更可伸缩、响应更快的 web 应用。

字体归属

我在这本书里使用了一些自定义的网络字体。字体文件包含在从 Apress.com 下载的源代码中。我使用的字体来自可移动类型联盟([www.theleagueofmoveabletype.com](http://www.theleagueofmoveabletype.com))和谷歌网络字体服务([www.google.com/webfonts](http://www.google.com/webfonts))。

总结

在这一章中,我概述了这本书的内容和结构,并列出如果你想用书中的例子进行实验所需的软件。我还介绍了 CheeseLux 示例,该示例贯穿了本书。在下一章,我将使用一些基本的技术来增强静态网页,并介绍一些我在本书中使用的核心工具。从那时起,我将向您展示一系列更好的工业级技术,这是本书的核心。

二、入门指南

在这一章中,我将增强我在第一章中介绍的示例 web 应用。这些都是入门级的技术,本书的大部分内容致力于向您展示改善结果的不同方法。这并不是说本章中的例子没有用;对于简单的 web 应用来说,它们绝对没问题。但是它们对于大型复杂的 web 应用来说是不够的,这就是为什么接下来的章节解释了如何从服务器端开发的世界中获取关键概念并应用到您的 web 应用中。

这一章也让我为一些我将在本书中使用的 web 应用开发原则奠定了基础。首先,我将尽可能依赖 JavaScript 库,以避免创建别人已经生成和维护的代码。我将最常用的库是 jQuery,以使使用 DOM API 变得更简单和容易(我在本章的例子中解释了一些 jQuery 基础知识)。第二,我将专注于单个 HTML 文档。

升级提交按钮

首先,我将使用 JavaScript 替换第一章中基线示例中的提交按钮。浏览器从一个类型为submitinput元素创建了这个按钮,我将把它换成与文档其余部分视觉上一致的东西。更具体地说,我将使用 jQuery 来替换input元素。

准备使用 jQuery

DOM API 很全面,但使用起来很笨拙——笨拙到有许多 JavaScript 便利库包装了 DOM API,使它更容易使用。根据我的经验,这些库中最好的是 jQuery,它易于使用,并且得到了积极的开发和支持。jQuery 也是许多其他 JavaScript 库的基础,其中一些我稍后会用到。jQuery 只是 DOM API 的包装器,如果需要的话,它允许使用底层的 DOM 对象和方法。

您可以从 jQuery.com 下载 jQuery 库。和大多数 JavaScript 库一样,jQuery 有两个版本。未压缩版本包含完整的源代码,对于开发和调试非常有用。压缩版本(也称为最小化的版本)要小得多,但是不可读。较小的尺寸使最小化版本非常适合在 web 应用部署到生产环境中时节省带宽。对于流行的网络应用来说,带宽可能很贵,任何节省都是值得的。

下载你想要的版本,放在你的content目录下,和example.html放在一起。我将在本书中使用未压缩版本,所以我下载了一个名为jquery-1.7.1.js的文件。

Image 提示我使用的是未压缩版本,因为它们使调试更容易,当你探索本书中的例子时,你会发现这很有用。对于真正的 web 应用,您应该在部署之前切换到最小化版本。

文件名包括 jQuery 版本,在我写这篇文章时是 1.7.1。使用一个script元素将 jQuery 库导入到示例文档中,如清单 2-1 所示。我已经在文档的head部分添加了script元素。

清单 2-1。将 jQuery 导入示例文档

`...

<head>     <title>CheeseLux</title>     <link rel="stylesheet" type="text/css" href="styles.css"/> **    <script src="jquery-1.7.1.js" type="text/javascript"></script>** </head> ...`

使用 CDN 进行 JQUERY

在您自己的 web 服务器上托管 jQuery 库的另一种方法是使用托管 jQuery 的公共内容分发网络 (CDN)。CDN 是一个由服务器组成的分布式网络,使用离用户最近的服务器向用户交付文件。使用 CDN 有几个好处。首先是用户体验更快,因为 jQuery 库文件是从离他们最近的服务器上下载的,而不是从您的服务器上。通常根本不需要这个文件。jQuery 如此受欢迎,以至于用户的浏览器可能已经缓存了来自另一个也使用 jQuery 的应用的库。第二个好处是,您不会将宝贵而昂贵的带宽花费在向用户交付 jQuery 上。

使用 CDN 的时候,一定要对 CDN 运营商有信心。您希望确保用户收到他们应该收到的文件,并且服务将始终可用。谷歌和微软都免费为 jQuery(以及其他流行的 JavaScript 库)提供 CDN 服务。两家公司都有运行高可用性服务的丰富经验,不太可能故意篡改 jQuery 库。你可以在[www.asp.net/ajaxlibrary/cdn.ashx](http://www.asp.net/ajaxlibrary/cdn.ashx)了解微软服务,在[code.google.com/apis/libraries/devguide.html](http://code.google.com/apis/libraries/devguide.html)了解谷歌服务。

CDN 方法不适合在内部网中交付给用户的应用,因为它会导致所有浏览器都通过互联网来获取 jQuery 库,而不是访问本地服务器,后者通常更近、更快且带宽成本更低。

所以,让我们直接使用 jQuery 来隐藏现有的input元素,并在它的位置上添加一些东西。清单 2-2 展示了这是如何做到的。

清单 2-2。隐藏输入元素并添加另一个元素

`

    CheeseLux           **    **     
                     Gourmet European Cheese     


            
French Cheese


                
                
            


                
                
            


                
                
            

        

            
        

    

`

我在文档中添加了另一个script元素。该元素包含内联代码,而不是加载外部 JavaScript 文件。我这样做是因为这样更容易向你展示我所做的改变。jQuery 并不要求使用内联代码,如果愿意,可以将 jQuery 代码放在外部文件中。在script元素的四个 JavaScript 语句中发生了很多事情,所以我将在接下来的章节中一步一步地进行分解。

了解就绪事件

jQuery 的核心是$函数,这是开始使用 jQuery 特性的一种便捷方式。使用 jQuery 最常见的方式是将$视为一个 JavaScript 函数,并传递一个 CSS 选择器或一个或多个 DOM 对象作为参数。在 jQuery 中使用$函数是很常见的。例如,我在四行代码中使用了三次。

$函数返回一个 jQuery 对象,您可以在其上调用 jQuery 方法。jQuery对象是您选择的元素的包装器,如果您传递一个 CSS 选择器作为参数,jQuery对象将包含文档中与您指定的选择器匹配的所有元素。

Image 提示这是 jQuery 优于内置 DOM API 的主要优势之一:可以更容易地选择和修改多个元素。DOM API 的最新版本(包括 HTML5 的一部分)提供了使用选择器查找元素的支持,但是 jQuery 做得更简洁和优雅。

第一次使用清单中的$函数时,我将document对象作为参数传入。document对象是 DOM 中元素层次的根节点,我用$函数选择了它,这样我就可以调用ready方法,如清单 2-3 中突出显示的。

清单 2-3。选择文档并调用就绪方法

`...

...`

浏览器一找到文档中的script元素就执行 JavaScript 代码。当您想要操作 DOM 中的元素时,这就给我们带来了一个问题,因为您的代码是在浏览器解析完 HTML 文档的其余部分、发现了您想要处理的元素并将对象添加到 DOM 以表示它们之前执行的。最好的情况是您的 JavaScript 代码不工作,最坏的情况是当这种情况发生时您会导致一个错误。有许多方法可以解决这个问题。最简单的解决方案是将script元素放在文档的末尾,这样浏览器就不会发现并执行您的 JavaScript 代码,直到 HTML 的其余部分被处理完。一种更优雅的方法是使用 jQuery ready方法,它在刚刚显示的清单中突出显示。

您将一个 JavaScript 函数作为参数传递给ready方法,一旦浏览器处理完文档中的所有元素,jQuery 就会执行这个函数。使用ready方法允许您将script元素放在文档中的任何地方,因为知道您的代码直到正确的时刻才会被执行。

Image 注意一个常见的错误是忘记将要执行的 JavaScript 语句封装在一个函数中,这会导致奇怪的效果。如果您向ready方法传递一个语句,那么它将在浏览器处理script元素时立即执行。如果您传递多个语句,那么浏览器通常会报告一个 JavaScript 错误。

ready方法为ready事件创建一个处理程序。在本章的后面,我将向您展示 jQuery 支持事件的更多方式。ready事件仅对document对象可用,这就是为什么您会在几乎所有使用 jQuery 的 web 应用的清单中看到突出显示的语句。

选择和隐藏输入元素

既然我已经将 JavaScript 代码的执行推迟到 DOM 准备好之后,那么我可以转到任务的下一步,即隐藏提交表单的input元素。清单 2-4 突出显示了例子中这样做的语句。

清单 2-4。选择并隐藏输入元素

`...

...`

这是一个经典的由两部分组成的 jQuery 语句:首先,我选择我想要处理的元素,然后我应用一个 jQuery 方法来修改所选择的元素。您可能不认识我使用的选择器,因为:submit部分是除 CSS 规范中定义的选择器之外 jQuery 定义的选择器之一。表 2-1 包含了最有用的 jQuery 自定义选择器。

Image 注意jQuery 定制选择器非常有用,但是它们会影响性能。只要有可能,jQuery 就使用本地浏览器支持来查找文档中的元素,这通常非常快。然而,jQuery 必须以不同的方式处理自定义选择器,因为浏览器对它们一无所知,这比本机方法花费的时间更长。这种性能差异对于大多数 web 应用来说无关紧要,但是如果性能很重要,您可能希望坚持使用标准的 CSS 选择器。

Image

在清单 2-4 中,我的选择器匹配任何类型为submitinput元素,并且它是id属性为buttonDiv的元素的后代。我不需要对选择器如此精确,因为它是文档中唯一的submit元素,但是我想演示 jQuery 对选择器的支持。$函数返回一个包含所选元素的jQuery对象,尽管在本例中只有一个元素与选择器匹配。

选择元素后,我调用hide方法,通过将 CSS display属性设置为none来改变所选元素的可见性。input元素是这样的方法调用之前的:

<input type="submit">

并且在方法调用之后像这样转换:

<input type="submit" **style="display: none; "**>

浏览器不会显示display属性为none的元素,因此input元素变得不可见。

Image 提示hide方法的对应方法是show,它删除了display设置,并将元素返回到可见状态。我将在本章后面演示show方法。

插入新元素

接下来,我想在文档中插入一个新元素。清单 2-5 突出显示了例子中这样做的语句。

清单 2-5。向文档添加新元素

`...

...`

在这个语句中,我将一个 HTML 片段字符串传递给了 jQuery $函数。这导致 jQuery 解析片段并创建一组对象来表示它包含的元素。然后这些元素对象在一个jQuery对象中返回给我,就好像我从文档本身中选择了元素一样,只是浏览器还不知道这些元素,它们还不是 DOM 的一部分。

在这个清单的 HTML 片段中只有一个元素,所以 jQuery 对象包含一个a元素。为了将这个元素添加到 DOM 中,我调用 jQuery 对象上的appendTo方法,传入一个 CSS 选择器,它告诉 jQuery 我希望将元素插入到文档中的什么位置。

appendTo方法将我的新元素作为选择器匹配的元素的最后一个子元素插入。在本例中,我指定了buttonDiv元素,这意味着我的 HTML 片段中的元素被插入到隐藏的input元素旁边,就像这样:

`...

              **Submit Order**
...`

Image 提示如果我传递给appendTo方法的选择器匹配了多个元素,那么 jQuery 将复制 HTML 片段中的元素,并插入一个副本作为每个匹配元素的的最后一个子元素。

jQuery 定义了许多方法,可以用来将子元素插入到文档中,其中最有用的方法在表 2-2 中有描述。当您追加元素时,它们成为其父元素的最后一个子元素。当你前置元素时,它们成为其父元素的第一个子元素。(我将在本章后面解释为什么有两个 append 和两个 prepend 方法。)

Image

应用 CSS 类

在前面的例子中,我插入了一个a元素,但是我没有将它分配给一个 CSS 类。清单 2-6 展示了我如何通过调用addClass方法来纠正这个遗漏。

清单 2-6。链接 jQuery 方法调用

`...

...`

请注意,我只是将对addClass方法的调用添加到了语句的末尾。这被称为方法链接,一个支持方法链接的库据说有一个流畅的 API

大多数 jQuery 方法返回的 jQuery 对象与调用该方法时返回的对象相同。在这个例子中,我通过向$函数传递一个 HTML 片段来创建jQuery对象。这产生了一个包含一个a元素的jQuery对象。appendTo方法将元素插入到文档中,并返回一个 jQuery 对象,该对象包含与结果相同的a元素。这允许我进行进一步的方法调用,比如对addClass的调用。流畅的 API 可能需要一段时间来适应,但它们可以使代码简洁而富有表现力,并减少重复。

addClass方法将参数指定的类添加到选定的元素中,如下所示:

`...

         Submit Order
...`

styles.css中定义了a.button类,它使a元素的外观与文档的其余部分保持一致。

了解方法对和方法链

如果你看一下表 2-2 中描述的方法,你会发现你可以用两种方式附加或前置元素。插入的元素可以包含在调用方法的 jQuery 对象中,也可以包含在方法参数中。jQuery 提供了不同的方法,因此您可以选择哪些元素包含在用于方法链接的jQuery对象中。在我的例子中,我使用了appendTo方法,这意味着我可以安排事情,使jQuery对象包含从 HTML 片段解析的元素,允许我链接对addClass方法的调用,并将类应用于a元素。

append方法颠倒了父元素和子元素之间的关系,如下所示:

$('#buttonDiv').append('<a href=#>Submit Order</a>').addClass("button");

在这个语句中,我选择父元素并提供 HTML 片段作为方法参数。append 方法返回一个包含buttonDiv元素的jQuery对象,因此addClass对父div元素生效,而不是新的a元素。

概括一下,我隐藏了原来的input元素,添加了一个a元素,最后,将a元素赋给了button类。你可以在图 2-1 中看到结果。

Image

图 2-1。替换标准表单提交按钮

用四行代码(其中只有两行操作 DOM),我将标准的提交按钮升级为与 web 应用的其余部分一致的东西。正如我在本章开始时所说的,一点点代码可以带来显著的增强。

应对事件

我还没有完全完成新的a元素。浏览器知道一个类型属性为submitinput元素应该向服务器提交 HTML 表单,当按钮被点击时,它会自动执行这个动作。

我添加到 DOM 中的a元素看起来像一个按钮,但是浏览器不知道这个元素的用途,所以没有应用相同的自动操作。我必须添加一些 JavaScript 代码来完成这个效果,并使a元素的行为像一个按钮,而不只是看起来像。

您可以通过响应事件来做到这一点。事件是当元素的状态改变时,例如,当用户单击元素或将鼠标移到元素上时,浏览器发送的消息。您告诉浏览器您对哪些事件感兴趣,并提供事件发生时执行的 JavaScript 回调函数。当一个事件被浏览器发送时,据说已经被触发,回调函数负责处理该事件。在接下来的部分中,我将向您展示如何处理事件来完成替换按钮的功能。

处理点击事件

本例中最重要的是click,当用户按下并释放鼠标按钮时(换句话说,当用户单击一个元素时)触发该事件。对于这个例子,我想通过向服务器提交 HTML 表单来处理click事件。DOM API 提供了处理事件的支持,但是 jQuery 提供了一个更好的选择,你可以在清单 2-7 中看到。

清单 2-7。处理点击事件

`

    CheeseLux               
                     Gourmet European Cheese     


            
French Cheese


                
                
            


                
                
            


                
                
            

        

    

    
`

jQuery 提供了一些有用的方法,使得处理常见事件变得简单。这些事件以事件命名;因此,click方法将作为方法参数传递的回调函数注册为click事件的处理程序。我已经将对click事件的调用链接到创建和格式化a元素的其他方法。为了提交表单,我通过类型选择了form元素并调用了submit方法。这就是全部了。我现在已经有了按钮的基本功能。它不仅与 web 应用的其他部分具有相同的视觉样式,而且单击按钮会将表单提交给服务器,就像最初的按钮一样。

处理鼠标悬停事件

我还想处理另外两个事件来完成按钮功能;他们是mouseentermouseleave。当鼠标指针在元素上移动时触发mouseenter事件,当鼠标离开元素时触发mouseleave事件。

我想处理这些事件,给用户一个可以点击按钮的视觉提示,当鼠标在元素上时,我通过改变按钮的样式来做到这一点。处理这些事件最简单的方法是使用 jQuery hover方法,如清单 2-8 所示。

清单 2-8。使用 jQuery 悬停方法

`...

...`

hover方法将两个函数作为参数。第一个函数在mouseenter事件被触发时执行,第二个函数响应mouseleave事件被触发。在这个例子中,我使用这些函数从a元素中添加和删除了buttonHover类。该类更改 CSS background-color属性的值,以便当鼠标位于元素上方时高亮显示按钮。你可以在图 2-2 中看到效果。

Image

图 2-2。使用事件将类应用于元素

使用事件对象

在前一个例子中,我作为参数传递给hover方法的两个函数基本相同。我可以将这两个函数合并成一个可以处理这两个事件的处理器,如清单 2-9 所示。

清单 2-9。在单个处理函数中处理多个事件

`...

...`

本例中的回调函数接受一个参数e。这个参数是浏览器提供的一个Event对象,为您提供关于您正在处理的事件的信息。我已经使用了Event.type属性来区分我的函数所期望的事件类型。type属性返回一个包含事件名称的字符串。如果事件名是mouseenter,那么我调用addClass方法。如果没有,我调用removeClass方法,该方法的作用是从 jQuery 对象的元素的class属性中删除指定的类,与addClass方法的作用相反。

处理默认动作

为了让程序员的生活更轻松,当特定元素类型的特定事件被触发时,浏览器会自动执行一些操作。这些被称为默认动作,它们意味着你不必为 HTML 文档中的每个事件和元素创建事件处理程序。例如,浏览器将导航到由a元素的href属性指定的 URL,以响应click事件。这是网页导航的基础。

我把href属性设置成#有点作弊。这是定义其动作将由 JavaScript 管理的元素时的常用技术,因为当执行默认动作时,浏览器不会离开当前文档。换句话说,我不必担心默认动作,因为它并没有真正做任何用户会注意到的事情。

当你需要改变元素的行为,而你又不能像使用#作为 URL 那样做一些小技巧的时候,默认动作可能会更重要。清单 2-10 提供了一个演示,我已经将a元素的href属性更改为一个真实的网页。我已经使用了attr方法将a元素的href属性设置为[apress.com](http://apress.com)。通过这种修改,单击元素不再提交表单;它会导航到 Apress 网站。

清单 2-10。管理默认操作

`...

...`

要解决这个问题,需要调用传递给事件处理函数的Event对象上的preventDefault方法。这将禁用事件的默认操作,意味着将只使用事件处理函数中的代码。你可以在清单 2-11 中看到这个方法的使用。

清单 2-11。防止违约行为

`...

...`

对于a元素上的mouseentermouseleave事件没有默认动作,所以在这个清单中,我只需要在处理click事件时调用preventDefault方法。当我现在点击元素时,表单被提交,href属性值没有任何影响。

添加动态购物篮数据

您已经看到了如何通过添加和修改元素以及处理事件来改进 web 应用。在本节中,我将进一步演示如何使用这些简单的技术,通过将购物篮阶段显示的信息与产品选择结合起来,创建一个响应更快的奶酪店。我称之为动态购物篮,因为当用户改变单个奶酪产品的数量时,我将更新向他们显示的信息,而不是静态购物篮,当用户使用该 web 应用的未增强版本提交他们的选择时,就会显示该信息。

添加购物篮元素

第一步是向文档中添加我需要的附加元素。我可以使用 HTML 片段和appendTo方法添加元素,但是为了多样化,我将使用另一种技术,称为潜在内容。潜在内容指的是文档中的 HTML 元素,它们使用 CSS 隐藏,使用 JavaScript 显示和管理。那些没有启用 JavaScript 的用户将看不到这些元素,并将获得基本的功能,但是一旦我揭示了这些元素并设置了我的事件处理,那些使用 JavaScript 的用户将获得更丰富、更完美的体验。清单 2-12 展示了向 HTML 文档添加潜在内容。

清单 2-12。在 HTML 文档中添加隐藏元素

`

    CheeseLux                    
                     Gourmet European Cheese     


            
French Cheese


                


                


                

**            

**
**            
**
**                **
**                **
**                $0                    **
**            
**
        


            
        

    

`

我已经突出显示了清单中的附加元素。它们都被分配给了latent类,该类在styles.css文件中有如下定义:

... .latent {     display: none; } ...

在这一章的前面,我向你展示了 jQuery hide方法将 CSS display属性设置为none来对用户隐藏元素,我在设置这个类的时候也遵循了同样的方法。这些元素在文档中,但对用户不可见。

显示潜在的内容

既然潜在元素已经就位,我就可以使用 jQuery 来处理它们了。第一步是向用户展示它们。因为我使用 JavaScript 操作这些元素,所以它们将只对启用了 JavaScript 的用户显示。清单 2-13 展示了对script元素的添加。

清单 2-13。揭示潜在内容

`...

...`

突出显示的语句选择所有属于latent类的元素,然后调用show方法。show方法为每个选中的元素添加了一个style属性,该属性将display属性设置为inline,具有显示元素的效果。这些元素仍然是latent类的成员,但是在style 属性中定义的值覆盖了在style 元素中定义的值,因此这些元素变得可见。

响应用户输入

为了创建一个动态购物篮,我希望能够显示每一项的小计,以及每当用户更改产品数量时的总计。我将处理两个事件来获得我想要的效果。第一个事件是change,当用户输入一个新值,然后将焦点移动到另一个元素时触发。第二个事件是keyup,当用户释放之前按下的一个键时触发。这两件事的结合意味着我可以自信地对新的价值观做出平稳的反应。jQuery 定义了changekeyup方法,我可以像之前使用click方法一样使用它们,但是因为我想以同样的方式处理这两个事件,所以我将使用bind方法,如清单 2-14 所示。

清单 2-14。绑定到 change 和 keyup 事件

`...

...`

bind方法的优点是它允许我使用同一个匿名 JavaScript 函数处理多个事件。为此,我选择了文档中的input元素来获取一个 jQuery 对象,并对其调用了bind方法。bind方法的第一个参数是一个包含要处理的事件名称的字符串,其中事件名称由空格字符分隔。第二个参数是当事件被触发时处理事件的函数。事件处理函数中只有两个语句,但是它们值得一解,因为它们包含了 jQuery、DOM API 和纯 JavaScript 的有趣组合。

Image 提示像这样处理两个事件意味着我的回调函数可能会在不需要的时候被调用。例如,如果用户按下 Tab 键,焦点将转移到下一个元素,并且changekeyup事件都将被触发,即使input元素中的值没有改变。我倾向于接受这种重复,作为确保流畅用户体验的代价。我宁愿我的功能执行得比实际需要的更频繁,不要错过任何用户交互。

计算小计

函数中的第一条语句负责计算input值已更改的奶酪产品的小计。以下是声明:

var subtotal = $(this).val() * priceData[this.name];

用 jQuery 处理事件时,可以使用名为this的变量来引用触发事件的元素。this变量是一个HTMLElement对象,DOM API 用它来表示文档中的元素。有一组由HTMLElement定义的核心属性,其中最重要的在表 2-3 中描述。

补充了核心属性,以适应不同元素类型的独特特征。一个这样的例子是name属性,它返回那些支持它的元素的name属性的值,包括input元素。我已经在this变量上使用了这个属性来获取input元素的名称,这样我就可以用它来从我添加到脚本中的priceData对象中获取一个值:

var subtotal = $(this).val() * **priceData[this.name];**

priceData对象是一个简单的 JavaScript 对象,它有一个对应于每种奶酪的属性,每个属性的值就是奶酪的价格。

this变量也可以用来创建jQuery对象,如下所示:

var subtotal = **$(this)**.val() * priceData[this.name];

通过将一个HTMLElement对象作为参数传递给 jQuery $函数,我创建了一个jQuery对象,它的行为就像我使用 CSS 选择器选择了元素一样。这允许我轻松地将 jQuery 方法应用于来自 DOM API 的对象。在这个语句中,我调用了val方法,该方法返回jQuery对象中第一个元素的value属性的值。

Image 提示我的jQuery对象中只有一个元素,但是 jQuery 方法被设计成可以处理多个元素。当您使用类似于val的方法从元素中读取一些值时,您从选择的第一个元素中获取值,但是当您使用相同的方法设置值(通过将值作为参数传递)时,所有选择的元素都将被修改。

使用this变量,我已经能够获得触发事件的input元素的值以及与之相关的产品价格。然后,我将价格和数量相乘以确定小计,并将其赋给一个名为subtotal的局部变量。

显示小计

处理函数中的第二条语句负责向用户显示小计。这一声明也分为两部分。第一部分选择将用于显示值的元素:

**$(this).siblings("span").children("span").**text(subtotal)

我再次使用this变量创建了一个 jQuery 对象。我调用了siblings方法,该方法返回一个jQuery对象,该对象包含原始jQuery对象中匹配指定 CSS 选择器的元素的任何兄弟元素。该方法返回一个jQuery对象,该对象包含触发事件的input元素旁边的潜在span元素。

我链接了对children方法的调用,该方法返回一个jQuery对象,该对象包含前面的jQuery对象中匹配指定选择器的元素的所有子元素。我以一个包含嵌套的span元素的jQuery对象结束。在这个例子中,我本来可以简化选择器,但是我想演示 jQuery 如何支持在文档中导航元素,以及在一系列方法调用中jQuery对象的内容如何改变。这些变化在表 2-4 中描述。

Image

通过像这样组合方法调用,我能够在元素层次结构中导航以创建一个jQuery对象,该对象精确地包含我想要处理的一个或多个元素,在本例中,是触发事件的元素的兄弟元素的子元素。

语句的第二部分是对text方法的调用,该方法设置jQuery对象中元素的文本内容。在本例中,文本是subtotal变量的值:

$(this).siblings("span").children("span")**.text(subtotal)**

最终结果是,一旦用户更改了所需的数量,奶酪的小计就会更新。

计算整体总数

为了完成这个篮子,我需要在每次小计发生变化时生成一个总计。我在script元素中定义了一个新函数,并在input元素的事件处理函数中添加了对它的调用。清单 2-15 显示了增加的内容。

清单 2-15。计算总体总数

`...

...`

calculateTotal函数中的第一条语句定义了一个局部变量,并初始化为零。我用这个变量来计算各个小计的总和。下一个语句是这个函数中最有趣的一个。语句的第一部分选择一组元素:

... **$('span.subtotal span').not('#total')**.each(function(index, elem) { ...

我首先选择所有的span元素,它们是属于subtotal类的span元素的后代。这是选择小计元素的另一种方式。然后我使用not方法从选择中移除元素。在这种情况下,我删除了idtotal的元素。我这样做是因为我使用相同的类和样式定义了 subtotal 和 total 元素,并且我不希望在计算新的总计时包含当前的总计。

选择完项目后,我使用each方法。这个方法为一个jQuery对象中的每个元素调用一次函数。该函数的参数是选择中当前元素的索引和代表 DOM 中元素的HTMLElement对象。

我使用text方法获取每个 subtotal 元素的内容。我通过将HTMLElement对象作为参数传递给$函数来创建一个jQuery对象,就像我在本章前面对this变量所做的一样。

text方法返回一个字符串,所以我使用 JavaScript Number函数创建一个数值,我可以将它添加到运行总数中:

total += **Number**($(elem).text());

最后,我选择了total元素,并使用text方法来显示总计:

$('#total').text("$" + total);

添加此函数的效果是奶酪数量的变化会立即反映在总数和单个小计中。

改变表单目标

通过添加动态购物篮,我将购物篮 web 页面的功能引入了应用的主页面。当启用 JavaScript 的用户提交表单时,将他们发送到购物篮 web 页面是没有意义的,因为它只是复制了他们已经看到的信息。我将更改form元素的目标,以便提交表单直接进入发货页面,完全跳过购物篮页面。清单 2-16 显示了改变目标的语句。

清单 2-16。更改表单元素的目标

`...

...`

至此,新语句的工作原理应该显而易见了。我按类型选择了form元素(因为文档中只有一个这样的元素),并调用attr方法为action属性设置一个新值。提交表单时,用户会被带到 shipping details 页面,完全跳过购物篮页面。你可以在图 2-3 中看到效果。

Image

图 2-3。改变申请流程

正如这个例子所演示的,您可以改变 web 应用的流程以及各个页面的外观和交互性。当然,后端服务需要了解不同类型的用户可以通过 web 应用遵循的各种路径,但这很容易通过一点预先考虑和规划来实现。

了解渐进式改进

我在这一章中展示的技术是基本的,但是非常有效。通过使用 JavaScript 管理 DOM 中的元素并响应事件,我已经能够使示例 web 应用对用户的响应更快,提供关于用户产品选择成本的有用和及时的信息,并简化应用本身的流程。

但是——这很重要——因为这些变化是通过 JavaScript 完成的,所以对于非 JavaScript 用户来说,web 应用的基本性质和结构保持不变。图 2-4 显示了启用和禁用 JavaScript 时的 web 应用主页面。

Image

图 2-4。禁用和启用 JavaScript 时的 web 应用

非 JavaScript 用户体验的版本仍然功能齐全,但使用起来比较笨拙,需要更多步骤才能下订单。

创建一个基本的功能级别,然后有选择地丰富它,这是渐进增强的一个例子。渐进式改进不仅仅是关于 JavaScript 的可用性;它包括基于任何因素的选择性丰富,例如带宽量、浏览器类型,甚至用户的经验水平。然而,在创建 web 应用时,最常见的渐进式改进形式是由用户是否启用了 JavaScript 来驱动的。

Image 提示渐进增强类似的术语是优雅退化。就我在本书中的目的而言,渐进增强和适度降级是相同的,即 web 应用的核心内容和功能对所有用户都是可用的,而与用户浏览器的功能无关。

如果您不想支持非 JavaScript 浏览器,那么您应该让非 JavaScript 访问者清楚地看到存在问题。最简单的方法是使用noscriptmeta元素将浏览器重定向到解释情况的页面,如清单 2-17 所示。

清单 2-17。处理非 JavaScript 用户

`...

<head>     <title>CheeseLux</title>     <link rel="stylesheet" type="text/css" href="styles.css"/>     <script src="jquery-1.7.1.js" type="text/javascript"></script>     <script>` `        ... JavaScript code goes here...         </script> **    <noscript>** **        <meta http-equiv="refresh" content="0; noscript.html"/>** **    </noscript>** </head> ...`

这些元素的组合将用户重定向到一个名为noscript.html的页面,这是一个 HTML 文档,告诉用户我需要 JavaScript(显然,并不依赖于 JavaScript 本身)。你可以在本书附带的源代码下载中找到这一页,并在图 2-5 中看到结果。

Image

图 2-5。在 web 应用中强制执行仅 JavaScript 策略

要求 JavaScript 很诱人,但我建议谨慎;您可能会惊讶于有多少用户不启用 JavaScript 或者根本不能启用。对于大公司的用户来说尤其如此,在大公司中,计算机通常被锁定,普通人群中常见的功能以安全的名义被禁用,遗憾的是,包括浏览器中的 JavaScript。有些 web 应用没有 JavaScript 就没有意义,但是在决定开发它们之前,要仔细考虑你要排除的潜在用户/客户。

Image 注意这是一本关于用 JavaScript 构建 web 应用的书,所以我不打算在接下来的章节中保持渐进式增强。不要认为这是对纯 JavaScript 政策的认可。在我自己的项目中,我尽可能地支持非 JavaScript 用户,即使这需要很多额外的工作。

重温按钮:使用 UI 工具包

在本章的最后,我想向你展示一种不同的方法来获得本章中的一个结果:创建一个视觉上一致的按钮。我之前使用的技术演示了如何操作 DOM 和响应事件来定制元素的外观和行为,这是本章的主要前提。

也就是说,对于专业开发来说,最好不要写可以从好的 JavaScript 库中获得的东西,当我想创建视觉上丰富的元素时,我会使用 UI 工具包。在这一节中,我将向您展示使用 jQuery UI 创建自定义按钮是多么容易,jQuery UI 是由 jQuery 团队开发的,是使用最广泛的 JavaScript UI 工具包之一。

设置 jQuery UI

设置 jQuery UI 是一个多阶段的过程。第一步是创建一个主题,它定义了 jQuery UI 小部件(UI 工具包创建的样式元素的名称)使用的 CSS 样式。要创建主题,请转到[jqueryui.com](http://jqueryui.com),点击主题按钮,展开屏幕左侧的每个部分,并指定您想要的样式。当您进行更改时,屏幕右侧的示例小部件将会更新以反映新的设置。我花了大约五分钟(以及一点点尝试和错误)来创建一个与示例 web 应用外观相匹配的主题。如果您不想创建自己的主题,我已经在本书的源代码下载中包含了我创建的主题。

Image 提示如果您不想创建自定义主题,您可以从图库中选择预定义的样式。如果你不想匹配现有的应用设计,这可能是有用的,尽管一些画廊风格中使用的颜色非常惊人。

完成后,点按“下载主题”按钮。您将看到一个屏幕,允许您选择下载中包含哪些 jQuery UI 组件。如果您深入了解 jQuery UI 的细节,您可以创建一个较小的下载,但是对于本书,请确保选择了所有的组件并单击 download 按钮。您的浏览器将下载一个.zip文件,其中包含 jQuery UI 库、您创建的 CSS 主题和一些支持图片。

设置的第二部分是将以下文件从.zip文件复制到 Node.js 服务器的content目录中:

  • development-bundle\ui\jquery-ui-1.8.16.custom.js file
  • File development-bundle\themes\custom-theme\jquery-ui-1.8.16.custom.css file
  • development-bundle\themes\custom-theme\images folder

这些文件的名称包括 jQuery UI 版本号。当我写这篇文章的时候,当前的版本是 1.8.16,但是在这本书出版的时候,你可能会有一个更高的版本。

Image 提示我再次使用 JavaScript 文件的未压缩版本,以使调试更容易。你会在.zip文件的js文件夹中找到最小化版本。

创建 jQuery UI 按钮

既然 jQuery UI 已经设置好了,我可以在 HTML 文档中使用它来创建一个按钮小部件并简化我的代码。清单 2-18 显示了将 jQuery UI 导入文档并创建一个按钮所需的附加内容。

导入 jQuery UI 只是添加一个用于导入 JavaScript 文件的script元素和一个用于导入 CSS 文件的link元素。你不需要明确地引用images目录。

Image 提示注意,导入 jQuery UI JavaScript 文件的script元素出现在导入 jQuery 的元素之后的元素。这种排序很重要,因为 jQuery UI 依赖于 jQuery。

清单 2-18。使用 jQuery UI 创建一个按钮

`

    CheeseLux               ****     ****

...`

在使用 jQuery UI 时,我不必隐藏input元素并插入一个替代。相反,我使用 jQuery 选择我想要修改的元素并调用button方法,如下所示:

$('#buttonDiv input:submit').button()

通过一次方法调用,jQuery UI 改变标签的外观,并在鼠标悬停在按钮上时处理高亮显示。在这种情况下,我不需要担心处理click事件,因为submit input元素的默认动作是提交表单,这正是我想要发生的。

我使用css方法进行了一次额外的方法调用。该方法使用style属性将 CSS 属性直接应用于所选元素,我已经用它在input元素上设置了font-family属性。jQuery UI 主题系统不太支持处理字体,并且使用单一的字体系列生成小部件。我已经从 Google Fonts ( [www.google.com/webfonts](http://www.google.com/webfonts)和 the excellent League of mobile Type([www.theleagueofmoveabletype.com](http://www.theleagueofmoveabletype.com))中设置了 web 字体,所以我必须覆盖 jQuery UI CSS 样式,以便将我喜欢的字体应用到 button 元素。在图 2-6 中可以看到使用 jQuery UI 创建按钮的结果。如您所见,结果与 web 应用的其余部分一致,但用 JavaScript 创建要简单得多。

Image

图 2-6。用 jQuery UI 创建按钮

像 jQuery UI 这样的工具包只是我前面描述的相同 DOM、CSS 和事件技术的方便包装。理解幕后发生的事情很重要,但是我推荐使用 jQuery UI 或其他好的 UI 库。这些库经过了全面的测试,它们让您不必编写和调试定制代码,让您可以将更多时间花在使您的 web 应用从竞争中脱颖而出的功能上。

总结

正如我在本章开始时提到的,我在这些例子中使用的技术简单、可靠,并且完全适合小型 web 应用。如果应用很小,维护起来不会有任何问题,那么使用这些方法本质上没有任何问题,因为它的行为的每个方面对程序员来说都是显而易见的。

然而,如果你正在读这本书,你想更进一步,创建大型的、复杂的、有许多活动部件的 web 应用。当应用于这样的网络应用时,这些技术会产生一些根本性的问题。潜在的问题是 web 应用的不同方面都混在一起了。应用数据(产品和购物篮)、数据的表示(HTML 元素)以及它们之间的交互(JavaScript 事件和处理函数)分布在整个文档中。这使得很难在不引入错误的情况下添加额外的数据、扩展功能或修复错误。

在接下来的章节中,我将向您展示如何将服务器端开发领域的重型技术应用到 web 应用中。多年来,客户端开发一直是服务器端工作的穷亲戚,但是随着浏览器变得更加强大(以及 web 应用程序员变得更加雄心勃勃),我们再也不能假装客户端不是一个完全成熟的平台。是时候认真对待 web 应用开发了,在接下来的章节中,我将向您展示如何为您的 web 应用创建一个坚实、健壮、可伸缩的基础。

三、添加视图模型

如果你做过任何严肃的桌面或服务器端开发,你会遇到模型-视图-控制器 (MVC)设计模式或者它的衍生模型-视图-视图-模型 (MVVM)。我不打算详细描述这两种模式,只想说这两种模式的核心概念都是将应用的数据、操作和表示分离成独立的组件。

将相同的基本原则应用于 web 应用有很多好处。我不会陷入设计模式和术语中。相反,我将重点演示构建 web 应用的过程,并解释这样做的好处。

重置示例

理解如何应用视图模型以及这样做所带来的好处的最好方法就是简单地去做。要做的第一件事是把应用中除了基础的东西都删掉,这样我就有了一个全新的开始。正如你在清单 3-1 中看到的,除了文档的基本结构,我已经删除了所有内容。

清单 3-1。擦石板

`

    CheeseLux                               ` `    
                     Gourmet European Cheese     
    


            
        

    

`

创建视图模型

下一步是定义一些数据,这将是视图模型的基础。首先,我添加了一个描述奶酪店产品的对象,如清单 3-2 所示。

清单 3-2。向文档添加数据

``

我创建了一个包含奶酪产品详细信息的对象,并将其分配给一个名为cheeseModel的变量。该对象描述了我在第二章中使用的相同产品,并且是我的视图模型的基础,我将在这一章中构建它;现在它是一个简单的数据对象,但是我很快会用它做更多的事情。

提示如果你发现自己盯着闪烁的光标,不知道如何定义你的应用数据,那么我的建议很简单:开始输入。采用视图模型的最大好处之一是它使更改变得更容易,这包括对底层数据结构的更改。如果你做得不对也不要担心,因为你以后总是可以改正的。

采用视图模型库

遵循不编写好的 JavaScript 库中可用内容的原则,我将使用视图模型库将视图模型引入 web 应用。我要用的这个叫做击倒。我喜欢应用结构的 KO 方法,KO 的主要程序员是 Steve Sanderson,他是我的合著者,也是来自 Apress 的Pro ASP.NET MVC一书的作者,是一个全面的好人。要获得 KO,请转到[knockoutjs.com](http://knockoutjs.com)并点击下载链接。从文件列表中选择最新的版本(在我撰写本文时是 2.0.0 ),并将其复制到 Node.js content目录。

Image 提示如果你和 KO 处不好也不用担心。其他结构库是可用的。主要竞争来自于骨干([documentcloud.github.com/backbone](http://documentcloud.github.com/backbone))和 AngularJS ( [angularjs.org](http://angularjs.org))。这些备选库中的实现细节可能有所不同,但基本原理是相同的。

在接下来的小节中,我将把我的视图模型和视图模型库放在一起,以分离示例应用的各个部分。

从视图模型生成内容

首先,我将使用数据在文档中生成元素,以便向用户显示产品。这是对视图模型的简单使用,但它再现了第二章中实现的基本功能,并为本章的其余部分打下了良好的基础。清单 3-3 显示了将 KO 库添加到文档中,并从数据中生成元素。

清单 3-3。从视图模型生成元素

`

    CheeseLux                    ****               

    
                     Gourmet European Cheese     
    
        
**            
**

**            

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

        

            
        

    

`

这个清单中有三组附加内容。第一个是用一个script元素将 KO JavaScript 库导入到文档中。第二个附加项告诉 KO 使用我的视图模型对象:

ko.applyBindings(cheeseModel);

ko对象是 KO 库功能的网关,applyBindings方法将视图模型对象作为参数,顾名思义,使用它来完成文档中定义的绑定;这是第三组新增内容。你可以在图 3-1 中看到这些绑定的结果,我将在接下来的章节中解释它们是如何工作的。

Image

图 3-1。从视图模型创建内容

了解值绑定

绑定的值是视图模型中的属性和 HTML 元素之间的关系。这是现有的最简单的绑定方式。下面是一个具有值绑定的 HTML 元素的示例:

<div class="grouptitle" **data-bind="text: category"**></div>

所有 KO 绑定都是使用data-bind属性定义的。这是一个text绑定的例子,它将 HTML 元素的文本内容设置为指定的视图模型属性的值,在本例中是category属性。

当调用applyBindings方法时,KO 搜索绑定并将适当的数据值插入到文档中,像这样转换元素:

<div class="grouptitle" data-bind="text: category">**French Cheese**</div>

Image 提示我喜欢在将要应用 KO 数据绑定的元素中定义它们,但是有些人不喜欢这种方法。有一个简单的库支持不显眼的 KO 数据绑定,这意味着绑定是在script元素中使用 jQuery 建立的。您可以在[gist.github.com/1006808](https://gist.github.com/1006808)获取代码并查看示例。

我在这个例子中使用的另一个绑定是attr,它将元素属性的值设置为模型中的一个属性。下面是清单中的一个attr绑定示例:

<input **data-bind="attr: {name: id}"** value="0"/>

该绑定指定 KO 应该为name属性插入id属性的值,这在应用绑定时会产生以下结果:

<input data-bind="attr: {name: id}" value="0" **name="camembert"**>

KO 值绑定不支持任何格式或值的组合。事实上,值绑定只是将单个值插入到文档中,这意味着通常需要额外的元素作为值绑定的目标。您可以在清单中的label元素中看到这一点,这里我添加了几个span元素:

<label data-bind="attr: {for: id}" class="cheesename"> **    <span data-bind="text: name"></span>** $(**<span data-bind="text:price"></span>**) </label>

我想插入两个数据值作为label元素的内容,并用一些环绕的字符来表示货币。获得想要的效果的方法很简单,尽管它给 HTML 结构增加了一些复杂性。另一种方法是创建自定义绑定,我会在第四章的中解释。

Image 提示textattr绑定是最有用的,但是 KO 也支持其他类型的值绑定:visiblehtmlcssstyle。我在本章后面使用了visible绑定,在第四章的中使用了css绑定,但是你应该在knockoutjs.com查阅 KO 文档以了解其他的细节。

了解流控制绑定

流控制绑定提供了使用视图模型来控制文档中包含哪些元素的方法。在清单中,我使用了foreach绑定来枚举items视图模型属性。foreach绑定用于视图模型属性,这些属性是数组,并为数组中的每一项复制子元素集:

`<div data-bind="foreach: items">
    ...

`

子元素上的值绑定可以引用单个数组项的属性,这就是我能够为input元素上的attr绑定指定id属性的原因:KO 知道正在处理哪个数组项,并从该项插入适当的值。

Image 提示除了foreach绑定之外,KO 还支持ififnotwith绑定,这些绑定允许有选择地在文档中包含或排除内容。我将在本章的后面描述ififnot绑定,但是你应该在knockoutjs.com查阅 KO 文档以获得完整的细节。

利用视图模型

现在我已经有了应用的基本结构,我可以使用视图模型和 KO 做更多的事情。我将从一些基本特性开始,然后逐步向您展示一些更高级的技术。

向视图模型添加更多产品

视图模型带来的第一个好处是能够更快地进行更改,并且错误更少。这方面最简单的演示就是向奶酪店目录中添加更多的产品。清单 3-4 显示了添加来自其他国家的奶酪所需的更改。

清单 3-4。添加到视图模型

`

    CheeseLux                                   

    
                     Gourmet European Cheese     
    

**        

**
            

                


                    

                        
                        
                    

                

            

**        
**


            
        

    

`

最大的变化是视图模型本身。我改变了数据对象的结构,使得每个产品类别都是分配给products属性的数组中的一个元素(当然,我添加了两个新类别)。就 HTML 内容而言,我只需添加一个foreach流控制绑定,这样每个类别中包含的元素都是重复的。

Image 提示这些添加的结果是一个又长又细的 HTML 文档。这不是显示数据的理想方式,但正如我在《??》第一章中所说,这是一本关于高级编程的书,而不是一本关于设计的书。有很多方法可以更有效地呈现这些数据,我建议从查看 UI 工具包(如 jQuery UI 或 jQuery Tools)提供的选项卡小部件开始。

创建可观察的数据项

在前面的例子中,我像使用简单的模板引擎一样使用 KO;我从视图模型中获取值,并使用它们来生成一组元素。我喜欢使用模板引擎,因为它们简化了标记,减少了错误。但是当你创建可观察的数据项时,视图模型带来了更大的好处。简而言之,可观察的数据项是视图模型中的一个属性,当它被更新时,会导致所有绑定到该属性的值的 HTML 元素也被更新。清单 3-5 展示了如何创建和使用一个可观察的数据项。

清单 3-5。创建可观察的数据项

`

    CheeseLux                                   

    
                     Gourmet European Cheese     
    
**        
** **            ** **        
**


            

                


                    

                        
                        
                    

                

            

        
        

            
        

    

`

mapProducts函数是一个简单的工具,它允许我对每个单独的奶酪产品应用一个函数。这个函数使用 jQuery each方法,该方法为数组中的每一项执行一个函数。通过使用两次each函数,我可以到达每个类别中奶酪产品的内部数组。

在这个例子中,我已经将每个奶酪产品的price属性转换成一个可观察的数据项,如下所示:

mapProducts(function(item) {     item.price = **ko.observable(item.price);** });

ko.observable方法将数据项的初始值作为其参数,并设置将更新传播到文档中的绑定所需的管道。我不必对绑定本身做任何更改;KO 为我处理所有的细节。

剩下的就是创造一个环境,让改变发生。我在文档中添加了一个新按钮,并为click事件定义了一个处理程序,如下所示:

$('#discount').click(function() {     mapProducts(function(item) { **        item.price(item.price() - 2);**     }); });

当单击按钮时,我使用mapProducts函数来更改视图模型中每个 cheese 对象的 price 属性值。由于这是一个可观察的数据项,新的值将被推送到值绑定,并导致文档被更新。

注意我在修改值时使用的稍微奇怪的语法。最初的 price 属性是一个 JavaScript Number,这意味着我可以像这样更改值:

item.price -= 2;

但是ko.observable方法将属性转换成 JavaScript 函数,以便与一些旧版本的 Internet Explorer 一起工作。这意味着通过调用函数(换句话说,通过调用item.price())读取可观察数据项的值,并通过向函数传递一个参数(换句话说,通过调用item.price(newValue))更新该值。这可能需要一点时间来适应,我仍然会忘记这样做。

图 3-2 显示了可观测数据项的效果。当点击应用折扣按钮时,显示给用户的所有价格都被更新,如图图 3-2 所示。

Image

图 3-2。使用可观测的数据项

可观察数据项的能力和灵活性非常重要;它创建了一个应用,在该应用中,视图模式的更改(无论它们是如何发生的)都会导致文档中的数据绑定立即更新。正如你将在本章的其余部分看到的,当我向示例 web 应用添加更复杂的特性时,我使用了大量可观察的数据项。

创建双向绑定

一个双向绑定是一个form元素和一个可观察数据项之间的双向关系。当视图模型更新时,元素中显示的值也会更新,就像常规的可观察对象一样。此外,改变元素值会导致向其他方向的更新:视图模型中的属性被更新。因此,例如,如果我对一个input元素使用双向绑定,KO 确保当用户输入一个新值时模型被更新。通过使用多个元素和同一个模型属性之间的双向关系,您可以轻松地使复杂的 web 应用保持同步和一致。

为了演示双向绑定,我将向 cheese shop 添加一个特价商品部分。这让我可以从完整的部分中挑选一些产品,应用折扣,理想情况下,将客户的注意力吸引到他们可能不会考虑的产品上。

清单 3-6 包含了对 web 应用的更改,以支持特殊优惠。为了建立双向绑定,我将做另外两件有趣的事情:扩展视图模型和使用 KO 模板生成元素。我将在清单后面的小节中解释这三个变化。

清单 3-6。使用动态绑定创建特别优惠

`

    CheeseLux                                   
**    **

    
                     Gourmet European Cheese     

**    

**
    

**        
**
        

            
        

    

`
扩展视图模型

JavaScript 的松散类型和动态特性使其非常适合创建灵活且适应性强的视图模型。我喜欢能够获取初始数据并重塑它,以创建更符合 web 应用需求的东西,在这种情况下,添加对特殊优惠的支持。首先,我向视图模型添加了一个名为specialsproperty,将其定义为一个对象,该对象与模型的其余部分一样具有categoryitems属性,但添加了一些有用的内容:

cheeseModel.specials = {     category: "Special Offers",     **discount: 3,**     **ids: ["stilton", "tomme"],**     items: [] };

属性discount指定了我希望应用于特价商品的美元折扣,属性ids包含了将成为特价商品的产品 id 数组。

当我第一次定义数组时,它是空的。为了填充数组,我枚举了products数组来查找那些在specials.ids数组中的产品,如下所示:

mapProducts(function(item) { **    if ($.inArray(item.id, cheeseModel.specials.ids) > -1) {** **        item.price -= cheeseModel.specials.discount;** **        cheeseModel.specials.items.push(item);** **    }**     item.quantity = ko.observable(0); });

我使用inArray方法来确定迭代中的当前项目是否是将作为特价商品包含的项目之一。inArray方法是另一个 jQuery 工具,如果某项包含在数组中,它将返回该项的索引,如果不包含在数组中,则返回-1。对于我来说,这是一种快速简单的方法,可以查看当前商品是否是我感兴趣的特价商品。

如果某个商品在特价商品列表上是,那么我将price属性的值减少discount的数量,并使用push方法将该商品插入到specials.items数组中。

item.price -= cheeseModel.specials.discount; cheeseModel.specials.items.push(item);

在我遍历了视图模型中的商品之后,specials.item数组包含了一组完整的要打折的商品,在此过程中,我降低了它们的价格。

在这个例子中,我将quantity属性变成了一个可观察的数据项:

item.quantity = ko.observable(0);

这很重要,因为我将为特价商品显示多个input元素:一个元素在原始奶酪类别中,另一个在新的Special Offers类别中,我将在下一节中解释。通过在input元素上使用一个可观察的数据项和双向绑定,我可以很容易地确保输入的奶酪数量得到一致的显示,而不管使用的是哪个input元素。

生成内容

现在剩下的工作就是从视图模型中生成内容。我想为特价商品和普通商品生成相同的元素集,所以我使用了 KO 模板特性,它允许我在文档中的多个点生成相同的元素集。下面是清单中的模板:

<script id="categoryTmpl" type="text/html">     <div class="cheesegroup">         <div class="grouptitle" data-bind="text: category"></div>         <div data-bind="foreach: items">             <div class="groupcontent">                 <label data-bind="attr: {for: id}" class="cheesename">                     <span data-bind="text: name">                     </span> $(<span data-bind="text:price"></span>)</label>                 <input data-bind="attr: {name: id}, value: quantity"/>             </div>         </div>     </div> </script>

模板包含在一个script元素中。type属性被设置为text/html,这阻止浏览器将内容作为 JavaScript 执行。模板中的大多数绑定与我在前面的例子中使用的textattr绑定相同。对input元素的重要添加如下:

<input data-bind=**"attr: {name: id}, value: quantity"**/>

这个元素的data-bind属性定义了两个绑定,用逗号分隔。第一个是常规的attr绑定,但是第二个是value绑定,这是 KO 定义的双向绑定之一。我不必采取任何行动来使value绑定双向;KO 会自动处理。在这个清单中,我创建了一个到quantity可观察数据项的双向绑定。

我使用template绑定从模板生成内容。当使用模板时,KO 复制它所包含的元素,并将它们作为具有template绑定的元素的子元素插入。文档中有两点我使用了模板,它们略有不同:

`

**    
**


        
    

`

当使用template绑定时,name属性指定模板元素的id属性值。如果您只想生成一组元素,那么您可以使用data属性来指定将使用哪个视图模型属性。我使用data来指定清单中的specials属性,这为我的特价产品创建了一个内容部分。

Image 提示您必须记住用引号将模板元素的id括起来。如果不这样做,KO 将会悄悄地失败,而不会从模板生成元素。

如果想为数组中的每一项生成一组元素,可以使用foreach属性。我已经通过指定products数组为常规产品类别完成了这项工作。这样,我可以将模板应用于数组中的每个元素,以一致地生成内容。

Image 提示注意,特价元素被插入到了form元素的外部。特价产品的input元素将具有与常规产品类别中相应的input元素相同的name属性值。通过在form之外插入特价元素,我可以防止在提交表单时向服务器发送重复的条目。

查看结果

既然我已经解释了我为设置双向绑定所做的每一个改变,是时候看看结果了,你可以在图 3-3 中看到。

Image

图 3-3。扩展视图模型、创建动态绑定和使用模板的结果

这很好地展示了使用视图模型可以节省时间和减少错误。我对特价产品应用了 3 美元的折扣,这是通过修改视图模型中的属性price的值来实现的。尽管price属性是不可见的,视图模型和模板的结合确保了在最初生成元素时整个文档中显示正确的价格。(您可以看到两个Stilton列表的价格都是 6 美元,而不是视图模型最初指定的 9 美元。)

双向绑定是这个例子中最有趣和最有用的特性。所有的input元素都与它们对应的quantity属性有双向绑定,并且由于在文档中有两个input元素用于每种特价奶酪,在其中一个元素中输入一个值将会使立即在另一个元素中显示该值;您可以在图中看到Stilton产品发生了这种情况(但这种效果最好通过在浏览器中加载示例来体验)。

因此,只需很少的努力,我就可以增强视图模型,并使用这些增强来保持表单的一致性和响应性,同时为应用添加新的特性。在下一节中,我将在这些增强的基础上创建一个动态篮子,向您展示视图模型带来的其他一些好处。

Image 提示如果您将此表单提交给服务器,订单摘要将显示原始的未打折价格。当然,这是因为我只在浏览器中应用了折扣。在一个实际的应用中,服务器也需要知道特别的优惠,但是我将跳过这一点,因为这本书关注的是客户端开发。

添加动态购物篮

既然我已经解释并演示了如何使用值和双向绑定来检测和传播更改,我可以完成这个示例,这样用户就可以使用第二章中的所有功能。这意味着我需要实现一个动态购物篮,我将在接下来的小节中实现它。

添加小计

使用视图模型,可以快速添加新功能。虽然我需要使用一些额外的 KO 特性,但是添加每一项小计的更改非常简单。首先,我需要增强视图模型。清单 3-7 突出显示了对mapProduct函数的调用中script元素的变化。

清单 3-7。扩展视图模型以支持小计

`...
mapProducts(function(item) {
    if ($.inArray(item.id, cheeseModel.specials.ids) > -1) {
        item.price -= cheeseModel.specials.discount;
        cheeseModel.specials.items.push(item);
    }
    item.quantity = ko.observable(0);
    item.subtotal = ko.computed(function() {
        return this.quantity() * this.price;
    }, item);

});
...`

我已经为subtotal属性创建了所谓的计算可观测数据项。这就像一个常规的可观察项,只是值是由一个函数产生的,该函数作为第一个参数传递给ko.computed方法。第二种方法在函数执行时用作this变量的值;我已经将它设置为item循环变量。

这个特性的好处是 KO 管理所有的依赖项,这样当我的计算出的可观察函数依赖于一个常规的可观察数据项时,对常规项的改变会自动触发计算值的更新。在本章的后面,我将使用这个行为来管理总的总数。

接下来,我需要添加一些绑定到模板的元素,如清单 3-8 所示。

清单 3-8。向模板添加元素以支持小计

``

内部的span元素使用一个text数据绑定来显示我刚才创建的subtotal属性的值。更有趣的是,外层的span元素使用了另一个 KO 绑定;这一个是visible。对于这个绑定,当指定的属性为 false-like ( zeronullundefinedfalse)时,子元素被隐藏。对于真值值(1true或非null对象或数组),显示子元素。我已经为visible绑定指定了subtotal值,这个小技巧意味着只有当用户在input元素中输入非零值时,我才会显示小计。你可以在图 3-4 中看到结果。

Image

图 3-4。选择性显示小计

您可以看到,一旦将基本结构添加到应用中,创建新功能是多么简单快捷。一些新的标记和一点点脚本大有帮助。此外,小计功能可以与特别优惠无缝协作;因为两者都在视图模型上操作,所以应用于特价的折扣无缝地(并且毫不费力地)合并到小计中。

添加购物篮行项目和合计

我不想使用我在第二章中采用的内嵌购物篮方法,因为有些产品会显示两次,而且文档太长,用户无法向下滚动以查看其选择的总成本。相反,我将创建一个单独的购物篮元素集,它将与产品一起显示。你可以在图 3-5 中看到我所做的。

Image

图 3-5。添加单独的购物篮

清单 3-9 显示了支持购物篮所需的变更。

清单 3-9。添加购物篮元素和行项目

`

    CheeseLux               ` `               
    
**    **

    
        ` `        Gourmet European Cheese     

**    

**
**        
Basket
**
**        
**
**            **
**                **
**                **
**                    **
**                    **
**                **
**                **
**                    **
**                    **
**                        **
**                    **
**                **
**            
CheeseSubtotal
Total:$
**
**        
**
**        
**

**        

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


    

        

    

`

我将逐一介绍我所做的每一类改变,并解释其效果。当我这样做的时候,请思考一下添加这个特性只需要做很少的改变。同样,视图模型和一些基本的应用结构创建了一个基础,可以快速方便地添加新特性。

扩展视图模型

清单中对视图模型的更改是添加了total属性,这是一个计算出来的可观察值,它将各个subtotal值相加:

cheeseModel.total = ko.computed(function() {     var total = 0;     mapProducts(function(elem) {         total += elem.subtotal();     });     return total; });

正如我之前提到的,KO 自动跟踪可观察数据项之间的依赖关系。对subtotal值的任何更改都将导致 total 被重新计算,新值将显示在与其绑定的元素中。

添加购物篮结构和模板

我添加到文档中的 HTML 元素的外部结构只是一个奶酪类别的副本,以保持视觉一致性。篮子的核心是table元素,它包含几个数据绑定:

`


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

CheeseSubtotal
Total:$<span data-bind="text: total">
`

这里最重要的补充是格式奇怪的 HTML 注释。这就是所谓的无容器绑定,它允许我应用template绑定,而不需要为将要复制的内容提供容器元素。从嵌套数组向表中添加行是这种技术的理想情况,因为添加一个元素以便应用绑定会导致布局问题。无容器绑定包含在常规的foreach绑定中,但是您可以像嵌套常规元素一样嵌套绑定注释。

另一个绑定是一个简单的text值绑定,它使用我刚才创建的计算出的total可观察值显示篮子的总数。我不必采取任何措施来确保总数是最新的;KO 管理视图模型中的totalsubtotalquantity属性之间的依赖链。

我添加来生成table行的模板有四个数据绑定:

<script id="basketRowTmpl" type="text/html">     <tr **data-bind="visible: quantity, attr: {'data-prodId': id}"**>         <td **data-bind="text: name"**></td>         <td>$<span **data-bind="text: subtotal"**></span></td>         <td><a href="#"></a></td>     </tr> </script>

您以前见过这些类型的绑定。在tr元素上的visible绑定确保表行只对那些quantity不为零的奶酪可见;这可以防止购物篮中塞满用户不感兴趣的产品。

注意tr元素上的attr绑定。我已经使用 HTML5 data属性特性定义了一个定制属性,该特性将行所代表的产品的id值嵌入到tr元素中。我将很快解释我为什么这样做。

我还移动了submit按钮,使其位于购物篮下方,方便用户提交订单。我分配给篮子元素的样式使用 CSS position属性的fixed值,这意味着篮子总是可见的,即使用户向下滚动页面。为了适应这个篮子,我使用 jQuery 将 CSS width属性的一个新值直接应用到 cheese category 元素(而不是篮子本身):

$('div.cheesegroup').not("#basket").css("width", "50%");

从购物篮中移除项目

最后一组更改基于添加到basketRowTmpl模板中每个表格行的a元素:

$('#basketTable a')     .button({icons: {primary: "ui-icon-closethick"}, text: false})     .click(function() {         var targetId = $(this).closest('tr').attr("data-prodId");         mapProducts(function(item) {             if (item.id == targetId) {                 item.quantity(0);             }         });     })

我使用 jQuery 选择所有的a元素,并使用 jQuery UI 从它们创建按钮。jQuery UI 主题包括一组图标,我传递给 jQuery UI button方法的对象创建了一个按钮,该按钮使用这些图像中的一个,并且不显示任何文本。这给了我一个漂亮的小十字按钮。

click函数中,我使用 jQuery 从触发click事件的a元素导航到使用closest方法的第一个祖先tr元素。这将选择包含自定义data属性的tr元素,该属性是我之前插入到模板中并使用attr方法读取的:

var targetId = $(this).closest('tr').attr("data-prodId");

这个语句让我确定用户想要从购物篮中移除的产品的id。然后我使用mapProducts函数找到匹配的奶酪对象,并将quantity设置为零。由于quantity是一个可观察的数据项,KO 传播新的值,这使得subtotal值被重新计算,并且相应的tr元素上的visible绑定被重新评估。由于quantity为零,表格行将自动隐藏。并且,因为subtotal是可观察的,所以total也将被重新计算,并且新的值被显示给用户。如您所见,拥有一个无缝管理数据值之间依赖关系的视图模型是非常有用的。最终结果是一个动态的篮子,它总是与视图模型中的值一致,因此总是向用户提供正确的信息。

完成示例

在我结束这个话题之前,我想调整一些事情。首先,当用户没有选择任何项目时,购物篮看起来很差,如图图 3-6 所示。为了解决这个问题,我将在购物篮为空时显示一些占位符文本。

Image

图 3-6。空篮子

第二,用户没有办法通过一个单一的动作清空购物篮,所以我将添加一个按钮,将所有产品的数量重置为零。最后,通过将submit按钮移到form元素之外,我已经失去了依赖默认动作的能力。我必须添加一个事件处理程序,以便用户可以提交表单。清单 3-10 显示了我为支持这些特性而添加的 HTML 元素。

清单 3-10。添加元素以完成示例

`...

<body>     <div id="logobar">         <img src="cheeselux.png">         <span id="tagline">Gourmet European Cheese</span>     </div>


        
Basket

        

**            

**
**                No products selected**
**            
**

<table id="basketTable" data-bind="visible: total">
                
                    CheeseSubtotal
                
                


                                     
                    
                        Total:$
                    
                
            
        


        

**        

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


    

        

    

...`

我在包含占位符文本的div元素上使用了ifnot绑定。KO 定义了一对绑定,ififnot,它们类似于visible绑定,但是向 DOM 添加和移除元素,而不是简单地隐藏它们。当指定的视图模型属性为 true-like 时,if绑定显示其元素,如果为 false-like,则隐藏它们。ifnot绑定反转;当属性为 true-like 时,它显示其元素。

通过指定与total属性绑定的ifnot,我确保我的占位符元素仅在total为零时显示,这发生在所有subtotal值为零时,这发生在所有quantity值为零时。我再次依赖 KO 的能力来管理可观察数据项之间的依赖关系,以获得我需要的效果。

我希望占位符显示时table元素不可见,所以我使用了visible绑定。

我本可以使用if绑定,但是这样做会导致一个问题。绑定到 total property意味着最初不会显示table,通过绑定if,元素将从 DOM 中移除。这意味着当我试图选择a元素来设置移除按钮时,它们也不会出现。visible绑定将元素留在文档中供 jQuery 查找,但对用户隐藏它们。

您可能想知道为什么我不移动 jQuery 选择,让它在调用ko.applyBindings之前执行。原因是我想用 jQuery 选择的a元素包含在 KO 模板中,该模板在调用applyBindings方法之前不会用来创建元素。没有好的方法可以解决这个问题,因此需要visible绑定。

对 HTML 元素的另一个改变是添加了一个类型为resetinput元素。这个元素在form元素之外,所以我必须处理click事件来从篮子中移除项目。清单 3-11 显示了script元素的相应变化。

清单 3-11。增强脚本以完成示例

`...

`

我在清单中只展示了部分脚本,因为改动很小。请注意我是如何使用 jQuery 和普通 JavaScript 来操作视图模型的。我不需要为购物篮占位符添加任何代码,因为它将由 KO 管理。事实上,我需要做的就是扩大 jQuery 的选择范围,这样我就可以为submitreset input元素创建 jQuery UI 按钮小部件,并添加一个click处理函数。在函数中,我提交表单或将quantity值改为 0,这取决于用户点击的按钮。您可以在图 3-7 中看到篮子的占位符。

Image

图 3-7。当购物篮为空时使用占位符

如果你想知道按钮是如何工作的,你必须在浏览器中加载这些例子。最简单的方法是使用本书附带的源代码下载,在 Apress.com 可以免费获得。

总结

在这一章中,我向你展示了如何拥抱你以前在桌面或服务器端开发中使用过的那种设计哲学,或者至少是对你的项目有意义的那种哲学。

通过向我的 web 应用添加一个视图模型,我能够创建一个更加动态的示例应用版本;它更具可伸缩性,更易于测试和维护,并且使更改和增强变得轻而易举。

您可能已经注意到,结构化 web 应用的形状发生了变化,因此相对于 HTML 标记的数量来说,代码要多得多。这是一件好事,因为它将应用的复杂性放到了您可以更好地理解、测试和修改它的地方。HTML 成为数据的一系列视图或模板,由视图模型通过结构库驱动。我再怎么强调采用这种方法的好处也不为过;它确实为专业级 web 应用奠定了基础,并将使创建、增强和维护您的项目变得更简单、更容易、更愉快。

四、使用 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                              ` `                   

         
        
            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 路由来实现的:

``

突出显示的前三条语句设置了 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。合并路线

``

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。通过将意外值添加到视图模型中来处理它们

``

如果变量 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                                                  

         
        
            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                     **    **                              

         
        
            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                                                  

<body>     <div id="logobar">         <img src="cheeselux.png">         <span id="tagline">Gourmet European Cheese</span>     </div>


        
    


        
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>\)

        

        


            
        
    


        
        

            

            

                

                    

            

        

        
    

`

我不打算逐行分解这个清单,因为大部分功能与前面的例子相似。然而,有一些值得学习的技术和我需要解释的一些变化,所有这些我将在接下来的章节中介绍。图 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                                                                   

    
                 Gourmet European Cheese     


        
    


        

            

                

                
                

                    

                
                

                    
                    
                        $
                                        
                

            

        

        

            
        

    

`

这个例子建立在前面章节的视图模型和路由概念之上,但是我简化了一些功能。我在每一类奶酪的底部增加了一个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                                                                   

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


        
    


        

            

                

                
                

                    

                
                

                    
                    
                        $
                    
                    
                

            

        

        

            
        

    

`

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

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
        

            Check for Updates
            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。检测网络状态

``

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

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

`


    
    

        Gourmet European Cheese
        

            
                Check for Updates
                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 错误的支持

``

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

六、在浏览器中存储数据

客户端数据存储是对离线应用的自然补充。HTML5 定义了一些用于在浏览器中存储数据的有用的 JavaScript APIs,从简单的名称/值对到使用 JavaScript 对象数据库。在本章中,我将向您展示如何构建依赖持久存储数据的应用,包括如何在离线 web 应用中使用这些数据的细节。

Image 注意浏览器对数据存储的支持喜忧参半。您应该使用 Google Chrome 运行本章中的示例,但 IndexedDB 部分的示例除外,它们只能在 Mozilla Firefox 中运行。

使用本地存储

在浏览器中存储数据最简单的方法是使用 HTML5 本地存储特性。这允许您存储简单的名称/值对,并在以后检索或修改它们。数据会永久存储,但不能保证永远存储。如果需要空间(或者如果数据很长时间没有被访问),浏览器可以自由删除你的数据,当然,用户可以随时清除数据存储,即使你的 web 应用正在运行。其结果是数据是广泛持久的,但不是无限持久的。使用本地存储非常类似于使用常规的 JavaScript 数组,如清单 6-1 所示。

清单 6-1。使用本地存储

`

    Local Storage Example                                            ` `           

         
        
            The selected item is:         
       
`

为了演示本地存储,我使用了第四章中的简单例子,它允许我专注于存储技术,而不会妨碍其他章节的特性。如清单所示,开始使用本地存储非常简单。全局localStorage对象就像一个数组。当用户在这个简单的 web 应用中做出选择时,我使用数组样式的符号存储所选项,如下所示:

**localStorage["selection"]** = item;

Image 提示键区分大小写(因此selectionSelection将代表不同的数据项),给已经存在的键赋值会覆盖先前定义的值。

这条语句创建了一个新的本地存储条目,我可以使用相同的数组样式符号读回它,如下所示:

viewModel.selectedItem**(localStorage["selection"]** || viewModel.items[0]);

将这两条语句添加到示例中的效果是为用户的选择创建简单的持久性。当加载 web 应用时,我检查是否有数据存储在selection键下,如果有,在视图模型中设置相应的数据项,这将恢复用户在早期会话中的选择。

Image用户可以查看和编辑本地存储的内容,这意味着您存储的任何内容都不是秘密的,任何内容都可以更改。不要存储任何你不想公开传播的内容,也不要依赖本地存储来给你的 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事件被触发时,我调用这个函数,并使用它来完成视图模型。

Image 提示本地存储的持久性意味着,如果您重复使用一个密钥来存储不同类型的数据,您将面临遇到以前会话中存储的旧格式的风险。在开发中处理这个问题的最简单的方法是清除浏览器的缓存。在生产中,您必须能够检测旧数据并处理它,或者至少能够在不产生任何错误的情况下丢弃它。

如果有与viewModelData键相关联的本地存储数据,我加载 JSON 字符串并使用JSON.parse方法创建一个 JavaScript 对象。然后我可以读取对象的属性来填充视图模型。当然,我不能依赖现有的数据,所以如果需要的话,我会使用一些合理的默认值。

存储对象数据

在我的简单示例中,从包含数据的对象中分离数据并不难,但是在复杂的 web 应用中,这可能要困难得多。您可能想通过直接存储对象来简化这个过程,而不是将数据映射到字符串。不要这样;这只会给你带来麻烦。下面是一段代码,展示了对象使用的本地存储:

`...

...`

这种技术行不通。当您存储对象时,浏览器不会抱怨,如果您在同一个会话中读回值,一切看起来都很好。但是浏览器会序列化该对象,以便为将来的会话存储它。对于大多数 JavaScript 对象,存储的值将是[object Object],这是调用toString方法得到的结果。当用户再次访问 web 应用时,本地存储中的值不是有效的 JavaScript 对象,无法解析。这是应该在测试过程中发现的问题,但是我经常看到这个问题,尤其是因为即使是认真对待测试的项目通常也不会在多个会话中重新访问应用。

存储表单数据

本地存储非常适合使表单数据持久化。键/值映射非常适合表单元素的本质,而且不费吹灰之力,就可以创建会话间持久的表单,如清单 6-3 所示。

清单 6-3。使用本地存储创建持久表单

`

    Local Storage Example                                  

         
        
            
Your Details
            
                
                    :                                      
            
        
        
                                  
    
`

我在这个例子中定义了一个简单的三字段form元素,你可以在图 6-1 中看到。该表单捕获用户的姓名、城市和国家,并被发送到服务器上的/formecho URL,服务器简单地响应所提交数据的细节。

Image

图 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 按钮重置。

Image 提示严格来说,我不需要处理 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 中描述。

Image

在示例中,我使用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                                       

` `    
        
Embedded Document
        
            
                :                              
        
    
`

这个文档复制了主示例中的input元素,但是没有formbutton元素。但是,它有一个使用persistentObservable数据项的视图模型,这意味着对本文档中input元素值的更改将反映在本地存储中,同样,本地存储中的更改将反映在input元素中。我没有为持久可观察项提供默认值;如果没有本地存储值,那么我希望初始值默认为null,这是通过不向persistentObservable函数提供第二个参数来实现的。

剩下的就是修改主文档了。为了简单起见,我将一个文档嵌入到另一个文档中,但是本地存储由来自相同来源的任何文档共享,这意味着当这些文档在不同的浏览器选项卡或窗口中时,这种技术将会工作。清单 6-6 显示了对example.html的修改,包括嵌入embedded.html文档。

清单 6-6。修改主示例文档

`

    Local Storage Example                                       

         
        
            
Your Details
            
                
                    :                                      
            
        

**        **


            
            
        

    

`

在定义视图模型时,我为persistentObservable函数使用了相同的键,并添加了一个嵌入其他 HTML 文档的iframe元素。由于两者都是从相同的原点加载的,因此浏览器在它们之间共享相同的本地存储。通过本地存储和两个视图模型,更改一个文档中的input元素的值将触发另一个文档中相应的更改。

Image 注意如果两个文档的更新同时写入本地存储器,浏览器不提供任何关于数据项完整性的保证。很难考虑这种可能性(我从未见过这种情况发生),但谨慎的做法是假设如果您共享本地存储,可能会发生数据损坏。

使用会话存储

本地存储的补充是会话存储,它通过sessionStorage对象访问。sessionStoragelocalStorage对象以相同的方式使用,并发出相同的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;
}`

由于sessionStoragelocalStorage对象暴露了相同的特性并使用了相同的事件,所以我能够很容易地修改我的本地存储可观察项来添加对会话存储的支持。我在函数中添加了一个参数,如果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)}**     ] }; ...

使用会话存储处理CityCountry元素的值,而Name元素保留在本地存储中。如果您将示例加载到浏览器中,您会发现重新加载文档不会清除您输入的任何值。但是,如果您关闭并重新打开文档,只有Name值保留。

对离线 Web 应用使用本地存储

使用本地存储的部分好处是它可以脱机使用。这意味着当浏览器离线时,我们可以使用本地数据来解决 Ajax GET 请求引起的问题。清单 6-9 显示了上一章的缓存 CheeseLux web 应用,更新后可以利用本地存储。

清单 6-9。为使用 Ajax 的离线 Web 应用使用本地存储

`

    CheeseLux                                                                   

    
                 
            Gourmet European Cheese             
                                     Check for Updates                     Apply Update                     News                                  ` `                    (Offline)                                          
        
    


        
    


        

            

                

                
                

                    

                
                

                    
                    
                        $
                    
                    
                

            

        

        

            
        

    

`

在这个清单中,当 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。更改脚本元素以支持离线表单

``

这是一个简单的改变,你会很快意识到我正在做一些轻微的误导。当浏览器在线时,用户可以正常提交表单,本地存储中的所有数据都将被清除。当浏览器离线,用户点击保存以备后用按钮时,就会出现误导。我所做的就是调用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 ,可以用来存储和处理更复杂的数据。

Image 注意 IndexedDB 只是在浏览器中存储复杂数据的两个竞争标准之一。另一个是 WebSQL。在我写这篇文章的时候,W3C 正在支持 IndexedDB,但是 WebSQL 完全有可能卷土重来,或者至少成为事实上的标准。我没有在本章中包括 WebSQL,因为目前对它的支持是有限的,但是这是一个功能性的领域,还远没有解决,在您的项目中采用其中一个标准之前,您应该查看对这两个标准的支持。

IndexedDB 仍处于早期阶段,在我撰写本文时,该功能只能通过供应商指定的前缀获得,这意味着浏览器实现仍处于试验阶段,可能会偏离 W3C 规范。目前,最符合 W3C 规范的浏览器是 Mozilla Firefox,所以这是我用来演示 IndexedDB 的浏览器。

Image 注意本章中的例子可能不适用于 Firefox 以外的浏览器。事实上,除了我在本章中使用的版本(版本 10)之外,它们甚至不能在其他版本的 Firefox 上运行。也就是说,即使规范或实现发生了变化,您仍然应该能够对 IndexedDB 的工作原理有一个坚实的理解。

IndexedDB 特性是围绕数据库组织的,这些数据库与本地和会话存储一样,都是基于每个来源隔离的,因此它们可以在来自相同来源的应用之间共享。IndexedDB 不遵循关系数据库中常见的基于 SQL 的表结构。IndexedDB 数据库由对象存储库组成,其中可以包含 JavaScript 对象。您可以将 JavaScript 对象添加到对象存储中,并且可以用不同的方式查询这些存储,其中一些我将很快演示。

这种方法的结果是一种更符合 JavaScript 语言风格的存储机制,但最终使用起来有点笨拙。IndexedDB 中的几乎所有操作都是作为异步请求执行的,函数可以附加到这些请求上,以便在操作完成时执行它们。为了演示 IndexedDB 如何工作,我将创建一个奶酪查找器应用。我将奶酪产品数据放入 IndexedDB 数据库,并为用户提供一些不同的方法来搜索他们可能喜欢的奶酪数据。图 6-2 展示了完成的 web 应用,为后面的代码提供一些上下文。

Image

图 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 中的实现之间的差异。

Image 提示版本号是一个重要的机制,可以确保我为我的应用使用正确的模式版本。稍后,我将向您展示如何检查模式版本,如果需要的话,如何升级模式。

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 应用来说,这很好,因为所有的数据都来自服务器,很容易替换,但是如果您的数据库包含用户操作生成的数据,您可能需要采用更复杂的方法。

Image 注意分配给onupgradeneeded属性的函数是您修改数据库模式的唯一机会。如果您尝试在其他地方添加或删除对象存储,浏览器将会生成错误。

一旦现有的对象存储不碍事了,我可以使用createObject store 方法创建一些新的。此方法的参数是新存储的名称和一个可选对象,该对象包含要应用于新存储的配置设置。我使用了keyPath配置选项,它允许我为添加到存储中的对象设置一个默认键。我已经指定了id属性作为键。我还使用createIndex方法在新创建的对象存储上创建了一个索引。索引允许我使用除键以外的属性在对象存储中执行搜索,在本例中,属性是 category。我将很快向您展示如何使用索引。

最后,我将对象添加到数据存储中。当我在主文档中使用这个函数时,我将使用从 Ajax 请求中获得的数据来处理products.json文件。这与我在本书中使用的数据格式相同。我使用 jQuery each函数来枚举每个类别及其包含的项目。我已经为每个商品添加了一个category属性,这样我可以更容易地找到属于同一类别的所有产品。

Image 提示使用 HTML5 结构化克隆技术克隆你添加到对象库中的对象。这是一种比 JSON 更全面的序列化技术,浏览器通常可以处理复杂的对象,只要这些属性都不是函数或 DOM API 对象。

对成功结果的回应

我关心的第二个结果是成功,我通过为打开数据库的请求的onsuccess属性分配一个函数来处理它,如下所示:

req.onsuccess = function(e) {     DBO.db = this.result;     callback(); };    

该函数中的第一条语句将打开的数据库分配给DBO对象的db属性。这只是保持数据库句柄的一种便捷方式,这样我就可以在其他函数中使用它,稍后我将演示这一点。

第二条语句调用作为第二个参数传递给setupDatabase函数的回调函数。在执行onsuccess函数之前假设数据库是打开的是不安全的,这意味着我需要某种机制来通知函数调用方数据库已经成功打开,可以开始与数据相关的操作。

Image 提示 IndexedDB 请求有一个对应的结果属性叫做onerror。我不会在这些例子中做任何错误处理,因为在我写这篇文章的时候,试图处理 IndexedDB 错误导致的问题比它解决的问题还多。理想情况下,当你阅读本章时,这种情况会有所改善,你将能够编写更健壮的代码。

将数据库整合到 Web 应用中

清单 6-15 显示了示例应用的标记和内联 JavaScript。除了特定于数据库的函数之外,本例中的所有内容都依赖于前面章节中介绍的主题。

清单 6-15。消耗数据库的网络应用

`

    CheeseLux Cheese Finder                                                                       

    
                 
            Cheese Finder         
    


        
    


        
Search Criteria

        

            
            
            Search            
        

    


        
Search Results

        

            
                
                    
                    
                                
                
                    
                    
                        
                        
                        
                    
                    
                    
                
            
NamePriceDescription

            

                No matches
            

        

    

`

正如您现在所期望的,我已经使用了一个视图模型来将应用的状态绑定到 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方法打开一个对象存储。

Image 提示您不需要显式关闭对象存储或事务;当它们超出范围时,浏览器会为您关闭它们。试图显式强制关闭商店或事务没有任何好处。

我使用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 中看到效果。

Image

图 7-1。手机浏览器中默认视口的效果

Image 图 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                                                       **    **     

    
                 Gourmet European Cheese     


        
    


        
Basket

        


                No products selected
            


                
                
                    
                        
                            
                            

                    
                    
                
                
                    
                    
                        
                    
                
            
CheeseSubtotal
$
Total:$
        

        


            
        

    


        
        

            

            

                

                    

            

        

        
    

`

将高亮显示的meta元素添加到文档中会禁用缩放功能。你可以在图 7-2 中看到效果。这个特殊的meta标签告诉浏览器使用显示器的实际宽度显示 HTML 文档,而不进行任何放大。当然,web 应用仍然是一团糟,但它是以正确的大小显示的,这是向响应性应用迈出的第一步。在本章的其余部分,我将向您展示如何响应不同的设备特性和功能。

Image

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

Image 提示 Opera Mobile 大肆缓存 CSS 和 JavaScript 文件。当试验媒体查询时,最好的技术是在主 HTML 文档中定义 CSS 和脚本代码,当您对结果满意时,将它移到外部文件中。否则,您需要清除缓存(或重新启动模拟器)以确保应用您的更改。

标签告诉浏览器这是一个媒体查询。我已经指定只有当设备是屏幕(相对于投影仪或印刷材料)并且宽度不大于 500 像素时,才应该应用该查询中包含的largeScreenOnly样式。

Image 提示在这一章中,我将把世界分为两类显示器。小型显示器将是那些宽度不超过 500 像素的显示器,而大型显示器将是其他所有的显示器。这是简单而随意的,你可能需要设计更多的类别来获得你的 web 应用所需要的效果。我将完全忽略显示器的高度。我的简单分类将使本章中的例子易于管理,尽管是以牺牲粒度为代价的。

如果满足这些条件,那么定义一个样式,将分配给largeScreenOnly类的任何元素的 CSS display属性设置为none,这将隐藏该元素。添加到样式表后,我可以通过对我的标记应用largeScreenOnly类来确保 CheeseLux 标识只在大显示器上显示,如清单 7-3 所示。

清单 7-3。使用 CSS 媒体查询来响应屏幕尺寸

`...

         Gourmet European Cheese
...`

CSS 媒体查询是实时的,这意味着如果调整浏览器窗口的大小,屏幕大小的类别会改变。这在移动设备上用处不大,但这意味着一个响应迅速的 web 应用即使在桌面平台上也能适应显示尺寸。你可以在图 7-3 中看到布局是如何变化的。

Image

图 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 该做什么。

Image 提示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,如果testfalse求值,我想加载什么资源。在本例中,我指定了包含多填充代码的matchMedia.js文件。有一个相应的属性yep,它告诉 Modernizr 如果testtrue需要什么资源,但是我不需要在这个例子中使用它,因为如果testtrue,我将依赖于对matchMedia的内置支持。complete属性指定了一个函数,当yepnope属性指定的资源都已被加载和执行时,该函数将被执行。

Modernizr.load异步获取并执行 JavaScript 脚本,这就是为什么detectDeviceFeatures函数将回调函数作为参数。我在complete函数的末尾调用这个回调函数,传入一个包含已检测到的特性细节的对象。

检测屏幕尺寸

我现在可以开始计算这款设备的屏幕属于我的大类还是小类。为此,我向matchMedia方法传递一个媒体查询,就像我在 CSS 中使用的一样,如下所示:

var screenQuery = **window.matchMedia('screen AND (max-width:500px)');**

我通过读取从matchMedia返回的对象的matches属性来确定我的媒体查询是否匹配。如果matchestrue,那么我正在处理的屏幕属于我的小类(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 应用的其余部分引用屏幕尺寸时,这样我就可以引用smalllScreenlargeScreen来弄清楚我正在处理什么,而不是smallScreen!smallScreen。这是一件小事,但我这样做可以减少打字错误。

一些浏览器在处理媒体查询中状态变化的方式上不一致。例如,当我写这篇文章的时候,谷歌浏览器的最新版本并不总是在屏幕尺寸改变的时候更新媒体查询。作为一项严格的措施,我添加了一个对屏幕尺寸的简单检查,这是使用setInterval函数设置的:

setInterval(function() {     deviceConfig.smallScreen(window.innerWidth <= 500); }, 500);

该函数每 500 毫秒执行一次,并更新视图模型中的屏幕尺寸项目。这并不理想,但重要的是一个响应迅速的 web 应用能够适应设备的变化,这可能意味着采取一些不可取的预防措施,包括轮询状态变化。

Image 提示注意,我使用了window.innerWidth属性来计算屏幕的大小。我正在解决的问题是,媒体查询不能在所有浏览器中正常工作,所以我需要找到一种替代机制来评估屏幕大小。

将能力检测集成到 Web 应用中

我想在 web 应用中做任何事情之前检测设备的功能,这就是我为什么添加了一个对detectDeviceFeatures函数的回调。在清单 7-5 中,你可以看到我是如何将这个函数的使用集成到 web app script元素中的。

清单 7-5。从内联脚本元素调用 detectDeviceFeatures 函数

`                                                      

    
                 Gourmet European Cheese` `    


        
    

<div id="basket" class="cheesegroup basket"
**            data-bind="visible: cheeseModel.device.largeScreen()">**
        

Basket

        


                No products selected
            


                
                
                    
                        
                            
                            

                    
                    
                
                
                    
                    
                        
                    
                
            
CheeseSubtotal
$
Total:$

        

        


            
        

    


        

            

                
                
                

                    

                
**                
**
**                    **
**                    **
**                        $**
**                    
**
**                
    **
            

        

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

`

这种方法的乐趣在于,只需要很少的改变就能让 web 应用响应屏幕大小(以及这些改变是多么简单)。也就是说,有少量的变化需要解释,我将在下面的部分中提供。你可以在图 7-4 中看到我的响应式 web app 是如何出现在大小屏幕上的。

Image

图 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属性。你可以忽略这种方法,让布局保持初始状态,但是我认为这是一个为桌面用户提供良好体验的机会。

从文档中删除元素

在大多数情况下,我只是根据屏幕的大小隐藏和显示文档中的元素。但是,有时需要使用ififnot绑定来确保元素完全从文档中移除。在清单中可以看到一个简单的例子,我使用了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);
        }
    });
};`

我已经建立了两个视图模型数据项,landscapeportrait,遵循我用于smallScreenlargeScreen的相同模式。我不想重复我的代码来测试设备的方向,所以我创建了一个简单的名为setOrientation的内嵌函数来设置landscape数据项的值:

var setOrientation = function() {     deviceConfig.landscape(**window.innerWidth > window.innerHeight**); }

我发现比较window对象的innerWidthinnerHeight值是确定屏幕方向最可靠的方法。screen.widthscreen.height应该起作用,但是一些浏览器在设备重定向时不会改变这些值。属性提供了很好的信息,但是它并没有被普遍实现。这无疑是一种妥协,我建议您在目标设备上测试这种方法的有效性。

其余的添加实现了调用setOrientation的各种方法:通过orientationchangeresize事件,通过媒体查询,以及通过轮询。判断轮询方向的正确频率很困难,但我通常使用 500 毫秒。它并不总是像我希望的那样响应迅速,但它达到了合理的平衡。

Image 提示我本可以使用一个单独的setInterval调用来轮询屏幕大小和方向,但是我更喜欢将代码功能的区域尽可能分开。

将屏幕方向整合到网络应用中

我可以让 web 应用响应屏幕方向,因为视图模型已经有了portraitlandscape项。为了演示这一点,我将解决一个问题:web 应用目前需要用户向下滚动,才能在小屏幕设备上以横向模式查看所有元素。图 7-5 显示了我修改 web 应用布局后的问题和结果。

Image

图 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 库。

Image 提示如果你下载了你自己版本的 Modernizr,确保你包含了触摸测试。本章源代码中的版本 I 包含了所有可用的测试。

注意,我使用了callback属性来设置对处理刷卡的支持。使用callback属性设置的函数在加载指定的资源时执行,而使用complete指定的函数在测试结束时执行,不管测试结果如何。我想只有在已经加载 touchSwipe 的情况下才处理 swipe 事件(这本身表明存在触摸支持),所以我使用了callback来赋予 Modernizr 我的功能。

使用swipe方法应用 touchSwipe 库。在这个例子中,我选择了html元素作为检测滑动手势的目标。一些浏览器限制了body元素的大小,这样当内容小于可用空间时就不会填满整个窗口。这通常不是问题,但在处理手势时,它会在屏幕上产生盲点,因为手势可能不是针对单个元素的。解决这个问题最简单的方法是处理html元素。

touchSwipe 库能够区分不同种类的触摸事件和在一系列方向上的滑动。在这个例子中,我只关心左右滑动,这就是为什么我在传递给swipe方法的对象中为swipeLeftswipeRight属性定义了一个函数。在这两种情况下,我都指定了advanceCategory函数,这个函数就是我之前用来更改所选类别的函数。结果是向左滑动移动到上一个类别,向右滑动进入下一个类别。关于这个清单需要注意的最后一点是传递给Modernizr.load方法的数组中的最后一项:

{     complete: function() {         callback(deviceConfig);     } }

我不想调用回调函数,除非我已经在将被添加到视图模型的结果对象中设置了所有的设备细节。确保这一点的最简单方法是创建一个额外的测试,只包含一个complete函数。Modernizr 不会执行这个函数,直到所有其他的测试都已执行,所需的资源都已加载,并且前面所有测试的callbackcomplete函数都已执行。

使用触摸浏览网络应用历史

在前面的例子中,我通过循环浏览可用的产品类别来响应滑动手势。在这一节中,我将向您展示如何以更有效的方式回应这些手势。

诱惑是使用浏览器的历史来响应滑动。问题是,没有办法查看历史记录中的上一个或下一个条目,看它是否属于 web 应用。如果不是,那么你最终会让用户离开你的 web 应用,潜在地导航到一个他们无意访问的 URL。清单 7-15 显示了对utils.js文件中的enhanceViewModel函数所需的更改,以建立跟踪用户类别选择的基本支持。

Image 提示你可以选择使用本地存储,让刷卡相关的历史持久化。我不喜欢这样做,因为我认为将历史记录限制在 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 应用。

八、创建移动 Web 应用

创建适应不同设备功能的 web 应用的另一种方法是创建一个专门针对移动设备的版本。在响应式 web 应用和特定于移动设备的实现之间做出选择可能很困难,但我的经验是,当我想为移动和桌面用户提供完全不同的体验时,或者当在响应式实现中处理设备限制变得笨拙和过于复杂时,移动版本是有意义的。当然,你的决定将取决于你项目的具体情况,但是这一章是针对当你决定你的 web 应用的一个版本,不管它的响应速度有多快,都不能满足你的移动用户的需求。

检测移动设备

第一步是决定如何引导移动设备的用户使用 web 应用的移动版本。你在这个阶段做出的决定将会塑造你在构建移动网络应用时的许多假设。有两种广泛的方法,我将在下面的部分中描述。

检测用户代理

传统的方法是查看浏览器用来描述自己的用户代理字符串。这可以通过navigator.userAgent属性获得,它返回的值可以用来标识浏览器,通常还可以用来标识浏览器运行的平台。作为一个例子,下面是 Chrome 在我的 Windows 系统上返回的navigator.userAgent的值:

"Mozilla/5.0 (Windows NT 6.1; WOW64) AppleWebKit/535.7 (KHTML, like Gecko) Chrome/16.0.912.77 Safari/535.7"

作为对比,下面是我从 Opera 移动模拟器得到的结果:

Opera/9.80 (Windows NT 6.1; Opera Mobi/23731; U; en) Presto/2.9.201 Version/11.50"

您可以通过构建用户代理值列表并跟踪哪些用户代理值代表移动浏览器来识别移动设备。然而,你不必自己创建和管理这些列表——网上有一些很好的信息来源。(一个名为 WURFL 的非常全面的数据库可以在[wurfl.sourceforge.net](http://wurfl.sourceforge.net)找到,但这需要集成到你的服务器端代码中,这对于本书来说并不理想。)

[detectmobilebrowsers.com](http://detectmobilebrowsers.com)可以找到一个不太全面的客户端解决方案,您可以下载一个小的 jQuery 库,将用户代理与已知的移动浏览器列表进行匹配。这种方法不像 WURFL 那样完整,但它使用起来更简单,并且可以检测最广泛使用的移动浏览器。为了演示这种移动设备检测,我将 jQuery 代码下载到我的 Node.js content目录下的一个名为detectmobilebrowser.js的文件中(您可以在本书的源代码下载中找到这个文件,可以从 Apress.com 获得)。清单 8-1 展示了如何使用这个插件来检测移动设备。

清单 8-1。在客户端检测移动设备

`

    CheeseLux                                                  **    **                             ` `              

    
                                              Would you like to use our mobile web app?                  
                                          
    
`

Image 提示我很快会解释如何获得清单中提到的 CSS 和 JavaScript 文件。

该文档为用户提供了两个按钮,用户可以使用它们来选择想要使用的 web 应用的版本。你可以在图 8-1 中看到文档是如何在浏览器中显示的。

Image

图 8-1。询问用户他们需要哪个版本的网络应用

这个小小的 web 应用给了我一个很好的例子来介绍 jQuery Mobile,这也是我将在本章中用到的。jQuery Mobile 是一个针对移动设备优化的工具包,它包括易于使用触摸进行交互的小部件,以及处理触摸事件和手势的内置支持。

jQuery Mobile 是 jQuery 主项目的“官方”移动工具包,它非常好,尽管有些布局有些粗糙,需要用少量 CSS 进行调整。还有其他基于 jQuery 的移动小部件工具包,其中一些也非常好。我之所以选择 jQuery Mobile,是因为它与 jQuery UI 有着广泛的共同方法,并且它具有一些大多数移动工具包的典型设计特征,在编写复杂的 web 应用时需要特别注意。

避免伪本地移动应用

我使用 jQuery Mobile 的另一个原因是,它不试图重新创建原生智能手机应用的外观,这是其他一些工具包采用的方法。我不喜欢那种方法,因为它不太管用。如果你给用户一个看起来像本地 iOS 或 Android 应用的东西,那么你需要确保它的行为完全符合本地应用应有的方式——至少在目前,这是不可能的。

最糟糕的方法是尝试只为一个平台重新创建一个本地应用。你经常会看到这种情况,而 web 应用开发者瞄准的通常是 iOS。如果再现是忠实的,并且所有移动设备都运行 iOS,这可能不是那么糟糕,但 Android 和其他操作系统的用户会得到一些完全陌生的东西,iOS 用户会得到一些最初看起来熟悉但后来证明令人困惑和不一致的东西。

在我看来,设计一个真正显而易见且易于使用的 web 应用要好得多。结果会更好,你的用户会更高兴,你也不必扭曲你的 web 应用来适应你无论如何都无法正确遵守的平台约束。

我不打算提供关于 jQuery Mobile 的冗长教程,但是为了演示如何创建一个可靠的移动 web 应用,我需要解释一些重要的特性。我将在接下来的章节中解释核心概念。如果你想了解更多关于 jQuery Mobile 的信息,那么请访问项目网站或者阅读我的书,这本书由 Apress 出版,包含了使用 jQuery Mobile 的完整参考。

安装 jQuery Mobile

可以从[jquerymobile.com](http://jquerymobile.com)下载 jQuery Mobile。jQuery Mobile 依赖于 jQuery,将 jQuery 导入文档的script元素必须位于导入 jQuery Mobile 库的元素之前,如下所示:

<head>     <title>CheeseLux</title> **    <script src="jquery-1.7.1.js" type="text/javascript"></script>** **    <script src="jquery.mobile-1.0.1.js" type="text/javascript"></script>** **    <link rel="stylesheet" type="text/css" href="jquery.mobile-1.0.1.css"/>**

jQuery Mobile 依赖于自己的 CSS 和图像,这些不同于 jQuery UI 使用的 CSS 和图像。下载 jQuery Mobile 时,将 CSS 文件和 JavaScript 文件一起复制到 Node.js content目录,将图片和 jQuery UI 中的图片一起放入images目录。

了解 jQuery 移动数据属性

jQuery Mobile 依靠数据属性来配置 web 应用的布局。数据属性允许将自定义属性应用于元素,就像我一直用于数据绑定的data-bind属性一样。HTML 规范中没有定义data-bind属性,但是任何以data-为前缀的属性都会被浏览器忽略,并且允许您在标记中嵌入有用的信息,然后您可以通过 JavaScript 访问这些信息。数据属性已经被非正式地使用了几年,现在是 HTML5 的正式部分。

jQuery Mobile 使用数据属性,而不是 jQuery UI 要求的以代码为中心的方法。使用data-role属性告诉 jQuery Mobile 应该如何处理元素——当加载文档和创建小部件时,会自动处理标记。

您并不总是需要使用data-role属性。对于某些元素,jQuery Mobile 将假设它需要基于元素类型创建一个小部件。文档中的按钮已经发生了这种情况:当 jQuery Mobile 在标记中找到一个button元素时,它将创建一个按钮小部件。所以,这个元素:

<button data-inline="true" id="no">No</button>

不需要一个data-role属性,但是如果你愿意,可以写成这样:

<button data-role="button" data-inline="true" id="no">No</button>        

定义页面

data-role属性最重要的值是page。在构建移动 web 应用时,最好尽量减少对服务器的请求数量。jQuery Mobile 通过支持单页面应用在这方面提供了帮助,其中多个逻辑页面的标记和脚本包含在单个文档中,并根据需要显示给用户。一个页面由一个div元素表示,其data-role属性为pagediv元素的内容就是该页面的内容:

`...

<body>     <div id="page1" **data-role="page"** data-theme="a">

*        ...page content goes here...*

...`

在我的askmobile.html文档中只有一页,但是当我们在本章后面构建完整的移动 CheeseLux 应用时,我会回到页面的主题。

配置小组件

jQuery Mobile 也使用数据属性来配置小部件。默认情况下,jQuery 移动按钮跨越整个页面。这使得一个大的目标在一个小的纵向屏幕上出现,但在其他布局上看起来很奇怪。为了禁用这种行为,我告诉 jQuery Mobile 我想要内联按钮,其中的按钮足够大,可以包含它的内容。我通过将button元素的data-inline属性设置为true来实现这一点,如下所示:

<button **data-inline="true"** id="no">No</button>  

有许多特定于元素的数据属性可用,您应该查阅 jQuery Mobile 网站以获得详细信息。然而,我将提到的一个重要的配置属性是data-theme,它将样式应用于它所应用的页面或小部件。一个 jQuery Mobile 主题包含许多名为ABC等的样本。我已经将页面元素的data-theme属性设置为a,以便为文档中的单个页面及其所有内容设置主题:

<div id="page1" **data-role="page"** data-theme="a">

您可以使用 jQuery Mobile ThemeRoller 创建自己的自定义主题,该工具可在jquerymobile.com获得。我使用的是默认主题,swatch A为 web 应用提供了深色风格。作为对比,我将“是”按钮上的样本设置为b,如下所示:

<button data-inline="true" **data-theme="b"** id="yes">Yes</button>

swatch B中的按钮是蓝色的,这为用户提供了关于推荐决策的强烈建议。

Image 提示我已经为 jQuery Mobile 定义了一个新的 CSS 样式表。它名为[styles.mobile.css](http://styles.mobile.css),与其他示例文件一起位于 Node.js content目录中。这个文件中的样式只是稍微调整了一下布局,允许我将元素放在页面的中心,并对默认的 jQuery Mobile 布局进行其他小的调整。您可以在本书的源代码下载中找到样式表,可以从 Apress.com 获得。

处理 jQuery 移动事件

使用基于 jQuery 的小部件库意味着我们可以使用熟悉的技术处理事件。如果您查看askmobile.html文档中的script元素,您会发现处理按钮被点击时触发的事件需要我在本书中一直使用的相同的基本 jQuery 代码:

``

我使用 jQuery 来选择button元素,使用标准的click方法来处理click事件。然而,jQuery Mobile 处理事件的方式有一个非常重要的区别。这是:

$(document)**.bind("pageinit",** function() {           *    ...code to handle button click events...* }

当标准的 jQuery ready事件触发时,jQuery Mobile 处理数据属性的标记。这意味着如果我想在 jQuery Mobile 设置完小部件后执行代码,我必须bindpageinit事件。没有方便的方法为这个事件指定一个函数,所以我使用了bind方法。本例中的代码将会非常愉快地响应 jQuery ready事件,因为我没有直接与 jQuery Mobile 创建的小部件交互。当我使用完整的 jQuery Mobile CheeseLux web 应用时,这种情况将会改变,在所有 jQuery 移动应用中使用pageinit事件是一种很好的做法。

存储用户的决定

现在我已经描述了askmobile.html的 jQuery Mobile 部分,我们可以回到应用的功能,即记录和存储用户对用户想要使用的 web app 版本的偏好。如果本地存储可用,我就使用本地存储,如果本地存储不可用,我就使用普通的 cookie。使用 cookies 没有方便的 jQuery 支持,所以我编写了自己的函数setCookie:

function setCookie(name, value, days) {     var date = new Date();     date.setTime(date.getTime()+(days * 24 * 60 * 60 *1000));     document.cookie = name + "="+ value         + "; expires=" + date.toGMTString() +"; path=/";             }

如果我必须使用 cookie,那么我将生命周期设置为 30 天,之后浏览器将删除 cookie,用户将不得不再次表达他们的偏好。为了简单起见,在使用本地存储时,我没有设置任何生存期,但是这样做将是一个很好的实践。

提示询问用户是否希望你存储他们的选择也是一个很好的做法。在我的简单示例中,我还没有采取这一步,但是有些用户对这些问题很敏感,尤其是涉及到 cookies 的时候。

检测用户在网络应用中的决定

最后一步是在 CheeseLux web 应用的桌面版本中检测用户的决定。清单 8-5 显示了我添加到utils.js中的一对函数来支持这个过程。

清单 8-5。在执行重定向之前检查先前的决定

`function checkForVersionPreference() {
    var previousDecision;
    if (localStorage && localStorage["cheeseLuxMode"]) {
        previousDecision = localStorage["cheeseLuxMode"];
    } else {
        previousDecision = getCookie("cheeseLuxMode");
    }
    if (!previousDecision && cheeseModel.device.mobile) {
        location.href = "/askmobile.html";
    } else if (location.pathname == "/mobile.html" && previousDecision == "desktop") {
        location.href = "/example.html";
    } else if (location.pathname != "/mobile.html" && previousDecision == "mobile") {        
        location.href = "/mobile.html";
    }
}

function getCookie(name) {
    var val;
    $.each(document.cookie.split(';'), function(index, elem) {
        var cookie = $.trim(elem);
        if (cookie.indexOf(name) == 0) {
            val = cookie.slice(name.length + 1);
        }
    })
    return val;
}`

checkForVersionPreference函数使用视图模型值来查看用户是否有移动设备,如果有,则尝试从本地存储或 cookie 中恢复先前决策的结果。cookie 很难处理,所以我添加了一个getCookie函数,通过名称查找 cookie 并返回其值。如果没有存储值,那么我将用户定向到askmobile.html文档以获得他们的偏好。如果的存储值,那么我用它来切换到移动版本,如果这是用户的偏好。剩下的就是将对checkForVersionPreference函数的调用合并到example.html中,它包含了 web 应用的桌面版本,如下所示:

`...
detectDeviceFeatures(function(deviceConfig) {
    cheeseModel.device = deviceConfig;
**    checkForVersionPreference();**

$.getJSON("products.json", function(data) {
        cheeseModel.products = data;

}).success(function() {
        $(document).ready(function() {
*            ... code removed for brevity...*
        });
    });
)};
...`

我用代码片段展示了这些变化,因为我不想在关于移动设备的章节中使用 pages 来列出桌面 web 应用代码。您可以从 Apress.com 免费下载的源代码中获得完整的清单。

Image 提示当决策的效果被自动存储和应用时,给用户提供改变主意的机会是有意义的。我跳过了这一步,因为我想在本章中将重点放在移动应用上,但是您应该始终包含某种 UI 提示,允许用户切换到 web 应用的另一个版本,尤其是在决策被持久存储和使用的情况下。

构建移动网络应用

我将从 CheeseLux web 应用的基本移动版本开始,然后在此基础上向您展示如何为用户创造更好的体验。当我创建一个有桌面版的移动版 web 应用时,我有两个目标:

  • 尽可能多地重用桌面代码
  • 确保手机能够优雅地响应不同的设备功能

第一个目标是长期可维护性。我拥有的通用代码越多,我不得不在两个不同的地方找到并修复 bug 的情况就越少。我喜欢提前决定哪个版本的 web 应用是首要的,哪个版本必须灵活才能使用代码。总的来说,我倾向于首先创建桌面版本,然后让移动 web 应用适应。例外情况是大多数用户将使用移动设备。

先说移动怎么样?

有一种观点(通常称为 mobile first )首先关注移动平台的设计和开发,主要是因为它迫使您在最受限制的环境中工作,因为移动设备具有桌面上没有的功能,如地理定位。

在我的项目中,我不想要最初的约束——我想尽我所能构建最丰富、最深刻、最沉浸式的体验,至少目前是桌面。一旦我掌握了大屏幕和丰富互动的可能性,我就开始处理设备限制的过程,削减和定制我的应用,直到我得到在移动设备上运行良好的东西。我也不相信移动设备的独特功能。正如我在第七章中提到的,设备类别之间的硬性区别正在迅速消失。我最近感到惊讶的一个时刻是,谷歌能够使用其街景产品收集的 Wi-Fi 数据,在几英尺内精确定位我的位置。这是在一台需要叉车移动的机器上。

但是,正如我前面提到的,我不是模式狂热者,您应该遵循对您和您的项目最有意义的方法。不要让任何人支配你的开发风格,包括我。

第二个目标是确保我的移动 web 应用能够响应和适应用户可能拥有的各种设备类型。即使只针对移动设备,你也不能对屏幕尺寸和输入机制做出假设。

Image 注意您可能会尝试创建一个 web 应用,根据正在使用的设备类型在 jQuery UI 和 jQuery Mobile(或等效的库)之间切换。这样的技巧是可能的,但要在不创建大量扭曲的代码和标记的情况下实现却非常困难。如果您想利用特定于某个库的特性,最明智的方法是创建单独的版本。

为了让事情进展顺利,清单 8-6 展示了使用 jQuery Mobile 创建核心功能的第一步。这个清单依赖于视图模型中的一些变化,我将很快解释这些变化。

清单 8-6。CheeseLux 移动网络应用的初始版本

`

    CheeseLux                                                                 

    
        
                         Gourmet European Cheese         


            
            
        


            

                

                    

                        


                    

                    
                    

                        

                            
                        

                        

                            
                        

                    

                    
                    

                        


                            
                             <span data-bind="formatText: {prefix: '$',
                                value: cheeseModel.total()}"
                        


                    

                

            

                
                
                
            


                
            

        
            
    

`

在很大程度上,这是一个简单的 web 应用,依赖于 jQuery Mobile 的核心功能,但是您需要注意一些我在下面几节中描述的细节和附加内容。你可以在图 8-2 中看到小屏幕设备的横向和纵向布局。该 web 应用还支持大屏幕移动设备的布局。我没有展示这些布局,但它们与图中所示的布局相似,只是在导航按钮中显示了 CheeseLux 徽标和完整的类别名称。

Image

图 8-2。移动 CheeseLux web 应用的基本实现

您将注意到清单中新的数据绑定和视图模型项。formatText数据绑定允许我对元素的文本内容应用前缀和后缀,这简化了组合字符串的处理,尤其是货币金额。这是我通常添加到项目和代码中的一组自定义绑定之一,包含在utils.js文件中,如清单 8-7 所示。这个绑定使用的composeString函数与我在第四章中介绍自定义formatAttr绑定时展示的函数相同。

清单 8-7。formatText 自定义数据绑定

ko.bindingHandlers.formatText = {     update: function(element, accessor) {               $(element).text(composeString(accessor()));     } }

其他新增内容是添加到视图模型中设备功能信息的一些有用的快捷方式。虽然 KO 可以处理数据绑定中的表达式,但我不喜欢用这种方式定义代码,我一般会创建计算数据项,允许我通过单个视图模型项来确定设备的状态。在这一章中,我定义了一对计算值,让我可以轻松地读取我对移动 web 应用感兴趣的屏幕大小和方向的组合。这些快捷方式在utils.js文件的detectDeviceFeatures函数中定义,如清单 8-8 所示。

清单 8-8。在视图模型中创建快捷方式以避免绑定中的表达式

`...
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);
        }
    }, {
        test: Modernizr.touch,
        yep: 'jquery.touchSwipe-1.2.5.js',    
        callback: function() {            
            $('html').swipe({
                swipeLeft: advanceCategory,
                swipeRight: advanceCategory
            })
        }
    },{
        complete: function() {
            deviceConfig.mobile = Modernizr.touch && deviceConfig.smallScreen();

**            deviceConfig.smallAndLandscape = ko.computed(function() {**
**                return deviceConfig.smallScreen() && deviceConfig.landscape();**
**            });**
**            deviceConfig.smallAndPortrait = ko.computed(function() {**
**                return deviceConfig.smallScreen() && deviceConfig.portrait();**
**            });**

callback(deviceConfig);
        }
    }]);
};
...`

管理事件序列

正如我在askmobile.html文档中演示的,jQuery Mobile 将自动处理文档,并基于元素类型和data-role属性的值创建小部件。这是一个很好的特性,它显著减少了简单 web 应用所需的代码量。不幸的是,当您使用视图模型生成或格式化元素时,它会碍事,尤其是如果视图模型中的数据是通过 Ajax 获得的。jQuery Mobile 将在用数据绑定填充视图模型之前处理文档,这意味着不能正确创建小部件。

这是我以前在 jQuery UI 中遇到的同样的问题,但是在 jQuery Mobile 中这个问题更严重,因为它假设它对页面中的元素拥有唯一的控制权,并且很难创建绑定来协商 jQuery Mobile 在设置小部件时使用的额外元素。(这是一个问题,我将在本章后面的不同原因中再次讨论。)

禁用自动处理

最好的方法是阻止 jQuery Mobile 自动处理文档。为此,我需要处理mobileinit事件,该事件由 jQuery Mobile 在库首次加载时发出。我需要在加载 jQuery Mobile 之前注册我的处理函数,这意味着我必须在导入 jQuery 的元素之后和导入 jQuery Mobile 的元素之前插入一个新的script元素,如下所示:

`...

...`

通过将$.mobile.autoInitializePage属性设置为false,我禁用了自动处理文档中标记的 jQuery Mobile 特性。

Image 提示公平地说,只有当我想使用bind方法时,我才需要在 jQuery 后插入我的script元素,但是我更喜欢这样做,而不是使用笨重的 DOM API 来处理事件。

禁用自动处理停止了视图模型和 jQuery Mobile 之间的竞争,并允许我发出 Ajax 请求、填充视图模型和完成任何其他任务,而不用担心过早创建小部件。当我完成设置时,我明确地告诉 jQuery Mobile 它应该处理页面,就像这样:

`$.getJSON("products.json", function(data) {                        
    cheeseModel.products = data;
    enhanceViewModel();

\((document).ready(function() {         ko.applyBindings(cheeseModel);         \)('button#left, button#right').live("click", function(e) {
            e.preventDefault();    
            advanceCategory(e, e.target.id);         })
**        $.mobile.initializePage();**
    });
});`

mobile对象提供对 jQuery Mobile API 的访问,initializePage方法启动页面处理。

响应 pageinit 事件

现在我已经控制了主要事件,在 jQuery Mobile 处理完文档中的页面后,我可以使用pageinit来执行任务。jQuery Mobile 通常非常可靠,但是它有一些布局上的怪癖。一个特别的问题是按钮组不在页面的中心。对于页面底部的按钮,我已经能够用 CSS 解决这个问题(这就是centered样式在styles.mobile.css文件中的作用)。但是导航按钮的大小会改变,这需要一个 JavaScript 解决方案,如下所示:

... $(document).bind("**pageinit**", function() {     function positionCategoryButtons() {         setTimeout(function() {             $('fieldset:visible').each(function(index, elem) {                 var fsWidth = 0;                     $(elem).children().each(function(index, child) {                     fsWidth+= $(child).width();                 });                 if (fsWidth > 0) {                     $(elem).width(fsWidth);                 } else {                                                 positionCategoryButtons();                 }             });         }, 10);     };     positionCategoryButtons();     cheeseModel.device.smallAndPortrait.subscribe(positionCategoryButtons); }); ...

我想在 jQuery Mobile 完成创建后将按钮居中,这是对pageinit事件的理想使用。在函数中,我将每个fieldset元素的子元素的宽度相加,然后使用总值来设置fieldset的宽度。jQuery Mobile 将fieldset设为窗口的宽度,创建一组按钮所需的元素序列使得很难通过其他方式将按钮居中。

Image 提示我使用 jQuery each方法,这样我可以确保children方法只返回一个fieldset元素的子元素。这意味着如果我稍后添加另一个fieldset元素,我的代码不会中断。元素选择器是贪婪的,如果我只是调用$('fieldset').children(),我将得到文档中所有fieldset元素的子元素,这将抛出宽度计算。

我将设置宽度的代码放在了对setTimeout函数的调用中,因为当导航按钮的内容改变时,我希望正确地调整fieldset元素的大小,当大小和方向改变时就会发生这种情况。

元素的内容通过数据绑定来改变,数据绑定在视图模型中可观察的数据项被更新时执行。因为我使用了subscribe方法来接收相同类型的通知,所以我需要确保在按钮内容改变之前不会执行我的代码来调整fieldset的大小,这是通过使用setTimeout函数引入一个小延迟来实现的。

为内容变更做准备

jQuery Mobile 假设它能够控制作为小部件基础的元素。对于按钮,jQuery Mobile 将button内容(或者使用单选按钮时的label内容)包装在一个span元素中,以便应用样式。

这是 jQuery UI 造成的同样的问题,jQuery Mobile 的解决方案也是一样的:自己将内容包装在一个span元素中,这样就有了数据绑定的目标。一旦有了可以附加数据绑定的元素,就不需要担心 jQuery Mobile 如何将元素转换成小部件。您可以看到我是如何为导航按钮做这些的:

`


    
    

`

这看起来似乎是一个简单的技巧,但是许多移动 web 应用程序员被这个问题所困扰,并最终试图通过一些痛苦而不可靠的替代方法来解决它。这个简单的方法相当巧妙地解决了这个问题。我使用过的所有移动小部件工具包都以类似的方式与数据绑定发生冲突。在 jQuery Mobile 的例子中,你知道当数据绑定改变按钮内容时,按钮的格式丢失,问题就发生了,如图 8-3 所示。

Image

图 8-3。jQuery Mobile 添加样式元素引起的问题

复制元素和使用模板

并不是所有小部件库和数据绑定之间的冲突都能这么容易解决。在清单 8-6 中,我创建了显示在页面底部的按钮的副本,如下所示:

`


    
    
    

    
`

一组有额外的按钮,用户可以点击这些按钮来浏览产品类别。我正在解决的问题是,jQuery Mobile 创建了一组按钮,却没有考虑到它所处理的元素的可见性。这意味着即使外部按钮是不可见的,它们也会被赋予圆角,这意味着使用visible绑定不会创建格式良好的按钮组。

if绑定有它自己的问题,因为当新元素添加到容器中时,jQuery Mobile 不会自动更新按钮的样式,而让 jQuery Mobile 刷新内容并不能解决这个问题。因此,最简单的方法是创建重复的元素集。

使用两遍数据绑定

对于简单的情况,复制元素是可以的,但是当您处理具有大量绑定和格式的复杂元素集时,这就成问题了。在某些情况下,一个更改将应用于一组元素,而不是另一组。当这种问题发生时,跟踪它是非常耗时的。另一种方法是从单个模板生成重复的元素集。这是一种优雅但复杂的技术——你可以在清单 8-9 中看到所需的变化。

清单 8-9。使用模板创建重复的元素集

`

    CheeseLux     ` `                                                           
**    **

    
        
                         Gourmet European Cheese         


            
            
        


            

                

                    

                        


                    

                    
                    

                        

                            
                        

                        

                            
                        

                    

                    
                    

                        


                            
                             <span data-bind="formatText: {prefix: '$',
                                value: cheeseModel.total()}"
                        


                    

                

            

**            **
**            **
        

            
    

`

这项技术有三个部分,为了展示这些部分是如何组合在一起的,我需要按照它们在文档中出现的相反顺序来解释它们。

使用自定义数据调用模板

我已经使用了模板绑定来从 Knockout.js 模板生成元素,我在第三章中描述了这种技术:

`

`

奇怪的是,我没有使用视图模型来驱动模板。相反,我创建了一个包含truefalse值的数组。我在一个非常简单的情况下应用了这种技术,我只需要知道我创建的是允许类别导航的按钮集(由true值表示)还是不允许的按钮集(由false值表示)。重点是您可以对不属于视图模型的数据使用foreach绑定。您可以对更复杂的元素集使用更复杂的数据结构。

使用模板生成绑定

第二步有点奇怪。我使用attr数据绑定来设置模板生成的元素的data-bind属性值,如下所示:

<script id="buttonsTemplate" type="text/html"> **    <div class="deferred middle" data-role="controlgroup" data-type="horizontal"** **        data-bind="attr: {'data-bind': 'visible: ' + ($data ? '' : '!')** **            + 'device.smallAndLandscape()' }">**
**        <!-- ko if: $data -->**         <button id="left" data-icon="arrow-l">&nbsp;</button> **        <!-- /ko -->**         <input type="submit" value="Submit Order"/> **        <!-- ko if: $data -->**         <button id="right" data-icon="arrow-r" data-iconpos="right">&nbsp;</button> **        <!-- /ko -->**     </div>     </script>    

该模板最简单的部分是使用if绑定来确定何时应该生成类别导航按钮。我的模板将被使用两次:一次用于我传递给foreach绑定的truefalse值。当值为true时,按钮元素包含在 DOM 中,当值为false时,它们被省略。

更复杂的部分是我使用了attr绑定来为模板生成的元素中的data-bind属性指定一个值。下面是模板中data-bind属性的值:

data-bind="attr: {'data-bind': 'visible: ' + ($data ? '' : '!') +       'device.smallAndLandscape()'}"

在这个绑定中发生了很多事情。需要理解的最重要的一点是,我指定了我希望生成的元素作为一个字符串的data-bind值,这个字符串目前不会被处理。我将很快回到处理过程。

我使用$data来引用我在调用模板时传递给foreach绑定的值。$data的值将是truefalse。首先,Knockout 将解析绑定的这一部分,因此当我处理true值时,生成的div元素将具有如下绑定:

data-bind="attr: {'data-bind': 'visible: device.smallAndLandscape()'}"

false值将导致这样的绑定:

data-bind="attr: {'data-bind': 'visible: !device.smallAndLandscape()'}"

然后,一旦数据值被解析,Knockout 将处理整个attr绑定,这相当简洁地在生成的元素中替换了它自己,就像这样:

data-bind="visible: device.smallAndLandscape()"

重新应用数据绑定

Knockout 只处理一次数据绑定属性,这意味着我的模板生成带有我想要的数据绑定的元素,但是这些绑定不是活动的。视图模型中的变化不会影响它们,因为当我调用ko.applyBindings方法时,还没有定义data-bind属性。

为了解决这个问题,我简单地再次调用applyBindings,但是这一次我使用了可选的参数,该参数允许我指定处理哪些元素:

$(document).ready(function() {     ko.applyBindings(cheeseModel);     **$('*.deferred').each(function(index, elem) {** **        ko.applyBindings(cheeseModel, elem);** **    });**     $('button#left, button#right').live("click", function(e) {
        e.preventDefault();             advanceCategory(e, e.target.id);     })     $.mobile.initializePage(); });            

我将按钮容器元素添加到了deferred类中。我现在选择这个类的所有成员,并使用each方法依次调用每个元素的applyBindings方法。这使得 Knockout.js 处理我从模板生成的绑定,并使它们生效。这最后一步意味着我的绑定将响应视图模型中的变化。

关于这项技术有几点需要注意。首先,我并没有试图阻止 DOM 中元素的重复。如果没有重复的元素集,就没有简单的方法来处理 jQuery Mobile 格式问题。我的目标是从一组源元素中生成副本,这样我就可以在一个地方进行更改,并在生成副本时使它们在所有副本中生效。

其次,当使用这种技术时,您必须确保除了在一对引号字符内(即,在一个字符串内)之外,不引用视图模型项。如果你引用了一个字符串之外的变量,那么 Kockout.js 会尝试寻找一个值来解析引用,你会得到一个错误。视图模型值在第二次调用applyBindings方法时被解析,而不是在使用模板创建元素时被解析。

Image 注意正确设置字符串可能很困难,但是对于复杂的元素集合来说,这种努力是值得的。对于更简单的情况,我建议您简单地在文档中复制您需要的内容,并完全跳过模板。本书的源代码下载包含了这个例子的完整清单。

采用多页模式

我的移动 web 应用正在成形,但我仍然缺少 URL 路由,这意味着移动和桌面版本之间存在显著差异。添加路由支持的第一步是采用多页面模型。正如我前面解释的,jQuery Mobile 支持在一个 HTML 文档中包含多个页面的想法。我将使用这个特性为用户提供在类别之间导航的方法。清单 8-10 显示了所需的变更。

清单 8-10。添加对多页面模型的支持

`

    CheeseLux               ` `                                                 

     **        
**             
                                 Gourmet European Cheese             
                              

**            <fieldset class="middle" data-role="controlgroup" data-type="horizontal"**
**                      data-bind="foreach: $root.products,**
**                        visible: \(root.device.largeScreen() ||** **                            \)root.device.smallAndPortrait()">**
**                **
**                    **
**                
**
**            **


                

                    

                        

                            


                        

                        
                        

                            
                                
                            

                            

                                
                            

                        

                        
                        

                            


                                
                                 <span data-bind="formatText: {prefix: '$',
                                    value: cheeseModel.total()}"
                            


                        

                    

                

                
                
            
            
        
        
    

`

我已经强调了最重要的变化(稍后我会描述它们),但是基本的方法是为每个类别创建一个页面。每个页面都包含一组重复的导航项目,只有个别产品的详细信息不同。在大多数情况下,对数据绑定的更改会产生这种效果。然而,有些变化需要更多的解释。

返工类别导航

jQuery Mobile 使用我在桌面版本中使用的基于 URL 片段的方法在页面之间导航。例如,如果有一个div元素,其data-role属性被设置为page,其id属性被设置为mypage,我可以通过导航到#mypage片段让 jQuery Mobile 显示该页面。

与桌面 web 应用的不同之处在于,jQuery Mobile 对可用于页面的名称进行了一些限制。我以前使用完整的类别名称(例如British Cheese),但是空格对于 jQuery Mobile 来说是个问题,所以我使用了简短的类别名称(例如British)。下面是设置页面 ID 的绑定:

<div data-role="page" data-theme="a"     data-bind="attr: {'id': shortName, 'data-category': category}">

注意,我添加了一个包含完整类别名称的data-category属性。我将很快回到这个属性。

用锚点替换单选按钮

页面导航模型意味着我可以用a元素替换我的单选按钮。如果data-role属性设置为button,jQuery Mobile 将从a元素创建按钮小部件,并且href属性的值可用于文档内的导航:

<a data-role="button" data-bind="formatAttr: {attr: 'href',     prefix: '#', value: shortName},     css: {'ui-btn-active': (category == $root.selectedCategory())}">     <span data-bind="text: $root.device.smallAndPortrait()? shortName :         category"></span> </a>                

当数据绑定被解析后,我得到了一个导航元素,它的用途更容易理解:

<a data-role="button" href="#British"     <span>British</span> </a>                

单击 jQuery Mobile 从这种元素创建的按钮之一,将导航到适当的类别页面。作为一个额外的好处,jQuery Mobile 正确地将从a元素创建的按钮组居中,所以我不必担心显式设置包含fieldset元素的宽度。

Image 提示请注意,我已经使用了css绑定来将ui-btn-active类应用到按钮,当选择的类别与按钮代表的类别匹配时。这是一个 jQuery Mobile CSS 类,当一个按钮处于活动状态时使用,应用这个类创建蓝色突出显示,这是我在以前版本的移动 web 应用中使用的。在工具包 CSS 中挖掘并不理想,但有时别无选择。

将页面名称映射到路线

为了能够重用我的 JavaScript 代码来处理路由,我希望使用与桌面版本相同的路由名称。这是一个问题,因为 jQuery Mobile 对页面名称进行了限制。为了解决这个问题,我添加了一个路由,它映射了 jQuery Mobile 需要的路由和我真正想要的路由:

`...
hasher.initialized.add(crossroads.parse, crossroads);
hasher.changed.add(crossroads.parse, crossroads);
hasher.init();    
crossroads.addRoute("category/:newCat:", function(newCat) {                
    cheeseModel.selectedCategory(newCat ||
        cheeseModel.products[0].category);
});

crossroads.addRoute("{shortCat}", function(shortCat) { **    $.each(cheeseModel.products, function(index, item) {**
**        if (item.shortName == shortCat) {**
**            crossroads.parse("category/" + item.category);**
**        }**
**    });**
});
crossroads.parse(location.hash.slice(1));
...`

当用户点击其中一个a元素导航到一个新的类别时,URL 片段会改变。哈希库检测到这一变化,并将新的哈希传递给 crossroads 路由引擎。jQuery Mobile URL 与突出显示的路线匹配,我在视图模型中枚举产品,以找到具有匹配的shortName值的产品。我使用产品的category属性创建桌面版本使用的 URL 类型,并调用crossroads.parse方法使其与应用路由相匹配。这项技术允许我在 jQuery Mobile URLs 和我想要的路由之间建立桥梁,允许我在 web 应用的所有版本中保持路由的一致性。对于我的简单示例 routes 来说,这没什么大不了的,但是如果您有一个外部 JavaScript 文件,其中充满了在 URL 匹配时执行的 JavaScript 代码,这就变成了一个有用的技巧。

明确地改变页面

最后一个变化与我添加到页面div元素的data-category属性有关。当用户滑动屏幕或使用一个横向导航按钮时,调用advanceCategory函数,视图模型中的selectedCategory项的值被更新。但是,更新视图模型不会自动导致 jQuery Mobile 导航到所选类别的页面。为了解决这个问题,我添加了一个对mobile.changePage方法的调用。该方法将接受一个要导航到的 URL 或一个 jQuery 对象作为要显示的元素:

`$('button.left, button.right').live("click", function(e) {

e.preventDefault();
    advanceCategory(e, \((e.target).hasClass("left") ? "left" : "right"); **    \).mobile.changePage($('div[data-category="'**
**        + cheeseModel.selectedCategory() + '"]'));**
})`

我使用data-category项为新的selectedCategory值选择页面元素,而不必遍历产品。通过这个小小的添加,我可以依赖于我在 web 应用的桌面版本中使用的相同的advanceCategory代码,但是获得了 jQuery 移动页面模型的好处。

添加最后的铬合金

我想对 CheeseLux 移动应用做最后一个更改。在某种程度上,这完全是一个微不足道的变化,但是它也允许我演示 jQuery Mobile 显示的一个重要的行为怪癖。

当显示新页面时,jQuery Mobile 会播放滑动动画。默认情况下,页面从右侧滑入。我想做的更改是,当用户按下左侧横向导航按钮或按下当前类别之前视图模型中出现的类别的纵向/大屏幕按钮时,新页面从左侧滑入。

jQuery Mobile changePage方法接受一个可选的配置对象。jQuery Mobile 识别的对象属性之一是reverse。当该属性的值为true时,页面从左侧显示。默认值false使新页面从右侧出现。

对于纵向导航按钮,我在utils.js中添加了一个名为getIndexOfCategory的功能。该函数如清单 8-11 所示,枚举视图模型数据,以找到指定完整或简短类别名称的索引。

清单 8-11。getIndexOfCategory 函数

function getIndexOfCategory(category) {     var result = -1;     for (var i = 0; i < cheeseModel.products.length; i++) {         if (cheeseModel.products[i].category == category ||                 cheeseModel.products[i].shortName == category) {             result = i;             break;         }     }     return result; }

清单 8-12 显示了mobile.html中使用该功能的变化。

清单 8-12。管理页面过渡动画方向

``

我只需要给changePage方法提供可选参数,让水平按钮工作。对于a元素,我决定处理click事件,找出过渡方向,直接调用changePage方法。在 jQuery Mobile 中还有其他方法可以做到这一点,但这是最简单、最直接的方法。

我想展示的重要 jQuery Mobile 特性与内部 URL 的管理方式有关。如果您使用changePage方法导航到表示文档中第一页的 URL,jQuery Mobile 将导航到整个文档的 URL,而不是特定页面。例如,如果您呼叫changePage('#British'),jQuery Mobile 将导航到cheeselux.com/mobile.html,而不是cheeselux.com/mobile.html#British

为此,我需要更改 jQuery Mobile 友好片段 URL 与桌面版 web 应用共享的路由之间的映射,如下所示:

crossroads.addRoute("**:shortCat:**", function(shortCat) {     $.each(cheeseModel.products, function(index, item) {         if (item.shortName == (**shortCat || cheeseModel.products[0].shortName)**) {                                               crossroads.parse("category/" + item.category);         }     });                         });

我使段可选,而不是可变的(我在第四章中解释了区别),如果 URL 中没有提供类别名称,我假设应该使用视图模型中的第一个类别。对于我的 web 应用来说,这是一个简单的更改,但如果您正在绘制复杂的路径集,则必须确保您为所有预期的路径段设置了默认值,这些路径段通常由桌面版本提供。

总结

在这一章中,我为我的 CheeseLux web 应用创建了一个可靠的移动实现。我向您展示了采用您正在使用的移动工具包提供的导航模型的重要性,以及集成专业级 web 应用的核心功能的各种方法,例如路由、视图模型和数据绑定。移动小部件工具包通常需要一些调整和技巧才能很好地与专业 web 应用配合,但结果是值得找出解决出现的问题的方法。在下一章中,我将向您展示不同的技术来改进您编写和打包 JavaScript 代码的方式。

九、编写更好的 JavaScript

在这一章中,我将解释一些我用来创建更好的 JavaScript 的技术。这不是一本语言指南,我也不会演示任何代码修改或调整。我的编码偏好是你的维护噩梦,反之亦然。我看到过一些原本温文尔雅的人最终因为“正确”的编码方式而大吵大闹,当我自己也有一些坏习惯时,我看不出对你说教有什么意义。

相反,我将向您展示一些我用来使我的代码更容易被其他程序员和项目使用的技术。大部分大型 web apps 都有一个程序员团队,代码共享变得很重要。

在本书中,我一直在将有用的函数放入utils.js文件中。这就是我的工作方式,用一个普通的厨房水槽文件,我把我希望重复使用的函数放在那里。对于这本书来说,使用utils.js让我在每一章的主题上花更多的时间,而不必花很多页列出我在前一章定义的代码。它还让我演示了在创建同一个 web 应用的桌面和移动版本时使用一组核心通用功能的想法。

仅仅以这种方式将函数转储到一个文件中的问题是,它们变得难以管理和维护,并且,正如我稍后将解释的那样,其他人很难将它们集成到他们的项目中。出于这个原因,当我在一个项目中达到一个基本功能稳定的点,并且我对不同功能组合在一起的方式有很好的感觉时,我会重新访问我的厨房水槽文件。此时,而不是之前,我开始将代码重组成模块,以便它能很好地与其他库一起工作。在这一章中,我将向你展示我在这方面使用的技术。

一旦我整理和模块化了代码,我就开始单元测试。测试是一件非常个人化的事情,许多测试传道者会坚持测试必须在你开始编码时就开始,如果不是更早的话。我理解这种观点,但我也知道,在项目取得一定进展之前,我甚至不会考虑测试。很自然地,当我有了足够的进步,我的思想开始转向巩固和提高我所拥有的。

测试是另一个我不想讲的话题。我唯一的建议是你应该对自己诚实。在感觉合适的时候进行测试,测试到你对代码满意为止,并使用适合你的技术和工具。做对你的项目合适的事情,并且接受稍后的测试将需要更多的代码修改,并且根本不测试意味着你的用户将不得不为你找到你的 bug。

管理全局名称空间

大型 JavaScript 项目的最大问题之一是有可能出现命名冲突,两个代码区域出于不同的目的使用相同的全局变量名称。全局变量是存在于函数或对象之外的变量。JavaScript 使这些在您的 web 应用中可用,因此在内联script元素或外部 JavaScript 文件中定义的全局函数对您使用的所有其他script元素和 JavaScript 文件都可用。当一个全局函数或变量被创建时,它驻留在全局名称空间中。

对于小型应用,这是一个有用的特性;这意味着当应用加载时,您可以只对代码进行分区,并依靠浏览器将它们合并在一起。这就是允许我的utils.js文件工作的原因:浏览器加载我的文件中的所有函数,并通过全局变量使它们可用。我不需要知道在哪里定义了mapProducts函数来使用它;它是自动可用的。

当您使用的代码中的函数和变量与您使用的名称相同时,问题就来了。如果我使用一个定义了mapProducts函数的 JavaScript 库,会出现各种各样的问题。包含在最后加载的文件中的mapProducts将会胜出,任何期待另一个版本的代码都会大吃一惊。

随着 web 应用的规模和复杂性的增长,在小型 web 应用中有用的技巧变成了维护的噩梦。很快就很难想出一个尚未使用的有意义的名字,冲突的可能性急剧增加。在接下来的几节中,我将描述一些有用的技术,通过结构化代码和减少由此产生的全局变量的数量,帮助您避免命名冲突。

避免隐含的全局变量

全局变量的一个常见原因是给没有使用var关键字定义的变量赋值。JavaScript 将其解释为创建全局变量的请求:

... (function() {     var var1 = "my local variable"; **    var2 = "my global variable";** })(); ...

在这个清单中,变量var1只存在于定义它的函数范围内,但是var2是在全局名称空间中定义的。当小心谨慎地使用时,这可能是一个有用的特性,允许您控制哪些变量是全局导出的,但是这种情况通常是由于错误而不是故意造成的。我已经在一个自执行函数中展示了这一点,但它也可能发生在任何没有使用var关键字定义变量的函数中。

定义 JavaScript 名称空间

第一种技术是使用名称空间,它限制了变量和函数的范围。如果您使用过 Java 或 C#之类的语言,您会对名称空间很熟悉。JavaScript 不像那些语言那样有名称空间语言结构,但是您可以通过依赖 JavaScript 作用域对象的方式来创建解决问题的东西。清单 9-1 展示了这是如何完成的。

清单 9-1。定义一个 JavaScript 名称空间

`var cheeseUtils = {};

cheeseUtils.mapProducts = function(func, data, indexer) {     \(.each(data, function(outerIndex, outerItem) {         \).each(outerItem[indexer], function(itemIndex, innerItem) {
            func(innerItem, outerItem);
        });
    });
}

cheeseUtils.composeString = function(bindingConfig ) {
    var result = bindingConfig.value;
    if (bindingConfig.prefix) { result = bindingConfig.prefix + result; }
    if (bindingConfig.suffix) { result += bindingConfig.suffix;}
    return result;
}`

为了创建名称空间效果,我创建了一个对象,然后将我的函数和变量作为属性分配给它。这意味着要在其他地方访问这些函数,我必须使用对象的名称作为前缀,就像这样:

**cheeseUtils.mapProducts**(function(item) {     if (item.id == id) { item.quantity(0); } }, cheeseModel.products, "items");

明确地说,这不是一个真正的名称空间,因为 JavaScript 不支持它们;它只是看起来和行为有点像。但是这足以减少对全局名称空间的污染,因为我从共享上下文中取出了两个函数,并用一个对象名cheeseUtils代替了它们。

仍然存在名称冲突的风险,因此为特定于您的项目或功能区域的对象选择一个名称是很重要的。您可以通过嵌套对象来嵌套命名空间,从而创建必须导航才能使用您的代码的层次结构。清单 9-2 显示了一个例子。

images 提示为了节省空间,我不会列出utils.js文件中的所有函数。我将挑选一些有代表性的样品来展示不同的技术。

清单 9-2。创建嵌套名称空间

`if (!com) {
**    var com = {};**
}
com.cheeselux = {};
com.cheeselux.utils = {};

com.cheeselux.utils.mapProducts = function(func, data, indexer) {
    \(.each(data, function(outerIndex, outerItem) {         \).each(outerItem[indexer], function(itemIndex, innerItem) {
            func(innerItem, outerItem);
        });
    });
} com.cheeselux.utils.composeString = function(bindingConfig ) {
    var result = bindingConfig.value;
    if (bindingConfig.prefix) { result = bindingConfig.prefix + result; }
    if (bindingConfig.suffix) { result += bindingConfig.suffix;}
    return result;
}`

在这个清单中,我使用了一种非常标准的命名空间方法,即使用我的域名的结构,但顺序相反。然而,由于com很可能被遵循相同方法的其他库使用,所以我在自己这么做之前检查它是否已经被定义了。我不必为cheeselux部分做这些,因为我是cheeselux.com域名的所有者,几乎没有碰撞的机会。

直接引用嵌套命名空间中的函数会导致冗长的代码。当我在嵌套命名空间中使用代码时,我倾向于将最内部的对象别名化为局部变量,就像这样:

var utils = com.cheeselux.utils;

这创建了一个由 Java 和 C#定义的importusing语句的松散等价物(尽管没有其他语言支持的隔离特性)。

我喜欢使用嵌套的名称空间,可能是因为我倾向于用 C#编写我的服务器端代码,这鼓励了同样的方法。为了简化名称空间的创建,我依赖于这样一个事实,即全局变量实际上被定义为window browser 对象上的属性。这使得通过名字创建变量变得容易,而不需要依赖可怕的eval函数,如清单 9-3 所示。

清单 9-3。使用函数创建嵌套命名空间

`createNamespace("com.cheeselux.utils");

function createNamespace(namespace) {
**    var names = namespace.split('.');**
**    var obj = window;**
**    for (var i = 0; i < names.length; i++) {**
**        if (!obj[names[i]]) {**
**            obj = obj[names[i]] = {};**
**        } else {**
**            obj = obj[names[i]];**
**        }**
**    }**
};

com.cheeselux.utils.mapProducts = function(func, data, indexer) {
    \(.each(data, function(outerIndex, outerItem) {         \).each(outerItem[indexer], function(itemIndex, innerItem) {
            func(innerItem, outerItem);
        });
    });
}

com.cheeselux.utils.composeString = function(bindingConfig) {
    var result = bindingConfig.value;
    if (bindingConfig.prefix) { result = bindingConfig.prefix + result; }
    if (bindingConfig.suffix) { result += bindingConfig.suffix;}     return result;
}`

createNamespace函数将一个名称空间作为一个参数,并将其分成几段。代表每个片段的对象只有在不存在的情况下才会被创建,这意味着我不会与其他任何人对com的使用相冲突,也不会与我在单独的 JavaScript 文件中为我的项目创建的其他com.cheeselux.*名称空间相冲突。

images 提示创建单独的文件完全是可选的。如果愿意,可以在一个文件中定义多个名称空间。单个文件的优点是浏览器只需发出一个请求就可以获得所有代码。如果你确实喜欢使用多个文件,那么当你发布你的 web 应用时,你可以简单地把它们连接成一个文件。

我可以更进一步,使名称空间本身更容易配置,如清单 9-4 所示。这使得在有冲突的情况下重命名我的名称空间变得更加容易,也意味着我可以选择一个更短的名称来节省一些输入。

清单 9-4。使名称空间易于配置

`function createNamespace(namespace) {
    var names = namespace.split('.');
    var obj = window;
    for (var i = 0; i < names.length; i++) {
        if (!obj[names[i]]) {
            obj = obj[names[i]] = {};
        } else {
            obj = obj[names[i]];
        }
    }
**    return obj;**
};

var utilsNS = createNamespace("cheeselux.utils");

utilsNS.mapProducts = function(func, data, indexer) {
    \(.each(data, function(outerIndex, outerItem) {         \).each(outerItem[indexer], function(itemIndex, innerItem) {
            func(innerItem, outerItem);
        });
    });
}

utilsNS.composeString = function(bindingConfig) {
    var result = bindingConfig.value;
    if (bindingConfig.prefix) { result = bindingConfig.prefix + result; }
    if (bindingConfig.suffix) { result += bindingConfig.suffix;}
    return result;
}`

我已经更新了createNamespace函数,以便它返回自己创建的名称空间对象。这允许我创建一个名称空间,并将结果作为一个变量进行赋值,然后我可以使用这个变量向名称空间添加函数。如果我需要更改名称空间的名称,那么我只需要在对createNamespace方法的调用中这样做(当然,在任何依赖于我的函数的代码中)。在这个例子中,我通过去掉前缀com来缩短我的名称空间。发生冲突的可能性仍然很小,但如果真的发生了,适应起来也很简单。

使用自执行功能

前一种技术的一个缺点是,我最终创建了另一个全局变量,utilsNS。这仍然是一个比全局定义所有变量更好的方法,但是它有点弄巧成拙。

我可以通过使用自执行函数来解决这个问题。这种技术依赖于这样一个事实,即函数中定义的 JavaScript 变量只存在于该函数的范围内。自执行方面意味着函数的运行不需要从代码的另一部分显式调用。诀窍是定义一个函数并让它立即执行。当没有任何其他代码时,更容易看到自执行函数的结构:

**(function() {**     ...statements go here... **})();**

要让一个函数自动执行,你可以用括号把它括起来,然后在最后加上另一对括号。这将在一个步骤中定义和调用函数。函数中定义的任何变量在函数执行完毕后都会被整理,不会出现在全局命名空间中。清单 9-5 显示了我如何将它应用到我的效用函数中。

清单 9-5。使用自执行函数定义名称空间

`(function() {
    function createNamespace(namespace) {
        var names = namespace.split('.');
        var obj = window;
        for (var i = 0; i < names.length; i++) {
            if (!obj[names[i]]) {
                obj = obj[names[i]] = {};
            } else {
                obj = obj[names[i]];
            }
        }
        return obj;
    };

var utilsNS = createNamespace("cheeselux.utils");     utilsNS.mapProducts = function(func, data, indexer) {
        \(.each(data, function(outerIndex, outerItem) {             \).each(outerItem[indexer], function(itemIndex, innerItem) {
                func(innerItem, outerItem);
            });
        });
    }

utilsNS.composeString = function(bindingConfig) {
        var result = bindingConfig.value;
        if (bindingConfig.prefix) { result = bindingConfig.prefix + result; }
        if (bindingConfig.suffix) { result += bindingConfig.suffix;}
        return result;
    }
})();`

唯一剩下的全局变量是cheeselux名称空间对象。我的函数在cheeselux.utils名称空间中定义,当自执行函数完成时,我的utilsNS变量由浏览器整理。

使用以这种方式定义的函数仍然只是通过名称空间引用该函数,就像这样:

**cheeselux.utils.mapProducts**(function(item) {     if (item.id == id) { item.quantity(0); } }, cheeseModel.products, "items");

创建私有属性、方法和函数

在 JavaScript 中,每个属性、方法和函数都可以从创建它们或可以访问它们的代码的任何其他部分使用。这使得很难指出哪些成员供其他人使用,哪些是功能的内部实现。

区别很重要;您希望能够更改内部实现来修复错误或添加新功能,而不必担心有人创建了您没有预料到的依赖项。任何使用您的代码的人都需要知道他们可以依赖什么属性和方法,不会在没有适当通知的情况下更改。JavaScript 没有任何控制访问的关键字(比如其他语言中的publicprivate,所以我们需要找到替代方法来解决这个问题。

这个问题最简单的解决方案是采用一种命名约定,明确一些属性和方法不打算供公共使用。最广泛采用的惯例是在私有名称前加上一个下划线字符(_)。

我的composeString函数是一个理想的私有函数。我只在自定义数据绑定中使用这个函数,并且随着绑定的发展,我希望可以自由地改变这个函数的各个方面(包括它的存在)。任何其他程序员都没有理由依赖这个函数,即使他们使用我的绑定。清单 9-6 显示了应用于这个函数的下划线命名风格和依赖它的数据绑定。

清单 9-6。应用命名约定来表示私有函数

`(function() {

function createNamespace(namespace) {
        var names = namespace.split('.');
        var obj = window;
        for (var i = 0; i < names.length; i++) {
            if (!obj[names[i]]) {                 obj = obj[names[i]] = {};
            } else {
                obj = obj[names[i]];
            }
        }
        return obj;
    };

var utilsNS = createNamespace("cheeselux.utils");

utilsNS.mapProducts = function(func, data, indexer) {
        \(.each(data, function(outerIndex, outerItem) {             \).each(outerItem[indexer], function(itemIndex, innerItem) {
                func(innerItem, outerItem);
            });
        });
    }

utilsNS._composeString = function(bindingConfig) {
        var result = bindingConfig.value;
        if (bindingConfig.prefix) { result = bindingConfig.prefix + result; }
        if (bindingConfig.suffix) { result += bindingConfig.suffix;}
        return result;
    }
})();

ko.bindingHandlers.formatAttr = {
    init: function(element, accessor) {
        \((element).attr(accessor().attr, **cheeselux.utils._composeString**(accessor()));     },     update: function(element, accessor) {               \)(element).attr(accessor().attr, cheeselux.utils._composeString(accessor()));
    }
}

ko.bindingHandlers.formatText = {
    update: function(element, accessor) {      
        $(element).text(cheeselux.utils._composeString(accessor()));
    }
}
...`

采用命名约定不会阻止其他人使用私有成员,但它确实表明这样做违背了开发人员的意愿,并且属性、方法或函数可能会在不通知的情况下发生更改。使用广泛采用的命名约定(如下划线)或显而易见的命名约定(如在名称前加上单词private)非常重要。

另一种方法是限制私有函数的范围,使它们不被定义为名称空间的一部分。这阻止了在 web 应用的其他地方访问该函数,但这意味着该函数的所有依赖项必须出现在同一个自执行函数中,这并不总是可行的。清单 9-7 显示了这种方法是如何工作的。

清单 9-7。使用自执行函数保持函数私有

`(function() {

function createNamespace(namespace) {
        var names = namespace.split('.');
        var obj = window;
        for (var i = 0; i < names.length; i++) {
            if (!obj[names[i]]) {
                obj = obj[names[i]] = {};
            } else {
                obj = obj[names[i]];
            }
        }
        return obj;
    };

var utilsNS = createNamespace("cheeselux.utils");

utilsNS.mapProducts = function(func, data, indexer) {
        \(.each(data, function(outerIndex, outerItem) {             \).each(outerItem[indexer], function(itemIndex, innerItem) {
                func(innerItem, outerItem);
            });
        });
    }

function _composeString(bindingConfig) {
        var result = bindingConfig.value;
        if (bindingConfig.prefix) { result = bindingConfig.prefix + result; }
        if (bindingConfig.suffix) { result += bindingConfig.suffix;}
        return result;
    }

ko.bindingHandlers.formatAttr = {
        init: function(element, accessor) {
            \((element).attr(accessor().attr, **_composeString**(accessor()));         },         update: function(element, accessor) {                   \)(element).attr(accessor().attr, _composeString(accessor()));
        }
    }

ko.bindingHandlers.formatText = {
        update: function(element, accessor) {      
            $(element).text(_composeString(accessor()));
        }
    }

})();`

_composeString函数从未被定义为局部或全局名称空间的一部分,并且仅可用于相同的封闭自执行函数中。这种技术是可行的,因为 JavaScript 支持闭包,即使变量和函数是以这种方式定义的,它也会将它们包含在范围内。

管理依赖性

将我的函数打包到名称空间中使得它们更易于管理,并且有助于清理全局名称空间,但是仍然有一个主要问题:对其他库的依赖性。在接下来的几节中,我将向您展示一种管理库中依赖项的技术,这种技术开始流行起来,您可以使用它来使您的代码更容易共享和使用。

理解假设的依赖性问题

utils.js这样的外部 JavaScript 文件中有两种依赖关系。第一种是假设依赖,我只是使用一个库的功能,并假设它是可用的。我在utils.js中做过很多这样的事情,尤其是用 jQuery。假定的依赖关系将责任放在 HTML 文档上,该文档使用 JavaScript 文件加载所需的库,并在我的代码执行之前完成。mapProducts函数是假设依赖的一个很好的例子:

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

这个函数假设 jQuery $.each方法是可用的。如果您想使用这个函数,那么您需要在调用mapProducts之前确保 jQuery 已经加载并准备好。清单 9-8 展示了一个非常简单的 jQuery Mobile web 应用,它使用了mapProducts函数。这个小小的 web 应用并没有什么新内容,但是在接下来的章节中,我将使用它来演示不同的依赖问题和解决方案。

清单 9-8。一个简单的 Web 应用,它使用一个 JavaScript 文件,该文件包含一个假定的依赖关系

`

    CheeseLux               ****     ` `                             

  
        
                     
        
            There are             cheeses in this category         
  
`

images 注意这是一个完全无用的网络应用。每个奶酪类别都会显示一个按钮,单击该按钮会显示该类别中奶酪的数量。如果你愿意,可以忽略这样一个事实:有比使用mapProducts方法更容易的方法来获得这些信息,而且在的每一个类别中都有三种奶酪。这个愚蠢的 web 应用非常适合演示依赖性管理的关键方面。

了解直接解决的依赖关系

这个小小的 web 应用可以工作,因为在我调用mapProducts函数之前,jQuery 早就被加载了。如果我重写 web 应用以使用不同的工具包,情况会有所不同。当大多数程序员第一次明白假定的依赖是一个问题时,他们会做同样的事情:他们假定对情况的控制,并采取直接的行动来修复它。清单 9-9 展示了一个典型的解决方案。

清单 9-9。采取直接行动解决假定的依赖性

`(function() {

function createNamespace(namespace) {
*        ...code removed for brevity...    *
    };

var utilsNS = createNamespace("cheeselux.utils");

**    Modernizr.load({**
**        load: 'jquery-1.7.1.js',**
**        complete: function() {**
            utilsNS.mapProducts = function(func, data, indexer) {
                \(.each(data, function(outerIndex, outerItem) {                     \).each(outerItem[indexer], function(itemIndex, innerItem) {
                        func(innerItem, outerItem);
                    });
                });
            }
**        }**
**    })**
*    ...code removed for brevity...    *
})();`

在这个清单中,我负责解决我对 jQuery 的依赖,在创建我的mapProducts函数之前使用 Modernizr 加载它。(Modernizr.load对象中的 load 属性指定 JavaScript 文件应该总是被加载。)

在这样做的时候,我将一个假定的依赖关系转换成了一个直接解析的依赖关系。一个直接解决的依赖是当我依赖另一个 JavaScript 库时,我采取直接行动使我的代码工作,通常是通过自己加载库。

了解由解决依赖关系引起的问题

直接解决一个依赖关系会导致一系列问题。首先,我在 Modernizr 上创建了一个假定的依赖项,以确保 jQuery 被加载,这并不是一个巨大的进步。但真正的损害是我已经确保了mapProducts函数的工作;然而,这样做,我破坏了 web 应用本身的稳定性。

要查看问题,请加载 web 应用,并多次重新加载页面。有两个问题。如果 web 应用工作正常,那么您遇到的只是最不严重的问题,即 jQuery 库被加载了两次。您可以在浏览器开发人员工具或 Node.js 服务器的控制台输出中看到这一点,该服务器打印出每个请求的 URL。下面是服务器报告的由 web 应用加载的文件列表,带有注释以突出显示 jQuery 的两个加载:


`The "sys" module is now called "util". It should have a similar interface.

Ready on port 80
Ready on port 81
GET request for /example.html
GET request for /jquery.mobile-1.0.1.css
GET request for /styles.mobile.css
GET request for /jquery-1.7.1.js              <-- first load
GET request for /jquery.mobile-1.0.1.js
GET request for /knockout-2.0.0.js
GET request for /modernizr-2.0.6.js
GET request for /utils.js
GET request for /products.json
GET request for /jquery-1.7.1.js              <-- second load
GET request foimg/ajax-loader.png`


您可以判断是否只遇到了第一个问题,因为您会看到三个按钮,单击其中一个按钮会出现一条消息。如果你只是得到一个空窗口,你就知道你遇到了第二个问题。图 9-1 显示了两种结果。

images

图 9-1。由直接解析的依赖关系产生的两个结果

第二个问题是竞争条件,当您从本地机器加载 web 应用的所有资源时,它并不总是表现出来。如果在 Modernizr 加载了 jQuery 库并执行了回调函数之后 Ajax 请求完成,那么您将得到一个空白窗口,并且在 JavaScript 控制台中会有一条如下所示的错误消息:


Uncaught TypeError: Cannot call method 'initializePage' of undefined


具体措辞会因浏览器而异,但问题是对$.mobile.initializePage的调用失败了,因为没有$.mobile对象。为了帮助迫使问题出现,我在 Node.js 服务器上添加了一个特殊的 URL,它在返回 JSON 内容时引入了一个延迟。要触发这个延迟,改变由getJSON方法请求的 JSON 文件的名称,如清单 9-10 所示。

清单 9-10。故意在对 JSON 数据的 Ajax 请求中引入延迟

`...

  

...`

请求products.json.slow而不是products.json会给 Ajax 请求增加一秒钟的延迟,这将迫使 Ajax 请求花费比 Modernizr 加载 jQuery 库所需的时间更长的时间。如果你没有发现问题,你可以编辑server.js文件来增加一个更长的延迟,但是一秒钟总是让我白屏。

images 提示这是这个问题如此严重的部分原因;它通常不会在开发过程中出现,因为 Ajax 请求会很快完成。不幸的是,当通过拥塞的网络向繁忙的服务器发出请求时,部署中确实会出现 ??。如果您发现自己收到无法复制的空白屏幕的用户报告,看看您的库是否是自我解决的依赖项总是一个好主意。

下面是 Ajax 请求在 Modernizr 加载 jQuery 之前完成时的事件序列:

  1. jQuery 由浏览器从example.html中的script元素加载,并设置$速记引用。
  2. jQuery Mobile 被加载,并将mobile属性添加到 jQuery $速记中。
  3. Ajax 请求完成,调用$.mobile.initializePage方法。
  4. Modernizr 再次加载 jQuery 库,用一个没有 jQuery Mobile mobile属性的对象替换$简写。

这是最好的情况,jQuery 被加载并执行两次,但至少 web 应用可以工作。在 Modernizr 加载 jQuery 之后,当 Ajax 请求完成时,序列发生变化:

  1. jQuery 由浏览器从example.html中的script元素加载,并设置$速记引用。
  2. jQuery Mobile 被加载,并将mobile属性添加到 jQuery $速记中。
  3. Modernizr 再次加载 jQuery 库,用一个没有 jQuery Mobile mobile属性的对象替换$简写。
  4. Ajax 请求完成,调用$.mobile.initializePage方法。

您可以看到问题所在:对$.mobile.initialPage的调用是在 jQuery 的第二个实例被加载并且$速记被重新定义之后进行的,这将删除 mobile 属性。结果是第二次加载 jQuery 已经卸载了 jQuery Mobile,所以 web 应用死得很惨。即使在最好的情况下,web 应用工作的唯一原因是因为它太简单了;一旦 Modernizr 导致mobile对象被删除,任何对 jQuery 移动函数的调用都会导致问题。

images 提示这种情况下还有第二个竞态条件。在 Modernizr 加载 jQuery 库之前,不会定义mapProducts函数,这意味着处理请求的延迟(因为服务器或网络繁忙)会导致内联script元素中的代码在它存在之前调用mapProducts。我不打算演示这个问题,但是您会明白:直接解析的依赖关系是极其危险的。

把一个坏问题变成一个微妙的坏问题

在转向真正的依赖解决方案之前,我想向您展示一个修复双重加载问题的常见尝试:测试库是否被加载,如下所示:

... Modernizr.load({ **    test: $.each,** **    nope: 'jquery-1.7.1.js',**     complete: function() {         utilsNS.mapProducts = function(func, data, indexer) {             $.each(data, function(outerIndex, outerItem) {                 $.each(outerItem[indexer], function(itemIndex, innerItem) {                     func(innerItem, outerItem);                 });             });         }     } }) ...

我已经使用 Modernizr 测试了 jQuery 已经加载的一些指标,如果还没有加载,就使用nope属性加载 JavaScript 文件。将这种技术应用于我的小型示例 web 应用将使一切工作正常。但这不是一个真正的解决方案,虽然我创造的新问题出现的频率降低了,但要找到它却困难得多。

潜在的问题是,我仍然只是试图让我的代码工作。如果utils.js是唯一使用这种技术的文件,那么一切都很好,除了如果 jQuery 库确实需要加载并且请求有延迟,那么mapProducts函数可能不能及时定义。但是,如果在多个文件中使用这种技术,那么就会出现非常微妙的竞争情况。假设有两个文件使用 Modernizr 来测试 jQuery: fileA.jsfileB.js。大多数情况下,事件的顺序是这样的:

  1. 浏览器执行fileA.js中的代码,测试 jQuery。jQuery 还没有加载,所以 Modernizr 请求文件,然后执行complete函数。
  2. 浏览器执行fileB.js中的代码,测试 jQuery。jQuery 已经通过fileA.js加载,Modernizr 执行complete函数,不需要加载任何文件。

然而,Modernizr 请求是异步的,这意味着当 Modernizr 等待服务器的响应时,浏览器将继续执行 JavaScript 代码。所以,如果时机恰到好处,顺序真的会如下:

  1. 浏览器执行fileA.js中的代码,测试 jQuery。jQuery 尚未加载,所以 Modernizr 请求该文件。
  2. 当 Modernizr 等待并开始处理fileB.js时,浏览器继续执行代码。来自fileA.js的 Modernizr 请求还没有完成,所以fileB.js让 Modernizr 再次请求 jQuery 文件。
  3. fileA.js请求完成,jQuery 被加载,并且fileA.js完成函数被执行。
  4. fileB.js请求完成,第二次加载 jQuery,执行fileB.js完成函数。

当 Modernizr 再次加载 jQuery 时,fileA.js中的完整函数添加到 jQuery $简写中的任何属性都将丢失。这种情况很少发生,但是一旦发生,它会通过删除至少一个 JavaScript 文件中必需的基本功能来终止 web 应用。你可能认为不经常出现的问题是可以接受的,但是当你的 web 应用拥有数百万用户时,不经常出现仍然是一个严重的问题。

使用异步模块定义

消除竞争条件和重复库加载的唯一真正的方法是以协调的方式处理依赖关系,这意味着负责从单个 JavaScript 文件中加载依赖关系并合并它们。做这件事的最好模型是异步模块定义 (AMD),我将在接下来的部分中解释和演示。

定义 AMD 模块

定义一个模块非常简单,并且依赖于define函数的使用。清单 9-11 展示了我如何在一个名为utils-amd.js的新文件中创建一个模块。你不必在文件名中包含amd;这只是我的偏好,因为我喜欢让我的代码的消费者尽可能清楚地知道他们正在与 AMD 打交道。提供define功能是 AMD 加载程序的责任。作为 AMD 模块的作者,你可以依赖现有的define函数,而不必担心使用的是哪个加载器或者函数是如何实现的。

清单 9-11。utils-amd.js 文件

define(['jquery-1.7.1.js'], function() {     return {         mapProducts: function(func, data, indexer) {             $.each(data, function(outerIndex, outerItem) {                 $.each(outerItem[indexer], function(itemIndex, innerItem) {                     func(innerItem, outerItem);                 });             });         },         composeString: function(bindingConfig) {             var result = bindingConfig.value;             if (bindingConfig.prefix) { result = bindingConfig.prefix + result; }             if (bindingConfig.suffix) { result += bindingConfig.suffix;}             return result;         }     }; });

define函数创建一个 AMD 模块。第一个参数是模块中的代码所依赖的库的数组。第二个参数是一个函数,称为工厂函数,它包含模块代码。一个文件中只能定义一个 AMD 模块,由于我喜欢将功能集中在一个模块中,所以我的utils-amd.js文件只包含了mapProductscomposeString函数。(过一会儿我会回到来自utils.js的一些其他代码。)

AMD 模块可以依赖于在执行工厂函数之前加载的所有声明的依赖项。在这种情况下,我已经声明了对jquery-1.7.1.js的依赖,并且我可以假设当我设置我的mapProductscomposeString函数时,这个 JavaScript 文件将被加载并且 jQuery 将可供使用。工厂函数的结果是一个对象,该对象的属性是我想要导出的函数,以便在 web 应用的其他地方使用。当工厂函数执行后,我定义的不属于结果对象的任何变量或函数都将被整理,而不会污染全局名称空间。

images 提示注意,在我的模块中没有名称空间。AMD 的一个很好的特性是,由我的模块的消费者来决定如何引用我定义的功能,我将在下一节中演示。

使用 AMD 模块

AMD 通过让单个资源加载器负责加载库来解决依赖性问题。这个加载器负责执行一个模块的工厂函数,并确保它所依赖的库在这发生之前被加载并准备好。模块和加载器之间的主要通信方式是通过define函数,该函数由加载器负责实现。

通过标准化加载过程,关于使用哪个加载器的决定留给了 AMD 模块的消费者,而不是作者。所以,当我写一个 AMD 模块时,我不必担心解决依赖关系,我甚至不必担心它们将如何被处理。

尽管 AMD 格式越来越受欢迎,但并不是所有的资源加载器都支持 AMD。这包括Modernizr.load,我在本书中一直使用它来加载库(并在本章中演示为什么这是一个坏主意)。我最喜欢的 AMD 感知加载器是 requireJS,你可以从[requirejs.org](http://requirejs.org)下载。在清单 9-12 中,你可以看到我是如何将 requireJS 应用到我的微型 web 应用中的。

清单 9-12。使用 requireJS 加载 AMD 模块

`

    CheeseLux               ****          

  
        
                     
        
            There are             cheeses in this category         
  
`
声明依赖关系

首先要做的是删除文档的head部分中的所有script元素,并用导入 requireJS 的单个元素替换它们。这确保了 requireJS 拥有 web 应用中所有依赖项的完整视图,并且如果依赖库中需要脚本文件,您不会两次加载脚本文件。

`...

** **    **               **    

AMD Tests

** **    

**` `**    
** **    

** **    
    ** **    
    test markup, will be hidden
    ** `

    要使用 QUnit,请确保您复制到content目录中的脚本和 CSS 文件被导入到文档中。

    对于我想要测试的每个模块,我使用 QUnit module函数来表示一系列测试的开始,并使用 requireJS 来加载模块代码。(QUnit module函数与 AMD 模块无关;它只是在输出显示中将一组相关的测试组合在一起。)

    添加到模板中的标记允许 QUnit 显示结果。您可以更改标记以不同的方式格式化您的结果,关于每个元素含义的信息可以在[docs.jquery.com/QUnit](http://docs.jquery.com/QUnit)找到,还有完整的 API 文档。

    我已经将 jQuery 添加到我的script导入列表中,但是 QUnit 不需要 jQuery 运行。我发现 jQuery 对于创建更复杂的测试很有用,我将很快演示这一点。

    images 提示如果使用 requireJS 来加载 QUnit,要小心。QUnit 库在响应window browser 对象上的load事件时会初始化自己,而这个事件通常是在 requireJS 加载 jQuery 库并执行回调函数之前触发的。如果你一定要使用 requireJS,那么你可以在 requireJS 回调函数中调用QUnit.load()

    为模块添加测试

    有了基本的结构,我就可以开始为我的模块添加测试了。为了保持简单,我将对composeString函数进行一些参数测试,确保null参数不会导致奇怪的结果。清单 9-17 显示了向tests.html文件添加测试。

    清单 9-17。向 tests.html 文件添加测试

    `

                                     

    AMD Tests

        

        
        

        
          
      test markup, will be hidden
           `

      每个测试都是用test函数定义的,带有测试名称的参数和包含测试代码的函数。在我添加的四个测试的每一个中,我创建了一个具有prefixsuffixvalue属性的对象,这些属性通过我的自定义数据绑定传递给我的函数,并将其传递给composeString函数,我通过 requireJS 回调函数的utils参数来访问该函数,如下所示:

      equal(**utils.composeString(config)**, "prefixvalue");

      像大多数单元测试包一样,QUnit 提供了一系列测试操作结果的断言。在本例中,我使用了equal函数来检查调用composeString函数的结果是否符合我的预期。一系列不同的断言是可用的,你可以在[docs.jquery.com/QUnit](http://docs.jquery.com/QUnit)看到完整的列表。

      要运行单元测试,只需将tests.html加载到浏览器中。QUnit 将依次执行每个测试,并使用标记作为结果的容器。我的composeString函数通过了其中一项测试,但没有通过另外两项。结果显示在浏览器中,如图图 9-2 所示。

      images

      图 9-2。对 composeString 函数执行单元测试

      composeString函数中有一个 bug,它没有检查作为参数传递的对象的value属性是否存在或者是否已经被赋值。为了解决这个问题,我对清单 9-18 中的进行了修改,并再次运行测试。

      清单 9-18。修复 composeString 函数

      ... composeString: function(bindingConfig) { **    var result = bindingConfig.value || "";**     if (bindingConfig.prefix) { result = bindingConfig.prefix + result; }     if (bindingConfig.suffix) { result += bindingConfig.suffix;}     return result; } ...

      我可以再次运行单独的测试,或者通过重新加载文档,运行所有的测试。我的简单修复解决了两个中断测试的问题,重新加载tests.html让我解除了警报。

      使用 jQuery 对 HTML 进行测试

      我不打算为我的模块编写一套完整的测试,因为 QUnit 的行为就像任何其他单元测试包一样,只是它在浏览器中对 JavaScript 进行操作,特别是对于像composeString这样的自包含函数,其中输入和结果都是用 JavaScript 表示的。

      然而,当被测试的代码的效果或结果用 HTML 表示时,需要一种稍微不同的方法。这就是我在我的 QUnit 测试模板中包含 jQuery 的原因,为了演示这种技术,我将为custombindings-amd模块中的formatAttr绑定编写一些测试,如清单 9-19 所示。

      清单 9-19。来自 custombindings-amd 模块的 formatAttr 绑定

      ko.bindingHandlers.formatAttr = {     init: function(element, accessor) {         $(element).attr(accessor().attr, utils.composeString(accessor()));     },     update: function(element, accessor) {               $(element).attr(accessor().attr, utils.composeString(accessor()));     } }

      jQuery 使得创建、使用和测试 HTML 片段变得容易,而不需要将它们添加到文档中。清单 9-20 显示了针对formatAttr绑定对tests.html的添加。

      清单 9-20。使用 HTML 片段的单元测试

      `

                              

          

      AMD Tests

          

          
          

          
            
        test markup, will be hidden
             `

        我添加了一个新的测试,使用 jQuery 创建一个a元素并应用一个data-bind属性。如果将一个 HTML 片段传递给 jQuery $速记函数,结果是一个没有附加到文档的 DOM API 元素。另外,在使用 jQuery attr方法时,我不必确保data-bind属性中的单引号和双引号被正确转义:

        var testElem = $("<a></a>").attr("data-bind",     "formatAttr: {attr: 'href', prefix: '#', value: cat}")**[0]**;

        注意,我使用了一个数组风格的索引器来获取 jQuery $速记函数返回的对象中的第一个元素。ko.applyBindings方法作用于 DOM API 对象,而不是 jQuery 对象,所以我需要解开我从 jQuery 对象创建的a元素。此时,我可以让 Knockout.js 使用我的测试视图模型将绑定应用到我的 HTML 片段:

        ko.applyBindings(**viewModel, testElem**);

        为了测试结果,我使用 QUnit equal函数以及 DOM API 和 jQuery 来检查结果:

        equal(testElem.attributes.length, 2); equal($(testElem).attr("href"), "#British");

        jQuery 使得创建和准备用于测试和检查结果的 HTML 变得容易,正如本例所示,在测试完成后,您可以使用 DOM API 来获取关于元素的信息。如您所见,jQuery 和 QUnit 一起使得测试 web 应用的各个方面成为可能,并且在很大程度上很容易做到。

        总结

        在这一章中,我向您展示了我用来编写更好的 JavaScript 的工具和技术,不是更好地更完整地使用语言特性,而是更好地让其他人更容易使用,让我更容易维护,并且,通过单元测试的应用,用户会遇到更少的问题。这些技术与前面章节中的技术相结合,为您构建易于使用和维护的可伸缩、动态、灵活的 web 应用奠定了坚实的基础。祝你所有的项目好运,记住,正如我在第一章中所说的,任何值得在服务器端做的事情都值得在客户端考虑。

        posted @ 2024-08-19 17:12  绝不原创的飞龙  阅读(2)  评论(0编辑  收藏  举报