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

56 阅读25分钟

JavaScript Web 应用高级教程(四)

原文:Pro JavaScript for Web Apps

协议:CC BY-NC-SA 4.0

八、创建移动 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                                                  **    **               

        var cheeseModel = {};         ...`

一旦我用一个script元素将这个库添加到我的文档中,我就可以通过读取$.browser.mobile属性来查看我的 web 应用是否在移动浏览器上运行,如果用户代理被识别为属于一个移动浏览器,这个属性将返回true。在这种情况下,我将移动用户重定向到mobile.html文档,我将在本章稍后使用它来构建我的移动 web 应用。

使用用户代理的主要问题是它并不总是准确的,正如我在前一章提到的,移动设备和桌面设备之间的区别变得模糊了。本质上,你依赖于其他人关于什么定义了移动的决定,而这并不总是符合你想要的细分用户群的方式。此外,虽然浏览器列表通常是准确的,但要正确识别和分类新型号可能需要一段时间,尤其是来自利基硬件提供商的新型号。

一个相关的问题是,许多浏览器允许用户改变用户代理,以便识别另一个浏览器。没有多少用户做出这种改变,但是这确实意味着你不能完全依赖通过navigator.userAgent属性报告的用户代理。

检测设备能力

我更喜欢通过检测设备的能力来将其归类为移动设备,就像我在《??》第七章中所做的那样。这让我可以决定在我的网络应用工作的环境中,什么定义了移动。对于 CheeseLux web 应用,我已经决定将我的 web 应用的移动版本提供给支持触摸且屏幕小于 500 像素的设备。你可以在清单 8-2 中看到我是如何实现这个策略的,它显示了从utils.js文件到detectDeviceFeatures函数的变化。

清单 8-2。根据移动设备的能力检测移动设备

`function detectDeviceFeatures(callback) {     var deviceConfig = {};

*    ...code removed for brevity...*

    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() { **            deviceConfig.mobile = Modernizr.touch && deviceConfig.smallScreen();**             callback(deviceConfig);         }     }]); };`

我已经向视图模型添加了一个mobile属性;如果设备符合我获取 web 应用移动版本的标准,它将返回true。清单 8-3 展示了我如何在example.html使用这个新的属性。

清单 8-3。在主 Web 应用文档中使用移动设备检测

`var cheeseModel = {};

detectDeviceFeatures(function(deviceConfig) {     cheeseModel.device = deviceConfig; **    if (cheeseModel.device.mobile) {** **        location.href = "mobile.html";** **    }**

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

    }).success(function() {         $(document).ready(function() { ...`

我在加载 JSON 数据之前添加了功能检查,这样我就可以在开始发出网络请求和处理 DOM 中的元素之前将用户引导到mobile.html

Image 提示在这个例子和上一个例子中,我将移动检测代码放在 jQuery ready事件之外,这样浏览器就会在代码到达文档时立即执行代码。更彻底的方法是将检测代码放在文档的顶部,以便在加载任何 JavaScript 库之前执行。然而,由于我依赖其中一些库来实际执行检测,所以需要对脚本元素进行仔细排序。

创建简单的移动网络应用

我向你展示的两种方法都假设用户会想要查看我的 web 应用的移动版本——但情况并不总是这样。我更喜欢识别一个移动设备,然后问用户他们想做什么。这种方法将控制权交给了用户(这是应该的),但这确实意味着我必须提供一种机制,让他们选择并记住他们所做的选择。因此,我使用了一个名为askmobile.html的临时文档,而不是简单地将移动设备指向 web 应用的移动版本。我把这个文件放在 Node.js content目录下,你可以在清单 8-4 中看到文件内容。这是一个非常简单的 web 应用,使用 jQuery 和 jQuery Mobile。

清单 8-4。询问用户是否想使用 Web 应用的移动版本

`

    CheeseLux                   ` `              ('button').click(function(e) {                 var useMobile = e.target.id == "yes";                 var useMobileValue = useMobile ? "mobile" : "desktop";                 if (localStorage) {                     localStorage["cheeseLuxMode"] = useMobileValue;                 } else {                     setCookie("cheeseLuxMode", useMobileValue, 30);                 }                 location.href = useMobile ? "mobile.html" : "example.html";             });         });                     

    
                                              Would you like to use our mobile web app?                  
            Yes             No                 
    
`

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元素的内容就是该页面的内容:

`...

    

*        ...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 代码:

`

    (document).bind("pageinit", function() { **        ('button').click(function(e) {** **            var useMobile = e.target.id == "yes";** **            var useMobileValue = useMobile ? "mobile" : "desktop";** **            if (localStorage) {** **                localStorage["cheeseLuxMode"] = useMobileValue;** **            } else {** **                setCookie("cheeseLuxMode", useMobileValue, 30);** **            }** **            location.href = useMobile ? "mobile.html" : "example.html";** **        });**     });                 `

我使用 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                                                                 

            $.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();                 });                         });

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

    
        
                         Gourmet European Cheese         

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

        

            
                
                    
                        

                    
                                         
                        
                                                     
                        
                                                     
                    
                                         
                        

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

                    
                
            

            <div class="middle" data-role="controlgroup" data-type="horizontal"                     data-bind="visible: device.smallAndLandscape()">                                                    <button id="right" data-icon="arrow-r"                         data-iconpos="right">              

            <div class="middle" data-role="controlgroup" data-type="horizontal"                     data-bind="visible: !device.smallAndLandscape()">                                                        

`

在很大程度上,这是一个简单的 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元素,如下所示:

`... **    (document).bind("mobileinit", function() {** **        .mobile.autoInitializePage = false;** **    });**

...`

通过将$.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 如何将元素转换成小部件。您可以看到我是如何为导航按钮做这些的:

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

`

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

Image

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

复制元素和使用模板

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

`<div class="middle" data-role="controlgroup" data-type="horizontal"         data-bind="visible: device.smallAndLandscape()">                <button id="right" data-icon="arrow-r"             data-iconpos="right"> 

    
`

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

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

使用两遍数据绑定

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

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

`

    CheeseLux     ` `                                                           

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

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

            (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);             });         });         </script> **    <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()' }">** **            ** **             ** **            ** **            ** **            ** **             ** **            ** **        ** **    **

    
        
                         Gourmet European Cheese         

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

        

            
                
                    
                        

                    
                                         
                        
                                                     
                        
                                                     
                    
                                         
                        

                             Total:                              <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               ` `                                                 

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

                (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).hasClass("left")                             ? "left" : "right"); **                        .mobile.changePage(.mobile.changePage(('div[data-category="'** **                            + cheeseModel.selectedCategory() + '"]'));**                     })

                    $.mobile.initializePage();

                    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));                                     });                         });         });         </script>     <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()'}">                                                                               <button class="right" data-icon="arrow-r"                 data-iconpos="right">                                    

     **        
**             
                                 Gourmet European Cheese             
                              

**            <fieldset class="middle" data-role="controlgroup" data-type="horizontal"** **                      data-bind="foreach: root.products,                        visible:root.products,** **                        visible: root.device.largeScreen() ||** **                            root.device.smallAndPortrait()">** **                <a data-role="button" data-bind="formatAttr: {attr: 'href',** **                    prefix: '#', value: shortName},** **                    css: {'ui-btn-active': (category == root.selectedCategory())}">** **                    ** **                ** **            **

            

                
                    
                        
                            

                        
                                                 
                            
                                                                                              
                            
                                                             
                        
                                                 
                            

                                 Total:                                  <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");    (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。管理页面过渡动画方向

`

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

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

            (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).hasClass("left") ? "left" : "right"); **                    .mobile.changePage(.mobile.changePage(('div[data-category="'** **                        + cheeseModel.selectedCategory() + '"]'),** **                        {reverse: $(e.target).hasClass("left")});**                 })`

`**                ('a[data-role=button]').click(function(e) {** **                    e.preventDefault();** **                    var cIndex = getIndexOfCategory(cheeseModel.selectedCategory());** **                    var newIndex = getIndexOfCategory(this.hash.slice(1));** **                    .mobile.changePage(this.hash, {reverse: cIndex > newIndex});** **                });**

                $.mobile.initializePage();

                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 ||** **                            cheeseModel.products[0].shortName)) {** **                                crossroads.parse("category/" + item.category);** **                        }** **                    });** **                });**

                crossroads.parse(location.hash.slice(1));                                 });                     });     });     `

我只需要给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               ****     ` `                             

            (document).ready(function() {                 ko.applyBindings(cheeseModel);                 .mobile.initializePage();

                $('a[data-role=button]').click(function(e) {                     var count = 0; **                    cheeselux.utils.mapProducts(function(inner, outer) {** **                        if (outer.category == e.currentTarget.id) {** **                            count++;** **                        }** **                    }, cheeseModel.products, "items")**                     cheeseModel.selectedCount(count);                 });             });         });       

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

            var cheeseModel = {                 selectedCount: ko.observable(0)             };

            (document).bind("mobileinit", function() {                 .mobile.autoInitializePage = false;             });

            .getJSON("products.json", function(data) {                                         cheeseModel.products = data;                 device.detectDeviceFeatures(function(deviceConfig) {                     cheeseModel.device = deviceConfig;                     (document).ready(function() {                                           ko.applyBindings(cheeseModel);                         requirejs(['jquery.mobile-1.0.1.js'], function() {

                            $.mobile.initializePage();

                            $('a[data-role=button]').click(function(e) {                                 var count = 0; **                                utils.mapProducts**(function(inner, outer) {                                     if (outer.category == e.currentTarget.id) {                                         count++;                                     }                                 }, cheeseModel.products, "items")                                 cheeseModel.selectedCount(count);                             });                         });                     });                 });             });         });       

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

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

`...

AMD 加载程序最重要的特性是require功能,它是define的对应功能。require函数有两个参数:web 应用所依赖的模块和脚本文件的数组,以及当它们都被加载时要执行的回调函数。我发现将依赖数组定义为变量会使我的代码可读性更好,但这纯粹是个人喜好。

images 注意AMD 模块负责解决如何解决依赖关系的问题,但是它仍然要求 JavaScript 文件可以从 web 服务器获得。当与他人分享你的代码时,你仍然需要让他们知道你依赖于哪些库,并且清楚地表明你正在使用 AMD,所以他们需要一个 AMD 加载器。

注意,依赖数组中的一些项有一个.js后缀,而其他的没有。并非所有的依赖项或 web 应用都将被编写为 AMD 模块。如果您将 requireJS 作为一个 JavaScript 文件的名称(即带有一个.js后缀),那么它将加载该文件并执行其中的代码,就像任何常规的资源加载器一样。

如果您省略了.js后缀,那么 requireJS 会认为您已经指定了一个 AMD 模块,并相应地采取行动。当它从服务器请求文件时,它将添加.js后缀,当它收到响应时,它将寻找define函数,以便发现依赖项和工厂函数。

images 提示通过强制每个文件只包含一个模块,AMD 增加了获取 web 应用脚本所需的 HTTP 请求的数量。在这个例子中,我已经从一个文件(utils.js)变成了三个文件(utils-amd.jsdevice-amd.jscustombindings-amd.js)。如果我恰当地打包了utils.js包含的所有功能,我会得到更多。为了解决这个问题,requireJS 支持服务器端优化器,将多个 AMD 模块文件连接成一个响应。详见[requirejs.org/docs/optimization.html](http://requirejs.org/docs/optimization.html)

处理回调参数

对于传递给require的列表中的每个 AMD 模块,都有一个对应的参数传递给回调函数。每个参数都设置为模块中工厂函数返回的对象。这是名称空间的一个很好的替代方案;模块的消费者可以决定如何引用模块函数,而不是创建者。

我的列表中的第一个模块是utils-amd,这对应于我的回调函数中的util参数。当我想使用模块定义的mapProducts函数时,我这样调用:

**utils.mapProducts**(function(inner, outer) {     if (outer.category == e.currentTarget.id) {         count++;     } }, cheeseModel.products, "items") cheeseModel.selectedCount(count);

如果我以后开始使用一个使用utils作为全局变量的常规 JavaScript 库,我可以通过重命名回调函数的参数来轻松地改变我在utils-amd模块中引用代码的方式。而且,由于函数的作用域在回调参数的上下文中,AMD 模块根本不会污染全局名称空间。

那么,为什么列表中有三个 AMD 模块却只有两个回调参数呢?答案是,如果模块不需要导出函数,它们就不需要返回对象,这是我对custombindings-amd模块采取的方法,你可以在清单 9-13 中看到。

清单 9-13。不导出函数的 AMD 模块

`define(['utils-amd', 'jquery-1.7.1.js', 'knockout-2.0.0.js'], function(utils) {

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

    ko.bindingHandlers.fadeVisible = {

        init: function(element, accessor) {             $(element)accessor() ? "show" : "hide";         },

        update: function(element, accessor) {             if (accessor() && (element).is(":hidden")) {                 var siblings = (element).siblings(element.tagName + ":visible");                 if (siblings.length) {                     siblings.fadeOut("fast", function() {                         (element).fadeIn("fast");                     })                 } else {                     (element).fadeIn("fast");                 }             }         }     } });`

在这个模块中,我简单地将我的自定义数据绑定添加到ko.bindingHandlers对象中,并且没有新的函数可以直接从模块中导出以在其他地方使用。

images 提示注意,custombindings-amd模块依赖于utils-amd模块。AMD loader 负责确保所有的依赖关系都被解析,这使得重用模块变得非常简单。

当加载一个不返回对象的模块时,require回调函数接收一个参数,但是这个参数的值是null。因此,我可以很容易地编写这样的回调函数:

require(libs, function(utils, device, **bindings**) {     ... }

但是没有什么意义,因为 bindings 对象将是null。参数的顺序总是反映出require列表中模块的顺序,所以我总是把不返回对象的模块放在列表的最后,这样我就可以省略与它们对应的null参数。

声明内联依赖项

在一个script块的开头声明所有的依赖关系并不总是可能的。例如,为了防止 jQuery Mobile 自动处理文档,我需要在加载 jQuery Mobile 库之前加载 jQuery 并设置一个事件处理程序。您可以简单地调用requirejs函数在require语句中声明依赖关系,如下所示:

`... requirejs(['jquery.mobile-1.0.1.js'], function() {

    $.mobile.initializePage();

    $('a[data-role=button]').click(function(e) {         var count = 0;         utils.mapProducts(function(inner, outer) {                             if (outer.category == e.currentTarget.id) {                 count++;             }         }, cheeseModel.products, "items")         cheeseModel.selectedCount(count);     }); }); ...`

这样,我就能够声明我的依赖项,而不必一次加载所有的代码文件。这给了我在 jQuery 和 jQuery Mobile 加载之间的空间,我可以在其中设置我的事件处理程序。

这也是我在device-amd模块中用来代替Modernizr.load方法的技术。清单 9-14 显示了来自第七章的代码,其中我基于浏览器特性的存在加载了一个多填充。

清单 9-14。使用 Modernizr 装载聚合填料

`... 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);     } }, {     complete: function() {                           callback(deviceConfig);     } }]); ...`

Modernizr 语法非常优秀;我喜欢能够如此优雅地结合测试、加载依赖项和回调函数。requireJS 等价物如清单 9-15 中的所示,其中显示了device-amd.js文件。

清单 9-15。使用 requires js加载聚合填充

`define(['modernizr-2.0.6.js', 'knockout-2.0.0.js'], function() {

    return {

        detectDeviceFeatures: function(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);                 }             }

            function setupMediaQuery() {                 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);             }

            if (window.matchMedia) {                 setupMediaQuery();             } else { **                requirejs(['matchMedia.js'], function() {** **                   setupMediaQuery();** **                });**             }         }     };     });`

这是一个不太好的方法,但是它不会遇到我在本章前面描述的问题。如果你正在处理一个大项目或者和其他人共享代码,那么一个单一的、协调的方法对于依赖关系是必不可少的,即使代码风格不是很流畅。

单元测试客户端代码

我想在本书中讨论的最后一个主题是单元测试。用于单元测试 web 应用的工具不像用于桌面或服务器端代码的工具那样复杂,但它们仍然非常好,并且您会发现将客户端单元测试作为开发周期的一部分是很容易的——如果您是单元测试的信徒的话。

正如我在本章开始时所说的,我不会告诉你测试的重要性,也不会告诉你什么时候应该开始测试你的代码。从我自己的经验来看,我抵制单元测试很长一段时间,部分原因是有很多狂热者坚持测试要在特定的时间以特定的方式进行。这些天来,我逐渐明白了单元测试的价值,但是何时以及如何最好地应用单元测试因项目和程序员而异。我非常相信编写更高质量的代码,但是我非常不喜欢以同样的方式对待每种情况的僵化方法。

考虑到这一点,我将简要地向您介绍我喜欢使用的客户端测试工具,然后让您来弄清楚如何应用它。像本书中的所有技巧一样,你应该选择对你有用的,让一切适应你自己的需要,忽略任何不能解决你所面临的任何问题的东西。

使用 QUnit

我使用 QUnit,这是 jQuery 团队为他们的单元测试开发的工具。它简单有效,效果很好。可以从[github.com/jquery/qunit](http://github.com/jquery/qunit)获得 QUnit。要安装 QUnit,需要下载 QUnit 包,将qunit.jsqunit.css文件从存档的qunit文件夹复制到 Node.js content文件夹。

QUnit 测试是从一个 HTML 文档中运行的,这个文档中需要一个基本的元素结构,这样 QUnit 就可以显示测试结果。清单 9-16 显示了我在测试 AMD 模块时使用的模板,它是我在content目录下创建的文件tests.html

清单 9-16。用于 AMD 测试的 QUnit 模板文档

`

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

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 片段的单元测试

      `

                              

      **            require(["custombindings-amd", "knockout-2.0.0.js"], function() {** **                module("Custombindings-AMD Module");** **                test("Correct attribute applied", function() {                     var viewModel = {** **                        cat: "British"** **                    };** **                    var testElem = $("").attr("data-bind",** **                        "formatAttr: {attr: 'href', prefix: '#', value: cat}")[0];  ** **                    ko.applyBindings(viewModel, testElem);**

      **                    equal(testElem.attributes.length, 2);** **                    equal($(testElem).attr("href"), "#British");** **                });** **            });**         });         

          

      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 应用奠定了坚实的基础。祝你所有的项目好运,记住,正如我在第一章中所说的,任何值得在服务器端做的事情都值得在客户端考虑。