jQuery-热点-全-

jQuery 热点(全)

原文:zh.annas-archive.org/md5/80D5F95AD538B43FFB0AA93A33E9B04F

译者:飞龙

协议:CC BY-NC-SA 4.0

序言

欢迎来到 jQuery Hotshot。本书旨在尽可能多地介绍组成 jQuery 的不同方法和实用程序。您不需要是 jQuery 热门人物来阅读和理解本书包含的项目,但是当您完成本书时,您应该是 jQuery 热门人物。

除了学习如何使用 jQuery,我们还将研究一系列相关技术,包括使用一些更近期的 HTML5 和相关的 API,比如 localStorage,如何使用和创建 jQuery 插件,以及如何使用其他 jQuery 库,比如 jQuery UI、jQuery Mobile 和 jQuery 模板。

jQuery 已经改变了我们多年来编写 JavaScript 的方式。它并不是第一个在开发者中流行和广泛使用的 JavaScript 库,但是它强大的选择器引擎、跨浏览器兼容性和易于使用的语法迅速将其推上了史上最受欢迎和广泛使用的 JavaScript 框架之一。

除了易于使用和将复杂而强大的技术抽象成简单的 API 外,jQuery 还得到了一个日益壮大的开发者社区的支持,并且可能是唯一由非营利基金会保护的 JavaScript 库,以确保该库的开发保持活跃,并且它始终是开源的,对于每个人都是免费的,只要它可用。

最好的事情之一是任何人都可以参与进来。您可以为其他开发人员编写插件,以完成常见或不太常见的任务。您可以使用 bug 跟踪器提出新问题,或者与源代码一起工作以添加功能,或者修复错误,并通过 Git 形式的拉取请求回馈。简而言之,每个想要参与的人,无论其背景或技能水平如何,都有事情可做。

入门 jQuery

本书中的每个项目都是围绕 jQuery 构建的;它是我们做的一切的基础。要下载 jQuery 的副本,我们可以访问 jQuery 网站 jquery.com/。这里有下载按钮可以获取库的生产和开发版本,以及大量其他资源,包括完整的 API 文档、教程等等,以帮助您熟悉使用该库。

jQuery 的核心概念之一是基于从网页的 文档对象模型 (DOM) 中选择一个或多个元素,然后使用库提供的方法对这些元素进行某种操作。

在本书的项目中,我们将查看从页面中选择元素的一系列不同方式,以及我们可以在元素上调用的各种不同方法,但是现在让我们看一个基本的示例。

假设页面上有一个具有 id 属性为 myElement 的元素。我们可以使用以下代码使用它的 id 选择此元素:

jQuery("#myElement");

如您所见,我们使用简单的 CSS 选择器来选择我们希望处理的页面元素。这些选择器可以是从简单的 id 选择器(如此示例中)到 class 选择器,或者更复杂的属性选择器。

除了使用 jQuery 选择元素之外,使用 $ 别名也很常见。这将使用 $ 而不是 jQuery 编写,如下所示:

$("#myElement");

一旦以这种方式选择了元素,我们会说该元素被 jQuery 包装了,或者说它是一个包含该元素的 jQuery 对象。使用带有选择器的 jQuery (或 $)方法始终会返回一个元素集合。

如果没有匹配选择器的元素,则集合的长度为0。当使用 id 选择器时,我们期望集合包含单个元素。集合中可以返回的元素数量没有限制;这完全取决于所使用的选择器。

现在,我们可以调用操作已选择的元素或元素的 jQuery 方法。大多数 jQuery 方法的一个很棒的特性是,相同的方法可以用于获取值或设置值,这取决于传递给方法的参数。

因此,继续我们的例子,我们已经选择了 id 属性为 myElement 的元素,如果我们想要找出其像素宽度,我们可以使用 jQuery 的 width() 方法:

$("#myElement").width();

这将返回一个数字,指定元素有多少像素宽。然而,如果我们希望设置我们的元素的 width,我们可以将要设置为元素宽度的像素数作为参数传递给相同的方法:

$("#myElement").width(500);

当然,使用 jQuery 并不仅仅是这些简单示例展示的内容,我们将在本书中的项目中探索更多,但这种简洁是该库的核心,也是使其如此受欢迎的因素之一。

这本书涵盖了什么内容

项目 1, 滑动拼图,帮助我们构建一个滑动拼图游戏。我们将使用 jQuery 和 jQuery UI 结合起来制作这个有趣的应用程序,还会看看 localStorage API。

项目 2, 带动画滚动的固定位置侧边栏,帮助我们实现了一个流行的用户界面特性 - 固定位置的侧边栏。我们专注于处理元素的 CSS,动画和事件处理。

项目 3, 交互式谷歌地图,教我们如何使用谷歌庞大的地图 API 来创建一个交互式地图。我们将查看一系列 DOM 操作方法,并了解如何将 jQuery 与其他框架一起使用。

项目 4, jQuery Mobile 单页应用,介绍了优秀的 jQuery Mobile 框架,以构建一个结合了 jQuery 和 Stack Exchange API 的移动应用程序。我们还研究了 jQuery 的官方模板引擎 JsRender。

项目 5, jQuery 文件上传器,再次使用 jQuery UI,这次实现了一个动态前端文件上传器的 Progressbar 小部件。我们还通过将我们的上传器制作成可配置的 jQuery 插件来讲解编写 jQuery 插件。

项目 6, 使用 jQuery 扩展 Chrome 浏览器,向我们展示了如何使用 jQuery、HTML 和 CSS 扩展流行的 Chrome 浏览器。我们再次利用了 JsRender。

项目 7, 构建你自己的 jQuery,介绍了如何使用一系列关键的 web 开发工具(包括 Node.js、Grunt.js、Git 和 QUnit)构建 jQuery 的自定义版本。

项目 8, 使用 jQuery 实现无限滚动,介绍了另一个流行的用户界面特性 - 无限滚动。我们关注 jQuery 的 AJAX 能力,再次使用 JsRender,并查看了方便的 imagesLoaded 插件。

项目 9, 使用 jQuery 构建热图,帮助我们构建一个由 jQuery 驱动的热图。这个项目有几个方面,包括捕获访问页面时的点击的代码,以及管理员控制台,该控制台汇总并显示信息给站点管理员。

项目 10, 使用 Knockout.js 构建可排序、分页的表格,向我们展示了如何使用 jQuery 与 MVVM 框架 Knockout.js 构建动态应用程序,使用户界面与数据保持同步。

本书所需材料

本书涵盖的一些项目可以仅使用浏览器和简单的文本编辑器完成。当然,完整的 IDE 总是会让事情变得更容易,具有代码完成、代码着色和可折叠块等功能。因此,建议使用 IDE 而不是简单的文本编辑器。

其他项目依赖于其他 JavaScript 框架或社区构建的插件。几个项目使用互联网上托管的第三方服务来消耗数据。其中一个项目需要使用几个额外的高度专业化的应用程序。

如果需要额外的软件或脚本,或者需要 API 访问,这些要求将在相关项目中进行讨论,并包括在哪里获取所需代码或应用程序的信息,如何安装它们以及如何充分使用它们以完成项目。

本书适合谁

本书主要面向具有一定 HTML、CSS 和 JavaScript 知识和理解的前端开发人员。希望具有一些 jQuery 经验,但不是必要条件。所有代码,无论是 HTML、CSS 还是 JavaScript(包括 jQuery),都会进行充分讨论,以解释它如何用于完成项目。

约定

在本书中,你会经常看到几个标题出现。

为了清晰地说明如何完成某个程序或任务,我们使用:

任务简报

本节解释了你将建立的内容,并附有完成项目的截图。

为什么这很棒?

该部分解释了为什么该项目很酷、独特、令人兴奋和有趣。它描述了项目将给你带来的优势。

你的火热目标

本节解释了完成项目所需的主要任务。

  • 任务 1

  • 任务 2

  • 任务 3

  • 任务 4 等等

任务清单

本节解释了项目的任何先决条件,例如需要下载的资源或库等等。

任务 1

本节解释了你将执行的任务。

为升空做准备

本节解释了在开始任务之前可能需要做的任何初步工作。

启动推进器

本节列出了完成任务所需的步骤。

目标完成 - 迷你总结

本节解释了在上一节中执行的步骤如何帮助我们完成任务。本节是必需的。

机密情报

本节中的额外信息与任务相关。

您还将找到一些区分不同信息类型的文本样式。以下是一些这些样式的示例,并解释了它们的含义。

文本中的代码词将显示如下:"首先,我们定义一个名为correctPieces的新变量,并将其值设置为0"。

一个代码块设置如下:

<!DOCTYPE html>

<html lang="en">
  <head>
    <meta charset="utf-8" />
    <title></title>
    <link rel="stylesheet" href="css/common.css" />
  </head>
  <body>
    <script src="img/jquery-1.9.0.min.js"></script>
  </body>
</html>

两行独立的代码显示如下:

<div data-role="header">
    <a href="bounty-hunter.html" data-icon="home" 

由于空间限制而导致换行的代码行将显示为如下所示:

        filter: "!)4k2jB7EKv1OvDDyMLKT2zyrACssKmSCXeX5DeyrzmOdRu8sC5L8d7X3ZpseW5o_nLvVAFfUSf"

当我们希望引起您对代码块的特定部分的注意时,相关行或项目将以粗体显示:

pieces.appendTo(imgContainer).draggable("destroy");

if (timer) {
 clearInterval(timer);
 timerDisplay.text("00:00:00");
}

timer = setInterval(updateTime, 1000);
currentTime.seconds = 0;
currentTime.minutes = 0;
currentTime.hours = 0;

pieces.draggable({

任何命令行输入或输出均按以下方式编写:

cd C:\\msysgit\\msysgit\\share\\msysGit

新术语重要单词 以粗体显示。例如屏幕上看到的单词、菜单或对话框中的单词会在文本中显示为:"单击 下一个 按钮将您移到下一个屏幕"。

注意

警告或重要说明以这样的框显示。

提示

贴士和技巧会以此形式出现。

第一章:滑动拼图

在我们的第一个项目中,我们将在一个有趣和轻松的环境中看到各种技术的实际运用。把它看作是本书其余部分的轻松热身。

我们将看到如何使用 jQuery UI 使元素可拖动,以及如何配置可拖动元素的行为。我们还将研究其他主题,包括排序算法,以及使用 localStorage API 进行客户端存储。

任务简报

在这个项目中,我们将制作一个简单但有趣的拼图游戏,在这个游戏中,一张图片被打乱,必须通过移动板上的不同片段将其复原成原始图片 - 这是对昔日经典游戏的现代基于网络的改编。

通常在板上有一个空白空间,片段只能移动到这个空白空间,因此我们需要建立一个跟踪空白空间位置并只允许片段直接相邻的地方被拖动的系统。

为了给玩家一些动力,我们还可以看看如何跟踪玩家解决拼图所需的时间,以便记录玩家的最佳时间。以下是显示这个项目的最终结果的屏幕截图:

任务简报

为什么它如此棒?

游戏很有趣,它可以吸引人们回到您的网站,尤其是年轻的观众。非闪存浏览器游戏以非常大的方式起飞,但是进入顶层行动可能有一个陡峭的学习曲线。

这样一个简单的基于拖动的游戏是让你毫不费力地进入游戏市场的完美方式,而不是立即跳入深水区,让您用一些游戏开发的简单概念来磨练您的技能。

这也是学习如何构建一个精确而引人入胜的可视化接口的绝佳方式,非常适合其预期目标,并且易于直观使用。我们还可以研究一些更高级的可拖动概念,例如避免碰撞和精确定位。我们还将学习如何使用 localStorage API 与会话之间存储和检索数据。

你的热门目标

这个项目将被分解成以下任务,我们将按顺序逐步进行工作以产生一个可工作的最终结果:

  • 布置基础 HTML

  • 创建代码包装器并定义变量

  • 将图像分割成片段

  • 洗牌拼图片段

  • 使拼图片段可拖动

  • 启动和停止计时器

  • 确定拼图是否已解决

  • 记住美好时光,并增加一些最终的样式

任务检查清单

除了 jQuery,我们还将在这个项目中使用 jQuery UI,所以现在是时候获取这些库并将它们放在合适的位置。我们还可以花一点时间来设置我们的项目文件夹,这是我们可以存储在整本书中创建的所有文件的地方。

在某个地方创建一个名为 jquery-hotshots 的新文件夹。在此文件夹中创建三个新文件夹,分别命名为 jscssimg。我们创建的所有 HTML 页面都将放在根目录 jquery-hotshots 文件夹中,而我们使用的其他文件将根据其类型分布在子文件夹中。

对于本书中涵盖的项目,我们将使用最新版本的 jQuery 的本地副本,撰写本文时是全新的 1.9.0。从 code.jquery.com/jquery-1.9.0.min.js 下载压缩版本的副本并将其保存在 js 文件夹中。

提示

使用 Google 的内容传送网络CDN)加载 jQuery,并链接到文件而不指定协议被认为是最佳实践。使用 CDN 意味着文件更可能在访问者的浏览器缓存中,使库加载速度更快。

还建议在某种原因导致 CDN 不可访问时提供一个备用方案。如果未找到 CDN 版本,我们可以非常容易地使用优秀的 yepnope 来加载脚本的本地版本。有关此及其他资源加载技巧和技巧的更多信息,请参阅 yepnope 网站 yepnopejs.com/

要下载我们需要的 jQuery UI 组件,请访问下载构建器 jqueryui.com/。我们将在后续项目中使用各种其他组件,所以为了简单起见,我们可以使用 Stable 按钮下载完整库。撰写本文时的当前版本为 1.10.0。

下载完成后,您需要从存档中的 js 目录中获取 jquery-ui-x.x.x.custom.min.js 文件(其中 x.x.x 是版本号),并将其粘贴到您的 js 文件夹中。

提示

最近版本的 jQuery UI,以及一些通过 Themeroller 生成的更受欢迎的预定义主题,也可以通过 Google 的 CDN 获取。

奠定基础 HTML

首先,我们需要构建包含滑动拼图的页面。初始页面将是一个主要只包含几个容器的外壳;当需要时,可以动态创建组成拼图的可拖动元素。

为起飞做准备

我们将为本书中的所有不同项目使用标准起点,因此现在简要介绍一下以节省在每个项目中显示它的时间:

<!DOCTYPE html>

<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title></title>
        <link rel="stylesheet" href="css/common.css" />
    </head>
    <body>
        <script src="img/jquery-1.9.0.min.js"></script>
    </body>
</html>

提示

下载示例代码

您可以从您在 www.packtpub.com 购买的帐户中下载您购买的所有 Packt 图书的示例代码文件。如果您在其他地方购买了这本书,您可以访问 www.packtpub.com/support 并注册,直接将文件发送到您的邮箱。

我们所涵盖的每个项目都将包含在一个页面中,该页面的开头与此相同。现在在您的本地项目文件夹中保存上一个文件的副本,并将其命名为template.html。在每个项目的开头,我会说类似于"将模板文件另存为project-name.html"。这就是我将要引用的文件。

因此,在主项目目录(jquery-hotshots)中保存上一个 HTML(或template.html,如果您愿意),并将其命名为sliding-puzzle.html

我们还将使用一个通用的样式表进行基本样式设置,每个项目都将使用它。它包含诸如 HTML5 重置、清除浮动和其他实用工具,以及一些基本的排版修复和主题设置,以确保项目之间的一致性。虽然我不会在这里详细介绍它,但你可以查看本书附带下载的common.css源文件以获取更多信息。

每个项目还将需要自己的样式表。在适当的情况下,这些将会涵盖,并将根据需要按项目讨论。我们现在可以创建这个项目中将使用的自定义样式表。

创建一个新文件并将其命名为sliding-puzzle.css,然后将其保存在css文件夹中。我们可以使用以下代码在页面的<head>部分链接到这个文件:

<link rel="stylesheet" href="css/sliding-puzzle.css" />

这应该直接出现在common.css样式表引用之后。

我们还可以链接到本项目中将要使用的脚本文件。首先,我们下载并复制到js文件夹中的 jQuery UI 文件可以使用以下代码链接:

<script src="img/jquery-ui-1.10.0.custom.min.js"></script>

记得在 jQuery 本身的脚本之后始终添加 jQuery UI 的脚本。

最后,我们可以添加用于此项目的脚本文件。创建一个新文件并将其保存为sliding-puzzle.js,保存在js文件夹中。我们可以通过在 jQuery UI 引用之后直接添加以下<script>元素来链接到它:

<script src="img/sliding-puzzle.js"></script>

启动推进器

在根项目文件夹中将模板文件另存为sliding-puzzle.html,然后将以下标记添加到<body>元素中(在 jQuery<script>元素之前):

<div id="puzzle" class="clearfix">
    <figure>
        <img src="img/space-girl-vera.jpg" />
    </figure>
    <div id="ui">
        <p id="time">Current time: <span>00:00:00</span></p>
        <button id="start">Start!</button>
    </div>
</div>

目标完成 - 小结

这个简单的 HTML 就是开始的全部。由于这是一本关于 JavaScript 的书,我不会详细介绍 HTML,除非绝对必要。在这种情况下,大部分元素本身并不重要。

主要的是我们有一系列具有id属性的容器,这使得选择它们变得快速简便。唯一真正重要的元素是<img>,它显示我们将要转换成拼图的原始图像。

注意

此示例中使用的精彩图像是由极具天赋的奥登纽·奥达诺休先生创建的。您可以在eamonart.com/上查看更多他精美作品的示例。项目中使用的图像可以在eamonart.com/IMAGES/PINUPSLINKS/Space%20Girl%20Vera.html找到。

创建代码包装器和定义变量

我们所有的代码都需要包含在一个在页面加载完成后立即执行的包装函数中。

准备起飞

我们在项目的这一部分将完成的步骤如下:

  • 为我们的代码添加一个包装函数,该函数将在页面加载完成后立即执行

  • 定义我们将在整个脚本中使用的变量

启动推进器

第一步是为我们的代码创建一个包装函数,该函数将在页面加载完成后立即执行。将以下代码添加到一个名为 sliding-puzzle.js 的新脚本文件中,该文件应保存在我们之前创建的 js 目录中:

$(function () {

    //all our code will be in here...

});

我们在野外看到的大多数 jQuery 代码都位于某种包装器内。使用 $(function(){}); 是 jQuery 的 document.ready 函数的快捷方式,该函数在页面的 DOM 加载完成后触发。

提示

使用 $

如果我们与其他开发人员共享我们的代码,我们通常不会在全局范围内像这样使用 $,因为页面上可能还有其他库也在使用它。最佳实践是在自动调用的匿名函数内或者您喜欢的立即调用的函数表达式内将 $ 字符别名化。可以使用 (function($) { … }(jQuery)); 语法来完成此操作。

接下来,我们可以在脚本文件的顶部设置一些变量。这样做是为了避免我们希望稍后更改的大量值分布在整个文件中。组织是编写可维护代码的关键之一,我们应该始终努力使我们的代码以及我们的意图尽可能清晰。

接下来,在我们刚刚定义的函数内添加以下代码,替换前一个代码示例中显示的注释:

var numberOfPieces = 12,
    aspect = "3:4",
    aspectW = parseInt(aspect.split(":")[0]),
    aspectH = parseInt(aspect.split(":")[1]),
    container = $("#puzzle"),
    imgContainer = container.find("figure"),
    img = imgContainer.find("img"),
    path = img.attr("src"),
    piece = $("<div/>"),
    pieceW = Math.floor(img.width() / aspectW),
    pieceH = Math.floor(img.height() / aspectH),
    idCounter = 0,
    positions = [],
    empty = {
        top: 0, 
        left: 0,
        bottom: pieceH, 
        right: pieceW
    },
    previous = {},
    timer,
    currentTime = {},
    timerDisplay = container.find("#time").find("span");

这不是我们将使用的所有变量,只是其中大部分。列表还包括我们将需要在回调函数中使用的任何变量,以便我们不会遇到作用域问题。

完成目标 - 迷你总结

我们首先定义的变量是简单(原始)值和我们将在整个代码中使用的对象或数组以及缓存的 jQuery 元素的组合。在使用 jQuery 时,为了获得最佳性能,最好从页面中选择元素并将它们存储在变量中,而不是反复从页面中选择它们。

虽然我们的变量都没有直接赋值给window,因此实际上不是全局变量,但由于我们将它们定义在最外层函数的顶部,它们将在整个代码中可见,我们可以将它们视为全局变量。这样我们就能获得全局变量的可见性,而不会实际上使全局命名空间混乱。

注意

最佳实践是在它们所属的函数顶部定义变量,因为存在一种被称为提升的现象,其中在函数内部深处定义的变量,例如在for循环内部,有时会在函数顶部“提升”,可能导致难以追踪的错误。

在可能的情况下,在函数顶部定义变量是避免此类情况发生的简单方法,在编写 jQuery 或一般 JavaScript 时被认为是一种良好的实践。

大多数变量都非常直接。我们存储了我们想要使用的拼图块数以及所使用图像的宽高比。重要的是,拼图块的数量可以被宽度和高度的比率组件等分。

我们使用 JavaScript 的split()函数将宽高比拆分为其组成部分,并指定冒号作为拆分字符。我们还使用 JavaScript 的parseInt()函数确保我们最终得到的是实际数字而不是字符串,存在aspectWaspectH变量中。

接下来的三个变量都是我们需要操作的页面中选择的不同元素。随后是使用 jQuery 创建的新元素。

接下来,我们根据原始图像的宽度和高度以及宽高比计算每个拼图块需要调整大小的widthheight,并初始化一个计数器变量,我们将使用它向每个拼图块添加一个唯一的、有序的id属性。我们还添加了一个名为positions的空数组,我们将用它来存储每个新块的topleft位置。

当拼图块在板上移动时,我们需要一种方法来跟踪空白空间,因此我们创建了一个名为empty的对象,并赋予它topleftbottomright属性,以便我们随时知道空白位置在哪里。我们还希望跟踪任何给定块的上一个位置,因此我们创建了一个名为previous的空对象,我们将在需要时填充它的属性。

剩下的三个变量都与跟踪解决拼图所需的时间有关。我们定义了但没有初始化timer变量,稍后在脚本中将使用它来存储对 JavaScript setInterval()-based 定时器的引用。我们还创建了一个名为currentTime的空对象,当需要时会再次填充它,并缓存了一个引用,我们将用它来显示当前时间的元素。

将图像拆分为块

我们的下一个任务是将图像分割成指定数量的方块,以表示拼图的各个部分。为此,我们将创建一系列较小的元素,每个元素显示图像的不同部分,并可以单独操作。

准备起飞

完成此任务所需的单个步骤是创建指定数量的拼图块,并为每个拼图块设置唯一的背景位置和位置,以重新创建图像。

启动推进器

我们现在想要生成组成拼图的不同部分。我们可以使用以下代码来完成这个任务,这段代码应该直接添加在我们刚刚在 sliding-puzzle.js 中定义的变量之后:

for (var x = 0, y = aspectH; x < y; x++) {
    for (var a = 0, b = aspectW; a < b; a++) {
        var top = pieceH * x,
            left = pieceW * a;

        piece.clone()
             .attr("id", idCounter++)
             .css({
                 width: pieceW,
                 height: pieceH,
                 position: "absolute",
                 top: top,
                 left: left,
                 backgroundImage: ["url(", path, ")"].join(""),
                 backgroundPosition: [
                     "-", pieceW * a, "px ", 
                     "-", pieceH * x, "px"
                 ].join("")
        }).appendTo(imgContainer);

        positions.push({ top: top, left: left });
    }
}

目标完成 - 小结

我们使用嵌套的 for 循环来以网格模式创建新的拼图块。第一个循环将根据需要运行多少行;对于像本示例中使用的 3:4 宽高比图像,我们将需要四行方块。内部循环将根据需要运行多少列,本例中是三列。

在内部循环中,我们首先创建两个新变量 topleft。我们需要在几个地方使用这些值,因此一次创建并在每次需要时重用它们是有意义的。

top 位置等于外部循环的计数变量(x)的当前值乘以拼图块的 height,而 left 位置等于内部循环的计数变量(a)的当前值乘以拼图块的 width。这些变量用于使拼图块在网格中排列。

然后,我们使用 jQuery 的 clone() 方法复制我们存储的 <div> 元素,并使用 attr() 方法使用我们在项目的第一部分初始化的 idCounter 变量设置一个唯一的 id 属性。请注意,我们同时在 attr() 方法中设置变量并递增变量。

我们可以像这样在方法内部递增变量,也可以在方法外部递增变量;在性能或其他方面没有真正区别。我只是觉得在原地更新更简洁。

接下来,我们使用 css() 方法在新元素上设置一个 style 属性。我们设置拼图块的 widthheight 并使用我们的 topleft 变量定位它,以及设置其 backgroundImagebackgroundPosition 样式属性。

注意

通常使用连字符单词定义的任何样式属性,例如 background-image,在与 jQuery 的 css() 方法一起使用对象时,应该使用驼峰命名法。

backgroundImage 属性可以使用我们的 path 变量和样式的其余字符串组件设置,但是 backgroundPosition 属性需要为每个拼图块单独计算。

backgroundPosition样式属性的水平分量等于width乘以内部循环计数变量(a)的值,而垂直分量等于height乘以外部循环计数变量(x)的值。

一旦新元素被创建,我们可以使用 JavaScript 的push()方法将其位置添加到我们的positions数组中,传递一个包含元素的topleft位置属性的对象,以供以后使用。

机密情报

我们不再使用标准的字符串连接来构造backgroundImagebackgroundPosition字符串,而是将值放入数组文字中,然后使用 JavaScript 的join()方法将数组连接起来。通过指定一个空字符串作为用于连接字符串的值,我们确保不会向字符串添加任何附加字符。

将一个子字符串数组连接成一个单一字符串比使用+运算符在子字符串上构建字符串要快得多,并且由于我们在循环内部重复工作,我们应尽可能优化循环内的代码。

洗牌拼图块

在此步骤中,我们需要随机洗牌拼图块,使其成为一个谜题,以便访问者可以重新排列它们。我们还可以删除原始图像,因为它不再需要,并删除第一个拼图块以创建一个空白空间,以便其他拼图块可以移动。

准备升空

我们在本任务中将涵盖的步骤是:

  • 从页面中删除原始图像

  • 删除拼图的第一个块

  • 从位置数组中删除第一个项目

  • 随机洗牌拼图块

启动推进器

完成第一步仅需要添加以下代码行,应直接添加到上一任务中我们在sliding-puzzle.js中添加的外部for循环的结束大括号之后:

img.remove();

第二步同样简单;以下内容可以直接添加到上一行代码之后:

container.find("#0").remove();

我们还可以为下一步使用一行代码。将以下内容直接添加到上一行代码之后:

positions.shift();

洗牌拼图块将稍微复杂一些;您会记得项目的第一部分中我们添加基础 HTML 时其中一个元素是一个开始按钮。我们将使用此按钮来触发洗牌。将以下代码直接添加到我们刚刚添加的前两行代码之后(确保它们仍然在外部函数包装器内):

$("#start").on("click", function (e) {
    var pieces = imgContainer.children();

    function shuffle(array) {
        var i = array.length;

        if (i === 0) { 
            return false;
        }
        while (--i) {
            var j = Math.floor(Math.random() * (i + 1)),
                tempi = array[i],
                tempj = array[j];

                array[i] = tempj;
                array[j] = tempi;
        }
    }

    shuffle(pieces);

    $.each(pieces, function (i) {
        pieces.eq(i).css(positions[i]);
    });

    pieces.appendTo(imgContainer);

    empty.top = 0;
    empty.left = 0;

    container.find("#ui").find("p").not("#time").remove();

});

目标完成 - 迷你总结

jQuery 的remove()方法用于从页面中删除原始图像元素,这些元素在脚本开头声明变量时已经选择了。我们使用相同的方法来删除第一个拼图块,我们应该在拼图块被洗牌之前之前这样做,以避免删除关键部件,例如脸部。与此示例中使用的图像一样,其中感兴趣的主要项目不在左上角的图像是有益的。

我们从面板上移除了第一块拼图,因此我们也应该移除positions数组中的第一项。当我们来检查拼图是否已经还原时,我们将使用这个数组,由于第一个位置上没有拼图块,我们不需要存储它的位置。我们使用 JavaScript 的unshift()方法来实现这一点,它简单地移除调用它的数组中的第一个项目。

使用 on()为按钮添加事件处理程序

我们通过选择按钮并调用 jQuery 的on()方法为按钮添加了点击事件处理程序。在这个例子中,on()方法接受两个参数(尽管在需要事件委托时它可以接受三个参数)。

第一个参数是要监听的事件,第二个参数是每次检测到事件时要执行的处理程序函数。在这种情况下,我们正在监听click事件。

提示

全能的 on()方法

jQuery 的on()方法,自 1.7 版本引入,取代了现已废弃的bind()live()delegate()方法。现在使用on()是 jQuery 中附加事件处理程序的推荐方法。

在处理程序函数内部,我们首先定义一个变量,它存储了<figure>元素的子元素。虽然我们需要再次从页面中选择拼图块,但我们仍然可以使用我们缓存的imgContainer变量来避免创建新的 jQuery 对象。

洗牌拼图块

接下来我们定义了一个名为shuffle()的函数,它接受要洗牌的数组作为参数。这个函数执行了一个Fisher-Yates洗牌算法,这是一个创建给定值的随机排序的已知模式。

在函数内部,我们首先获取传入的数组的长度,如果数组为空,则返回false(退出函数)。然后,我们使用while循环遍历数组。在 JavaScript 中,while循环类似于for循环,但是当括号中指定的条件具有truthy值(或者评估为true)时执行,而不是执行指定次数的循环。使用预减量循环条件是为了避免在所有项都被洗牌后不必要地迭代循环。

注意

在 JavaScript 中,除了truefalse布尔值之外,其他类型的变量也可以被称为truthyfalsey。以下值都被认为是falsey

  • 布尔值false

  • 数字0

  • 空字符串

  • null

  • undefined

  • NaN

所有其他值都被认为是truthy。这样可以使非布尔值用作条件。falsey 和 false 之间的相似性可能会导致混淆;只需记住 false 是一个实际的值,而 falsey 是一个值的一个方面,除了 false 还有其他值也具有。

有关此主题的更多信息,请参见james.padolsey.com/javascript/truthy-falsey/

在循环内,对数组中的每个项(除第一个项外)进行随机选择,并与数组中的另一项交换位置。为了生成用作要交换的项的索引的随机数,我们首先使用 JavaScript 的Math.random()函数生成一个随机数,把得到的随机数(在01之间)乘以数组的长度加1。这将给我们一个在0和数组长度之间的随机数。

然后,我们从数组中取出当前索引的项,以及随机生成的索引处的项,并交换它们的位置。这可能看起来很复杂,但这几乎被普遍认为是随机洗牌数组中项的最有效方式。它给了我们最随机的结果,处理的工作量最少。

一旦我们定义了函数,我们就会调用它,将pieces数组作为要洗牌的数组传递进去。

注意

有关 Fisher-Yates 乱序的 JavaScript 实现的更多信息,请参阅sedition.com/perl/javascript-fy.html

定位元素

完成元素数组的洗牌后,我们使用 jQuery 的each()方法对其进行迭代。此方法传递了要迭代的数组,在这种情况下是刚刚洗牌的pieces数组。第二个参数是一个迭代器函数,将对数组中的每个项进行调用。

在这个函数中,我们使用我们的positions数组将洗牌后的元素放在页面的正确位置。如果我们不这样做,元素将被洗牌,但因为它们的absolute定位,它们仍会出现在页面的同一位置。我们可以使用在创建新元素时更新的positions数组来获得每个洗牌元素的正确topleft位置。

一旦元素集合被迭代并设置了它们的位置,我们就可以使用 jQuery 的appendTo()方法把它们再次附加到页面上。同样,我们可以把我们的imgContainer变量作为appendTo()的参数,以避免再次从页面选择容器。

定位空白空间

最后,我们应该确保空白空间确实位于板的顶部和左边的0位置。如果点击了按钮,移动了一些方块,然后再次点击按钮,我们必须确保空白空间在正确的位置。我们通过将empty对象的topleft属性都设置为0来实现这一点。

我们还可以删除显示在 UI 区域的任何先前消息(我们将在项目的最后部分涵盖添加这些消息)。但我们不想删除计时器,所以我们使用 jQuery 的not()方法来过滤出当前元素,该方法接受一个选择器,匹配的元素被丢弃,因此不会从页面中删除。

此时,我们应该能够在浏览器中运行页面,并通过点击开始!按钮来打乱拼图块:

定位空白区域

使拼图块可拖动

现在是时候启动 jQuery UI,使拼图的各个部分可拖动了。

jQuery UI 是一套用于构建交互式和高效用户界面的 jQuery 插件。它稳定、成熟,并被公认为是 jQuery 的官方 UI 库,尽管不是唯一的。

准备起飞

在此任务中,我们将涵盖以下步骤:

  • 使用 jQuery UI 的可拖动组件使拼图块可拖动

  • 配置可拖动的元素,以便只有直接相邻空白区域的块可以移动

  • 配置可拖动的元素,以便块只能移动到空白区域

启动推进器

首先,我们将使拼图块可拖动,并设置一些组件公开的配置选项。此代码应添加到上一个任务中添加的代码之后的sliding-puzzle.js中:

pieces.draggable({
    containment: "parent",
    grid: [pieceW, pieceH],
    start: function (e, ui) {

    },
    drag: function (e, ui) {

    },
    stop: function (e, ui) {

    }
});

在此任务的接下来几个步骤中,将在上一个代码示例的startdragstop回调函数中添加额外的代码。

我们还需要配置可拖动性,以便块只能移动到空白区域,而不是在彼此之间移动,并且只有直接相邻空白区域的块才能被移动。

现在让我们将以下代码添加到我们刚刚添加的start回调函数中:

var current = getPosition(ui.helper);

if (current.left === empty.left) {
    ui.helper.draggable("option", "axis", "y");
} else if (current.top === empty.top) {
    ui.helper.draggable("option", "axis", "x");
} else {
    ui.helper.trigger("mouseup");
    return false;
}

if (current.bottom < empty.top || 
    current.top > empty.bottom ||
    current.left > empty.right || 
    current.right < empty.left) {
        ui.helper.trigger("mouseup");
        return false;
    }

    previous.top = current.top;
    previous.left = current.left;

接下来,将以下代码添加到drag回调函数中:

var current = getPosition(ui.helper);

ui.helper.draggable("option", "revert", false);

if (current.top === empty.top && current.left === empty.left) {
    ui.helper.trigger("mouseup");
    return false;
}

if (current.top > empty.bottom ||
    current.bottom < empty.top || 
    current.left > empty.right || 
    current.right < empty.left) {
        ui.helper.trigger("mouseup")
                 .css({ 
                     top: previous.top, 
                     left: previous.left 
                 });
        return false;
}

最后,我们应该将以下代码添加到stop回调函数中:

var current = getPosition(ui.helper);

if (current.top === empty.top && current.left === empty.left) {

    empty.top = previous.top;
    empty.left = previous.left;
    empty.bottom = previous.top + pieceH;
    empty.right = previous.left + pieceW;
}

在我们的每个回调函数中,我们使用了一个辅助函数,返回当前可拖动元素的确切位置。我们还应该在draggable()方法之后添加此函数:

function getPosition(el) {
    return {
        top: parseInt(el.css("top")),
        bottom: parseInt(el.css("top")) + pieceH,
        left: parseInt(el.css("left")),
        right: parseInt(el.css("left")) + pieceW
    }
}

目标完成 - 小结

我们在上一个任务中写了很多代码,让我们来分解并看看我们做了什么。我们首先通过使用 jQuery UI 的可拖动组件使块可拖动。我们通过调用draggable()方法来实现这一点,传入一个对象字面量,设置可拖动组件公开的各种选项。

首先,我们将containment选项设置为parent,这样可以阻止任何拼图块被拖出它们所在的<figure>元素。我们还设置了grid选项,允许我们指定拼图块应该捕捉到的点的网格。我们将数组设置为此选项的值。

此数组中的第一项设置了网格的水平点,第二项设置了网格的垂直点。设置这些选项使块的移动更具真实感和触觉体验。

接下来我们设置的最后三个选项实际上是回调函数,在拖动的生命周期的不同点被调用。我们使用startdragstop回调。

当拖动开始时

start回调将在可拖动对象上的mousedown事件后的拖动交互的最开始触发一次。stop回调将在拖动交互的最后,即mouseup事件注册后触发一次。drag回调几乎在被拖动元素每移动一个像素时都会连续触发,因为它被用于每次拖动元素移动时都调用。

让我们首先看一下start回调。每个回调在被调用时由 jQuery UI 传递两个参数。其中之一是事件对象,在这个项目中我们不需要,而第二个是一个包含有关当前可拖动对象的有用属性的对象。

在函数开始时,我们首先获取拖动开始的块的确切位置。当我们调用我们的getPosition()函数时,我们传入ui对象的helper属性,它是对已开始被拖动的基础 DOM 元素的 jQuery 封装引用。

一旦我们获得了元素的位置,我们首先检查元素是否与空白空间在同一行,方法是将当前对象(由getPosition()返回的对象)的left属性与empty对象的left属性进行比较。

如果这两个属性相等,则将可拖动对象的axis选项设置为y,以便它只能水平移动。可以使用option方法在任何 jQuery UI 小部件或组件中设置配置选项。

如果它不在同一行,则通过比较currentempty对象的top属性来检查它是否在同一列。如果这两个属性相等,则我们将axis选项设置为x,以便块只能垂直移动。

如果这些条件都不为真,则该块不能与空白空间相邻,因此我们使用 jQuery 的trigger()方法手动触发mouseup事件来停止拖动,并从函数中返回false,以便我们的stop处理程序不会被触发。

我们需要确保只有与空白空间在同一行或同一列的方块可以被拖动,但我们还需要确保任何不直接与空白空间相邻的方块也不能被拖动。

为了阻止非邻近空白空间的块被拖动,我们只需检查:

  • 当前块的下边小于空白空间的上边

  • 当前块的上边大于空白空间的下边

  • 当前块的左边大于空白空间的右边

  • 当前块的右边小于空白空间的左边

如果这些条件中的任何一个为真,我们再次通过手动触发mouseup事件停止拖动,并通过返回false来停止调用拖动对象上的任何进一步事件处理程序(但仅限于当前拖动交互)。

如果回调函数在这一点没有返回,我们就知道我们正在处理一个与空白空间相邻的可拖动对象,因此我们通过在项目开始时初始化的previous对象的topleft属性来存储它当前的位置,以便以后使用。

提示

ui.helper 的位置

传递给我们回调函数的ui对象实际上包含一个称为position的对象,它可以用于获取当前可拖动物体的位置。然而,由于我们使用了grid选项,此对象中包含的值可能对我们的需求不够精细。

在拖动期间

接下来,我们可以走一遍drag回调,这将在每次当前可拖动物体的位置改变时调用。这将发生在mousedown事件期间。

首先,我们需要知道被拖动的拼图在哪里,所以我们再次调用我们的getPosition()辅助函数。

然后我们想要检查被拖动的拼图是否在空白空间中。如果是,我们可以像之前一样停止拖动-手动触发mouseup事件并返回false

在拖动过程中,只有有效的拼图才能被拖动,因为我们已经筛选掉了与空白空间不直接相邻的拼图。然而,我们还需要检查被拖动的拼图是否正在远离空白空间。我们可以在start回调中筛选出与空白空间不直接相邻的拼图的方式进行检查。

拖动结束时

stop回调是三个回调中最简单的。我们获取被拖动的拼图的位置,如果它确实在空白空间中,我们就把空白空间移到拖动时它所在的位置。记住,我们把这些信息存储在一个叫previous的对象中。

启动和停止计时器

此时,我们的游戏已经功能完善,拼图也可以被拼好了;但是为了增加乐趣,我们应该通过引入计时器来增加竞争元素。

为起飞做准备

在这个任务中,我们需要完成以下步骤:

  • 检查是否在单击开始按钮时计时器已经在运行

  • 0开始计时

  • 每秒增加一次计时器

  • 在页面上更新显示,以便玩家可以看到当前游戏已经进行了多长时间

启动推进器

要检查在单击开始按钮时计时器是否已经在运行,我们应该在将洗牌后的拼图追加到页面之后直接添加以下代码,并紧接着调用draggable()之前:

pieces.appendTo(imgContainer).draggable("destroy");

if (timer) {
 clearInterval(timer);
 timerDisplay.text("00:00:00");
}

timer = setInterval(updateTime, 1000);
currentTime.seconds = 0;
currentTime.minutes = 0;
currentTime.hours = 0;

pieces.draggable({

接下来,我们可以添加一个增加计时器并更新显示的函数。这段代码应该直接放在我们在前面更新currentTime.hours的代码之后:

function updateTime() {

    if (currentTime.hours === 23 && currentTime.minutes === 59 &&
currentTime.seconds === 59) {
        clearInterval(timer);          
    } else if (currentTime.minutes === 59 && currentTime.seconds === 59) {

        currentTime.hours++;
        currentTime.minutes = 0;
        currentTime.seconds = 0;
    } else if (currentTime.seconds === 59) {
        currentTime.minutes++;
        currentTime.seconds = 0;
    } else {
        currentTime.seconds++;
    }

    newHours = (currentTime.hours <= 9) ? "0" + currentTime.hours :

    currentTime.hours;
    newMins = (currentTime.minutes <= 9) ? "0" + currentTime.minutes :

    currentTime.minutes;
    newSecs = (currentTime.seconds <= 9) ? "0" + currentTime.seconds : 

    currentTime.seconds;

    timerDisplay.text([
        newHours, ":", newMins, ":", newSecs
    ].join(""));

}

目标完成-小结报告

在此任务中,我们首先要做的是检查定时器是否已经在运行。定时器将存储在我们的一个“全局”变量中,因此我们可以轻松地检查它。我们使用if语句来检查timer是否包含真值(请参阅有关 JavaScript 的真值和虚值的先前信息)。

如果有的话,我们知道定时器已经在运行,因此我们使用 JavaScript 的clearInterval()函数取消定时器,将我们的timer变量作为要清除的定时器传入。如果定时器已经在运行,我们还可以重置定时器显示。在项目开始时,我们从页面中选择了定时器显示元素,并在最初声明变量时对其进行了缓存。

接下来,我们使用 JavaScript 的setInterval()方法启动定时器,并将其分配给我们的timer变量。当定时器开始时,此变量将包含定时器的 ID,而不是定时器的值,这就是clearInterval()知道要清除哪个定时器的方式。

setInterval()函数接受一个要在指定间隔后执行的函数作为第一个参数,间隔作为第二个参数。我们将间隔指定为1000毫秒,等于 1 秒,因此将每秒调用作为第一个参数传递的函数,直到定时器被清除。

一旦定时器启动,我们还可以重置存储在我们将用于跟踪定时器的对象中的值 - currentTime对象。我们将此对象的secondsminuteshours属性设置为0。我们需要一个对象来跟踪时间,因为timer变量本身只包含定时器的 ID。

接下来,我们添加了updateTime()函数,该函数将由我们的间隔每秒调用一次。在此函数中,我们只需更新currentTime对象的相关属性,并更新显示。我们使用if条件来检查要更新定时器的哪些部分。

我们首先检查定时器是否尚未达到 24 小时。我希望没有人会实际花费那么长的时间来玩游戏,但是如果出于某种原因浏览器保持打开状态达到这么长时间,我们不希望时间显示为,例如,24 小时 1 分钟,因为在那时,我们真的应该更新显示为 1 天 0 小时 1 分钟。但我们不关心天数,所以我们只是停止定时器。

如果定时器尚未达到此时间长度,则我们检查当前分钟是否等于59,当前秒是否等于59。如果是,我们需要将currentTime.hours增加1,并将currentTime.minutescurrentTime.seconds属性重置为0

如果此检查失败,则我们检查秒是否等于59。如果是,则我们增加currentTime.minutes属性,然后将currentTime.seconds重置为0。如果此第二个测试也失败,则我们知道我们所要做的就是增加currentTime.seconds

接下来,我们需要检查是否需要在时间组件的前面加上前导0。我们可以使用另一个if else条件来实现,但 JavaScript 的三元结构更简洁更紧凑,所以我们使用它。

首先我们测试currentTime.hours是否小于或等于9,如果是,我们在值的开头添加0。对于currentTime.minutescurrentTime.seconds,我们也是这样做的。

最后,我们构建将用于更新计时器显示的字符串。我们不再使用乏味且缓慢的字符串连接,而是再次使用包含显示各个部分的数组,然后将数组连接起来。

结果字符串被设置为timerDisplay变量中包含的<span>元素的值,并使用 jQuery 的text()方法更新页面上的元素。

在这一点上,我们现在可以点击按钮来洗牌拼图块,并观察计时器开始递增。

确定拼图是否已解决

在这个任务中,我们将专注于确定拼图块是否已放回其正确位置,从而对拼图进行解开并解决。

准备起飞

在此任务中将涵盖以下步骤:

  • 检查拼图块的顺序是否与拼图块的初始顺序匹配

  • 停止计时器

  • 显示祝贺消息

启动推进器

首先,我们需要决定何时检查拼图是否已完成。在拖动的stop事件上进行检查的好地方。

首先,在stop()回调的顶部的现有current变量之后直接添加以下新变量:

var current = getPosition(ui.helper),
 correctPieces = 0;

不要忘记在第一个变量之后添加尾随逗号,就像前面的代码示例中所示的那样。接下来,在if语句之后直接添加以下代码:

$.each(positions, function (i) {
    var currentPiece = $("#" + (i + 1)),
        currentPosition = getPosition(currentPiece);

    if (positions[i].top === currentPosition.top && positions[i].left === currentPosition.left) {

        correctPieces++;
    }
});

if (correctPieces === positions.length) {
    clearInterval(timer);
    $("<p/>", {
        text: "Congratulations, you solved the puzzle!"
    }).appendTo("#ui");
}

完成目标 - 小结

首先,我们定义了一个名为correctPieces的新变量,并将其值设置为0。然后,我们使用 jQuery 的each()方法迭代了我们在代码早期,当我们最初对拼图块进行洗牌时,填充的positions数组。

在这一点上,我们需要做的是获取拼图的每一块,并检查这些块是否按正确的顺序排列。然而,我们不能仅仅使用 jQuery 的children()方法或find()方法选择页面上的元素,因为 jQuery 不会以它们在 DOM 中找到的顺序返回元素,尤其是因为我们已经将它们全部移动到了它们的父容器周围。

我们需要做的是通过其id属性选择每个元素,然后检查其在style属性中具有的topleftCSS 属性。positions数组的长度与拼图块的数量相同,因此我们可以迭代此数组,并使用 jQuery 自动传递给迭代器函数的索引参数。

在迭代器中,我们首先选择当前元素。每个方块的id属性将从1开始,而不是从0开始,因为我们已经从拼图中移除了第一个方块,所以在选择每个方块时,我们将索引值加1。我们还使用现有的getPosition()函数获取当前元素的位置,传入我们刚刚选择的元素。

接下来,我们将当前方块的topleft属性与positions数组中等效的项目进行比较,如果topleft属性都匹配,我们将增加correctPieces变量。

一旦页面上的每个方块和positions数组中的每个项目都被比较,并且each()方法完成迭代,我们接着检查correctPieces变量的值是否等于positions数组的长度。如果是的话,我们知道每个方块都在正确的位置上。

我们可以像以前一样停止计时器,使用clearInterval()函数,然后创建祝贺消息并将其附加到具有idui的元素。

记住最佳时间并添加一些最终样式

现在游戏已经可以玩得很好。我们可以打乱方块,只允许按规则拖动它们,游戏将会检测拼图何时完成。使用简单的计时器,我们可以告诉玩家解决问题所需的时间,但接下来呢?玩家应该做些什么,只是记住他/她的最高分吗?

当然,现在我们需要一种方法来保存玩家的最佳时间。如果他们超过存储的最佳时间,显示额外的消息也会很方便。我们将使用 JavaScript 的 localStorage API 来存储最佳时间。

我们还可以添加一些额外的样式来完成游戏的外观,并更好地布置不同的元素。

为起飞做准备

我们在这项任务中将要涉及的步骤如下:

  • 检查是否已保存了最佳时间

  • 检查当前最佳时间是否优于保存的最佳时间

  • 当当前最佳时间优于保存的最佳时间时更新保存的最佳时间

  • 在超过保存的最佳时间时显示额外消息

  • 用 CSS 整理游戏的呈现方式

启动推进器

我们在这项任务中需要做的一切都可以在if语句中完成,该语句在方块恢复正确顺序后执行。在上个任务中显示祝贺消息的地方后面直接添加以下代码:

var totalSeconds = (currentTime.hours * 60 * 60) + (currentTime.minutes * 60) + currentTime.seconds;

if (localStorage.getItem("puzzleBestTime")) {

    var bestTime = localStorage.getItem("puzzleBestTime");

    if (totalSeconds < bestTime) {

        localStorage.setItem("puzzleBestTime", totalSeconds);

        $("<p/>", {
            text: "You got a new best time!"
        }).appendTo("#ui");
    }
} else {
    localStorage.setItem("puzzleBestTime", totalSeconds);

    $("<p/>", {
        text: "You got a new best time!"
    }).appendTo("#ui");
}

我们已经创建了我们将用于此的样式表 – sliding-puzzle.css,所以我们只需要将以下选择器和样式规则添加到该文件中:

#puzzle { 
    width:730px; padding:5px; margin:auto; 
    border:1px solid #aaa; border-radius:5px; 
    background-color:#eee; 
}
#puzzle figure { 
    width:510px; height:676px; border:1px solid #aaa; 
    position:relative; float:left; background-color:#fff; 
}
#ui { padding:10px 0 0 10px; float:left; }
#ui button { margin-bottom: 2em; }
#ui p { font-size:1.7em; }
#start { width:204px; height:50px; font-size:1.75em; }

目标完成 - 小型总结

首先我们将当前时间转换为秒,这样我们就只有一个值可以使用和存储。秒数是使用currentTime对象的hoursminutesseconds属性来计算的,用来更新页面上可见的计时器。

hours 属性乘以 60 转换为分钟,然后再乘以 60 转换为秒。 minutes 属性仅乘以 60 一次,然后将这两个值加到 seconds 属性中剩余的秒数中,得到最终的总数,我们将其存储在 totalSeconds 变量中。

接下来,我们检查 localStorage 是否存在一个名称为 puzzleBestTime 的键。如果存在,则将 localStorage 中保存的值存储在 bestTime 变量中。如果 totalSeconds 变量的值小于 bestTime 变量,我们就有了一个新的最高分,我们将其保存在 localStorage 中,名称为 puzzleBestTime,以覆盖旧的最佳时间。然后,我们显示第二个祝贺消息,表示已经取得了新的最高分。

如果 localStorage 不包含具有此名称的键,那么这必须是此浏览器中首次玩游戏,因此我们将键的名称设置为并将 currentTime 变量的值存储为新的最佳时间,然后再次显示第二个祝贺消息。

在我们添加的 CSS 中没有什么真正关键的内容;它只是一点点轻微的样式,用来整理我们使用的各种元素,并以更清晰的风格呈现游戏。

机密情报

localStorage API 是 HTML5 通用术语中比较稳定的 JavaScript API 之一,并且受到所有常见浏览器的最新版本的广泛支持。

我们可能仍然需要支持的旧浏览器,比如 IE7 或 Firefox 2,不支持 localStorage。幸运的是,有大量的填充和解决方法可以在这些旧浏览器中添加基本的支持。

请参阅github.com/Modernizr/Modernizr/wiki/HTML5-Cross-Browser-Polyfills获取一系列支持现代 API 的填充和补丁,以在旧浏览器中添加支持。

任务完成

在这个项目的过程中,我们使用了大量的 jQuery 和原生 JavaScript 来创建这个简单的游戏。我们还研究了使用 jQuery UI 的可拖动组件以及 localStorage API。

我们涵盖了很多代码,所以让我们简要回顾一下我们做过的事情。

我们首先在 document.ready 函数的开头声明了大部分在整个项目中使用的变量。这样做很有用,因为变量可以在我们的代码中使用,而不需要将它们全局范围化。出于性能原因,最好缓存 jQuery 对象,以便它们可以经常被操作,而无需在页面中不断地重新选择它们。

我们接着看到了如何利用一些嵌套的for循环和简单的数学知识,轻松地将已知长宽比的图像分割成多个等大小的块,排列在一个网格中。我们还发现,使用子字符串数组来创建字符串而不是使用字符串连接是一个非常简单的优化,可以在构建长字符串时帮助加快我们应用程序的速度。

然后,我们看到了如何使用一个接受的算法来随机化——费希尔-耶茨洗牌算法,将各个部分随机排列。实际上,我们完全没有使用 jQuery 来做这个,但不要忘记,生成洗牌的代码是在使用 jQuery 的on()方法添加的事件处理程序内执行的。

接下来,我们看了如何使用 jQuery UI 使拼图的各个部分可拖动。我们看了组件暴露的一些可配置选项,以及如何在拖动部分时对生成的不同事件作出反应。具体来说,我们使用了startdragstop回调来执行游戏规则,限制哪些部分可以在游戏中移动,以及它们在游戏过程中如何移动。

之后,我们看了如何使用标准的 JavaScript 定时器来跟踪解谜游戏所需的时间,以及如何更新页面上可见的计时器,让玩家能够看到他们开始以来经过的时间。

检测拼图何时被解决也是代码的一个关键能力。我们在这里的主要障碍是,拼图的部分并不是按照我们在屏幕上看到的可见顺序选取的,但这很容易通过使用它们的编号id属性来选取部分,然后手动检查它们的 CSS 位置来克服。

最后,我们看了如何记录玩家解谜游戏的最佳时间。在这里,localStorage 是显而易见的选择,只需一小步检查是否已经存储了分数,然后比较当前时间和存储的时间,就能知道记录是否被打破了。

你准备好全力以赴了吗?一个高手的挑战

我们的简单游戏仍然可以添加许多更多的功能。为什么不更新游戏,让玩家可以选择不同的技能水平呢?

要实现这一点,我们只需要提供某种接口,允许访问者选择技能水平,然后考虑一种使游戏变得更难的方式。

如果我们假设当前游戏格式是最简单的技能水平,那么使游戏变得更难的一个非常简单的方法是增加将原始图像分割成的块数。尝试自己做这个吧。那些对数学有深刻理解的人可能会意识到我们的游戏还有另一个缺陷——一些随机组合的部分根本无法解决。存储或计算所有可解决的可能组合可能超出了实际可行,但还有另一种选择。

而不是随机洗牌一堆碎片,然后将它们的位置写入板上,我们可以通过程序化地在棋盘上移动它们来洗牌。根据玩家受限的游戏规则进行洗牌的拼图将每次都得到一个可解的拼图。

第二章:固定位置侧边栏带有动画滚动

position:fixed CSS 样式添加了一个有趣的效果,允许一个目标元素在页面被滚动时保持其位置。然而,它的有效性受到一个限制,即无论这个元素被嵌套在其他元素中多深,它始终是相对于整个文档固定的。

任务简报

在这个项目中,我们将创建一个侧边栏,模拟position:fixed的 CSS 样式,但不会受到纯 CSS 解决方案的相同限制。我们还可以在页面上添加一种吸引人的动画,以便当侧边栏中的导航项被点击时,页面的不同部分被滚动到视图中。

以下是此项目的最终结果的截图:

任务简报

为什么很棒?

能够在页面上固定一个元素是一种非常流行的 UI 设计模式,被许多大型和受欢迎的网站使用。

将访问者的主要工具或行动呼吁保持在任何时候都可以接触到,提高了网站的用户体验,并可以帮助保持您的访问者满意。方便是重要的,所以如果访问者必须向下滚动一个长页面,然后再次向上滚动才能点击某些内容,他们很快就会对页面失去兴趣。

这个原理在移动设备上也是一个新兴的趋势。实际的position:fixed样式在移动设备上普遍支持较差,但在某些当今最知名的应用程序中,将重要工具保持在手边,而不需要过多滚动或更改屏幕,这一想法正在被采用和实施。

你的炫酷目标

要完成此项目,我们需要完成以下任务:

  • 构建一个合适的演示页面

  • 存储固定元素的初始位置

  • 检测页面何时滚动

  • 处理浏览器窗口的调整大小

  • 自动滚动

  • 恢复浏览器的返回按钮

  • 处理页面加载时的哈希片段

构建一个合适的演示页面

在这个任务中,我们将准备演示页面和其他我们需要的文件,以便为脚本做好准备。

为了明显展示这种技术的好处,我们将需要使用一些额外的元素,严格来说,这些元素不是侧边栏所需的元素的一部分,我们将固定在一个地方。

我们将在此示例中使用的侧边栏需要位于完整页面结构内,为了看到固定位置效果,页面也需要非常长。

在构建我们的演示页面时,我们将使用一系列 HTML5 元素,你应该知道这些元素在某些浏览器的旧版本中不受支持。如果你发现你需要支持旧版本的浏览器,你将需要使用 Google Code 提供的html5shiv脚本(code.google.com/p/html5shiv/)。

为起飞做准备

我们应首先将模板文件的新副本保存到项目的根文件夹中,并将新文件命名为fixed-sidebar.html。我们还可以创建一个名为fixed-sidebar.css的新样式表,将其保存在css文件夹中,并创建一个名为fixed-sidebar.js的新 JavaScript 文件,应保存到js文件夹中。

我们可以使用以下新的<link>元素将新样式表链接到 HTML 页面的<head>部分,该元素应直接添加到链接到common.css之后:

<link rel="stylesheet" href="css/fixed-sidebar.css" />

请记住,common.css样式表用于提供诸如重置、简单的排版框架和一些常见的布局样式等有用内容,以尽量减少每个项目所需的 CSS。

我们可以使用以下新的<script>元素将新的 JavaScript 文件链接到fixed-sidebar.html页面的<body>部分中的 jQuery <script>文件之后:

<script src="img/fixed-sidebar.js"></script>

底层页面现在已设置好,准备为这个项目添加所需的元素。

启动推进器

我们将为我们的页面使用基本布局,其中包括以下元素,这些元素应添加到fixed-sidebar .html中:

<header>
    <h1>jQuery fixed sidebar example page</h1>
</header>

<div class="wrapper">
    <article>
        <h1>Example content area</h1>
        <section id="part1">
        </section>    
        <section id="part2">
        </section>
        <section id="part3">
        </section>  
        <section id="part4">
        </section> 
        <section id="part5">
        </section>
    </article>   
    <aside>
        <h2>Important content to fix in place</h2>
        <nav>
            <h3>Some in page navigation</h3>
            <ul>
                <li><a href="#part1">Section 1</a></li>
                <li><a href="#part2">Section 2</a></li>
                <li><a href="#part3">Section 3</a></li>
                <li><a href="#part4">Section 4</a></li>
                <li><a href="#part5">Section 5</a></li>
            </ul>
        </nav>
    </aside>
</div>

这些元素应直接添加到页面的<script>元素之前,该元素链接到 jQuery。

我们的示例页面还需要一些基本的 CSS,以创建此示例所需的布局。在我们为此示例创建的fixed-sidebar.css样式表中,添加以下样式:

header, .wrapper { width:80%; max-width:1140px; margin:auto; }
header { 
    padding-bottom:2em; border-bottom:4px solid; 
    margin-bottom:3em; 
}
header h1 { margin-top:.75em; }
article { 
    width:70%; padding-right:4%; border-right:4px solid;
    margin-right:5%; float:left; 
}
aside { width:20%; float:left; }

与之前一样,实际上并不需要任何这些代码,我们只是为了根据这个示例的需要布置演示页面。

目标完成 - 小型简报

我们添加了一个非常简单的布局来创建我们的演示页面。HTML5<article>填充了五个不同的 HTML5<section>元素,每个元素都有自己的id属性。稍后在项目中我们会使用这些来允许它们之间的动画导航。

在上面的代码示例中,每个<section>元素都是空的。但是,如果你一边跟着进行并编写示例代码,你应该用各种随机元素填充每个元素,以增加页面的长度。

在这个示例中,我们使用的元素都不重要。HTML5<aside>是我们将要固定的元素,但它是<aside>元素并不重要 - 任何元素都可以使用这种技术。

<aside>元素内部是一个 HTML5<nav>元素。正如我之前提到的,这将使我们能够稍后添加另一个很酷的功能,但同样,并不是基本技术的必需品。任何内容都可以在要固定在原位的元素中使用。

还要注意,在 CSS 中我们根本不使用position:fixed。其原因很简单。具有固定位置的元素相对于整个文档而言是定位的,而不是相对于其父容器。

如果没有提供像素坐标,则渲染一个固定位置元素,其元素在页面上的位置取决于其 DOM 位置(尽管从技术上讲它仍然不在页面的正常流中)。

如果我们尝试使用我们的示例布局来做这件事,它最终会出现在外部 .wrapper 元素的最左边,因为在 <article> 元素上指定的 float 也会将 <article> 元素从正常文档流中移除。这不好。

如果提供了像素坐标,渲染引擎将解释这些坐标相对于窗口的位置,就像绝对定位元素一样。在某些情况下,指定像素坐标可能是可以接受的,但是在使用本示例中的流式布局时,设置 <aside> 元素的 lefttop 样式属性所需的坐标将取决于用于查看页面的屏幕分辨率,这就是我们面临的困境,因此我们使用 jQuery 来实现它而不是简单的 CSS。

机密情报

为了节省创建示例布局(如本项目中使用的布局)的时间,我们可以使用诸如 Placehold It (placehold.it/) 这样的服务,用任意尺寸的占位图像代替图像,以及 HTML Ipsum (html-ipsum.com) 来填充常见 HTML 元素的 Lorem Ipsum 占位文本。

存储固定元素的初始位置

在我们能够将元素固定在某个位置之前,我们需要知道那个位置在哪里。在这个任务中,我们将获取我们将要固定在某个位置的 <aside> 元素的当前起始位置。

启动推进器

fixed-sidebar.js 中,我们应该从以下代码开始:

$(function() {

});

我们可以在函数顶部缓存一些 jQuery 选中的元素,并存储固定元素的初始位置,然后我们可以在刚刚添加的函数内添加以下代码:

var win = $(window),
    page = $("html,body"),
    wrapper = page.find("div.wrapper"),
    article = page.find("article"),
    fixedEl = page.find("aside"),
    sections = page.find("section"),
    initialPos = fixedEl.offset(),
    width = fixedEl.width(),
    percentWidth = 100 * width / wrapper.width();

目标完成 - 小结

我们使用了与第一个项目中相同的外部包装器。就像我之前提到的那样,这是在页面加载完成后执行代码的非常常见的方式。我们可能会在本书中的每个项目中都使用它。

然后,我们缓存我们将要引用的元素的引用,这样我们就不必一直从 DOM 中选择它们。稍后我们将在事件处理程序中查询这些元素,为了性能考虑,最好是从页面中选择一次并在我们的代码中引用保存或缓存的版本,而不是反复从页面中选择元素。

我们将引用 window 对象,因为我们将向其附加多个事件处理程序。稍后我们将滚动整个页面,为了实现全面的跨浏览器兼容性,我们应该选择并存储对 <html><body> 元素的引用,因为不同的浏览器使用 <html><body> 元素,所以这样涵盖了所有情况。

我们需要选择具有类名wrapper的元素,包含的<article>,所有不同的<section>元素,当然还有我们将在剩余代码中经常使用的<aside>元素。

我们还存储了固定元素的初始位置,以便我们知道要将元素固定到页面上的坐标。我们使用 jQuery 的offset()方法,该方法返回一个包含topleft属性的对象,显示相对于文档的当前位置,正是我们所需的。

根据周围元素应用的样式,被固定元素的width可能会发生变化。为了缓解这种情况,我们还使用了 jQuery 的width()方法来存储元素的初始width,该方法返回以像素表示的整数。

最后,我们还可以计算并将width存储为百分比。稍后当我们想要对浏览器窗口大小调整做出反应时,我们将需要知道这一点。通过将固定元素的width乘以100,然后将这个数字除以其容器的宽度,我们很容易就能计算出来,而我们再次使用 jQuery 的width()方法来获取容器的宽度。这也意味着固定侧边栏的width可以很容易地在 CSS 文件中更改,并且脚本将继续工作。

检测页面滚动时

我们的下一个任务是在页面滚动时检测到,并在发生滚动时将元素固定在原位。对于我们来说,通过 jQuery,检测滚动事件变得很容易,将position设置为fixed也很容易,因为有简单的 jQuery 方法可以调用来执行这些确切的操作。

启动推进器

在上一个任务中初始化变量之后,将以下代码直接添加到脚本文件中:

win.one("scroll", function () { 
    fixedEl.css({
        width: width,
        position: "fixed",
        top: Math.round(initialPos.top),
        left: Math.round(initialPos.left)
    });
});

目标完成 - 迷你简报

我们可以使用 jQuery 的one()方法将事件处理程序附加到我们存储在变量中的window对象上。one()方法将在第一次检测到事件时自动解除绑定,这很有用,因为我们只需要一次将元素设置为position:fixed。在本示例中,我们正在寻找scroll事件。

当检测到事件时,我们将作为one()的第二个参数传递的匿名函数将被执行。在发生这种情况时,我们使用 jQuery 的css()方法来设置一些style属性。我们将元素的width设置为对应情况的原因是,我们的目标元素的width因周围元素的float和/或margin而增加。

我们将position设置为fixed,并使用在项目开始时存储在initialPos变量中的元素的初始位置,设置topleft样式属性。我们使用 JavaScript 的Math.round()方法来将topleft像素位置四舍五入为整数,这有助于避免任何与子像素舍入相关的跨浏览器问题。

处理浏览器窗口调整

目前,我们的 <aside> 元素在页面滚动时会立即固定在原地,这符合我们的需求,只要浏览器保持相同的大小。

但是,如果由于某种原因调整了窗口大小,则 <aside> 元素将从其固定位置掉落,并且可能会丢失在视口的边界之外。在这个任务中,我们将通过添加一个事件处理程序来修复这个问题,该处理程序监听窗口的 resize 事件。

启动推进器

为了保持固定元素相对于页面其余部分的正确位置,我们应该在上一任务中添加的 one() 方法之后直接添加以下代码:

win.on("resize", function () {
    if (fixedEl.css("position") === "fixed") {
        var wrapperPos = wrapper.offset().left,
            wrapperWidth = wrapper.width(),
            fixedWidth = (wrapperWidth / 100) * percentWidth;

        fixedEl.css({
            width: fixedWidth,
            left: wrapperPos + wrapperWidth - fixedWidth,
            top: article.offset().top
        });
    }
});

目标完成 - 迷你总结

这次我们使用 jQuery 的 on() 方法来附加我们的事件处理程序。我们向这个方法传递两个参数;第一个是我们要监听的事件,在这个任务中是窗口的 resize 事件,第二个是我们希望在检测到事件时执行的函数。

我们只希望在页面已经滚动并且元素的 position 已经设置为 fixed 时重新定位和调整 <aside> 元素的大小,因此在我们做任何其他事情之前,我们首先检查这是否是这种情况。

如果元素的 position 设置为 fixed,我们首先使用 jQuery 的 offset() 方法返回的对象的 left 属性确定包装器元素的当前 left 样式属性。我们还使用 jQuery 的 width() 方法获取包装器元素的 width

因为我们的布局是液体的,所以我们还需要调整固定元素的 width。在 CSS 中,我们最初将 width 设置为 20%,所以我们可以通过将容器的当前宽度除以 100,然后乘以我们在第一个任务中存储的 percentWidth 变量来确保它保持在其容器的 20%。

然后,我们使用 jQuery 的 css() 方法设置固定元素的 width 以及它的 topleft 样式属性,以确保在 window 调整大小时它保持在正确的位置。

自动滚动

此时,我们应该能够单击固定元素中添加的导航菜单中的任何链接,页面将跳转以将相应的部分带入视图。固定元素仍然固定在原地。

跳转到部分的方式相当突兀,因此在这个任务中,我们将手动将每个部分滚动到位,以便每个部分的跳转不那么突然。我们还可以对滚动进行动画处理,以获得最大的美观效果。

启动推进器

对于这个任务,我们应该再添加另一个事件处理程序,这次是为导航列表中的链接的 click 事件,然后动画滚动页面以将所选的 <section> 带入视野。

首先,我们可以添加一个用于滚动页面的通用函数,该函数接受一些参数,然后使用这些参数执行滚动动画。我们应该在上一任务中添加的 one() 方法之后直接定义该函数,使用以下代码:

function scrollPage(href, scrollAmount, updateHash) {
    if (page.scrollTop() !== scrollAmount) {
        page.animate({
            scrollTop: scrollAmount
        }, 500, function () {
            if (updateHash) {
                document.location.hash = href;
            }
        });
    }
}

接下来,我们可以在我们的固定元素上为点击事件添加一个处理程序。这应该直接添加在我们刚刚添加的scrollPage()函数之后:

page.on("click", "aside a", function (e) {
    e.preventDefault();

    var href = $(this).attr("href"),
        target = parseInt(href.split("#part")[1]),
        targetOffset = sections.eq(target - 1).offset().top;

    scrollPage(href, targetOffset, true);
});

目标完成 - 小结

首先我们定义了scrollPage()函数,它接受三个参数。第一个是href,第二个是一个整数,代表页面的scrollTop属性需要动画到的数值,第三个是一个布尔值,将告诉函数是否更新浏览器地址栏中的哈希片段。

在这个函数中,我们首先检查页面是否确实需要滚动。为了确保它需要,我们只需检查当前页面的滚动,使用 jQuery 的scrollTop()方法获取,是否与我们希望滚动到的数量不同。

jQuery 的animate()方法还接受三个参数。第一个是一个对象,其中每个键都是要动画的属性,每个值都是要将其动画到的值。在这种情况下,我们要使用传递给我们的函数的scrollAmount参数来动画化scrollTop属性。

animate()方法的第二个参数是动画应该运行的持续时间。它接受一个代表以毫秒为单位的持续时间的整数。我们指定为500,这样动画将需要半秒钟来完成。

第三个参数是一个回调函数,我们希望在动画结束后立即执行。如果我们函数中传递的updateHash参数设置为true,我们可以更新浏览器的地址栏,显示所需的<section>元素的id

我们可以通过使用传递给我们的scrollPage()函数的href参数更新document.location对象的hash属性来实现这一点。这会更新地址栏,但因为它只是一个哈希片段,所以不会导致页面重新加载。

添加了scrollPage()函数后,我们随后添加了对固定元素内导航的click事件处理程序。我们再次使用 jQuery 的on()方法附加此事件,但这次我们向该方法传递了三个参数,这样可以启用事件委派。处理程序附加到我们已经存储在变量中的页面的<body>上。

第一个参数是我们要绑定处理程序的事件,在这种情况下是click事件。第二个参数是选择器;on()方法将过滤所有点击事件,以便只有那些来自与选择器匹配的元素的事件才会调用绑定的处理程序函数。

在这种情况下,我们只对我们的固定元素 - <aside>中的<a>元素的点击感兴趣。第三个参数是要绑定为处理程序的函数,jQuery 会自动将原始事件对象传递给它。

在这个函数内部,我们首先使用事件对象的preventDefault()方法停止浏览器导航到相应的<section>元素。

接下来,我们设置一个变量,告诉我们用户想要导航到哪个<section>。 在我们的事件处理程序函数中,$(this)对象的作用域限定为被点击的链接,因此我们可以通过使用 jQuery 的attr()方法获取所需的部分id来轻松地获取点击链接的href属性。 我们将其存储在名为href的变量中。

我们需要知道所需的<section>元素在页面上的位置,我们通过使用 JavaScript 的split()方法来分割刚刚设置的href变量中存储的字符串来获取它。

如果我们将#part指定为要拆分的字符串,则split()方法将返回一个包含两个项目的数组,其中第二个项目是被点击的部分号的字符串版本。 通过将此语句包装在 JavaScript 的parseInt()中,我们得到一个整数。 我们将此整数存储在target变量中。

我们设置的最后一个变量是所需<section>元素的偏移量。 要选择正确的<section>元素,我们可以使用我们在项目开始时存储的sections数组。

要从此数组中提取正确的元素,我们使用 jQuery 的eq()方法,并将其设置为刚刚保存在target变量中的值减去1。 我们需要减去1,因为 JavaScript 中的数组从0开始,但是我们的<section> id属性从1开始。

一旦我们获得了这些信息,我们就可以调用我们的scrollPage()函数,将我们刚刚计算的值传递给它,以动画形式滚动页面,以将所需的<section>元素带入视图。

恢复浏览器的后退按钮

此时,我们可以点击<aside>元素中的任何链接,页面将平滑滚动到页面上所需的位置。 浏览器的地址栏也将被更新。

但是,如果用户尝试使用其浏览器的返回按钮返回到先前的<section>,则什么也不会发生。 在此任务中,我们将修复此问题,以使返回按钮按预期工作,并且甚至可以在使用返回按钮返回到先前的<section>时使用平滑滚动。

启动推进器

我们可以通过在刚刚添加的点击事件之后直接添加另一个事件处理程序来非常容易地启用返回按钮:

win.on("hashchange", function () {

    var href = document.location.hash,
        target = parseInt(href.split("#part")[1]),
        targetOffset = (!href) ? 0 : sections.eq(target - 1).offset().top;

    scrollPage(href, targetOffset, false);
});

目标完成 - 小型总结

我们再次使用 jQuery 的on()方法附加我们的事件,这次我们不需要使用事件委托,因此我们恢复到该方法的两个参数形式。

这次我们正在监听hashchange事件,与之前一样,它作为第一个参数传递,并且每当document.location对象的hash属性更改时就会发生。

在我们的处理程序函数中,作为第二个参数传递,我们设置各种变量的不同值,以便传递给scrollPage()函数,以执行滚动。这次我们不需要阻止浏览器的默认行为,href变量是使用document.location.hash属性设置的,因为触发事件的是返回按钮,而不是<aside>中的链接之一。

实际上,当点击链接时,这个处理程序也会被触发,因为链接也会更新哈希值,但在scrollPage()函数内的条件检查将阻止不必要地调用 jQuery 的animate()方法。

target变量的计算方式与上一个事件处理程序中的计算方式完全相同,但这次,targetOffset变量需要处理浏览器地址栏中没有哈希片段的情况。为了处理这一点,我们使用 JavaScript 的三元条件结构来检查刚刚定义的target变量是否具有假值,这将指示空字符串。如果是,我们希望只是将滚动平滑返回到零。如果不是,我们确定所需的滚动量的方式与之前一样。

现在我们应该能够加载页面,在<aside>元素中点击链接后滚动到页面的某个部分,然后使用浏览器的返回按钮滚动回页面顶部。

处理页面加载时的哈希片段

目前浏览器返回按钮的功能已经恢复,访问者可以看到地址栏中的可书签的网址。

如果页面在其中包含哈希片段的情况下被请求,页面将在加载时自动跳转到指定的<section>。在这部分我们将添加一些代码,检查document.location对象的哈希属性,如果检测到哈希,则将平滑滚动到页面对应部分。

启动推进器

要实现这一点,我们应该在脚本文件顶部定义起始变量后直接添加以下代码,并在监听滚动事件之前直接添加:

if (document.location.hash) {

    var href = document.location.hash,
        target = parseInt(href.split("#part")[1]),
        targetOffset = sections.eq(target - 1).offset().top;

    page.scrollTop(0);
    document.location.hash = "";
    scrollPage(href, targetOffset, true);

}

目标完成 - 小型总结

在这段代码中,页面加载后将立即执行,我们首先检查document.location对象是否包含hash(或至少包含一个非空字符串的hash)。

如果是这样,我们获得hash,获取<section>的编号,并以与之前任务相同的方式计算距页面顶部的偏移量。然后我们将页面的scrollTop设置为0,强制浏览器滚动到页面顶部。此时我们还会移除哈希值。

最后,我们可以调用我们的scrollPage()函数,传入新的href片段,所需的滚动量,并将最后一个参数设置为true,以便将正确的哈希片段添加回浏览器的位置栏。所有这些都应该发生得非常快,用户不会注意到页面加载已被拦截并修改了行为。

任务完成

在这个项目中,我们看了一种非常简单的方法来模仿 CSS 的position:fixed样式,以固定一个重要的元素。只在页面开始滚动时应用固定定位的技巧简单而有效,并且是解决实际position:fixed在处理复杂或流动布局时的缺陷的绝佳方式。

我们看到了如何处理窗口大小调整,并添加了一个平滑滚动功能,以在页面的不同命名部分之间滚动页面。

我们还看了如何读取和写入window对象的document.location.hash属性,以及在页面加载时如何手动滚动到请求的部分。我们还修复了浏览器的后退按钮,使其与我们的平滑滚动动画配合工作。

你准备好全力以赴了吗?一个高手的挑战

很多时候,在我们在这个项目中使用的页面内导航中,当手动滚动到一个部分时,或者点击其中一个链接时,将导航链接显示为当前状态是很有用的。试着将这个简单但有效的补充添加到我们在本项目过程中看到的代码中。

第三章:一个交互式的 Google 地图

在这个项目中,我们将创建一个与 Google 最新 API 版本配合工作的高度交互式 Google 地图,以生成带有自定义覆盖层和标记、地理编码地址以及计算距离的地图。我们还将看看如何使用谷歌和 jQuery 事件处理程序的组合来保持我们的简单 UI 与地图上添加的位置同步。

任务简报

出于本项目的目的,我们将有一个场景,需要为一个将物品从一个地方运送到另一个地方的公司构建一个基于地图的应用程序。他们希望客户可以访问一个页面,通过点击地图上的不同区域来计算运输某物品从一个地方到另一个地方的成本,并可能下单。

我们将了解如何监听地图上的点击事件,以便可以添加标记并记录每个标记的精确位置。然后我们可以更新 UI 以显示被点击位置的实际街道地址,并允许访问者根据两个地址之间的计算距离生成报价。

为什么这很棒?

谷歌地图是一个很棒的 API 来构建应用程序。已经具有高度交互性和丰富的功能,我们可以在其提供的坚实基础上构建稳健且高度功能性的应用程序。谷歌提供地图数据和地图的交互性,而 jQuery 用于构建 UI——这是一个胜利的组合。

我们最终将得到的页面将类似于以下屏幕截图:

为什么这很棒?

你的高能目标

该项目将分解为以下任务:

  • 创建页面和界面

  • 初始化地图

  • 使用自定义覆盖层显示公司总部位置

  • 捕获地图上的点击事件

  • 更新 UI,显示起始位置和终点位置

  • 处理标记重新定位

  • 考虑权重因素

  • 显示预计距离和费用

任务清单

我们需要链接到由谷歌提供的脚本文件,以初始化地图并加载 API。我们还可以在此时创建项目中将要使用的新文件。

不用担心,我们不需要从谷歌获取 API 密钥之类的东西,这个项目可以直接通过链接使用脚本。

注意

谷歌地图 API 功能丰富且稳定,包含所有最知名的地图功能入口,包括街景、地理位置和导航服务。除了我们在此处使用的配置选项外,还有许多其他选项。有关更多信息,请参阅developers.google.com/maps/上的文档网站。

首先,我们应该将模板文件的新副本保存到我们的根项目文件夹中,并将其命名为google-map.html。还创建一个google-map.css文件和一个google-map.js文件,并将它们分别保存在cssjs文件夹中。

我们可以通过将以下<link>元素添加到页面的<head>中,直接在common.css<link>元素后面,来链接到此示例的样式表:

<link rel="stylesheet" href="css/google-map.css" />

提示

别忘了,我们每个项目都使用common.css,这样我们就可以专注于实际项目中需要的样式,而不用关注大多数网页所需的所有无聊的重置、浮动清除和其他常见 CSS 样式。

我们可以使用以下<script>元素直接在 jQuery 的<script>元素后面链接到 Google 的脚本文件以及我们刚刚创建的 JavaScript 文件:

<script src="img/js?sensor=false">
</script>
<script src="img/google-map.js"></script>

在这个项目中,我们还将使用几张图片,hq.pngstart.png,它们都可以在本书的附带代码下载中找到。你应该将它们复制到本地jquery-hotshots项目目录中的img目录下。我们的页面现在已经准备好进行第一个任务了。

创建页面和界面

在我们的第一个任务中,我们可以添加地图的不同容器,以及页面所需的初始 UI 元素。我们也可以添加一些基本的样式,将事物布局成我们想要的样子。

启动推进器

我们应该将以下元素添加到我们刚刚设置的google-map.html页面的<body>元素中:

<div id="map"></div>
<div id="ui">
    <h1>I Am Mover</h1>
    <p>Enter the weight of your goods below and click on two 
    different places on the map to see the distance between 
    them and the cost of moving your goods.</p>
    <h3>Our charges</h3>
    <dl class="clearfix">
        <dt>Base rate (per mile)</dt>
        <dd>&pound;3</dd>
        <dt>Cost per kg per mile</dt>
        <dd>&pound;0.25</dd>
    </dl>
    <input id="weight" placeholder="Weight (kg)" />
</div>

为了进行一些基本的样式设置,并为初始化地图做好页面布局准备,我们可以将以下选择器和样式添加到我们刚刚创建的google-map.css文件中:

#map { width:100%; height:100%; }
#ui { 
    width:16%; height:99.8%; padding:0 2%; 
    border:1px solid #fff; position:absolute; top:0; right:0;
    z-index:1; box-shadow:-3px 0 6px rgba(0,0,0,.5);
    background-color:rgba(238,238,238,.9); 
}
#ui h1 { margin-top:.5em; }
#ui input { width:100%; }
#ui dl { 
    width:100%; padding-bottom:.75em; 
    border-bottom:1px dashed #aaa; margin-bottom:2em; 
}
#ui dt, #ui dd { margin-bottom:1em; float:left; }
#ui dt { width:50%; margin-right:1em; clear:both; }
#ui dd { font-weight:bold; }

目标完成 - 迷你总结

在这个任务中,我们只是开始添加我们将在接下来的几个任务中正确填充的基础 HTML 元素。这是让示例页面开始运行并让项目启动的一个略微无聊但有些必要的第一步。

我们添加的第一个元素是 Google Maps API 将渲染地图瓦片到其中的容器。我们给它一个idmap,以便可以有效地选择它,但它一开始是完全空的。

下一个元素是各种 UI 元素的容器,示例需要它。它也有一个idui,以便我们的脚本可以轻松选择它,并且用 CSS 样式添加。

提示

使用 ID 进行样式设置

避免使用 ID 选择器添加 CSS 样式正逐渐成为一种普遍的最佳实践,例如CSSLint等工具建议不要使用它。

尽管使用类、元素或属性选择器的理由很有说服力,但为了简单起见,我们将在本书中的一些项目中使用它们。

CSSLint 是一个开源的 CSS 代码质量工具,它对源代码进行静态分析,并标记可能是错误或可能会给开发人员带来问题的模式。有关更多信息,请参见csslint.net/

在界面容器中,我们有一个虚构公司的名称,一些使用页面的基本说明,一个不同费用的列表,以及一个<input>元素用于输入权重。

我们在此任务中添加的大多数 CSS 仅仅是装饰性的,并且特定于此示例。如果需要不同的外观和感觉,它很容易会完全不同。我们已经让地图容器占据了页面的全宽度和高度,并且设计了界面,使其似乎漂浮在页面的右侧。

初始化地图

让一个可缩放和可平移的交互式 Google 地图运行起来只需要极少量的代码。在这个任务中,我们将添加这段代码,并设置稍后在脚本中将使用的一些变量。

为起飞做准备

在这个任务中,我们将初始化配置地图所需的变量,并调用 Google 地图 API。我们应该从添加标准 jQuery 封装到之前创建的空白 google-map.js 文件开始:

$(function () {
    //all other code in here...
});

记住,$(function () { … }); 结构是 jQuery 的 document.ready 事件处理程序的快捷方式。

启动推进器

在我们刚刚添加的封装器中,我们应该添加以下代码:

var api = google.maps,
    mapCenter = new api.LatLng(50.91710, -1.40419), 
    mapOptions = {
        zoom: 13,
        center: mapCenter,
        mapTypeId: api.MapTypeId.ROADMAP,
        disableDefaultUI: true
    },
    map = new api.Map(document.getElementById("map"), mapOptions),
    ui = $("#ui"),
    clicks = 0,
    positions = [];

目标完成 - 迷你简报

在这个任务中,我们首先创建了一些需要初始化地图的变量。我们将在整个代码中处理 google.maps 命名空间,因此我们设置的第一个变量是为了方便起见而设置的顶级两个命名空间的内容。

拥有一个本地范围的副本,可以直接访问我们想要使用的实际 API,这将使我们的代码稍微更有效率,因为我们的代码更容易解析一个变量。而且,一开始输入时也会快得多。

Google 地图 API 使用的所有属性和方法都是命名空间的。它们都位于 maps 命名空间中,而 maps 命名空间本身位于 google 命名空间中。Google 在许多不同应用程序中使用了如此庞大的代码库,因此使用命名空间将所有内容隔离并组织起来是有意义的。

注意

有关 JavaScript 命名空间复杂性的深入讨论,请参阅 JavaScript 专家 Addy Osmani 的关于这个主题的优秀文章(addyosmani.com/blog/essential-js-namespacing/)。

接下来,我们存储我们想要将地图居中显示的纬度和经度。这是使用 Google 地图 API 的 LatLng() 方法完成的,该方法接受两个参数,纬度和经度值,并返回一个用于其他 API 方法的 LatLng 对象。请注意我们如何使用本地的 api 变量调用 LatLng 构造函数。

然后,我们可以创建一个对象字面量,其中包含我们的地图将需要的一些配置选项。这些选项包括缩放级别、地图应该居中的位置、地图类型,以及一个禁用默认地图类型和缩放/平移控件的选项。我们可以使用 mapCenter 中包含的 LatLng 对象作为 center 配置选项。

然后,我们使用地图 API 的Map()构造函数创建一个新的地图实例。这个函数接受两个参数:第一个是地图应该呈现的 DOM 元素,第二个是包含我们想要设置的配置选项的对象文字。

第一个参数需要一个真正的 DOM 元素,而不是一个用 jQuery 包装的 DOM 元素。因此,虽然我们可以使用 jQuery 从页面中选择元素,然后提取原始的 DOM 元素,但更有效的方法是使用 JavaScript 的原生getElementById()函数来检索我们在上一个任务中添加到页面中的地图容器,并将其传递给Map()构造函数。

接下来,我们缓存一个用于 UI 容器的 jQuery 选择器,以便我们可以重复地从页面中访问它,而不必每次都从 DOM 中选择它,并定义一个名为clicks的变量,我们将用它来记录地图被点击的次数。我们需要在顶层函数范围内定义它,以便我们可以在代码中后续的点击处理程序中引用它。

最后,我们在变量positions中添加一个空的数组文字,以便在需要存储地图上不同区域时稍后填充。数组需要在顶层函数范围内,以便我们在后面的代码中从不同的事件处理程序中访问它。

显示公司总部及自定义叠加层

在这个任务中,我们将在地图上直接放置公司总部,通过添加一个自定义标记和叠加层,提供一些关于公司的基本信息,也许还有场所的图片。

准备升空

在这个任务中,我们将涵盖以下子任务:

  • 在地图上添加一个标记

  • 添加一个包含有关公司信息的隐藏元素

  • 在新标记被单击时添加一个自定义叠加层以显示公司信息

  • 在标记被单击时添加一个单击处理程序来显示叠加层

启动推进器

在上一个任务中添加的变量后面,可以通过以下简单的代码块实现在地图上添加自定义标记:

var homeMarker = new api.Marker({
    position: mapCenter,
    map: map,
    icon: "img/hq.png"
});

要为我们的新标记创建信息叠加层,或者使用正确的谷歌术语,信息窗口,首先应该添加一个包含我们希望在叠加层中显示内容的 HTML 元素。我们可以在 UI 容器后面直接添加以下新的元素集合到google-map.html中:

<div id="hqInfo">
    <img class="float-left" src="img/140x100"/>
    <h1>I Am Mover</h1>
    <p>This is where we are based.</p>
    <p>Call: 0123456789</p>  
    <p>Email: info@i-am-mover.com</p>
</div>

提示

我们再次使用placehold.it服务,这样我们就不必为这个示例内容担心获取或创建实际的图像。在快速创建原型时,这是一个很好的服务。

为了告诉地图新的信息窗口,我们可以使用以下代码,在google-map.jshomeMarker代码后直接添加:

var infoWindow = new api.InfoWindow({
    content: document.getElementById("hqInfo")
});

我们还需要一些额外的 CSS 来样式化信息窗口的内容,并在需要时隐藏它。将以下代码添加到google-map.css的底部:

body > #hqInfo { display:none; }
#hqInfo { width:370px; }
#hqInfo h1 { margin-bottom:.25em; line-height:.9em; }
#hqInfo p { margin-bottom:.25em; }

最后,我们可以添加一个简单的点击处理程序,使用以下代码,在刚刚在google-map.js中添加的infoWindow变量之后添加:

api.event.addListener(homeMarker, "click", function(){
    infoWindow.open(map, homeMarker);
});

目标完成 - 小结

首先,我们定义了一个新的标记,使用的是 Google 的Marker()构造函数。这个函数接受一个参数,即定义标记不同属性的对象字面量。

我们将标记的position设置为地图的中心,以简化操作,尽管在定义其他标记时,您会看到任何LatLng对象都可以使用。我们还应该定义标记所属的地图,我们将其设置为包含地图实例的map变量。要指定用作标记的图像,我们可以提供一个相对路径的字符串格式给icon选项。

然后,我们向页面添加了一个新的容器,其中包含我们想要在自定义信息窗口中显示的信息。这里的内容并不重要;重要的是技术。我们还为信息窗口的内容添加了一些额外的样式。

为了将信息窗口添加到我们的地图实例中,我们使用了 Google 的InfoWindow()构造函数。这个方法也接受一个参数,再次是一个对象字面量,其中包含我们希望设置的选项。在这个示例中,我们只是将content选项设置为包含我们刚刚添加到页面上内容的元素。

这应该是一个实际的 DOM 元素,因此我们使用 JavaScript 的document.getElementById()来获取元素,而不是使用 jQuery 进行选择。

最后,我们使用 Google 的addListener()方法向地图添加了一个事件处理程序。该方法接受要附加事件处理程序的元素作为第一个参数,本例中为我们添加的标记;要监听的事件作为第二个参数;以及处理事件的回调函数作为第三个参数。该方法的签名与其他常见 JavaScript 库中找到的事件处理方法非常相似,尽管与 jQuery 中添加事件处理程序的方式略有不同。

在作为addListener()方法的第三个参数传递的匿名函数中,我们所做的就是调用我们信息窗口的open()方法。open()方法接受两个参数;第一个是信息窗口所属的地图,第二个是信息窗口添加到的位置,我们将其设置为我们的标记。

在这一点上,我们应该能够在浏览器中运行页面,单击我们的自定义标记,并将隐藏的<div>的内容显示在信息窗口中,如下面的截图所示:

目标完成 - 小结

捕获地图上的点击事件

在这个任务中,我们需要为地图添加一个点击处理程序,以便访问者可以设置其交通旅程的起点和终点。

启动推进器

首先,我们需要添加当地图被单击时将执行的函数。在上一个任务中添加的监听器之后,直接添加以下函数表达式:

var addMarker = function (e) {

    if (clicks <= 1) {

        positions.push(e.latLng);

        var marker = new api.Marker({
            map: map,
            position: e.latLng,
            flat: (clicks === 0) ? true : false,
            animation: api.Animation.DROP,
            title: (clicks === 0) ? "Start" : "End",
            icon: (clicks === 0) ? "img/start.png" : "",
            draggable: true,
            id: (clicks === 0) ? "Start" : "End"
        });

        api.event.trigger(map, "locationAdd", e);

    } else {
        api.event.removeListener(mapClick);
        return false;
    }
}

然后,为地图上的单击附加一个触发此函数的监听器,我们可以在其后直接添加以下代码:

var mapClick = api.event.addListener(map, "click", addMarker);

目标完成 - 小型简报

首先,我们添加了每次单击地图时将执行的函数。该函数会自动通过addListener()方法传递事件对象,其中包含了在地图上单击的坐标的latLng对象。

函数中的第一件事是将事件对象的latLng属性存储在我们的positions数组中。我们需要知道单击了哪两个位置,因此将它们都添加到我们的positions数组中很有用,并且该数组可以在整个代码中可见。

然后我们检查之前定义的clicks变量是否小于或等于1。如果是,我们继续使用 Google 的Marker()构造函数创建一个新的标记。之前在添加标记显示公司总部时我们已经使用了该构造函数,但这次我们设置了一些不同的属性。

我们将map属性设置为我们的地图实例,并将标记的position设置为事件对象中包含的latLng对象,该对象将匹配在地图上单击的点。

我们将为第一次单击使用绿色标记图像,表示旅程的起始点。我们将使用的图像已经有了自己的阴影,因此当添加第一个标记时,我们可以使用 JavaScript 三元运算符确定是否clicks等于0,然后将flat属性设置为true以禁用 Google 否则会添加的阴影。

我们可以轻松地添加一个漂亮的掉落动画,以使当地图被单击时新的标记掉落到位。动画采用弹跳的缓动效果,视觉上也很愉悦。动画使用animation属性进行设置,该属性使用Animation API 设置为DROP

我们还可以设置标记的title,当光标悬停在上面时会显示,使用title属性。同样,我们使用一个简单的 JavaScript 三元运算符根据clicks变量的值设置StartEnd字符串。

我们使用icon属性指定用于起始标记的图像的路径。当clicks不等于0时,我们只指定一个空字符串,这会导致添加默认的红色标记。

我们还将draggable属性设置为true,以使标记可拖动。这将允许用户根据需要修改旅程的起始位置或终点位置。稍后我们可以添加处理这一功能的代码。

接下来,我们可以使用谷歌的eventAPI 来触发一个自定义事件。我们使用trigger()方法,指定map实例作为事件源对象,locationAdd作为我们自定义事件的名称,并将我们在addMarker()函数中使用的事件对象(存储在e中)作为参数传递给可能正在监听我们自定义事件的任何处理程序。我们在下一节中添加对此事件的处理程序。

最后,我们可以在标记上设置一个唯一的id属性,以便我们可以区分每个标记。当我们想要在标记拖动后更新我们的 UI 时,我们会用到这一点,稍后我们会讨论这一点。

这是我们在clicks变量仍小于或等于1的情况下想要做的一切。我们addMarker()函数中外部条件分支的第二个分支处理clicks大于1的情况。

在这种情况下,我们知道地图已经被点击了两次,所以当这种情况发生时,我们希望停止监听地图上的点击事件。我们可以使用eventAPI 的removeListener()方法解除绑定我们的处理程序。该方法只需一个对addListener()方法返回的eventListener的引用。

当我们将地图上的点击事件绑定到我们的addMarker函数时,我们将返回的内容存储在mapClick变量中,这是传递给removeListener()方法的内容。

在这一点上,我们应该能够在浏览器中运行页面,并通过单击不同位置来向地图添加新标记。

机密情报

在这个任务中,我们使用了函数表达式,通过将事件处理程序分配给一个变量,而不是更熟悉的函数声明。这通常被认为是一个好习惯,虽然在这种情况下不是必需的,但养成这种习惯肯定是一个好习惯。想要全面理解为什么函数表达式通常比函数声明更好,请参阅John Resig的博客文章ejohn.org/blog/javascript-as-a-first-language/

使用起点和终点位置更新 UI

一旦两个标记已添加到地图上,我们希望在页面右侧的 UI 侧边栏中显示它们的位置,以便在计算行程费用时使用。

我们将希望显示每个点击位置的完整街道地址,并添加一个按钮,触发基于访问者在地图上选择的位置计算报价。

为起飞做准备

在上一个任务中,我们使用了谷歌的trigger()方法,以便在每次通过点击向地图添加新标记时触发一个自定义事件。在这个任务中,我们将为该自定义事件添加一个处理程序。

到目前为止,在这个项目中,我们几乎完全使用了谷歌的地图 API,除了在代码的其余部分中添加了最初的document.load包装器之外,几乎没有使用 jQuery。在项目的这一部分,我们将纠正这一点,启动 jQuery 来更新我们的用户界面。

启动推进器

我们的自定义 locationAdd 事件的处理程序应该如下所示,可以直接添加到上一个任务的 mapClick 变量后面:

api.event.addListener(map, "locationAdd", function (e) {

    var journeyEl = $("#journey"),
        outer = (journeyEl.length) ? journeyEl : $("<div>", {
            id: "journey"
        });

    new api.Geocoder().geocode({
        "latLng": e.latLng }, 
        function (results) {

            $("<h3 />", {
                text: (clicks === 0) ? "Start:" : "End:"
            }).appendTo(outer);
            $("<p />", {
                text: results[0].formatted_address,
                id: (clicks === 0) ? "StartPoint" : "EndPoint",
                "data-latLng": e.latLng
            }).appendTo(outer);

            if (!journeyEl.length) {
                outer.appendTo(ui);
            } else {
                $("<button />", {
                    id: "getQuote",
                    text: "Get quote"
                }).prop("disabled", true).appendTo(journeyEl);
            }

            clicks++;
        });
});

因为我们将向页面添加一些新元素,所以我们还需要更新这个项目的样式表。在 google-map.css 的底部添加以下新样式:

#journey { margin-top:2em; }
#journey h3 { margin-bottom:.25em; }

目标完成 - 小型总结

我们以与添加点击事件相同的方式为我们的自定义 locationAdd 事件添加事件处理程序,使用 Google 的 addListener() 方法。

在事件处理程序中,我们首先定义了一些变量。第一个是一个缓存的 jQuery 对象,表示显示起始点和终点的元素。

然后我们设置的下一个变量是两者之一。如果我们将第一个变量设置为 jQuery 对象的长度,我们知道页面上存在行程元素,所以我们只是存储对它的引用。如果它不存在,我们将创建一个新元素用作行程元素,并将其 id 设置为 journey

当地图首次被点击时,行程元素不存在并将被创建。第二次点击地图时,该元素将存在,因此它将从页面中选择而不是被创建。

接下来我们使用 Google 的 Geocoder() API 的 geocode() 方法,它允许我们对 latLng 对象进行逆地理编码以获取街道地址。这个方法有两个参数。第一个是配置对象,我们可以用它来指定我们想要转换的 latLng 对象。

第二个参数是一个回调函数,一旦地理编码完成就会执行。这个函数会自动传递一个包含地址的 results 对象。

在这个回调函数中,我们可以使用 jQuery 创建新元素来显示地址,然后将它们附加到行程元素上。完整的街道地址在 results 对象的 formatted_address 属性中找到,我们可以将其设置为新元素之一的文本。我们还可以在此元素上设置一个 id 属性,以便在需要时可以轻松地通过编程选择它,并使用自定义的 data-latLng 属性存储位置的 latLng 对象。

results 对象还包含有关地址的一系列其他有用属性,因此一定要在您喜爱的基于浏览器的开发者工具包的对象浏览器中查看它。

如果行程元素不存在,我们可以将其附加到 UI 中以显示位置的地址。如果它存在,我们知道这是第二次点击,然后可以创建一个新的 <button>,该按钮可用于根据两个位置之间的距离生成报价。

我们使用 jQuery 的 prop() 方法禁用 <button> 元素来设置 disabled 属性。当 UI 中的 <input> 添加了重量后,我们可以稍后启用按钮。

一旦我们在 UI 中添加了显示行程起点和终点的新元素,我们就可以增加 clicks 变量,以便我们可以跟踪添加了多少个标记。

现在,当我们运行页面并点击地图两次以添加两个标记时,我们点击的点的地址应该显示在页面右侧的 UI 区域中。现在,我们还应该看到红色的结束标记,并且现在由于增加了 clicks 变量,我们只能添加两个标记。

处理标记重新定位

我们已经使我们的地图标记可拖动,因此我们需要处理标记拖动后的地址更改。这个任务将展示如何轻松完成。这只需要两个步骤:

  • 将每个标记绑定到 dragend 事件上

  • 为事件添加处理函数

启动推进器

首先,当创建标记时,我们需要将每个标记绑定到 dragend 事件上。为此,我们应该在 addMarker() 函数中添加以下突出显示的代码行,直接放在标记构造函数之后:

var marker = new api.Marker({
    map: map,
    position: e.latLng,
    flat: (clicks === 0) ? true : false,
    animation: api.Animation.DROP,
    title: (clicks === 0) ? "Start" : "End",
    icon: (clicks === 0) ? "img/start.png" : "",
    draggable: true,
    id: (clicks === 0) ? "start" : "end"
});

api.event.addListener(marker, "dragend", markerDrag);

接下来,我们应该添加 markerDrag() 函数本身。这可以直接放在我们在上一个任务中添加的 locationAdd 处理程序之后:

var markerDrag = function (e) {
    var elId = ["#", this.get("id"), "Point"].join("");

    new api.Geocoder().geocode({ 
        "latLng": e.latLng 
    }, function (results) {
        $(elId).text(results[0].formatted_address);
    });
};

目标完成 - 小型总结

在这个任务中,我们首先更新了 addMarker() 函数,将每个新的标记绑定到 dragend 事件上,该事件将在标记停止拖动时触发。我们将标记指定为 Google 的 addListener() 方法的第一个参数,该方法是要绑定到事件的对象。事件的名称 dragend 被指定为第二个参数,markerDrag 被指定为将处理事件的函数的名称。

然后,我们添加了 markerDrag() 作为函数表达式。因为它是一个事件处理程序,所以它将自动传递给事件对象,该对象再次包含我们需要传递给 Geocoder()latLng

在处理程序内,我们首先设置一个新变量,它将用作我们想要更新的 UI 元素的选择器。为了性能原因,我们使用 array.join() 技术来连接字符串,而不是将字符串连接在一起。我们连接的数组中的第一个和最后一个项目只是文本。

第二个项目将是一个字符串,其中包含 startend,这取决于拖动了哪个标记。在我们的事件处理程序内部,这指的是标记,因此我们可以使用它获取我们在创建每个标记时添加的自定义 id 属性,从而允许我们更新 UI 中的正确元素。

一旦构造了选择器,我们就可以像之前一样使用 Google 的 geocode() 方法来获取街道地址,这将给我们带来标记拖动后的新地址。

geocode() 的回调函数内,我们使用刚刚创建的选择器来选择 UI 中的 <p> 元素,并将其文本内容更新为新的地理编码地址。

现在当我们查看页面时,我们应该能够像以前一样将标记添加到地图中,然后拖拽它们并在页面右侧的 UI 区域中看到新的地址。

考虑到重量

现在我们有了两个地址——旅程的起点和终点标记。访客现在只需要输入一个重量,我们就能计算并显示距离和费用。

启动推进器

在这项任务中,我们所需要做的就是为 UI 区域中的<input>元素添加一个处理程序,这样一旦输入了重量,<button>就会变得可点击。我们可以通过以下代码实现这一点,直接添加到上一个任务中的markerDrag()函数之后:

$("#weight").on("keyup", function () {
    if (timeout) {
        clearTimeout(timeout);
    }

    var field = $(this),
        enableButton = function () {
            if (field.val()) {
                $("#getQuote").removeProp("disabled");
            } else {
                $("#getQuote").prop("disabled", true);
            }
        },
        timeout = setTimeout(enableButton, 250);
});

目标完成-迷你总结

我们可以使用 jQuery 的on()方法为用户生成的keyup DOM 事件添加事件处理程序。现在使用on()方法是在 jQuery 中附加事件处理程序的标准方法。旧的方法,如live()delegate()现在已被弃用,不应再使用。

在事件处理程序内部,我们首先检查是否设置了一个超时,如果设置了,就清除它。

然后我们缓存了<input>元素的选择器,以便我们可以在enableButton()函数中看到它。我们再次添加enableButton()函数,这次是作为函数表达式。

这个函数的作用只是检查<input>元素是否有值,如果有,我们使用 jQuery 的prop()方法将disabled属性设置为false。如果没有值,我们再次通过将disabled属性设置为true来禁用它。最后,我们使用 JavaScript 的setTimeout()函数设置了一个超时,将enableButton()函数作为第一个参数传递给它。我们将250,或四分之一秒,作为超时长度。超时存储在timeout变量中,准备好在下次函数被执行时检查。

机密情报

我们在这里使用超时的原因是为了限制enableButton()函数被执行的次数。每输入一个字符后,函数就会被调用。

四分之一秒的延迟几乎是难以察觉的,但如果有人快速在字段中输入了一个长数字,它就会大大减少函数运行的次数。在函数内部,我们从页面中选择一个元素并创建一个 jQuery 对象。这并不太过于密集,而且在这个例子中我们可能甚至不需要担心它。但像这样使用超时是一个健壮的解决方案,可以帮助在频繁触发的事件处理程序内执行更加密集的操作时提供帮助。

我们本来可以只使用 jQuery 的one()方法来附加一个事件处理程序,它只是简单地启用<button>,然后自行删除。但是,这样就不允许我们在字段中输入的数字被移除后再次禁用<button>

显示预计距离和费用

我们在这个项目中的最后一个任务是获取两个标记之间的距离并计算旅程的成本。一旦计算出来,我们可能也应该向访问者显示结果。

启动推进器

首先,我们应该为我们的<button>附加一个点击事件处理程序。在我们在上一个任务中添加的keyup事件处理程序之后,直接添加以下代码:

$("body").on("click", "#getQuote", function (e) {
    e.preventDefault();

    $(this).remove();
});

接下来,我们可以获取两点之间的距离。在我们刚刚添加的remove()方法之后(但仍在点击处理程序函数内部),添加以下代码:

new api.DistanceMatrixService().getDistanceMatrix({
    origins: [$("#StartPoint").attr("data-latLng")],
    destinations: [$("#EndPoint").attr("data-latLng")],
    travelMode: google.maps.TravelMode.DRIVING,
    unitSystem: google.maps.UnitSystem.IMPERIAL
}, function (response) {

});

现在我们只需要计算并显示成本,我们可以通过添加以下代码到我们刚刚添加的空回调函数来完成。首先我们可以添加我们需要的变量:

var list = $("<dl/>", {
        "class": "clearfix",
        id: "quote"
    }),
    format = function (number) {
        var rounded = Math.round(number * 100) / 100,
            fixed = rounded.toFixed(2);

        return fixed;
    },
    term = $("<dt/>"),
    desc = $("<dd/>"),
    distance = response.rows[0].elements[0].distance,
    weight = $("#weight").val(),
    distanceString = distance.text + "les",
    distanceNum = parseFloat(distance.text.split(" ")[0]),
    distanceCost = format(distanceNum * 3),
    weightCost = format(distanceNum * 0.25 * distanceNum),
    totalCost = format(+distanceCost + +weightCost);

接下来我们可以生成用于显示计算出的数字的 HTML 结构:

$("<h3>", {
    text: "Your quote",
    id: "quoteHeading"
}).appendTo(ui);

term.clone().html("Distance:").appendTo(list);
desc.clone().text(distanceString).appendTo(list);
term.clone().text("Distance cost:").appendTo(list);
desc.clone().text("£" + distanceCost).appendTo(list);
term.clone().text("Weight cost:")
            .appendTo(list);

desc.clone().text("£" + weightCost).appendTo(list); term.clone().addClass("total").text("Total:").appendTo(list);
desc.clone().addClass("total")
            .text("£" + totalCost)
            .appendTo(list);

list.appendTo(ui);

最后,我们可能应该为我们刚刚创建并添加到页面中的新元素添加一些额外的样式。在google-map.css的底部,添加以下新样式:

#quoteHeading { 
    padding-top:1em; border-top:1px dashed #aaa; 
    margin-top:1em;
}
#quote dt { margin-right:0; }
#quote dd { width:50%; }
#quote .total { 
    padding-top:.5em; border-top:1px dashed #aaa; 
    margin-bottom:0; font-size:1.5em; 
}

目标完成 - 小结

我们首先使用 jQuery 的on()方法将点击事件处理程序绑定到页面的body上。这次我们使用了该方法的三个参数形式,其中第一个参数仍然是事件的名称,第二个参数是用于筛选事件的选择器,第三个参数是事件发生时触发的函数。

JavaScript 中的事件会通过它们的容器冒泡,并且当事件到达body时,它将被第二个参数用作筛选器过滤,并且只有当它是由与选择器匹配的元素分派时,函数才会被执行。在这个示例中,只有由<button>分派的事件才会触发该函数。

使用这种形式的on()方法为我们提供了一种使用强大的事件委托的方法,这使我们能够为可能存在也可能不存在的元素绑定事件。

在处理程序函数中,我们首先阻止了浏览器的默认行为。因为页面上没有<form>,所以不应该有任何默认行为,因此<button>没有什么可提交的。但是如果有人试图在一个通常包含页面上大多数甚至所有元素的<form>的 ASPX 页面上运行这个,它可能会以意想不到的方式行事。除非绝对必要,否则应始终使用preventDefault()

然后我们从页面中移除了<button>。请注意,尽管事件处理程序绑定到了<body>,但处理程序函数内部的this对象仍指向触发事件的<button>元素。

然后我们使用了 Google 的另一个 API - DistanceMatrixService(),它允许我们在地图上计算两个或多个点之间的距离。因为我们不需要引用DistanceMatrixService()构造函数返回的对象,所以我们可以直接将getDistanceMatrix()方法链接到它上面。

这个方法有两个参数,第一个参数是一个配置对象,第二个参数是一个方法返回时执行的回调函数。回调函数会自动传入一个包含响应的对象。

我们使用第一个参数来设置几个配置选项。originsdestinations选项都采用了数组的形式,其中每个数组中的每个项目都是一个latLng对象。我们可以使用自定义的data-latLng属性,它在显示地址时设置,很容易地获取这两个标记的latLng对象。

我们还将travelMode选项设置为通过道路行驶的距离,使用google.maps.TravelMode.DRIVING常量,并将unitSystem选项设置为google.maps.UnitSystem.IMPERIAL,以获得英里而不是公里的距离,除了因为我是英国人,习惯使用英里之外,并没有其他原因。

我们提供的回调函数会自动传入一个结果对象,其中包含了距离矩阵返回的结果。回调函数的前半部分涉及创建变量和计算值。函数的后半部分处理显示已计算的信息。

我们首先创建一个新的<dl>元素,并给它一个class,这是在common.css样式表中需要使用的,以及一个id属性,主要用于装饰性样式。然后我们添加一个简单的函数表达式,接收一个数字作为参数,对其四舍五入,然后将其修正为两位小数,最后返回它。我们将使用这个函数来确保我们的财务数字符合要求的格式。

我们还创建了一个新的<dt>元素和一个新的<dd>元素,可以根据需要克隆多次,而无需反复创建新的 jQuery 实例,然后使用 jQuery 的val()方法存储在重量文本字段中输入的值。

接下来,我们从传递给回调函数的对象中提取distance属性。它的结构可能看起来复杂,因为我们实际上感兴趣的对象被埋在一个多维数组中,但正如方法的名字所暗示的,它能够返回多个起点和目的地的复杂结果矩阵。

在此之后,我们连接一个字符串,其中包括我们刚刚存储的distance对象的text属性和完整的单词miles。距离矩阵以mi的形式返回英里的结果,因此我们在值的末尾添加字符串les

然后我们通过在英里数量和字母mi之间进行拆分来获取数字距离。JavaScript 的split()函数会返回一个包含字符串部分的数组,该数组包含了拆分字符的前端,但不包括拆分字符和拆分字符后的部分。我们只对数组中的第一个项目感兴趣,并且使用 JavaScript 的parseFloat()函数来确保这个值绝对是一个数字而不是一个字符串。

现在我们有足够的信息来实际计算旅程的费用了。我们指定了每英里的费用为 £3,所以我们将距离乘以 3,然后将结果传递给我们的format()函数,以便数字的格式正确。

我们还可以通过非常类似的方式计算每千克每英里的费用,首先将重量乘以每千克的成本,然后乘以距离。再次将这个数字传递给我们的format()函数。然后,我们可以通过将这两个数字相加来计算总费用。我们一直在使用的数字变成了字符串。为了解决这个问题,我们仍然可以使用我们的format()函数,但是我们需要使用+字符作为我们要添加的每个值的前缀,这将强制它们成为数字而不是字符串。

一旦我们创建了要显示的图形,我们就可以创建我们需要用来显示它们的新元素,首先是一个漂亮的标题,以帮助澄清我们正在添加到 UI 的新信息集。

然后我们可以创建包含每个标签和图形的<dt><dd>元素的克隆。一旦这些被创建,我们就将它们附加到我们创建的<dl>元素上,然后最终将新列表作为一个整体附加到 UI 上,如下图所示:

目标完成 - 小结

机密情报

机敏的你会注意到,我们在这个例子中使用的数值舍入解决方案并不那么健壮,并且不会像真实处理实际货币所需的那样准确(或正确地)舍入所有分数。

JavaScript 不像其他一些语言那样优雅地处理浮点运算,因此创建一个完美的舍入系统,100% 正确地舍入是超出了本书范围的。

对于那些感兴趣的人,stackoverflow 网站上发布了一些极具启发性的关于 JavaScript 货币格式化的问题的答案。例如,参见:stackoverflow.com/questions/149055/how-can-i-format-numbers-as-money-in-javascript

任务完成

在这个项目中,我们涵盖了大量的 Google 和 jQuery 功能。具体来说,我们研究了以下主题:

  • 使用Marker()InfoWindow()构造函数将标记和覆盖物添加到地图上。

  • 对地图驱动事件的反应,比如点击标记或标记拖动。事件处理程序使用google.mapsAPI 的addListener()方法附加。我们还看到如何使用trigger()方法触发自定义事件。

  • 使用 Google 的服务来操作地图生成的数据。我们使用的服务是Geocoder(),用于反向地理编码地图上每个点击的点的latLng,以获取其地址,以及DistanceMatrixService(),用于确定点之间的距离。

  • 利用 jQuery 的事件功能,使用on()方法添加标准事件和委托事件,以便检测 UI 的不同部分与之交互的情况,比如点击<button>或输入<input>

  • 使用 jQuery 强大的 DOM 操作方法来更新 UI,包括地址和报价。我们使用了一系列这些方法,包括clone()html()text()prop(),既选择又创建新元素。

你准备好全力以赴了吗?一个火热的挑战

在这个例子中,访客只能生成一份报价。一旦点击getQuote <button>,结果就会显示,不再允许进一步交互。为什么不在生成报价时添加一个重置按钮到 UI?访客可以清除报价和地图上的标记,从头开始。

第四章:jQuery Mobile 单页面应用程序

jQuery mobile 是一个令人兴奋的项目,它将 jQuery 的强大功能带入了手持设备和移动体验的世界。与 jQuery UI 类似,它在 jQuery 核心基础上构建和扩展了一系列 UI 小部件和辅助工具。在这种情况下,这些小部件被优化用于移动显示和触摸界面。

我们还将使用 JsRender,这是 jQuery 的官方模板解决方案,也是 jQuery 模板插件 tmpl 的后继者。

任务简报

在本项目中,我们将构建一个简单的应用程序,该应用程序寻找在堆栈溢出上有未颁发奖励的问题。我们将其称为赏金猎人。它将只包含一些单独的页面,但将被制作成感觉像是一个本地应用程序,而不是一个标准的网站。

虽然使用 jQuery Mobile 构建的站点和应用程序在笔记本电脑或台式机上运行得很好,但 jQuery Mobile 坚持采用先移动的理念,先构建最小的布局。

这是我们在整个项目中将重点关注的布局。如果您没有智能手机或其他功能强大的移动设备,我们将构建的示例应用程序仍将在普通桌面浏览器中正常工作。

在本项目中,我们将构建的应用程序将如下截图所示:

任务简报

它为什么如此令人敬畏?

jQuery Mobile 提供了对所有主要现代智能手机和平板电脑的全面支持,并且非常重要的是提供了一致性。它还向更广泛范围的常见但可能更老、功能更差的移动设备提供了有限支持。它建立在 jQuery 本身稳固的基础之上,并且从 jQuery UI 那里借鉴了许多最佳实践,特别是在小部件如何初始化和配置方面。

jQuery Mobile 提供了两种小部件初始化的方式;我们可以使用广泛的 HTML5 data- 属性系统,它将自动触发小部件的初始化,无需任何额外的配置,或者我们可以动态创建小部件,并纯粹通过脚本调用它们。

这两种技术各有优缺点,我们将在本项目中学习这两种技术,这样您就可以决定哪种方式最适合您。

您的炫目目标

这些是本项目将分解成的任务:

  • 构建欢迎屏幕

  • 添加第二个页面

  • 创建脚本包装器

  • 获得一些赏金

  • 添加一个 JsRender 模板

  • 构建列表视图

  • 构建一个项目视图

  • 处理分页

任务清单

jQuery Mobile 网站提供了一个页面模板,可用作使用该框架进行开发时的起点。我们可以将该模板用作此项目的基础。要设置,请访问 jquerymobile.com/demos/1.2.0/docs/about/getting-started.html

复制“创建基本页面模板”部分显示的模板,并将其另存为 bounty-hunter.html 在我们的主工作目录中。 此模板包含我们启动所需的一切。

在这一点上,我们还应该链接到 JsRender; 在我们刚刚保存的模板中的链接到 jQuery Mobile 的 <script> 元素之后直接添加以下代码:

<script src="img/jsrender.js">
</script>

注意

在撰写时,当前版本的 jQuery Mobile 与 jQuery 1.9 不兼容。 我们将从 jQuery Mobile 网站获取的模板将链接到兼容版本的 jQuery,并且一旦 jQuery Mobile 达到 1.3 里程碑,1.9 支持将很快可用。

为了测试我们的移动应用,我们还应该为该项目使用 Web 服务器,以便使用适当的 http:// URL 而不是 file:/// URL 查看测试页面。 您可能已经在计算机上安装了开源 Web 服务器,例如 Apache,如果有,那就没问题了。

如果您尚未安装和配置 Web 服务器,我建议下载并安装微软的 Visual Web Developer ExpressVWDE)。 这是微软行业标准 IDE Visual Studio 的免费版本,除了包含内置的开发 Web 服务器外,还是一个非常强大的 IDE,具有 JavaScript 和 jQuery 的 Intellisense 支持以及一系列面向前端开发者的功能。

对于更喜欢开源软件的开发人员,Apache Web 服务器以及 PHP 和 MySQL 可以安装在 Mac 和 Windows 系统上。 为了使安装和配置更加简单,已经创建了一系列软件包,这些软件包一起安装软件并自动配置,例如 XAMPP。

注意

VWDE 可以通过访问 www.microsoft.com/visualstudio/en-us/products/2010-editions/visual-web-developer-express 进行安装。

XAMPP 下载可在 www.apachefriends.org/en/xampp.html 获取。

构建欢迎页面

许多应用程序都有一个欢迎或主屏幕,用户可以返回以选择常见操作。 在这个项目的第一个任务中,我们将构建欢迎屏幕,它将包含一些简单的页面家具,如标题,页脚,徽标,并将包含一个搜索框和按钮,用于触发对 Stack Exchange API 的调用。

准备起飞

在此时,我们可以创建项目中将使用的其他资源。 我们应该在 css 文件夹中创建一个名为 bounty-hunter.css 的新样式表,以及一个名为 bounty-hunter.js 的新脚本文件。

我们应该在页面的 <head> 中添加一个 <link> 元素来链接样式表。 以下代码应该直接添加在 jQuery 移动样式表之后(jQuery 移动 <script> 元素之前):

<link rel="stylesheet" href="css/bounty-hunter.css" />

我们可以将 <script> 元素添加到通常的位置,就在关闭的 </body> 标签之前:

<script src="img/bounty-hunter.js"></script>

注意

由于 jQuery Mobile 提供了自己的基线样式,其中包括重置和排版默认值,因此在此示例中,我们不需要链接到我们的common.css文件。

启动推进器

我们下载的 jQuery Mobile 模板包含了大多数 jQuery Mobile 页面应该构建的推荐基本结构。我们将使用推荐的结构,但会向现有标记添加一些额外的属性。

我们应该在bounty-hunter.html中具有data-role="page"属性的<div>元素中添加一个id属性;将id属性设置为welcome

<div data-role="page" id="welcome">

接下来,我们应该修改原始标记,使其显示如下。首先,我们可以添加一个标题区域:

<div data-role="header">
    <h1>Bounty Hunter</h1>
</div>

接下来,我们可以直接在标题区域后面添加主要内容区域:

<div data-role="content">
    <p>
        Enter tag(s) to search for bounties on. 
        Separate tags with a semi-colon, or leave blank to get
        all bounties. 
    </p>
    <div class="filter-form">
        <label for="tags" class="ui-hidden-accessible">
            Search by tag(s):
        </label>
        <input id="tags" placeholder="Tag(s)" />
        <button data-inline="true" data-icon="search">
            Search
        </button>
    </div>
</div>
<img src="img/boba.png" alt="Bounty Hunter" />

最后,我们可以在主要内容区域后面添加一个页脚区域:

<div data-role="footer" data-position="fixed" 
    data-id="footer">

    <small>&copy; 2012 Some Company Inc.</small>
    <a href="bounty-hunter-about.html" data-icon="info" 
        data-role="button" data-transition="slide">About</a>

</div>

我们还可以为欢迎屏幕添加一些样式。将以下选择器和规则添加到bounty-hunter.css中:

.filter-form .ui-btn { margin:10px 0 0 0; float:right; }

.ui-footer small { display:block; margin:10px; float:left; }
.ui-footer .ui-btn { margin:2px 10px 0 0; float:right; }

目标完成 - 迷你总结

首先,我们更新了具有data-role="header"属性的容器<div>内部<h1>元素中的文本。

然后我们向内容容器添加了一些内容,包括一段简介文字和一个容器<div>。容器内部我们添加了<label><input><button>元素。

出于可访问性原因,jQuery Mobile 建议为所有<input>元素使用具有有效for属性的<label>元素,因此我们添加了一个,但然后使用ui-hidden-accessible类将其隐藏。这将允许辅助技术仍然看到它,而不会在视觉上混淆页面。

<input>只是一个带有id属性的简单文本字段,用于从脚本中轻松选择,以及一个placeholder属性,该属性将指定的文本添加到<input>内部作为占位符文本。这很好地用于在标签被隐藏时提供视觉提示,但在较旧的浏览器中可能不受支持。

<button>元素具有几个自定义的 jQuery Mobiledata-属性,并且在页面初始加载时将由框架自动增强。jQuery Mobile 根据元素类型和任何data-属性自动增强一系列不同的元素。增强通常包括将原始元素包装在容器中或添加其他附加元素以与之并列。

data-inline="true"属性将包围<button>的容器设置为inline-block,以便它不会占据视口的全部宽度。data-icon="search"属性为其添加了一个搜索图标。

我们在原始模板中为容器<div>元素添加了一些额外的data-属性,其中包括data-role="footer"属性。data-position="fixed"属性与data-id="footer"属性配合使用,将元素固定在视口底部,并确保在页面更改时不进行过渡。

在页脚容器内,我们添加了一个 <small> 元素,其中包含一些虚假的版权信息,通常在网页的页脚中找到。我们还添加了一个新的 <a> 元素,链接到另一个页面,我们将在下一个任务中添加。

该元素还具有几个自定义 data- 属性。data-icon="info" 属性为增强元素提供了一个图标。data-role="button" 属性通过框架触发增强,并赋予这个简单链接类似按钮的外观。data-transition="slide" 属性在导航到新页面时使用幻灯片转换。

最后,我们为这个项目的样式表添加了一些基本的样式。我们将搜索按钮浮动到右边,并通过 jQuery Mobile 更改了给它的边距。样式是使用我们添加到容器的类和框架添加的类添加的。我们需要同时使用这两个类来确保我们的选择器比框架使用的选择器更具特异性。

我们还对页脚元素进行了样式设置,使它们左右浮动并按需定位。我们必须再次击败 jQuery Mobile 主题中默认选择器的特异性。

到目前为止,我们应该能够在浏览器中运行页面,并在顶部和底部分别看到带有标题和页脚的主页,简单的搜索表单以及给应用程序提供基本身份的大橙色图像。

机密情报

jQuery Mobile 是建立在自定义 data- 属性系统之上的,我们可以给元素添加特定属性,并让框架基于这些属性初始化小部件。这个自定义 data- 属性框架并非强制性;如果需要的话,我们可以手动初始化和配置小部件。

但是使用属性很方便,让我们能够专注于添加我们想要的行为的自定义脚本代码,而不用担心我们想要使用的 jQuery Mobile 小部件的设置和初始化。

添加第二个页面

在这个任务中,我们将添加一个页面,关于 超链接,我们在欢迎页面的页脚容器中添加了链接到它。这使我们能够通过仅通过 data- 属性系统配置来体验 jQuery Mobile 转换的效果。

注意

有关更多信息,请参阅 jQuery Mobile data- 属性参考文档:jquerymobile.com/demos/1.2.0/docs/api/data-attributes.html

为起飞做准备

保存一个新的 jQuery Mobile 页面模板,我们在上一个任务中使用过,但这次将其命名为 bounty-hunter-about.html,并将其保存在主项目目录中(与 bounty-hunter.html 页面并列)。

我们还需要像之前一样链接到我们的 bounty-hunter.css 文件,我们的 bounty-hunter.js 文件以及 JsRender。

注意

有关 JsRender 的更多信息,请参阅文档:github.com/BorisMoore/jsrender

启动推进器

在我们的新bounty-hunter-about.html页面中,将<div>内的标记更改为带有data-role="page"的以下内容:

<div data-role="header">
    <a href="bounty-hunter.html" data-icon="home" 
    data-shadow="false" data-iconpos="notext" 
    data-transition="slide" data-direction="reverse" 
    title="Home"></a>

    <h1>About Bounty Hunter</h1>
</div>

<div data-role="content">
    <p>
        Bounty Hunter is an educational app built for the  
        jQuery Hotshots book by Dan Wellman
    </p>
    <a href="http://www.danwellman.co.uk">
        danwellman.co.uk
    </a>
</div>

<div data-role="footer" data-position="fixed" 
    data-id="footer">

    <small>&copy; 2013 Some Company Inc.</small>
    <a class="ui-disabled" href="#" data-icon="info" 
        data-role="button">About</a>

</div>

目标完成 - 迷你总结

这一次,除了在标题容器内的<h1>中设置一些不同的文本之外,我们还添加了一个新链接。这个链接返回到应用程序的欢迎画面,并使用了几个自定义data-属性。

data-icon,如前所述,设置了按钮应该使用的图标。我们可以使用data-shadow="false"禁用应用于图标外部容器元素的默认阴影,并设置data-iconpos="notext"属性使按钮成为只有图像的按钮。

我们还指定了data-transition="slide"属性,这样页面就可以很好地转换回欢迎页面,但是这次我们还设置了data-direction="reverse"属性,这样页面看起来就好像是倒退(也就是说,它以相反的方向滑动)到主页。因为我们将此链接放在<h1>元素之前,所以它将自动按照框架的设置向左浮动。

我们在content容器中添加了一些基本内容。这并不重要,正如您所看到的,我在这里为我的个人网站做了一些无耻的宣传。然而,这个外部链接并不完全无用,因为它表明,当一个链接以http://作为前缀时,jQuery Mobile 知道它是一个外部链接,并且不会劫持点击并尝试将其转换成视图。

您会注意到页脚容器与之前的data-属性相同,包括相同的data-id="footer"属性。这就是页脚容器具有持久性的原因。当页面转换到视图时,页脚将出现在转换区域之外,并固定在页面底部。

我们稍微修改了页脚容器中的<a>元素。我们删除了data-transition属性,并改为添加ui-disabled类。我们还将href更改为简单的哈希。因为我们已经在关于页面上,所以关于链接将不会做任何事情,所以我们将其禁用以避免在点击时重新加载页面。

机密情报

jQuery Mobile 通过劫持任何相对链接来添加它美丽的页面到页面的过渡效果。当点击相对链接时,jQuery mobile 将通过 AJAX 获取页面,将其插入到当前页面的 DOM 中,并将其转换为视图。

通常在使用 jQuery Mobile 站点时,您永远不会离开起始页面,因为框架会悄悄地劫持同域链接,并动态地将内容插入页面。因此,您可能认为每个页面都不需要链接到所有的 CSS 和脚本资源。

然而事实并非如此 - 如果有人直接访问内部页面会发生什么呢?或者如果点击外部链接后,访问者使用浏览器的返回按钮返回呢?在这两种情况下,他们将看到一个未增强、失效的页面,看起来和预期看到的页面完全不一样。

现在我们应该能够重新加载主页,然后点击页脚的关于按钮,看到关于页面。

创建脚本包装器

我们不会使用 jQuery 的$(document).ready() { }函数(或$(function() { })快捷方式)在页面加载完成时执行我们的代码。然而,我们仍然需要保护我们的顶层变量和函数免受全局范围的影响,因此我们仍然需要某种包装器。在这个任务中,我们将创建这个包装器,以及我们的顶层变量。

启动推进器

在空的bounty-hunter.js文件中,我们可以首先添加以下代码:

(function() {

    var tags = "",
          getBounties = function(page, callback) {

        $.ajax({
            url: "https://api.stackexchange.com/2.0/questions/featured",
            dataType: "jsonp",
            data: {
                page: page,
                pagesize: 10,
                tagged: tags,
                order: "desc",
                sort: "activity",
                site: "stackoverflow",
                filter: "!)4k2jB7EKv1OvDDyMLKT2zyrACssKmSCX
                eX5DeyrzmOdRu8sC5L8d7X3ZpseW5o_nLvVAFfUSf"
            },
            beforeSend: function () {
                $.mobile.loadingMessageTextVisible = true;
                $.mobile.showPageLoadingMsg("a", "Searching");
            }
        }).done(function (data) {

            callback(data);

        });
    };

}());

目标完成 - 小型总结

我们的脚本包装器由一个自执行的匿名函数组成(或者如果你喜欢的话,它也可以是一个立即调用的函数表达式)。这个外部函数被括号包裹着,并且在末尾有一个额外的方括号对,它使匿名函数立即执行并立即返回。这是一个已经在大型应用程序中经常使用的 JavaScript 模式。

这创建了一个封闭环境,将其中的所有代码封装起来,并使它远离全局命名空间,这使得代码更健壮,当与其他库或插件一起使用时更不容易出错或失败。

注意

如果你不确定闭包是什么,或者它能做什么,可以在 Stack Overflow 网站上找到关于它的很好的讨论(stackoverflow.com/questions/111102/how-do-javascript-closures-work)。

它也允许我们几乎在文档加载完成后立即运行代码。因为它所在的<script>元素就在<body>的底部,所以它将等到浏览器解析完页面的其余部分后才会被执行。

在匿名外部函数中,我们首先定义了一些变量。第一个叫做tags,将在项目的整个过程中在各种函数中使用,所以它需要在任何地方都能访问。最初它可以被设置为空字符串。

接下来的变量是一个名为getBounties()的函数,我们同样在顶层范围内定义它,这样它就可以在代码的其他地方被调用而不会出现问题。我们将使用这个函数在应用程序的生命周期的不同节点发出 AJAX 请求,而且大多数请求的参数都不需要更改。

我们使用 jQuery 的ajax()方法向 Stack Exchange API 发出 AJAX 请求。这个方法是 jQuery 的默认用于发出 AJAX 请求的方法,也是该库的辅助方法(如getJSON())所代理的方法。

ajax() 方法接受一个对象字面量,该字面量可用于配置 jQuery 支持的任何标准 AJAX 选项,以控制请求的执行方式。

url 属性设置了请求所发出的 URL,我们将其设置为我们想要使用的 Stack Exchange API 的入口点。我们将 dataType 设置为 JSONP,以便我们可以从 Stack Exchange 域获取数据,而不触发浏览器的跨域安全限制。

JSONJavaScript 对象表示法)是一种数据格式,其语法与 JavaScript 中的对象字面量非常相似,用于在不同平台或系统之间交换数据。JSONP(带填充的 JSON)是一种技术,它动态将新脚本注入页面,将 JSON 数据暴露给浏览器中的 JavaScript 解析器。这是必要的,因为浏览器的同源安全策略限制了数据可以从当前域加载的域。

Stack Exchange API 可以通过使用标准查询字符串参数以非常特定的方式配置,并过滤我们收到的数据,以启用或禁用特定功能。我们可以使用 jQuery 的 data AJAX 属性来添加我们希望设置的查询字符串参数。

注意

有关 Stack Exchange API 的更多信息,请参阅api.stackexchange.com/ 的文档。

我们使用 page 参数指定我们想要获取结果的哪一页,这将作为参数传递给函数。我们将返回的问题数量设置为 10,以将一次显示的数据量分页。这是使用 pagesize 参数设置的。

tagged 参数使用标签变量的值,我们可以在项目后期需要时操纵它。如果我们发送此参数而没有值,Stack Exchange API 不会抱怨,因此我们可以安全地设置它,而不管实际上是否有任何标签。

我们指定希望结果按降序排列,并按活动排序,因此最近活动的问题将首先列出。site 设置为 stackoverflow,以便仅从 Stack Exchange 网站的整个网络中接收问题。

最后一个配置属性是我已经在 Stack Exchange 上创建并保存的预定义过滤器。当浏览任何 API 方法时,都包含了用于执行此操作的工具。过滤器的目的是精确控制在响应中返回哪些字段,以确保我们不会收到比我们需要的更多数据。

注意

在此示例中,我们仅匿名使用 Stack Exchange API。对于完全生产就绪、供公众使用的应用程序,我们必须始终在 Stack Applications 中注册应用程序,并在进行任何请求时使用 API 密钥。

我们想要的一些字段未包含在默认过滤器中(如果在发出请求时未提供过滤器,则使用默认过滤器),而返回了许多我们不需要的字段。我们将在此处使用的过滤器仅提供了我们此项目所需的字段,并且不需要身份验证即可使用。

这些是我们需要为此请求设置的大多数 AJAX 选项;目前不知道的选项可以在调用函数时传递。我们将在下一个任务中看到如何做到这一点。

我们可以利用 jQuery 的beforeSendAJAX 事件,在发出请求之前直接显示 jQuery Mobile 的 AJAX 旋转器。每次转换页面时,jQuery Mobile 都会使用旋转器,但是我们可以在进行 AJAX 请求时将其曲解为自己的要求。

框架将自动将mobile对象附加到当前页面上运行的 jQuery 实例上。此对象包含用于配置 jQuery Mobile 环境的各种属性,以及用于触发框架中不同行为的各种方法。我们现在可以使用其中的一些。

为了确保我们希望添加的消息被显示出来,因为默认情况下旋转器使用不可访问的文本,我们将mobile对象的loadingMessageTextVisible属性设置为true

注意

在页面加载时,jQuery Mobile 创建了一个名为mobile的对象,其中包含一系列有用的属性和方法。

要实际显示旋转器,我们可以使用 jQuery Mobile 的showPageLoadingMsg()方法。此方法将主题色作为第一个参数,本例中我们可以将其设置为默认主题a,并将要在旋转器内显示的文本作为第二个参数。

ajax()方法之后,我们链式调用done()方法。这是自 jQuery 1.8 起处理成功的 AJAX 请求的新方法,取代了 jQuery 的success()方法。我们将一个匿名函数传递给此方法,以在请求对象返回时执行,此函数接收响应作为参数。在此函数中,我们只需调用将作为第二个参数传递给getBounties()callback()函数,将数据从响应传递给它。

机密情报

在这个任务中,我们使用了done()方法来处理来自 Stack Exchange API 的成功响应,而不是更常见的success()方法。这现在是处理成功响应的首选方法(截至 jQuery 1.8)。任何 jQuery 的 AJAX 方法返回的jqXHR对象的error()complete()回调方法已经被弃用,改用fail()always()

自 jQuery 1.5 起,AJAX 方法套件已将jqXHR对象作为 promise 或 deferred 对象返回,因此此 API 的更改将 AJAX 方法与 jQuery 中其他实现的 promise API 同步。

获取一些赏金

在这个任务中,我们需要从堆栈溢出获取一些赏金。一旦我们的应用程序的欢迎页面初始化完成,我们将希望初始化我们脚本的一部分。一旦这种情况发生,我们就可以附加一个处理程序到页面上的<button>,以触发使用我们在上一部分中添加的getBounties()函数进行 AJAX 请求。

启动推进器

bounty-hunter.js中的外部函数内,但在getBounties()函数之后,添加以下代码:

$(document).on("pageinit", "#welcome", function () {

    $("#search").on("click", function () {

        $(this).closest(".ui-btn")
                  .addClass("ui-disabled");

        tags = $("tags").val();

        getBounties(1, function(data) {

            data.currentPage = 1;

            localStorage.setItem("res", JSON.stringify(data)); 

            $.mobile.changePage("bounty-hunter-list.html", {
                transition: "slide"
            });
        });
    });
});

我们还可以在刚刚添加的代码之后直接为pageshow事件添加处理程序:

$(document).on("pageshow", "#welcome", function () {
    $("#search").closest(".ui-btn")
                        .removeClass("ui-disabled");
});

完成目标 - 小结

我们使用pageinit事件在页面第一次初始化时执行代码。由于新页面被拉入现有页面的 DOM 并显示的 AJAX 性质,因此在使用 jQuery Mobile 时,此事件比document ready更可靠。

我们使用 jQuery 的on()方法将此事件的事件处理程序绑定到文档对象,并将方法的第一个参数设置为pageinit事件。因为我们的脚本将用于每个页面,但是我们在此处添加的代码仅在欢迎页面上相关,所以我们使用方法的第二个参数来确保事件处理程序(我们将其添加为第三个参数)仅在事件起源于欢迎页面时执行。

然后,我们使用 jQuery 的on()方法将click事件的处理程序绑定到搜索<button>,再次使用。在处理程序中,我们首先向外部<button>容器添加ui-disabled类,以阻止进一步发起请求。然后,我们使用 jQuery 的val()方法获取可能在文本字段中输入的任何标签。这将返回文本输入的值,然后我们将其存储在我们的顶级tags变量中。

接下来,我们可以调用上一任务中添加的getBounties()函数。由于请求是由欢迎页面发起的,所以我们需要获取结果的第一页,因此将1作为第一个参数传递给该函数。

我们将一个匿名函数作为getBounties()的第二个参数。请记住,我们为done()方法添加的处理程序将执行该函数,并自动将响应中的数据传递给它。

在这个功能中,我们首先需要向我们的data对象添加一个新属性来存储当前页码。然后,我们可以存储data对象,以便在下一页中使用。我们可以使用localStorage来实现这一点,但是因为localStorage只能存储数组和原始类型,所以我们需要使用浏览器的原生JSON.stringify()方法将对象转换为 JSON 字符串。

然后,我们使用 jQuery Mobile 的changePage()方法将当前页面更改为我们将显示响应的页面。该方法的第一个参数是要更改到的页面的 URL,第二个参数是一个配置对象。

我们使用此配置对象来设置显示新页面时要使用的转换,该转换选项我们设置为slide

pageinit处理程序之后,我们还添加了一个pageshow事件的事件处理程序。每次显示页面时都会分派此事件,与仅在给定页面初始化时分派的pageinit事件不同。

我们再次将事件绑定到document对象,并再次通过#welcome选择器过滤事件,以确保代码仅在显示欢迎页面时运行。在事件处理程序内部,我们只是从外部的<button>容器中移除ui-disabled类。如果我们返回到欢迎页面,那可能是因为我们想执行一个新的搜索,也许使用不同的标签。

添加一个 JsRender 模板

在上一个任务结束时,我们使用changePage()方法调用了一个新页面,所以现在我们需要创建该页面。我们可以在新页面中添加我们的 JsRender 模板,准备好在下一个任务中构建列表视图时使用。

为升空做准备

再次使用 jQuery Mobile 的起始模板创建一个新页面。将其命名为bounty-hunter-list.html并将其保存在项目文件夹的根目录中。将data-role="page"包装器的id属性更改为list

在标题<div>中的<h1>可以更改为类似于Active Bounties的内容,并且我们可以像在关于页面上那样再次添加主页图标。页脚可以与欢迎页面上的相同。内容<div>可以一开始为空。

启动推进器

在我们刚刚创建的新页面底部,页面容器内,添加以下 JsRender 模板:

<script id="listTemplate" type="text/x-jquery-tmpl">
    <ul data-role="listview">

        {{for items}}
            <li data-shadow="false" data-icon="arrow-r" 
            data-iconpos="right">

                <a href="#" id="item-{{:#index}}">
                    <div class="bounty">
                        <span>+{{:bounty_amount}}</span>
                        <span class="expires">Expires on: 
                            <span class="value">
                                {{:bounty_closes_date}}
                            </span>
                        </span>
                    </div>
                    <h1 class="title">{{:title}}</h1>
                    <div class="meta">
                        <span>Answers: 
                            <span class="value">
                                {{:answer_count}}
                            </span>
                        </span>
                        <span class="activity">
                            Last activity on: 
                            <span class="value">
                                {{:last_activity_date}}
                            </span>
                        </span> 
                    </div>
                </a>
            </li>
        {{/for}}
    </ul>
</script>

目标完成 - 小型总结

包含模板的<script>元素具有一个非标准的type属性,以阻止浏览器解析脚本。它还具有一个id属性,以便我们在想要将模板与数据进行插值并呈现到页面时轻松选择它。

<script>元素内,我们首先创建一个<ul>元素,这将由 jQuery Mobile 转换为 Listview 小部件。我们给这个元素一个data-role属性为listview。然后我们使用 JsRender 的循环结构{{for}},它接受要循环遍历的对象或数组。在这种情况下,我们对data对象中的items数组感兴趣,该数组是在上一个任务结束时保存在 localStorage 中的一部分,并且将被传递给呈现模板的模板函数。

我们在{{for}}循环内添加的代码将针对items数组中的每个项目重复执行,该数组将由一系列来自 Stack Overflow 的问题组成。当我们稍后调用 JsRender 的template()方法时,将传递模板将迭代的对象到循环中。

我们添加的第一个元素是 <li>,因为这应该自然地是外部 <ul> 列表的子元素。我们为 <li> 元素添加了几个 data- 属性,包括 data-shadow="false" 以在每个 <li> 下禁用阴影,data-icon="arrow-r" 以给每个列表项添加右指向箭头图标,data-iconpos="right" 以将图标定位在元素的右侧。

贴士

Listitem 图标

为了让我们添加到列表项的图标显示出来,每个项目应包含一个链接。如果初始化小部件时在项目内找不到 <a> 元素,就不会添加图标。

在列表项内部,我们添加一个 <a> 元素并为其添加一个唯一的 id,以便在以后显示该项视图时使用。我们可以使用模板的循环索引创建唯一的 id,这在循环中作为 #index 对我们可用。

<a> 元素内部,我们还有其他几个元素。第一个是当前问题上提供的悬赏的容器。在这个容器内,我们有另一个 JsRender 令牌,它将被替换为我们正在迭代的对象的数据。为了在我们的模板中访问对象的属性,我们使用 {{:,后跟属性名称,最后以 }} 结束。在开头的双大括号内的冒号表示不应执行任何 HTML 编码。Stack Exchange API 将为我们清理数据,所以我们可以直接使用它。

我们还可以使用一些嵌套的 <span> 元素显示一些文本和悬赏过期的日期,其中一个具有用于特定样式的 class,还有我们数据对象的另一个属性。

我们可以使用 <h1> 元素输出问题的标题,另外还有另一个 JsRender 模板标记,从 data 对象内提取出当前项的 title 属性。

最后,我们可以显示有关问题的一些元信息,比如它有多少答案以及上次有活动的时间。这些信息与以前一样添加,使用 <span> 元素和 JsRender 模板标记的组合来显示从我们的数据对象中提取出的各种属性。

构建列表视图

现在,我们的应用程序应该已经收到了需要进行格式化和显示的数据。我们还添加了一个准备好用于构建 Listview 小部件的 Listitem 元素的 JsRender 模板。

现在,我们只需渲染模板并在小部件中显示结果。我们还可以向小部件添加一些额外的控件,让访问者在分页结果中导航,尽管目前我们还不会使这些控件功能实现。

启动推进器

首先,我们可以为列表页面的内容容器(bounty-hunter-list.html)添加一些附加标记:

<div class="ui-bar ui-bar-c">
    <a href="#" data-role="button" data-icon="back" 
    data-inline="true" data-mini="true" class="ui-disabled">
    Prev
    </a>

    <h2>Page 
        <span class="num"></span> of <span class="of"></span>
    </h2>

    <a href="#" data-role="button" data-icon="forward" 
        data-iconpos="right" data-inline="true" 
        data-mini="true" class="ui-disabled">
        Next
    </a>
</div>

<div id="results"></div>

<div class="ui-bar ui-bar-c footer-bar">
    <a href="#" data-role="button" data-icon="back" 
    data-inline="true" data-mini="true" class="ui-disabled">
    Prev
    </a>

  <h2>Page 
    <span class="num"></span> of <span class="of"></span>
  </h2>

    <a href="#" data-role="button" data-icon="forward" 
    data-iconpos="right" data-inline="true" 
    data-mini="true" class="ui-disabled">
    Next
    </a>
</div>

接下来,我们需要更新我们的脚本以渲染模板并显示数据。在 bounty-hunter.js 中,在 pageshow 事件的事件处理程序后直接添加以下代码:

$(document).on("pageinit", "#list", function () {

    var data = JSON.parse(localStorage.getItem("res")),
          total = parseInt(data.total, 10),
          size = parseInt(data.page_size, 10),
          totalPages = Math.ceil(total / size),
          months = [
            "Jan", "Feb", "Mar", "Apr", "May", "Jun", "Jul", 
            "Aug", "Sep", "Oct", "Nov", "Dec"
    ];

    var createDate = function (date) {
        var cDate = new Date(date * 1000),
              fDate = [
                cDate.getDate(), months[cDate.getMonth()], 
                cDate.getFullYear()
        ].join(" ");

        return fDate;
    }

    $.views.helpers({ CreateDate: createDate });

    $("#results").append($("#listTemplate")
                 .render(data))
                 .find("ul")
                 .listview();

    var setClasses = function () {
        if (data.currentPage > 1) {
            $("a[data-icon='back']").removeClass("ui-disabled");
        } else {
            $("a[data-icon='back']").addClass("ui-disabled");
        }

        if (data.currentPage < totalPages) {
            $("a[data-icon='forward']").removeClass("ui-disabled");
        } else {
            $("a[data-icon='forward']").addClass("ui-disabled");
        }
    };

    $("span.num").text(data.currentPage);
    $("span.of").text(totalPages);

    if (totalPages > 1) {
        $("a[data-icon='forward']").removeClass("ui-disabled");
    }
});

我们还需要稍微改变我们的模板。我们的模板中有两个地方显示日期属性;这两个地方都需要改变,以便它们显示如下:

{{:~CreateDate(bounty_closes_date)}}

并:

{{:~CreateDate(last_activity_date)}}

最后,我们需要为我们的新元素添加一些额外样式,以及将添加到 Listview 小部件中的条目。在bounty-hunter.css底部添加以下样式:

.ui-bar { margin:0 -15px 14px -15px; text-align:center; }
.ui-bar a:first-child { margin-left:-5px; float:left; }
.ui-bar a:last-child { margin-right:-5px; float:right; }
.ui-bar h2 { margin-top:10px; font-size:14px; }
.footer-bar { margin-top:14px; }

.bounty { 
    width:24%; border-radius:3px; margin-right:5%; float:left;
    text-align:center; font-size:90%; line-height:1.5em; 
    font-weight:bold; color:#fff; background-color:#07d; 
    text-shadow:none; 
}
.bounty span { display:block; }
.expires { 
    font-size:70%; font-weight:normal; line-height:1em; 
}
.expires .value { 
    display:block; font-size:110%; font-weight:bold; 
    line-height:1.5em; 
}
.title { 
    width:70%; margin-top:-.25em; float:left; 
    white-space:normal; font-size:80%; line-height:1.25em; 
    color:#07d; 
}
.meta { clear:both; }
.meta span { 
    width:24%; margin-right:5%; float:left; font-size:70%; 
    font-weight:normal; color:#999; 
}
.meta .value { 
    width:70%; margin-right:0; float:none; font-size:90%; 
    font-weight:bold; 
}

完成目标 - 小结

在这项任务的第一步中,我们在页面的内容容器中添加了一些新的 HTML 元素。

我们添加的第一个元素将用作位于 Listview 小部件上方的工具栏。工具栏中含有用于让访问者在不同结果页之间导航的链接。这个工具栏将从 jQuery Mobile 中继承许多样式,因为我们为它添加了ui-barui-theme类名。

由于它们具有data-role="button"属性,链接会被 jQuery Mobile 增强为 Button 小部件。我们还使用data-icon属性为它们添加图标,使用data-inline属性使它们成为inline-block,并使用data-mini属性使它们比标准按钮小。

最后,我们最初给按钮添加了ui-disabled类名。我们可以根据我们所在的页面以及是否有前一页或后一页可导航来启用每个按钮。

除了按钮外,工具栏还包含一个<h2>元素,告诉访问者他们在哪一页,以及总共有多少页。该元素的内容分成带有id属性的 span,以便我们稍后可以轻松更新它们。

我们还在页面上添加了一个空的容器<div>,它的id为 results。这个容器将成为我们渲染 Listview 小部件的元素。

我们直接在空 Listview 容器后添加的第二个工具栏与第一个工具栏在所有方面都是相同的,只是它多了一个footer-bar的额外类。我们用这个类添加了一些仅需要在底部工具栏中使用的 CSS。

在我们的脚本中,我们首先为列表页的pageinit事件添加了一个新的事件处理程序。这与之前使用 jQuery 的on()方法绑定方式相同。

在事件处理程序中,我们首先设置一系列变量。我们在代码中的各个地方存储了之前任务中存储在 localStorage 中的数据的引用,以及data对象的total结果和page_size属性。

我们还根据刚刚保存的totalsize变量计算出总页数,并创建一个包含缩写月份名称的数组,我们在格式化 Stack Exchange 返回的日期时会用到这个数组。

接下来,我们需要添加一个新方法,作为模板内部的辅助函数使用。我们将这个方法称为createDate,并指定该方法可以接受一个日期字符串作为参数。

在这个方法中,我们首先使用传递给该方法的日期字符串创建一个新的日期。这将以 UNIX 时代格式呈现,因此需要将其乘以 1000,以便与 JavaScript 的Date()构造函数一起使用。

Date()构造函数返回的日期字符串将是完整的 UTC 日期字符串,对于显示在我们的小奖励框中来说太长了,所以接下来我们定义一个新的数组,数组中的每个项目都是我们希望将现有字符串格式化为的日期字符串的一部分。

我们可以使用getDay()函数获取月份的天数。getMonth()函数将返回一个从零开始的数字,因此我们可以使用它从我们先前创建的数组中提取正确的缩写月份名称。最后,我们使用getFullYear()函数获取四位数的年份。一旦数组填充完毕,我们立即使用空格字符作为连接字符连接它,并从方法中返回结果字符串。

接下来,我们需要将我们的新方法注册为帮助函数,以便我们正在使用的模板可以访问它。这是使用 JsRender 创建的views对象的helpers()方法完成的,并将其附加到 jQuery 上。该方法以对象作为其参数,对象中的每个键是帮助方法的名称,每个值是我们希望用作帮助器的实际函数。在这个例子中,我们将CreateDate帮助方法映射到我们刚刚定义的createDate函数。

然后,我们使用其id选取 Listview 小部件,并向其附加已渲染的模板。模板是使用 JsRender 的render()方法呈现的,它接受包含要呈现的数据的对象作为参数。

接下来,我们定义另一个简单的函数,它将根据我们在data对象上存储的currentPage属性添加或删除按钮上的ui-disabled类名。

我们现在可以更新标题,显示当前页和总页数。我们可以使用 jQuery 的text()方法来做到这一点,并显示我们之前存储的data.currentPagetotalPages变量。

因为这只是列表页面加载的第一次,我们知道只有下一页按钮需要启用。我们使用属性选择器仅基于它们的data-icon属性选择两个前进按钮。我们将在下一个和最后一个任务中添加使该按钮工作的功能。

我们脚本中的最后一件事是启用前进按钮,以便查看下一页,但仅在要显示更多页面时才能这样做,这可以通过再次检查totalPages变量来确定。

添加脚本后,我们然后更新了模板,以利用我们创建的新的日期格式化辅助方法。要在模板中使用辅助方法,我们只需要使用~字符,后跟方法的注册名称。需要传递的任何参数,例如模板迭代中的每个项目的bounty_closes_datelast_activity_date属性,都是使用括号传递的,就像调用普通 JavaScript 函数一样。

渲染模板后,我们需要初始化 Listview。首先,我们通过获取容器内的新<ul>元素,然后使用其小部件方法,在这种情况下是listview(),将其增强为一个 Listview 小部件。

最后,我们添加了一些额外的 CSS 样式来微调 jQuery Mobile 中默认主题应用的样式。我们需要使工具栏与 Listview 小部件匹配,这可以通过使用负边距来实现,与 Listview 小部件本身一样简单。

Listview 的topbottom属性以及其leftright属性具有负边距,因此我们需要通过为顶部工具栏添加一些正边距来抵消这一点,并为底部工具栏添加一些正top边距。

我们还可以将后退和前进按钮分别浮动到左侧和右侧,并将标题文本居中。我们还将标题文本的大小缩小了几个像素,以确保它不会干扰我们的按钮。

Listview 内的元素样式几乎完全是为了视觉效果而添加的。Listview 本身将继承大量框架的样式,所以我们只需要担心每个 Listitem 内的元素。

一旦点击了赏金按钮并返回了结果,列表视图页面应该看起来像下面的截图:

目标完成 - 小结

机密情报

与 jQuery UI 一样,jQuery Mobile 小部件可以完全从脚本初始化,而不使用底层标记中的任何硬编码data-属性。我们也可以像在标记中保留外部<ul>元素一样,完全从脚本构建整个 Listview 小部件。

要初始化小部件,我们只需调用其小部件方法。如果我们正在创建一个 Listview,则小部件方法就是listview()。其他小部件可以以相同的方式初始化。与 jQuery UI 小部件类似,jQuery Mobile 小部件可以接受配置选项和事件处理程序,并且具有可以从脚本调用的方法。

构建项目视图

列表视图为每个包含问题的列表项提供链接。在这个任务中,我们可以添加当其中一个问题被选中时显示的页面。这将是单个问题的更详细视图,所以我们可以利用 Stack Exchange 返回给我们的其他一些属性。这次,我们不是链接到现有页面,而是动态创建一个新页面并将其注入到应用程序中。

启动推进器

我们将使用另一个模板来渲染项目视图,因为它非常方便;直接在bounty-hunter-list.html中的列表模板后面添加以下代码。我们可以从添加外部<script>包装器、外部页面容器和标题开始:

<script id="itemTemplate" type="text/x-jquery-tmpl">
    <div data-role="page" id="{{:pageid}}" class="item-view">
        <div data-role="header" data-position="fixed">
            <a href="bounty-hunter-list.html" 
            data-shadow="false" data-icon="arrow-l" 
            data-transition="slide" 
            data-direction="reverse" 
            title="Back to list view">
            Back
            </a>

            <h1>{{:title}}</h1>

        </div>
    </div>
</script>

接下来,我们可以添加内容区域包装器和内容标题。这应该放在页面容器中,在标题区域之后:

<div data-role="content">
    <header class="ui-helper-clearfix">
        <div class="bounty">
            <span>+{{:bounty_amount}}</span>
      <span class="expires">
        Expires on: 
        <span class="value">
          {{:~CreateDate(bounty_closes_date)}}
        </span>
      </span>
    </div>

    <div class="meta">
        <span>Asked on: 
            <span class="value">
                {{:~CreateDate(creation_date)}}
            </span>
         </span>
        <span>Answers: 
            <span class="value">
                {{:answer_count}}
            </span>
        </span>
        <span class="activity">Last activity on: 
            <span class="value">
                {{:~CreateDate(last_activity_date)}}
            </span>
        </span> 
    </div>

    <h1 class="title">{{:title}}</h1>
    <ul class="tags">
        {{for tags}}
            <li>{{:#data}}</li>
            {{/for}}
    </ul>

    <div class="owner">
        <a href="{{:owner.link}}" 
            title="{{:owner.display_name}}">
                <img src="img/{{:owner.profile_image}}" 
                     alt="{{:owner.display_name}}" />
            <div>
                <h3>{{:owner.display_name}}</h3>
                <span>
                    {{:owner.accept_rate}}% accept rate
                </span>
            </div>
        </a>
    </div>
    <a data-role="button" data-icon="gear" 
    data-inline="true" href="{{:link}}" 
    title="Answer on Stack Overflow">
    Answer on Stack Overflow
    </a>

  </header>

</div>

接下来,我们可以添加问题和答案列表。这应该直接放在我们刚刚添加的标题元素之后(但仍然在内容<div>内):

<div class="question">{{:body}}</div>

<h2>Answers</h2>
<ul class="answer">
    {{for answers}}
        <li data-shadow="false">
            <h3>Answered by: 
                <span class="answer-name">
                    {{:owner.display_name}}
                </span>, on 
                <span class="answer-date">
                    {{:~CreateDate(creation_date)}}
                </span>
            </h3>

            <div>{{:body}}</div>
        </li>
      {{/for}}
</ul>

最后,我们可以为页面添加页脚。这应该直接放在内容区域之后,但仍然在外部页面容器内:

<div data-role="footer" data-position="fixed" 
    data-id="footer">

    <small>&copy; 2012 Some Company Inc.</small>
    <a href="bounty-hunter-about.html" 
    data-icon="info" data-role="button" 
    data-transition="slide">
    About
    </a>
</div>

我们还需要添加一些脚本来渲染模板并处理页面更改行为。我们可以在我们在上一个任务中添加的列表页面的pageinit处理程序中执行此操作:

$("#results").on("click", "li", function () {

    var index = $(this).find("a").attr("id").split("-")[1],
          question = data.items[index];

    question.pageid = "item-view-" + index;

    $("body").append($("#itemTemplate").render(question));

    var page = $("#item-view-" + index);

    page.attr("data-external-page", true).on
    ("pageinit", $.mobile._bindPageRemove);

    $.mobile.changePage(page, {
        transition: "slide"
    });
});

最后,我们需要一些用于我们添加的模板中新元素的 CSS。在bounty-hunter.css中,在文件的末尾添加以下代码:

header { 
    padding:15px; border-bottom:1px solid #fff; 
    margin:-15px -15px 0 -15px; 
    box-shadow:0 1px 10px rgba(0,0,0,.3); 
}
header:after { 
    content:""; display:block; clear:both; visibility:hidden;
}
header .bounty { margin-bottom:.75em; }
header .meta { width:70%; float:left; clear:none; }
header .meta span { width:100%; }
header .title { 
    width:auto; margin:0; float:none; clear:both; 
    font-size:125%; 
}
.tags { padding:0; }
.tags li { 
    padding:.5%; border-right:1px solid #7f9fb6; 
    border-bottom:1px solid #3e6d8e; margin-right:1%; 
    margin-bottom:1%; float:left; list-style-type:none; 
    font-size:90%; color:#4a6b82; background-color:#e0eaf1;
}
header a { 
    margin-left:0; float:left; clear:both;
    text-decoration:none; 
}
.owner { 
    padding:2.5%; margin:15px 0; float:left; clear:both; 
    font-size:70%; background-color:#e0eaf1; 
}
.owner img { width:25%; margin-right:5%; float:left; }
.owner div { width:70%; float:left; }
.owner h3 { margin:-.25em 0 0; }
.owner span { font-size:90%; color:#508850; }

.question { 
    padding:15px; border-bottom:1px solid #000; 
    margin:-15px -15px 0 -15px;
}
.question img { max-width:100%; }

.answer { padding:0; list-style-type:none; }
.answer li { border-bottom:1px solid #000; font-size:80%; }
.answer h1, .answer h2, .answer h4 { font-size:100%; }
.item-view pre { 
    max-width:95%; padding:2.5%; border:1px solid #aaa; 
    background-color:#fff; white-space:pre-wrap;
}

目标完成 - 小结。

我们首先添加了一个新模板,用于显示单个问题的页面。这个模板比我们添加的第一个模板要大得多,原因有几个。主要是因为我们使用这个模板来构建整个页面,而且因为我们使用这个模板显示了更多的内容。这是问题的详细视图,所以我们自然希望显示比列表视图中显示的摘要更多的内容。

我们指定的外部页面容器被赋予一个id,我们将在我们的脚本中添加,以便我们可以轻松地选择正确的页面以显示它。除此之外,我们在我们的模板中添加了一些与我们在实际页面中一直添加的相同元素,例如标题、内容和页脚容器。

大部分操作都在内容容器内部进行,尽管我们使用的模板方式与之前完全相同 - 定义 HTML 元素并使用传递给render()方法的对象的属性进行插值。

在此模板中唯一的新技巧是创建标签列表。我们使用for结构来迭代标签列表,但这次我们迭代的属性是一个平面字符串数组,而不是对象。由于在模板标签中没有可用于获取值的键,我们可以使用特殊值#data,它将给我们当前正在迭代的数组中的当前项目。

我们添加到脚本中的所有代码都包含在一个单击处理函数中,我们将其绑定到页面上显示的结果列表上,因为我们希望对单个列表项的点击做出反应。

在处理函数中,我们首先设置一个变量,该变量将包含被点击的列表项的id属性的数字部分。我们可以通过使用 JavaScript 的split()函数,并指定连字符作为分隔符,轻松获取数字部分。

当我们渲染模板时,我们只想显示单个项目,因此我们不需要传递从 AJAX 请求中接收到的整个对象。相反,我们使用刚刚设置的index变量,从data对象内的items数组中仅获取表示我们感兴趣的问题的对象。

一旦我们存储了要传递给模板以进行渲染的对象,我们需要向其添加一个新属性,该属性作为模板中页面容器的id属性添加。这就是我们在question对象上设置的pageid属性。

接下来,我们再次使用 JsRender 的render()方法呈现我们的模板。我们将刚刚准备好的question对象传递给它,这一次模板呈现到页面的主体上。因为它被呈现在页面容器之外,所以不会立即可见。

一旦模板呈现到页面上,我们选择外部页面容器,并将其引用存储在page变量中。当动态创建一个新页面并将其附加到页面上时,jQuery Mobile 将保持其标记在页面中,即使我们离开页面也是如此。

要阻止这种情况发生,我们需要做两件事:首先,我们需要将页面的data-external-page属性设置为true。其次,我们需要为动态页面的pageinit事件设置处理程序。一旦新页面已初始化,当访问者使用内部 jQuery Mobile _bindPageRemove方法导航离开页面时,我们将其标记为删除。

一旦完成这一步,我们可以使用changePage()方法转到新页面。我们将之前存储的页面元素传递给该方法,并使用配置对象设置转换。

因为我们将changePage()方法传递了一个 DOM 元素而没有指定 URL,所以浏览器的地址栏不会更新,并且浏览器的历史记录中不会留下条目。

此时,我们应该能够在智能手机或平板电脑上运行页面,单击列表视图页面上的其中一个列表项,并查看项目视图,如下图所示:

目标完成 - 迷你总结

处理分页

对于我们的最后一个任务,我们将查看如何连接之前添加的分页元素。Stack Exchange API 使得以分页格式获取结果变得很容易,因此我们可以利用这一点。

从 Stack Exchange 请求所有可用数据并节省一次性大量数据的代价是,我们在用户发起更多数据请求时会发出更小的请求。

启动推进器

在我们为 Listview 内的<li>元素添加的click处理程序之后,添加以下代码:

$("a[data-icon='forward'], a[data-icon='back']").on("click", function () {

    var button = $(this),
        dir = button.attr("data-icon"),
        page = parseInt($("span.num").eq(0).text(), 10);

    if (dir === "forward") {
        page++;
    } else {
        page--;
    }

    getBounties(page, function (newData) {

        data = newData;
        data.currentPage = page;
        localStorage.setItem("res", JSON.stringify(newData));

        $.mobile.hidePageLoadingMsg();

        $("#results").empty()
                     .append($("#listTemplate")
                     .render(newData))
                     .find("ul")
                     .listview();

        $("span.num").text(page);

        setClasses();
    });
});

目标完成 - 小结

我们再次使用data-icon属性为所有四个按钮附加监听器,以便从页面中选择它们。不要忘记,这只会在第一次加载列表页面时完成一次。

然后,我们将引用存储到被点击的按钮、被点击按钮的data-icon属性的值以及当前页面。然后我们检查dir属性的值,如果等于forward,则增加当前页面,否则减少当前页面。

然后,我们可以再次调用我们的getBounties()方法,传递更新后的page变量和请求后执行的处理程序函数。

在此处理程序函数中,我们首先通过使用最近一次调用getBounties()返回的新对象更新data变量来更新存储的数据。我们再次向data对象添加一个currentpage属性,并更新我们在 localStorage 中的副本。

然后,我们可以使用hidePageLoadingMsg()jQuery Mobile 方法手动隐藏旋转器,然后使用新数据重新渲染列表模板。完成后,我们可以更新显示当前页面的显示,并调用我们的setClasses()实用函数分别启用或禁用前进和后退按钮。

任务完成

此时,我们应该拥有一个完全可工作的 jQuery Mobile 应用程序,可在桌面和移动设备上运行。这是一个简单的应用程序,但我们已经探索了相当数量的框架。还有很多东西要学习,但是看到我们在这个项目中使用的一些部分应该足以激发你深入研究框架及其提供的功能。

你准备好全力以赴了吗?一个高手的挑战

在这个项目中,到目前为止我们还没有研究过 jQuery Mobile 的主题能力。像 jQuery UI 一样,jQuery Mobile 受益于 Themeroller 的高级主题能力。

你在这个项目中的挑战是前往jquerymobile.com/themeroller/,为已完成的应用程序构建一个自定义主题。

第五章:jQuery 文件上传器

现在可以仅使用一些最新的 HTML5 API 和 jQuery 创建一个功能齐全的文件上传小部件。我们可以轻松添加对高级功能的支持,例如多个上传和拖放界面,而且只需稍微借助 jQuery UI,我们还可以添加引人入胜的 UI 功能,例如详细的文件信息和进度反馈。

任务简报

在本项目中,我们将使用 HTML5 文件 API 提供核心行为构建一个高级多文件上传小部件,并使用 jQuery 和 jQuery UI 构建一个引人入胜的界面,访问者将乐于使用。

我们将构建小部件作为 jQuery 插件,因为这是我们可能想要封装的东西,这样我们就可以将其放入许多页面中,并且只需进行一些配置即可使其工作,而不是每次都需要构建自定义解决方案。

为什么很棒?

jQuery 提供了一些出色的功能,使编写可重复使用的插件变得轻而易举。在本项目中,我们将看到打包特定功能和生成所有必要标记以及添加所有所需类型行为的机制是多么容易。

在客户端处理文件上传为我们提供了许多增强体验功能的机会,包括有关每个选择的上传文件的信息,以及一个丰富的进度指示器,使访问者了解上传可能需要多长时间。

我们还可以允许访问者在上传过程中取消上传,或在上传开始之前删除先前选择的文件。这些功能纯粹使用服务器端技术处理文件上传是不可用的。

在此项目结束时,我们将制作以下小部件:

为什么很棒?

你的热门目标

要完成项目,我们需要完成以下任务:

  • 创建页面和插件包装器

  • 生成基础标记

  • 添加接收要上传文件的事件处理程序

  • 显示所选文件列表

  • 从上传列表中删除文件

  • 添加 jQuery UI 进度指示器

  • 上传所选文件

  • 报告成功并整理工作

任务清单

与我们以前的一些项目一样,除了使用 jQuery,我们还将在本项目中使用 jQuery UI。我们在书的开头下载的 jQuery UI 副本应该已经包含我们需要的所有小部件。

像以前的项目一样,我们还需要在此项目中使用 Web 服务器,这意味着使用正确的 http:// URL 运行页面,而不是 file:/// URL。有关兼容的 Web 服务器信息,请参阅以前的项目。

创建页面和插件包装器

在此任务中,我们将创建链接到所需资源的页面,并添加我们的插件将驻留在其中的包装器。

为起飞做准备

在这一点上,我们应该创建这个项目所需的不同文件。首先,在主项目文件夹中保存一个模板文件的新副本,并将其命名为 uploader.html。我们还需要一个新的样式表,应该保存在 css 文件夹中,命名为 uploader.css,以及一个新的 JavaScript 文件,应该保存在 js 文件夹中,命名为 uploader.js

新页面应链接到 jQuery UI 样式表,以便获取进度条小部件所需的样式,并且在页面的 <head> 中,直接在现有的对 common.css 的链接之后,添加该项目的样式表:

<link rel="stylesheet" href="css/ui-lightness/jquery-ui-1.10.0.custom.min.css" />

<link rel="stylesheet" href="css/uploader.css" />

我们还需要链接到 jQuery UI 和此示例的 JavaScript 文件。我们应该在现有的用于 jQuery 的 <script> 元素之后直接添加这两个脚本文件:

<script src="img/jquery-ui-1.10.0.custom.min.js"></script>
<script src="img/uploader.js"></script>

启动推进器

我们的插件只需要一个容器,小部件就可以将所需的标记渲染到其中。在页面的 <body> 中,在链接到不同 JavaScript 资源的 <script> 元素之前,添加以下代码:

<div id="uploader"></div>

除了链接到包含我们的插件代码的脚本文件之外,我们还需要调用插件以初始化它。在现有的 <script> 元素之后,直接添加以下代码:

<script>
    $("#uploader").up();
</script>

插件的包装器是一个简单的结构,我们将用它来初始化小部件。在 uploader.js 中,添加以下代码:

;(function ($) {

    var defaults = {
        strings: {
            title: "Up - A jQuery uploader",
            dropText: "Drag files here",
            altText: "Or select using the button",
            buttons: {
                choose: "Choose files", 
                upload: "Upload files" 
            },
            tableHeadings: [
                "Type", "Name", "Size", "Remove all x"
            ]
        }
    }

    function Up(el, opts) {

        this.config = $.extend(true, {}, defaults, opts);
        this.el = el;
        this.fileList = [];
        this.allXHR = [];
    }

    $.fn.up = function(options) {
        new Up(this, options);
        return this;
    };

}(jQuery));

目标完成 - 迷你简报

构建 jQuery 插件时,我们能做的最好的事情就是使我们的插件易于使用。根据插件的用途,最好尽可能少地有先决条件,因此,如果插件需要复杂的标记结构,通常最好让插件渲染它需要的标记,而不是让插件的用户尝试添加所有必需的元素。

鉴于此,我们将编写我们的插件,使得页面上只需要一个简单的容器,插件就可以将标记渲染到其中。我们在页面上添加了这个容器,并为其添加了一个 id 属性以便于选择。

使用我们的插件的开发人员将需要一种调用它的方法。jQuery 插件通过向 jQuery 对象添加附加方法来扩展 jQuery 对象,我们的插件将向 jQuery 添加一个名为 up() 的新方法,该方法像任何其他 jQuery 方法名称一样被调用 - 在被 jQuery 选择的一组元素上。

我们在 <body> 元素底部添加的额外 <script> 元素调用了我们的插件方法,以调用插件,这就是使用我们的插件的人会调用它的方式。

在我们的脚本文件中,我们以一个分号和一个立即调用的匿名函数开始。分号支持 jQuery 插件的模块化特性,并保护我们的插件免受其他不正确停止执行的插件的影响。

如果页面上另一个插件的最后一条语句或表达式没有以分号结束,而我们的插件又没有以分号开始,就可能导致脚本错误,从而阻止我们的插件正常工作。

我们使用一个匿名函数作为我们插件的包装器,并立即在函数体之后用一组额外的括号调用它。我们还可以通过在我们的插件中局部范围限定$字符并将jQuery对象传递给匿名函数作为参数,确保我们的插件与 jQuery 的noConflict()方法一起工作。

在匿名函数内部,我们首先定义一个称为defaults的对象字面量,该对象将用作我们插件的配置对象。该对象包含另一个称为strings的对象,其中我们存储了在各种元素中显示的所有不同文本部分。

为了使我们的插件易于本地化,我们使用配置对象来处理文本字符串,这样非英语母语的开发者就可以更容易地使用。尽可能使插件灵活是增加插件吸引力的一个好方法。

defaults对象之后,我们定义了一个构造函数,该函数将生成我们的小部件的实例。插件称为 Up,我们将其名称的第一个字母大写,因为这是应该使用new关键字调用的函数的一般约定。

构造函数可以接受两个参数;第一个是一个 jQuery 元素或元素集合,第二个是由使用我们的插件的开发者定义的配置对象。

在构造函数内部,我们首先向实例附加一些成员。第一个成员叫做config,它将包含由 jQuery 的extend()方法返回的对象,该方法用于合并两个对象,与大多数 jQuery 方法不同,它是在jQuery对象本身上而不是 HTML 元素集合上调用的。

它接受四个参数;第一个参数指示extend()方法深复制要合并到 jQuery 对象中的对象,这是我们需要做的,因为defaults对象包含其他对象。

第二个参数是一个空对象;任何其他对象都将被合并在一起,并将它们自己的属性添加到此对象中。这是方法将返回的对象。如果我们没有传递一个空对象,那么方法中传递的第一个对象将被返回。

下面的两个参数是我们要合并的对象。这些是我们刚刚定义的defaults对象和在调用构造函数时可能传递的opts对象。

这意味着如果开发者希望传递一个配置对象,他们可以覆盖我们在defaults对象中定义的值。未使用此配置对象覆盖的属性将被设置为默认值。

我们还将对元素或元素集合的引用作为实例的成员存储,以便我们可以在代码的其他部分轻松操作这些元素。

最后,我们添加了一对空数组,用于存储要上传的文件列表和进行中的 XHR 请求。我们将在项目的后期看到这些属性如何使用,所以现在不用太担心它们。

jQuery 提供了fn对象作为其原型的快捷方式,这是我们如何用我们的插件方法扩展 jQuery 的。在这种情况下,该方法被称为up(),并且是我们在uploader.html底部使用<script>元素调用的方法。我们指定该方法可能接受一个参数,该参数是包含插件使用者可能想要提供的配置选项的对象。

在方法内部,我们首先使用new关键字与我们的构造函数结合创建了一个上传器的新实例。我们将构造函数传递给方法所调用的元素(或元素集合)和options对象。

最后我们从方法中返回了this。 在添加到 jQuery 原型的方法中,this对象指的是 jQuery 集合。非常重要的是,为了保持链接,返回调用方法的元素集合。

机密情报

链接是 jQuery 的一个固有特性,使用它的开发人员来期望。重要的是满足开发人员对他们使用的编程样式的期望。使用我们的插件的人们希望在调用我们的插件方法后能够添加额外的 jQuery 方法。

现在我们通过返回this对象返回元素集合,开发人员可以做这样的事情:

$("#an-element").up().addClass("test");

所以这是一个简单的示例,但它应该说明为什么从插件中始终返回this是重要的。

生成底层标记

在这个任务中,我们将向我们的插件添加一个初始化方法,该方法将生成小部件所需的标记。

启动推进器

首先,我们应该直接在uploader.jsUp()构造函数之后添加以下代码:

Up.prototype.init = function() {
    var widget = this,
          strings = widget.config.strings,
          container = $("<article/>", {
            "class": "up"
          }),
    heading = $("<header/>").appendTo(container),
    title = $("<h1/>", {
        text: strings.title
    }).appendTo(heading),
    drop = $("<div/>", {
        "class": "up-drop-target",
        html: $("<h2/>", {
            text: strings.dropText
        })
    }).appendTo(container),
    alt = $("<h3/>", {
        text: strings.altText
    }).appendTo(container),
    upload = $("<input/>", {
        type: "file"
    }).prop("multiple", true).appendTo(container),
    select = $("<a/>", {
        href: "#",
        "class": "button up-choose",
        text: strings.buttons.choose
    }).appendTo(container),
    selected = $("<div/>", {
        "class": "up-selected"
    }).appendTo(container),
    upload = $("<a/>", {
        href: "#",
        "class": "button up-upload",
        text: strings.buttons.upload
    }).appendTo(container);

    widget.el.append(container);

}

我们还需要调用这个新的init()方法。修改添加到 jQuery 的fn对象的方法,使其如下所示:

$.fn.up = function(options) {
 new Up(this, options).init();
    return this;
};

我们还可以在插件生成的标记中添加 CSS。在uploader.css中,添加以下样式:

article.up { width:90%; padding:5%; }
article.up input { display:none; }
.up-drop-target { 
    height:10em; border:5px dashed #ccc; border-radius:5px; 
    margin-bottom:1em; text-align:center; 
}
.up-drop-target h2 { 
    margin-top:-.5em; position:relative; top:50%; 
}
.up-selected { margin:1em 0; border-bottom:1px solid #ccc; }

完成目标 - 迷你总结

我们可以通过将其添加到构造函数的prototype中来添加一个init()方法,该方法负责创建和注入小部件所构建的标记。构造函数创建的所有对象都将继承该方法。

我们首先存储了this对象,该对象在我们的init()方法中仍然指的是元素的 jQuery 集合,以便我们可以在下一个任务中轻松地在事件处理程序中引用它。

我们还将strings属性本地化作用域,以使解析稍微更快,因为我们经常引用此属性以将可见的文本字符串添加到小部件的可见 UI 中。

接下来,我们创建新的 HTML 元素并将它们存储在变量中。这意味着我们可以创建容器并将所有所需元素附加到其中,而它仍然在内存中,并且然后将整个小部件一次性注入到页面的 DOM 中,而不是重复地修改 DOM 并逐个添加元素。

小部件的外部容器是一个 <article> 元素,它具有一个易于样式化的类名。HTML5 规范描述了 <article> 作为一个独立的交互式小部件,所以我觉得这是我们小部件的完美容器。虽然同样相关,但 <article> 并不局限于我们传统上描述的“文章” - 例如,博客/新闻文章或编辑样式的文章。

我们有一个 <header> 元素来包含小部件的主标题,在其中我们使用一个标准的 <h1>。我们还在小部件内部使用两个 <h2> 元素来显示不同的部分(拖放区域和更传统的文件 <input>)。

<input> 元素具有 type 属性为 file,并且还给定了 multiple 属性,使用 jQuery 的 prop() 方法,以便在支持的浏览器中上传多个文件。目前的 IE 版本(9 及以下)不支持此属性。

我们还在 <input> 之后直接添加了一个 <a> 元素,我们将用它来打开用于选择要上传的文件的打开对话框。标准的 file 类型 <input> 的问题在于没有标准!

几乎每个浏览器都以不同的方式实现 file 类型的 <input>,一些浏览器显示一个 <input> 以及一个 <button>,而一些浏览器只显示一个 <button> 和一些文本。还不可能对由控件生成的 <input><button> 进行样式设置,因为它们是 shadow DOM 的一部分。

注意

有关影子 DOM 的更多信息,请参见 glazkov.com/2011/01/14/what-the-heck-is-shadow-dom/

为了解决这些跨浏览器的差异,我们将用 CSS 隐藏 <input>,并使用 <a> 元素,样式化为一个吸引人的按钮,来打开对话框。

我们还添加了一个空的 <div> 元素,我们将用它来列出所选文件并显示每个文件的一些信息,然后是另一个 <a> 元素,它将被样式化为按钮。这个按钮将用于启动上传。

我们使用了标准的 jQuery 1.4+ 语法来创建新的 HTML 元素,并为大多数我们创建的元素提供了配置对象。大多数元素都给定了一个类名,有些还会获得文本或 HTML 内容。我们使用的类名都受到合理前缀的限制,以避免与页面上已使用的现有样式潜在冲突。

我们添加的 CSS 主要是用于呈现。重要的方面是我们隐藏了标准的文件 <input>,并且给了拖放目标一个固定大小,以便文件可以轻松地放置在上面。

此时,我们应该能够在浏览器中运行页面(通过 web 服务器),并查看插件的基本元素和布局。页面应该与该项目的第一个截图中的样子一样。

添加接收要上传文件的事件处理程序

我们可以使用我们在上一个任务中添加的 init() 方法来附加小部件将需要处理的文件被选择上传的事件处理程序。这可能发生在文件被拖放到拖放目标上,或者使用按钮选择它们时。

启动推进器

uploader.js中的init()方法中向容器附加新的 HTML 元素之后(但仍在init()方法内部),添加以下代码:

widget.el.on("click", "a.up-choose", function(e) {
    e.preventDefault();

    widget.el.find("input[type='file']").click();
});

widget.el.on("drop change dragover", "article.up", function(e) {

    if (e.type === "dragover") {
        e.preventDefault();
        e.stopPropagation();
        return false;
    } else if (e.type === "drop") {
        e.preventDefault();
        e.stopPropagation();
        widget.files = e.originalEvent.dataTransfer.files;
    } else {
        widget.files = widget.el
        .find("input[type='file']")[0]
        .files;
    }

    widget.handleFiles();
});

目标完成 - 迷你总结

我们首先使用 jQuery 的 on() 方法,在事件委托模式下,将事件处理程序附加到小部件的外部容器上。我们将 click 事件指定为第一个参数,并将匹配我们带有类名 up-choose 的按钮的选择器指定为第二个参数。

在传递给 on() 的处理程序函数内部,我们首先使用 JavaScript 的 preventDefault() 阻止浏览器的默认行为,然后触发一个用于选择要上传的文件的隐藏<input>元素的click事件。这将导致文件对话框在浏览器中打开,允许选择文件。

然后,我们附加了另一个事件处理程序。这次我们正在寻找dropdragoverchange事件。当文件被拖放到拖放区域时,将触发drop事件;当文件被悬停在拖放区域上时,将触发dragover事件;如果文件被移除,将触发change事件。

所有这些事件将从拖放区域(带有类名up<article>)或隐藏的<input>中冒泡,并通过绑定事件处理程序的小部件的外部容器传递。

在这个处理程序函数内部,我们首先检查它是否是dragover事件;如果是,我们再次使用preventDefault()stopPropagation()阻止浏览器的默认行为。我们还需要从条件的这个分支返回false

if的下一个分支检查触发处理程序的事件是否是drop事件。如果是,我们仍然需要使用preventDefault()stopPropagation(),但这次我们还可以使用 jQuery 创建和传递给处理程序函数的事件对象获取所选文件的列表,并将它们存储在小部件实例的属性中。

如果这两个条件都不为true,我们就从<input>元素中获取文件列表。

我们需要的属性是 jQuery 封装到自己的事件对象中的originalEvent对象的一部分。然后,我们可以从dataTransfer对象中获取files属性。如果事件是change事件,我们只需获取隐藏的<input>files属性。

无论使用哪种方法,用于上传的文件集合都存储在小部件实例的 files 属性下。这只是一个临时属性,每次选择新文件时都会被覆盖,不像小部件的 filelist 数组,它将存储所有文件以进行上传。

最后我们调用 handleFiles() 方法。在下一个任务中,我们将把这个方法添加到小部件的 prototype 中,所以一旦完成了这个任务,我们就能在这里调用这个方法而不会遇到问题。

将两个事件组合起来,并以这种方式检测发生的事件要比附加到单独的事件处理程序要好得多。这意味着我们不需要两个分开的处理程序函数,它们都本质上做同样的事情,并且无论是用按钮和标准对话框选择文件,还是通过将文件拖放到拖放目标中选择文件,我们仍然可以获取文件列表。

此时,我们应该能够将文件拖放到拖放区域,或者点击按钮并使用对话框选择文件。然而,会抛出一个脚本错误,因为我们还没有添加我们插件的 handleFiles() 方法。

显示已选文件列表

在这个任务中,我们可以填充我们创建的 <div>,以显示已选择用于上传的文件列表。我们将构建一个表格,在表格中,每一行列出一个文件,包括文件名和类型等信息。

启动推进器

uploader.js 中的 init() 方法之后,添加以下代码:

Up.prototype.handleFiles = function() {

    var widget = this,
          container = widget.el.find("div.up-selected"),
          row = $("<tr/>"),
          cell = $("<td/>"),
          remove = $("<a/>", {
             href: "#"
          }),
    table;

    if (!container.find("table").length) {
        table = $("<table/>");

        var header = row.clone().appendTo(table),
              strings = widget.config.strings.tableHeadings;

        $.each(strings, function(i, string) {
                var cs = string.toLowerCase().replace(/\s/g, "_"),
                      newCell = cell.clone()
                                            .addClass("up-table-head " + cs)
                                            .appendTo(header);

                if (i === strings.length - 1) {
                    var clear = remove.clone()
                                                 .text(string)
                                                .addClass("up-remove-all");

                    newCell.html(clear).attr("colspan", 2);
                } else {
                    newCell.text(string);
                }
            });
        } else {
            table = container.find("table");
        }

        $.each(widget.files, function(i, file) {
        var fileRow = row.clone(),
              filename = file.name.split("."),
              ext = filename[filename.length - 1],
              del = remove.clone()
                                   .text("x")
                                   .addClass("up-remove");

        cell.clone()
              .addClass("icon " + ext)
              .appendTo(fileRow);

        cell.clone()
              .text(file.name).appendTo(fileRow);
        cell.clone()
             .text((Math.round(file.size / 1024)) + " kb")
             .appendTo(fileRow);

        cell.clone()
              .html(del).appendTo(fileRow);
        cell.clone()
              .html("<div class='up-progress'/>")
              .appendTo(fileRow);

        fileRow.appendTo(table);

        widget.fileList.push(file);
    });

    if (!container.find("table").length) {
        table.appendTo(container);
    }
}

我们还可以为我们创建的新标记添加一些额外的 CSS。将以下代码添加到 upload.css 的底部:

.up-selected table {
    width:100%; border-spacing:0; margin-bottom:1em;
}
.up-selected td {
    padding:1em 1% 1em 0; border-bottom:1px dashed #ccc;
    font-size:1.2em;
}
.up-selected td.type { width:60px; }
.up-selected td.name { width:45%; }
.up-selected td.size { width:25%; }
.up-selected td.remove_all_x { width:20%; }

.up-selected tr:last-child td { border-bottom:none; }
.up-selected a {
    font-weight:bold; text-decoration:none;
}
.up-table-head { font-weight:bold; }
.up-remove-all { color:#ff0000; }
.up-remove {
    display:block; width:17px; height:17px;
    border-radius:500px; text-align:center;
    color:#fff; background-color:#ff0000;
}
.icon { 
    background:url(../img/page_white.png) no-repeat 0 50%; 
}
.doc, .docx { 
    background:url(../img/doc.png) no-repeat 0 50%; 
}
.exe { background:url(../img/exe.png) no-repeat 0 50%; }
.html { background:url(../img/html.png) no-repeat 0 50%; }
.pdf { background:url(../img/pdf.png) no-repeat 0 50%; }
.png { background:url(../img/png.png) no-repeat 0 50%; }
.ppt, .pptx { 
    background:url(../img/pps.png) no-repeat 0 50%; 
}
.txt { background:url(../img/txt.png) no-repeat 0 50%; }
.zip { background:url(../img/zip.png) no-repeat 0 50%; }

目标完成 - 迷你总结

我们开始时将 handleFiles() 方法添加到小部件的 prototype 中,使得我们在上一个任务的最后添加的方法调用 widget.handleFiles() 起作用。它的添加方式与之前的 init() 方法完全相同,并且就像在 init() 内部一样,this 对象指向了小部件实例内部。这使得在页面上的元素、配置选项和选定文件列表都易于访问。

在方法内部,我们首先创建了一系列变量。就像在 init() 方法中一样,我们创建了一个名为 widget 的局部变量,用于存储 this 对象。虽然我们不会向这个方法添加任何事件处理程序,所以我们并不一定非要这样做,但我们确实多次访问对象,所以把它缓存在一个变量中是有道理的。

我们还使用 widget.el 缓存了选定的文件容器 - 不要忘记 el 已经引用了外部小部件容器的 jQuery 封装实例,所以我们可以直接在其上调用 jQuery 方法,如 find(),而无需重新封装它。

接下来,我们创建了一系列新的 DOM 元素,准备在循环内克隆它们。这是一种更好的创建元素的方法,特别是在循环内部,避免了不断创建新的 jQuery 对象。

我们还定义了一个名为table的变量,但我们并没有立即初始化它。相反,我们使用if条件来检查容器是否已经包含了一个<table>元素,通过检查 jQuery 的find("table")是否返回一个具有length的集合。

如果length等于false,我们知道没有选择任何<table>元素,因此我们使用 jQuery 创建了一个新的<table>元素,并将其赋给table变量。然后,我们为<table>创建了一个标题行,用于为新表的每一列添加标题。

此时,<table>元素只存在于内存中,因此我们可以将新行添加到其中,而不会修改页面的 DOM。我们还缓存了我们配置对象中使用的strings对象的tableHeadings属性的引用。

然后,我们使用 jQuery 的each()实用工具来创建用作表标题的所有<td>元素。除了能够在从页面选中的元素集合上调用each()之外,我们还可以调用each()在 jQuery 对象上,以便迭代一个纯 JavaScript 数组或对象。

each()方法接受要迭代的数组或对象。在这种情况下,它是一个数组,因此对数组中的每个项目调用的迭代函数接收到当前项目的索引和当前项目的值作为参数。

在迭代器内部,我们首先创建一个可以用作类名的新字符串。class这个词在 JavaScript 中是一个保留字,因此我们改用cs作为变量名。为了创建类名,我们只需使用 JavaScript 的toLowerCase()函数将当前字符串转换为小写,然后使用 JavaScript 的replace()函数删除任何空格。

注意

有关 JavaScript 中保留字的完整列表,请参阅 MDN 文档developer.mozilla.org/en-US/docs/JavaScript/Reference/Reserved_Words

replace()函数将正则表达式作为第一个参数匹配,将替换字符串作为第二个参数。我们可以使用字符串" "作为第一个参数,但那样只会删除第一个空格,而使用带有g标志的正则表达式允许我们移除所有空格。

然后,我们通过克隆在任务开始时创建并存储在变量中的元素之一来创建一个新的<td>元素。我们为了样式的目的给它一个通用的类名,以及我们刚刚创建的唯一类名,这样每一列都可以在需要时独立样式化,然后将它直接添加到我们刚刚创建的标题行中。

然后,我们通过检查当前索引是否等于数组长度减 1 来检查我们是否迭代了数组中的最后一项。如果是最后一项,我们通过克隆我们在任务开始时创建和缓存的<a>元素来添加一个清除所有链接。

我们将新<td>元素的文本设置为当前数组项的值,并添加up-remove-all类以进行样式设置,以便我们可以过滤由它分发的事件。我们还可以使用 jQuery 的attr()方法将colspan属性设置为2到这个<td>。然后,新的<a>元素被添加为新的<td>元素的 HTML 内容。

如果它不是数组中的最后一个项目,我们只需将新<td>元素的文本内容设置为当前数组项的值。

所有这些都是在外部if语句的第一个分支中完成的,当表不存在时发生。如果容器已经包含<table>元素,我们仍然通过选择页面上的<table>来初始化表变量。

不要忘记,我们所在的handleFiles()方法将在选择文件后被调用,所以现在我们需要为每个选择的文件在表中构建一行新行。

再次使用 jQuery 的each()方法,这次是为了迭代小部件的files属性中存储的文件集合。对于每个选择的文件(通过拖放到拖放区域或使用按钮),我们首先通过克隆我们的row变量创建一个新的<tr>

然后,我们在当前文件的name属性上使用.字符进行分割。通过获取split()函数创建的数组中的最后一个项目,我们存储文件的扩展名。

在这一点上,我们还创建一个删除链接,可以用来从要上传的文件列表中删除单个文件,方法是克隆我们在任务开始时创建的<a>元素。它被赋予文本x和类名up-remove

接下来,我们通过再次克隆缓存的cell变量中的<td>来创建一系列新的<td>元素。第一个<td>被赋予一个通用的类名icon,以及当前文件的扩展名,这样我们就可以为可以上传的不同文件类型添加图标,并将其附加到新行上。

第二个<td>元素显示文件的名称。第三个<td>元素显示文件的大小(以千字节为单位)。如果我们知道可能上传大文件,我们可以转换为兆字节,但对于这个项目的目的,千字节就足够了。

第四个<td>元素使用 jQuery 的html()方法添加了新的删除链接,最后一个<td>元素添加了一个空的<div>元素,我们将使用它来放置 jQuery UI 进度条小部件。

一旦新单元格被创建并附加到新行上,新行本身就被附加到表中。我们还可以将当前文件添加到我们的fileList数组中,准备上传。

最后,我们需要再次检查所选文件容器是否已经包含一个<table>元素。如果没有,我们将新建的<table>追加到容器中。如果它已经包含<table>,新行将已经添加到其中。

我们在这一部分添加的 CSS 纯粹是为了呈现。我做的一件事是添加一些类,以便显示可能选择上传的不同文件类型的图标。我只是添加了一些作为示例;您实际需要的会取决于您期望用户上传的文件类型。还为与我们添加的选择器不匹配的类型创建了通用图标。

注意

此示例中使用的图标属于 Farm Fresh 图标包。我已经为了简洁性而重命名了这些文件,并且可以在本书附带的代码下载中找到。这些图标可以在 Fat Cow 网络主机上获得 (www.fatcow.com/free-icons)。

在这一点上,我们应该能够在浏览器中运行页面,选择一些文件进行上传,并看到我们刚刚创建的新<table>

完成目标 - 小型总结

机密情报

在这个例子中,我们手动创建了显示所选文件列表所需的元素。另一种方法是使用模板引擎,比如 jsRender 或 Dust.js。这样做的好处是比我们手动创建更快更高效,能够使我们的插件代码更简单更简洁,文件也更小。

当然,这将给我们的插件增加另一个依赖,因为我们需要包含模板引擎本身,以及一个存储在 JavaScript 文件中的预编译模板。在这个例子中,我们并没有创建太多元素,所以可能不值得再添加另一个依赖。当需要创建许多元素时,添加依赖的成本被它增加的效率所抵消。

写 jQuery 插件时,这种事情需要根据具体情况逐案考虑。

从上传列表中移除文件

在这个任务中,我们将添加事件处理程序,使新文件列表中的删除全部删除链接起作用。我们可以将事件处理程序附加到我们之前添加其他事件处理程序的地方,以保持事情的井然有序。

启动推进器

upload.js中,在小部件的init()方法中,并且直接在现有的 jQuery on()方法调用之后,添加以下新代码:

widget.el.on("click", "td a", function(e) {

    var removeAll = function() {
        widget.el.find("table").remove();
        widget.el.find("input[type='file']").val("");
        widget.fileList = [];
    }

    if (e.originalEvent.target.className == "up-remove-all") {
        removeAll();
    } else {
        var link = $(this),
              removed,
              filename = link.closest("tr")
                                     .children()
                                     .eq(1)
                                     .text();

        link.closest("tr").remove();

        $.each(widget.fileList, function(i, item) {
        if (item.name === filename) {
            removed = i;
        }
    });
    widget.fileList.splice(removed, 1);

    if (widget.el.find("tr").length === 1) {
        removeAll();
    } 
  }
}); 

完成目标 - 小型总结

我们使用 jQuery 的on()方法再次添加了一个click事件。我们将它附加到小部件的外部容器,就像我们添加其他事件一样,这次我们根据选择器td a过滤事件,因为事件只会源自<td>元素内的<a>元素。

在事件处理程序内,我们首先阻止浏览器的默认行为,因为我们不希望跟随链接。然后,我们定义了一个简单的帮助函数,从小部件中移除<table>元素,清除文件<input>的值,并清除fileList数组。

我们需要清除<input>,否则如果我们选择了一些文件,然后将它们从文件列表中移除,我们将无法重新选择相同的一组文件。这是一个边缘情况,但这个简单的小技巧可以让它起作用,所以我们也可以包含它。

接下来,我们检查触发事件的元素的className属性是什么。我们可以使用传递给处理程序函数的 jQuery 事件对象中包含的originalEvent对象的target属性来查看此属性。我们还可以使用 jQuery 事件对象的srcElement属性,但这在当前版本的 Firefox 中不起作用。

className属性匹配up-remove-all时,我们简单地调用我们的removeAll()辅助函数来移除<table>元素并清除<input>fileList数组。

如果className属性与全部移除链接不匹配,我们必须仅移除包含被点击的<a><table>元素的行。我们首先缓存触发事件的<a>的引用,这在处理程序函数内部被设置为this

我们还定义了一个名为removed的变量,我们将很快初始化一个值。最后,我们存储了我们将要移除的行所代表的文件的filename

一旦我们设置了变量,我们首先要做的是移除我们可以使用 jQuery 的closest()方法找到的行,该方法找到与传递给该方法的选择器匹配的第一个父元素。

然后我们使用 jQuery 的each()方法来迭代fileList数组。对于数组中的每个项目,我们将项目的name属性与我们刚初始化的filename变量进行比较。如果两者匹配,我们将index号(由 jQuery 自动传递给迭代器函数)设置为我们的removed变量。

一旦each()方法完成,我们就可以使用 JavaScript 的splice()函数来移除当前<tr>所代表的文件。splice()函数接受两个参数(它可以接受更多,但我们这里不需要),第一个参数是要开始移除的项目的索引,第二个参数是要移除的项目数。

最后,我们检查<table>元素是否还有多于一行的行。如果只剩下一行,这将是标题行,所以我们知道所有文件都已删除。因此,我们可以调用我们的removeAll()辅助函数来整理并重置一切。

现在当我们已经将文件添加到上传列表中时,我们应该能够使用内联x按钮逐个删除文件,或者使用全部移除链接清除列表。

添加一个 jQuery UI 进度指示器

在这个任务中,我们将添加 jQuery UI 进度条小部件所需的元素和初始化代码。小部件实际上还不会执行任何操作,因为在下一个任务中我们不会上传任何东西,但我们需要连接好一切准备就绪。

启动推进器

我们将向小部件的原型添加一个initProgress()方法,用于选择我们添加到<table>元素中的<div>元素,并将它们转换为进度条小部件。我们还可以添加用于更新进度条的方法。

handleFiles()方法之后,直接添加以下代码:

Up.prototype.initProgress = function() {

    this.el.find("div.up-progress").each(function() {
        var el = $(this);

        if (!el.hasClass("ui-progressbar")) {
            el.progressbar();
        }
    });
}

接下来,我们需要在向<table>添加新行后调用此方法。在handleFiles()方法的末尾直接添加以下调用:

widget.initProgress();

现在我们可以添加更新进度条的代码了。在我们刚刚添加的initProgress()方法后面直接添加以下代码:

Up.prototype.handleProgress = function(e, progress) {

    var complete = Math.round((e.loaded / e.total) * 100);

    progress.progressbar("value", complete);
}

我们还需要为新的进度条添加一点 CSS。将以下代码添加到uploader.css的末尾:

.up-progress { 
    height:1em; width:100px; position:relative; top:4px; 
}

目标完成 - 迷你总结

这个任务比我们到目前为止在项目中涵盖的一些任务更短,但同样重要。我们添加了新方法的方式与为插件添加大部分功能的方式相同。

在这个方法中,我们首先选择所有类名为up-progress<div>元素。不要忘记我们可以使用this.el访问小部件的容器元素,并且作为 jQuery 对象,我们可以在其上调用 jQuery 方法,比如find()

然后,我们使用 jQuery 的each()方法遍历选择中的每个元素。在此任务中,我们使用标准的each()方法,其中集合中的当前元素在迭代函数中设置为this

在迭代函数中,我们首先缓存当前元素。然后我们检查它是否具有 jQuery UI 类名ui-progressbar,如果没有,我们将使用 jQuery UI 方法progressbar()将元素转换为进度条。

这样做意味着无论是选择要上传的初始文件集,还是将其他文件添加到现有的<table>中,进度条都将始终被创建。

handleFiles()方法末尾,我们还添加了对新的initProgress()方法的调用,每当选择新文件上传时都会调用该方法。

接下来,我们添加了handleProgress()方法,我们将在下一个任务中将其绑定到一个事件。该方法将传递两个参数,第一个是事件对象,第二个是一个已包装的 jQuery 对象,表示一个单独的进度条。

在方法中,我们首先计算已上传文件的比例。我们可以通过将事件对象的loaded属性除以total属性得出,然后除以 100 得出迄今为止已上传文件的百分比。

loadedtotal属性是特殊属性,当浏览器触发进度事件时会将它们添加到事件对象中。

一旦我们有了百分比,我们就可以调用进度条小部件的value方法,以便将值设置为百分比。这是一个 jQuery UI 方法,因此以特殊的方式调用。我们不直接调用value(),而是调用progressbar()方法,并将要调用的方法的名称value作为第一个参数传递。所有 jQuery UI 方法都是以这种方式调用的。

最后,我们添加了一些漂亮的 CSS 样式,以微调默认的 jQuery UI 主题提供的默认样式。现在,当我们添加要上传的文件时,我们应该在<table>中的每个文件后看到一个空的进度条。

正在上传所选文件

现在,我们有了附加到我们插件实例的文件列表,准备好上传。在这个任务中,我们将做到这一点,并使用 jQuery 异步上传文件。此行为将与我们添加到插件生成的标记中的上传文件按钮相关联。

我们还可以使用此任务来更新我们的进度条,显示每个正在上传的文件的当前进度。

启动推进器

由于这是另一个事件处理程序,我们将在init()方法中添加它,以及所有其他事件处理程序,以便它们都保持在一个地方。在现有的事件处理程序之后,在init()方法的末尾添加以下代码:

widget.el.on("click", "a.up-upload", function(e) {
    e.preventDefault();

  widget.uploadFiles();
}); 

接下来,添加新的uploadFiles()方法。这可以在我们在上一个任务中添加的与进度相关的方法之后进行:

Up.prototype.uploadFiles = function() {
    var widget = this,
    a = widget.el.find("a.up-upload");

    if (!a.hasClass("disabled")) {

        a.addClass("disabled");

        $.each(widget.fileList, function(i, file) {
            var fd = new FormData(),
                  prog = widget.el
                                        .find("div.up-progress")
                                        .eq(i);

            fd.append("file-" + i, file);

            widget.allXHR.push($.ajax({
                type: "POST",
                url: "/upload.asmx/uploadFile",
                data: fd,
                contentType: false,
                processData: false,
                xhr: function() {

                    var xhr = jQuery.ajaxSettings.xhr();

                    if (xhr.upload) {
                        xhr.upload.onprogress = function(e) {
                            widget.handleProgress(e, prog);
                        }
                    }

                    return xhr;
                }
            }));
        });     
    }
}

完成目标 - 迷你总结

在我们的uploadFiles()方法中,我们首先存储对小部件的引用,就像我们在添加的其他一些方法中所做的那样。我们还存储对上传文件按钮的引用。

接下来要做的是检查按钮是否没有disabled类名。如果它确实具有此类名,这意味着已为所选文件启动了上传,因此我们希望避免重复请求。如果按钮没有disabled类,则意味着这是第一次单击按钮。因此,为了防止重复请求,我们随后添加disabled类。

接下来,我们遍历我们收集到的文件列表,该列表存储在小部件实例的fileList属性中。对于数组中的每个文件,我们首先创建一个新的FormData对象。

FormData是新的 XMLHttpRequest (XHR) level 2 规范的一部分,它允许我们动态创建一个<form>元素,并使用 XHR 异步提交该表单。

一旦我们创建了一个新的FormData对象,我们还会存储与当前文件关联的进度条小部件的引用。然后,我们使用FormDataappend()方法将当前文件附加到新的FormData对象中,以便将文件编码并发送到服务器。

接下来,我们使用 jQuery 的ajax()方法将当前的FormData对象发布到服务器。ajax()方法将返回请求的jqXHR对象。这是 jQuery 增强了额外方法和属性的 XHR 对象的特殊版本。我们需要存储这个jqXHR对象,以便稍后使用。

我们将在下一个任务中详细介绍它的使用方式,但现在只需了解ajax()方法返回的jqXHR对象被推送到我们在项目开始时存储为小部件实例成员的allXHR数组中即可。

ajax()方法接受一个配置对象作为参数,允许我们控制请求的方式。我们使用type选项将请求设置为POST,并使用url选项指定要发布到的 URL。我们使用 data 选项将FormData对象添加为请求的有效载荷,并将contentTypeprocessData选项设置为false

如果我们不将contentType选项设置为false,jQuery 将尝试猜测应该使用哪种内容类型进行请求,这可能正确也可能不正确,这意味着一些上传将正常工作,而另一些上传将失败,看起来毫无明显原因。请求的content-type将默认设置为multipart/form-data,因为我们使用的是附加有文件的FormData

processData选项设置为false将确保 jQuery 不会尝试将文件转换为 URL 编码的查询字符串。

我们需要修改用于发出请求的基础 XHR 对象,以便我们可以将处理程序函数附加到进度事件上。在请求发出之前,必须将处理程序绑定到事件上,目前唯一的方法是使用xhr选项。

该选项接受一个回调函数,我们可以使用它来修改原始的 XHR 对象,然后返回给请求。在回调函数中,我们首先存储原始的 XHR 对象,可以从 jQuery 的ajaxSettings对象中获取它。

然后,我们检查对象是否具有upload属性,如果有,我们将匿名函数设置为onprogress的值。在此函数中,我们只需调用我们在上一个任务中添加的小部件的handleProgress()方法,将进度事件对象和我们在本任务开始处存储的 Progressbar 小部件传递给它。

报告成功并整理

在此任务中,我们需要显示每个文件何时完成上传。我们还需要清除小部件中的<table>,并在所有上传完成后重新启用上传按钮。

启动推进器

我们可以使用 jQuery 的done()方法显示每个单独文件上传完成的时间,我们可以在上一个任务中添加的ajax()方法之后链接此方法:

.done(function() {

    var parent = prog.parent(),
    prev = parent.prev();

    prev.add(parent).empty();
    prev.text("File uploaded!");
});

为了在上传后进行整理,我们可以利用 jQuery 的when()方法。我们应该在uploadFiles()方法中的each()方法之后直接添加以下代码:

$.when.apply($, widget.allXHR).done(function() {
    widget.el.find("table").remove();
    widget.el.find("a.up-upload").removeClass("disabled");
});

目标完成 - 迷你总结

因为 jQuery 的 ajax() 方法返回一个 jqXHR 对象,而且因为这个对象是一个称为promise 对象的特殊对象,我们可以在其上调用某些 jQuery 方法。done() 方法用于在请求成功完成时执行代码。

注意

你可能更习惯于使用 jQuery 的 success() 方法来处理成功的 AJAX 请求,或者 error()complete() 方法。这些方法在版本 1.9 中已从库中移除,因此我们应该使用它们的替代品 done()fail()always()

在这个函数中,我们只需要移除清除按钮和刚刚完成上传的文件的进度条小部件。我们可以通过从当前进度条小部件导航到它们来轻松找到需要移除的元素。

我们在上一个任务中存储了每个单独的进度条的引用,并且因为 done() 方法链接到了 ajax() 方法,所以在请求完成后仍然可以使用这个变量访问这个元素。

注意,在 done() 方法的末尾似乎有一个额外的闭合括号。这是因为它仍然位于我们在先前任务中添加的 push() 方法内部。关键是 done() 方法被添加到正确的位置——它必须链接到 push() 方法内部的 ajax() 方法。

一旦这些元素被移除,我们添加一个简单的消息,表示文件已完成上传。

一旦所有请求都完成,我们还需要从页面中移除 <table> 元素。这就是我们在上一个任务中上传文件时存储了所有生成的 jqXHR 对象的原因。我们可以使用 jQuery 的 when() 方法来做到这一点。

when() 方法可以接受一系列 promise 对象,并在它们全部解决时返回。然而,这个方法不接受数组,这就是为什么我们使用 JavaScript 的 apply() 方法调用它,而不是正常调用它。

我们可以再次使用 done() 方法来添加一个回调函数,一旦 when() 方法返回,就会调用该回调函数。在这个回调中,我们所做的就是移除显示已上传文件的 <table> 元素,并通过移除 disabled 类重新启用上传按钮。

这就是我们实际上需要做的,上传所选文件并分别接收每个文件的进度反馈,如下面的截图所示:

目标完成 - 迷你简报

提示

查看示例文件

要查看此项目的运行情况,您需要使用 Web 服务器查看我们创建的页面(在您自己的计算机上使用 http://localhost)。如果您在资源管理器或查找器中双击打开文件,它将无法正常工作。

任务完成

我们已经完成了项目。在这一点上,我们应该有一个易于使用并在支持的浏览器中提供丰富功能的上传插件,例如多个文件、文件信息、可编辑的上传列表和上传进度报告。

提示

并非所有浏览器都能使用此小部件旨在利用的功能。例如,Opera 浏览器认为通过程序触发文件对话框存在安全风险,因此不允许它。

此外,Internet Explorer 的旧版本(任何版本 10 之前的版本)根本无法处理此代码。

支持不兼容或遗留浏览器超出了此示例的范围,但添加一个备用方案是相对直接的,可以利用其他技术,比如 Flash,以支持我们的插件所展示的部分行为。

或者有一系列旧的 jQuery 插件,利用 <iframe> 元素来模拟通过 AJAX 上传文件。我选择关注支持的浏览器可以做什么,而不是专注于不支持的功能。

你准备好大干一场了吗?挑战高手

通过逐个上传文件,我们能够添加一个事件处理程序来监视正在上传的文件的进度。这也打开了取消上传单个文件的可能性。

对于这个挑战,为什么不试试看能否添加一个取消上传文件的机制。我们已经有了用于在上传之前删除文件的移除按钮。这些按钮可以很容易地更新,以便在上传进行中取消上传。

可以像附加进度事件处理程序一样向 XHR 对象添加取消事件的处理程序,因此这应该很容易实现。

第六章:使用 jQuery 扩展 Chrome

为 Chrome(或任何可以通过插件和扩展进行扩展的其他浏览器)构建一个扩展是创建自定义行为或附加工具以增强我们的浏览体验的简单方法。

Chrome 允许我们利用我们的 Web 开发技能扩展其浏览器界面,使用我们已经熟悉的技术,如 HTML、CSS 和 JavaScript,以及您可以使用 JavaScript 的地方通常也可以使用 jQuery。

任务简报

在这个项目中,我们将构建一个 Chrome 扩展,突出显示页面上用Schema.org 微数据标记的元素。微数据是一种用于指定有关各种不同实体(如企业、位置或人员)的描述性信息的方式,使用标准 HTML 属性,并据传言将成为 Google 排名算法中的重要因素。

每当我们访问包含联系方式描述的页面时,我们可以从页面中获取它们并将其存储在我们的扩展中,这样我们就可以逐渐建立起一个人们使用或制作我们喜爱的东西的联系信息目录。

在这个项目中,我们还可以使用模板化使创建重复的元素组更加高效,以及更易于维护。我们在上一个项目中使用了 JsRender,所以我们可以再次使用它,但这次我们需要以稍微不同的方式使用它。完成后,我们的扩展将类似于以下截图所示:

任务简报

为什么很棒?

微数据用于描述网页中包含的信息,以促进搜索引擎蜘蛛和 HTML 文档之间的更好互操作性。

当页面上的不同元素被描述为公司、人员、产品或电影时,它允许诸如搜索引擎之类的东西更好地理解页面上包含的信息。

微数据在 Web 上迅速变得更加普遍,并且在 Google 为搜索结果生成的结果中扮演着越来越重要的角色,因此现在是利用它的绝佳时机。

你的热门目标

这个项目分解成的任务如下:

  • 设置基本扩展结构

  • 添加一个清单并安装扩展

  • 添加一个沙箱 JsRender 模板

  • 将消息发布到沙盒

  • 添加内容脚本

  • 为微数据抓取页面

  • 添加保存微数据的机制

设置基本扩展结构

在这个任务中,我们将创建扩展所需的基础文件。扩展使用的所有文件都需要位于同一个目录中,因此我们将设置它并确保它包含我们需要的所有文件。

为起飞做准备

有一件事我应该指出,尽管希望你已经意识到 - 在该项目期间,我们将需要 Chrome 浏览器。如果你尚未安装它,作为一个网页开发人员,你真的应该安装它,至少是为了测试目的,立即下载并安装。

注意

Chrome 的最新版本可以从www.google.com/intl/en/chrome/browser/下载。

我们将把这个项目的所有文件保存在一个单独的目录中,所以现在在项目文件夹中建立一个目录,命名为chrome-extension。扩展将从与大多数其他项目使用的基本代码文件构建; 唯一的区别是所有文件都需要是扩展本地的。

我们需要一个 JsRender 的副本,所以我们也应该下载一个副本,并将其放在chrome-extension目录中。上次我们使用 JsRender 时我们链接到了在线托管的版本。这次我们将下载它。

注意

JsRender 的最新版本可以从github.com/BorisMoore/jsrender/下载。

我们可以使用用于启动其他项目的模板文件,但是我们应该确保指向 jQuery、JavaScript 文件和样式表的路径都指向同一个目录中的文件。Chrome 扩展使用的所有文件都必须在同一个文件夹中,这就是为什么我们下载脚本而不是链接到在线版本。

我们应该将 jQuery、JsRender 和common.css样式表的副本放入新目录中。我们还需要创建一个名为popup.js的新 JavaScript 文件和一个名为popup.css的新样式表,并将这些文件也保存到新目录中。

最后,我们可以创建一个名为popup.html的新 HTML 页面。这个文件也应该保存在chrome-extension目录中,并且应该包含以下代码:

<!DOCTYPE html>
<html lang="en">
    <head>
        <meta charset="utf-8" />
        <title>jQuery-Powered Chrome Extension</title>
        <link rel="stylesheet" href="common.css" />
        <link rel="stylesheet" href="popup.css" />
    </head>
    <body>
        <script src="img/jquery-1.8.0.min.js"></script>
        <script src="img/jsrender.js"></script>
        <script src="img/popup.js"></script>
    </body>
</html>

启动推进器

我们刚刚创建的 HTML 文件将被用作扩展的弹出窗口。这是当单击工具栏中扩展图标时显示为弹出窗口的页面。在这个项目中,我们将创建一种称为浏览器操作的扩展类型,它会自动向 Chrome 的工具栏添加一个按钮,用于打开弹出窗口。

弹出窗口将显示一个按钮,用于触发对当前页面的微数据进行扫描,并显示任何先前保存的联系人。任何先前存储的联系人都将使用 localStorage API 检索,并且我们可以使用模板来渲染它们。

首先,我们可以向页面添加一般的标记; 在popup.html中,将以下代码添加到页面的<body>中:

<section role="main">
    <header>
        <h1>Web Contacts</h1>
    </header>
    <ul id="contacts"></ul>
</section>
<iframe id="poster" src="img/template.html"></iframe>

我们还可以为这些元素添加一些基本样式。在 popup.css 中,添加以下代码:

body { width:32em; padding:0 2em; }
header { padding-top:2em; }
ul { padding:0 0 1em; font-size:1.5em; }
iframe { display:none; }

目标完成 - 小结

Chrome 扩展使用与我们习惯使用的相同文件构建 - HTML、CSS 和 JavaScript。该扩展将在工具栏中添加一个按钮,当单击此按钮时,将显示一个弹出窗口。我们在此任务中添加的 HTML 页面是此弹出窗口的基础。

我们创建页面的方式与创建任何其他标准 HTML5 页面的方式相同。我们像往常一样链接到 CSS 和 JavaScript 文件,然后添加一个小的<section>容器,它将用作任何先前保存的联系人的容器。最初不会有任何联系人,当有联系人时,我们将使用模板来呈现它们。

我们已经添加了一个包含<h1><header>,为保存的联系人添加了一个标题,并添加了一个空的<ul>元素,我们将很快用脚本填充它。

最后,我们在页面中添加了一个<iframe>,它将被隐藏。稍后我们将使用这个来与扩展的另一部分通信。元素的src属性设置为我们想要发送消息的页面。

我们添加的 CSS 纯粹是为了演示,并仅以简单的布局放置了初始元素。我们还链接到每个其他项目都使用的公共 CSS 文件,但不要忘记,扩展使用的所有文件都必须在扩展的目录中。

机密情报

因为我们正在创建浏览器操作,所以我们将在 Chrome 的工具栏中添加一个新按钮,只要加载了未打包的扩展,它就可见。默认情况下,它将具有标准扩展图标 - 一个拼图块,但我们可以用我们自己创建的图标替换它。

我们还可以创建其他类型的扩展,这些扩展不会将按钮添加到工具栏。我们可以创建页面操作而不是浏览器操作,该操作将在地址栏中添加一个图标而不是工具栏。

该图标是否在所有页面上可见取决于扩展的行为方式。例如,如果我们想要在每次页面在浏览器中加载时运行我们的扩展,但只在页面上找到Schema.org微数据时显示图标,我们可以使用页面操作。

浏览器操作,例如我们将在此创建的操作,在查看的页面不受影响时始终可访问。我们使用浏览器操作而不是页面操作,因为我们扩展的用户可能希望能够查看他们以前发现并保存的联系人,因此浏览器操作非常适合通过扩展存储的任何数据。

添加清单并安装扩展

为了实际安装我们的扩展并看到我们迄今为止的劳动成果,我们需要创建一个清单文件。这个特殊的文件以 JSON 格式保存,控制扩展的某些方面,例如它使用的页面以及它可以运行的内容脚本。

准备起飞

在新文件中添加以下代码:

{
    "name": "Web Contacts",
    "version": "1.0",
    "manifest_version": 2,
    "description": "Scrape web pages for Schema.org micro-data",
    "browser_action": {
        "default_popup": "popup.html"
    }
}

将此文件保存在我们在任务开始时在主项目目录中创建的chrome-extension目录中,文件名为manifest.json

注意

如果您使用的文本编辑器在另存为类型:(或相似)下没有显示.json,请选择所有类型 (*)选项,并在文件名:输入字段中键入完整的文件名manifest.json

启动推进器

要查看当前的扩展程序,需要将其加载到 Chrome 中作为扩展程序。为此,您应该转到设置 | 工具 | 扩展程序

注意

在最近的 Chrome 版本中,通过点击具有三条杠图标的按钮(位于浏览器窗口右上角)来访问设置菜单。

当扩展程序页面加载时,应该会有一个按钮来加载未打包的扩展程序…。如果没有,请选中开发者模式复选框,然后该按钮将出现。

点击按钮,然后选择chrome-extension文件夹作为扩展目录。这样应该会安装扩展程序,并为我们添加浏览器操作按钮到工具栏。

目标完成 - 迷你总结

在扩展程序加载到浏览器之前,需要一个简单的清单文件。当前版本的 Chrome 仅允许至少为 Version 2 的清单。扩展程序必须具有清单,否则将无法运行。这是一个简单的文本文件,以 JSON 格式编写,用于向浏览器提供有关扩展程序的一些基本信息,例如名称、作者和当前版本。

我们可以指定我们的扩展程序是一个浏览器操作,它将一个按钮添加到 Chrome 的工具栏上。我们还可以使用清单指定在弹出窗口中显示的页面。

单击我们扩展的新按钮时,将会在扩展程序弹出窗口中显示我们在上一个任务中添加的 HTML 页面(popup.html),如下面的屏幕截图所示:

目标完成 - 迷你总结

添加一个沙盒化的 JsRender 模板

在这个任务中,我们可以添加 JsRender 将用于显示已保存联系人的模板。此时,我们还没有保存任何联系人,但我们仍然可以准备好它,并且当我们有了一些联系人时,它们将被渲染到弹出窗口中,而无需任何麻烦。

准备起飞

Chrome 使用内容安全策略CSP)来防止大量常见的跨站脚本XSS)攻击,因此我们不允许执行使用eval()new Function()的任何脚本。

像许多其他流行库和框架一样,JsRender 模板库在编译模板时使用new Function(),因此不允许直接在扩展程序内部运行。我们可以通过两种方式解决这个问题:

  • 我们可以转换到一个提供模板预编译的模板库,比如流行的 Dust.js。然后我们可以在浏览器外部编译我们的模板,并在扩展内部链接到包含模板编译成的函数的 JavaScript 文件。使用 new Function() 创建的函数甚至在扩展安装之前就已经被创建了,然后模板可以在扩展内部呈现,并与扩展内部提供的任何数据插值。

  • 或者,Chrome 的扩展系统允许我们在指定的沙盒内部使用某些文件。由于代码与浏览器中的扩展数据和 API 访问隔离,因此允许在沙盒中运行不安全的字符串到函数特性,例如 eval()new Function()

在这个示例中,我们将使用沙盒功能,以便我们可以继续使用 JsRender。

启动推进器

首先,我们必须设置沙盒,这是通过使用我们之前创建的清单文件指定要沙盒化的页面来完成的。将以下代码直接添加到 manifest.json 中,直接在最终闭合大括号之前:

"sandbox": {
    "pages": ["template.html"]
}

提示

不要忘记在 browser_action 属性的最终闭合大括号之后直接添加逗号。

我们已将 template.html 指定为沙盒页面。创建一个名为 template.html 的新文件,并将其保存在 chrome-extension 目录中。它应包含以下代码:

<!DOCTYPE html>

<html lang="en">
    <head>
        <meta charset="utf-8" />
        <script id="contactTemplate" type="text/x-jsrender">
            {{for contacts}}
                <li>
                    <article>
                        <div class="details">
                            <h1>{{:name}}</h1>
                            {{if url}}
                                <span>website: {{url}}</span>
                            {{/if}}
                            {{if jobTitle}}
                                <h2>{{:jobTitle}}</h2>
                            {{/if}}
                            {{if companyName}}
                                <span class="company">
                                    {{:companyName}}
                                </span>
                            {{/if}}
                            {{if address}}
                                <p>{{:address}}</p>
                            {{/if}}
                            {{if contactMethods}}
                                <dl>
                                    {{for ~getMembers(contactMethods)}}
                                        <dd>{{:key}}</dd>
                                        <dt>{{:val}}</dt>
                                    {{/for}}
                                </dl>
                           {{/if}}
                        </div>
                    </article>
                </li>
            {{/for}}
        </script>
        <script src="img/jquery-1.9.0.min.js"></script>
        <script src="img/jsrender.js"></script>
        <script src="img/template.js"></script>
    </head>
</html>

模板页面还引用了 template.js 脚本文件。我们应该在 chrome-extension 目录中创建此文件,并将以下代码添加到其中:

(function () {
    $.views.helpers({
        getMembers: function (obj) {
            var prop,
                 arr = [];

            for (prop in obj) {
                if (obj.hasOwnProperty(prop)) {
                    var newObj = {
                        key: prop,
                        val: obj[prop]
                     }

                    arr.push(newObj);
                }
            }

            return arr;
        }
    });
} ());

完成目标 - 迷你总结

我们首先向扩展添加了一个新的 HTML 页面。名为 template.html 的页面类似于常规网页,只是没有 <body>,只有一个 <head>,它链接到一些 JavaScript 资源,并包含我们将使用的模板的 <script> 元素。

提示

通常在 Chrome 扩展中,CSP 阻止我们运行任何内联脚本 - 所有脚本都应驻留在外部文件中。在 <script> 元素上使用非标准的 type 属性允许我们规避这一点,以便我们可以将我们的模板存储在页面内,而不是使用另一个外部文件。

新页面的主体是模板本身。Schema.org 微数据允许人们添加大量附加信息以描述页面上的元素,因此扩展中可能存储各种不同的信息。

因此,我们的模板利用了很多条件来显示如果它们存在的东西。扩展程序应始终显示名称,但除此之外,它可能显示图像、工作标题和公司、地址或各种联系方式,或者它们的任何组合。

模板中最复杂的部分是getMembers()辅助函数。我们将使用 JsRender 的{{for}}标记为contactMethods对象中的每个对象调用此辅助函数,该标记使用波浪号(~)字符调用辅助函数。在循环内,我们将能够访问辅助函数返回的值,并将这些值插入到相关元素中。

接下来,我们添加了template.js脚本文件。此时,我们需要添加到此脚本文件的所有内容只是模板用于呈现任何联系方式的辅助方法。这些将采用{ email: me@me.com }的格式。

使用 JsRender 的helpers()方法注册辅助程序。此方法接受一个对象,其中指定辅助程序的名称为键,应调用的函数为值。

函数接收一个对象。我们首先创建一个空数组,然后使用标准的for in循环迭代对象。我们首先使用 JavaScript 的hasOwnProperty()函数检查正在迭代的属性是否属于对象,且不是从原型继承的。

然后,我们只需创建一个新对象,并将键设置为名为key的属性,将值设置为名为val的属性。这些是我们在模板中使用的模板变量,用于在我们的模板中的<dl>中插入。

然后,将此新对象推送到我们创建的数组中,并且一旦对传递给辅助函数的对象进行了迭代,我们将该数组返回给模板,以便{{for}}循环进行迭代。

在沙盒中发布消息

在此任务中,我们将建立我们的弹出窗口与沙盒模板页面之间的通信,以查看如何在打开弹出窗口时让模板进行呈现。

启动推进器

首先,我们可以添加将消息发送到沙盒页面以请求模板进行呈现的代码。在popup.js中,添加以下代码:

var iframe = $("#poster"),
    message = {
        command: "issueTemplate",
        context: JSON.parse(localStorage.getItem("webContacts"))
    };
    iframe.on("load", function () {
        if (message.context) {
            iframe[0].contentWindow.postMessage(message, "*");
        } else {
            $("<li>", {
                text: "No contacts added yet"
            }).appendTo($("#contacts"));
        }
    });

window.addEventListener("message", function (e) {
    $("#contacts").append((e.data.markup));
});

接下来,我们需要添加响应初始消息的代码。将以下代码直接添加到template.js中,放在我们上一个任务中添加的辅助方法之后:

var template = $.templates($("#contactTemplate").html());

window.addEventListener("message", function (e) {
    if (e.data.command === "issueTemplate") {

        var message = {
            markup: template.render(e.data.context)
        };

        e.source.postMessage(message, event.origin);
    }
});

目标完成 - 小型总结

首先,我们在popup.js中设置了初始消息传递。我们在变量中缓存了来自弹出窗口的<iframe>元素,然后编写了一条消息。消息是以对象文字的形式,具有command属性和context属性。

command属性告诉在<iframe>中运行的代码要执行什么操作,而context包含要渲染到模板中的数据。我们将要渲染的数据存储在 localStorage 的webContacts键下,并且数据将以 JSON 格式存储,因此我们需要使用JSON.parse()将其转换回 JavaScript 对象。

然后,我们使用 jQuery 的on()方法为<iframe>元素添加加载处理程序。传递给on()的匿名函数中包含的代码将在<iframe>的内容加载完成后执行。

一旦发生这种情况,我们检查 message 对象的 context 属性是否具有真值。如果是,我们使用 <iframe>contentWindow 属性的 postMessage() 函数将 message 对象发布到 <iframe>

postMessage() 函数接受两个参数 - 第一个是要发布的内容,在这种情况下是我们的 message 对象,第二个参数指定哪些文件可以接收此消息。我们将其设置为通配符 *,这样任何文件都可以订阅我们的消息。

如果没有存储的联系人,则我们 message 对象的 context 属性将具有假值 null。在这种情况下,我们只需创建一个新的 <li> 元素,其中包含一条文本消息,说明没有保存的联系人,并将其直接附加到 popup.html 中硬编码的空 <ul> 中。

我们的脚本文件 popup.js 也需要接收消息。我们使用标准的 JavaScript addEventListener() 函数将一个监听器附加到 window 上的 message 事件上。默认情况下,jQuery 不处理 message 事件。

popup.js 收到的消息将是包含要渲染的 HTML 标记的沙盒页面的响应。标记将包含在事件对象的 data 属性中的名为 markup 的属性中。我们简单地选择 popup.html 中的 <ul> 元素,并附加我们收到的标记。

我们还在 template.js 中添加了一些代码,该脚本文件被我们 <iframe> 内的页面引用。我们在这里再次使用 addEventListener() 函数来订阅消息事件。

这次我们首先检查发送消息的对象的 command 属性是否等于 issueTemplate。如果是,然后我们创建并渲染数据到我们的 JsRender 模板中,并构建一个包含渲染模板标记的新 message 对象。

创建了消息对象后,我们将其发布回 popup.js。我们可以使用事件对象的 source 属性获取 window 对象发送消息,并且可以使用事件对象的 origin 属性指定哪些文件可以接收消息。

这两个属性非常相似,除了 source 包含一个 window 对象,而 origin 包含一个文件名。文件名将是一个特殊的 Chrome 扩展名。在这一点上,我们应该能够启动弹出窗口,并看到没有联系人消息,因为我们还没有保存任何联系人。

添加一个内容脚本

现在,一切都已准备就绪以显示存储的联系人,因此我们可以专注于实际获取一些联系人。为了与用户在浏览器中导航的页面交互,我们需要添加一个内容脚本。

内容脚本就像一个常规脚本一样,只是它与浏览器中显示的页面进行交互,而不是与组成扩展的文件进行交互。我们会发现,我们可以在这些不同区域之间(浏览器中的页面和扩展)发送消息,方法与我们发送消息到我们的沙盒类似。

启动推进器

首先,我们需要向 chrome-extension 目录中添加一些新文件。我们需要一个名为 content.js 的 JavaScript 文件和一个名为 content.css 的样式表。我们需要告诉我们的扩展使用这些文件,因此我们还应该在此项目之前创建的清单文件(manifest.json)中添加一个新部分:

"content_scripts": [{
    "matches": ["*://*/*"],
    "css": ["content.css"],
    "js": ["jquery-1.9.0.min.js", "content.js"]
}]

这个新的部分应该直接添加到我们之前添加的沙盒部分之后(像以前一样,在sandbox属性后别忘了添加逗号)。

接下来,我们可以向 content.js 添加所需的行为:

(function () {

    var people = $("[itemtype*='schema.org/Person']"),
        peopleData = [];

    if (people.length) {

        people.each(function (i) {

            var person = microdata.eq(i),
                data = {},
                contactMethods = {};

            person.addClass("app-person");

        });
    }
} ());

我们还可以添加一些基本样式,用 content.css 样式表突出显示包含微数据属性的任何元素。现在更新此文件,使其包含以下代码:

.app-person { 
    position:relative; box-shadow:0 0 3px rgba(0,0,0, .5); 
    background-color:#fff;
}

目标完成 - 迷你总结

首先,我们更新了清单文件以包括内容脚本。正如我之前提到的,内容脚本用于与浏览器中显示的可见页面进行交互,而不是与扩展使用的任何文件进行交互。

我们可以使用清单中的 content_script 规则来启用内容脚本。我们需要指定内容脚本应加载到哪些页面中。我们在 URL 的 protocolhostpath 部分使用通配符(*)以便在访问任何页面时加载脚本。

使用 Schema.org 微数据来描述人物时,存在的不同信息被放置在一个容器内(通常是一个 <div> 元素,尽管任何元素都可以被使用),该容器具有特殊属性 itemtype

此属性的值是一个 URL,指定了它包含的元素描述的数据。所以,要描述一个人,这个容器将具有 URL schema.org/Person。这意味着容器中的元素可能有描述特定数据的附加属性,比如姓名或职务。容器内的元素上的这些附加属性将是 itemprop

在这种情况下,我们使用了一个 jQuery 属性包含选择器(*=)来尝试从页面中选择包含此属性的元素。如果属性选择器返回的数组长度(因此不为空),我们就知道页面上至少存在一个这样的元素,因此可以进一步处理该元素。

具有此属性的元素集合存储在名为 people 的变量中。我们还在变量 peopleData 中创建了一个空数组,准备存储页面上找到的所有人的所有信息。

然后,我们使用 jQuery 的each()方法来迭代从页面选择的元素。在我们的each()循环中,不使用$(this),我们可以使用我们已经从页面中选择的元素集合,与当前循环的索引一起使用 jQuery 的eq()方法来引用每个元素,我们将其存储在名为person的变量中。

我们还创建一个空对象并将其存储在名为data的变量中,准备存储每个人的微数据,以及一个名为contactMethods的空对象,因为任何电话号码或电子邮件地址的微数据都需要添加到我们的模板可消耗的子对象中。

此时我们所做的就是向容器元素添加一个新的类名。然后,我们可以使用content.css样式表向元素添加一些非常基本的样式,以引起用户的注意。

抓取页面的微数据

现在,我们已经安装好了我们的内容脚本,我们可以与扩展程序的用户访问的任何网页进行交互,并检查它是否具有任何微数据属性。

此时,任何包含微数据的元素都会被用户突出显示,因此我们需要添加功能,允许用户查看微数据并在愿意的情况下保存,这就是我们将在此任务中介绍的内容。

启动推进器

content.js中为每个具有itemtype属性的元素容器添加类名之后,添加以下代码:

person.children().each(function (j) {

    var child = person.children().eq(j),
        iProp = child.attr("itemprop");

    if (iProp) {

        if (child.attr("itemscope") !== "") {

            if (iProp === "email" || iProp === "telephone") {
                contactMethods[iProp] = child.text();
            } else {
                data[iProp] = child.text();
            }
        } else {

            var content = [];

            child.children().each(function (x) {
                content.push(child.children().eq(x).text());
            });

            data[iProp] = content.join(", ");
        }
    }
});

var hasProps = function (obj) {
    var prop,
    hasData = false;

    for (prop in obj) {
        if (obj.hasOwnProperty(prop)) {
            hasData = true;
            break;

        }
    }

    return hasData;
};

if (hasProps(contactMethods)) {
    data.contactMethods = contactMethods;
}

peopleData.push(data);

目标完成 - 小结

在上一个任务中,我们为每个标记了微数据的元素容器添加了一个类名。在此任务中,我们仍处于处理每个容器的each()循环的上下文中。

因此,在这个任务中添加的代码中,我们首先再次调用each(),这次是在容器元素的直接子元素上;我们可以使用 jQuery 的children()方法轻松获取这些子元素。

在这个each()循环中,我们首先使用传递给我们迭代函数的循环计数器(j)作为 jQuery 的eq()方法的参数来获取现有缓存的person变量中的当前项目。这样可以避免在我们的循环中创建一个全新的 jQuery 对象。

我们还将当前元素的itemprop属性的值存储在一个名为iProp的变量中,因为我们需要多次引用它,并且使用一个漂亮的短变量意味着我们需要输入更少的内容。

此时我们不知道我们是否正在处理常规元素还是包含微数据的元素,因此我们使用一个if语句来检查我们刚刚设置的iProp变量是否具有真值。如果元素没有itemprop属性,则此变量将保存一个空字符串,该空字符串为假值,如果元素只是常规元素,则停止代码进一步进行。

在此条件语句内部,我们知道我们正在处理包含微数据的元素,但数据可能采用不同的格式。例如,如果元素包含地址,它将不直接包含任何内容,而是将包含数据的自己的子元素。在这种情况下,元素将具有一个itemscope属性。首先,我们希望处理不包含itemscope属性的元素,因此我们嵌套条件的第一个分支检查通过选择itemscope属性返回的值是否不是空字符串。

如果记得我们的模板,我们设置了一个帮助函数,使用对象显示联系信息。为了创建这个新对象而不是创建data对象的新属性,我们使用另一个嵌套的if语句来检查iProp变量是否包含电子邮件或电话号码。

如果是这样,我们将iProp变量的值作为contactMethods对象的键,元素的文本作为值添加。如果iProp变量不包含电子邮件地址或电话号码,我们将iProp变量设置为data对象的键,并将其值设置为元素的内容。

第二个嵌套if语句的下一个分支是对具有itemscope属性的元素的。在这种情况下,我们首先定义一个空数组,并将其存储在名为content的变量中。然后,我们使用 jQuery 的each()方法迭代子元素,并将每个元素的文本内容推入content数组。

一旦我们遍历了子元素并填充了数组,我们就可以将当前的iProp变量和content数组中的数据添加到我们的data对象中。任何具有itemscope属性的元素仍应该具有itemprop属性,因此这应该仍然有效。

因此,在这一点上,我们的数据对象应该是对我们主容器内部元素设置的微数据的准确表示。但在对它们进行任何操作之前,我们需要检查contentMethods对象是否已填充,并且如果已填充,则将其添加到我们的data对象中。

我们可以使用hasProps()函数来检查对象是否具有自己的属性。该函数将接收要测试的对象作为参数。在函数内部,我们首先定义hasData变量,将其设置为false

然后,我们使用for in循环来迭代对象的每个属性。对于每个属性,我们检查该属性是否实际存在于对象上,并且未使用 JavaScript 的hasOwnProperty()函数继承。如果属性确实属于对象,我们将hasData设置为true,然后使用break退出循环。

然后,我们通过将其传递给我们的hasProps()函数来检查contactMethods对象是否有任何属性,如果有,我们将其添加到data对象中。最后,一旦所有这些处理都完成,我们将data对象添加到我们在代码开头定义的peopleData数组中。

添加一个保存微数据的机制

在这一点上,如果 Chrome 中显示的页面包含任何个人微数据,我们将有一个包含一个或多个包含微数据和描述其文本的对象的数组。在此任务中,我们将允许用户存储该数据(如果他/她愿意)。

因为我们的内容脚本在网页的上下文中运行而不是在我们的扩展中,所以我们需要再次使用消息传递来将任何收集到的数据传递回扩展以进行存储。

准备升空

为了在我们的内容脚本和扩展之间设置消息传递,我们需要添加一个背景页。背景页在扩展被安装和启用时持续运行,这将允许我们设置处理程序来监听并响应从内容脚本发送的消息。

背景页面可以是 HTML 或 JavaScript。在本项目中,我们将使用 JavaScript 版本。现在创建一个新文件,并将其保存在 chrome-extension 目录中为 background.js。我们还需要通过向 manifest.json 文件中添加一个新的 background 部分来将此文件注册为背景脚本:

"background": {
    "scripts": ["jquery-1.9.0.min.js", "background.js"]
}

这段代码应该直接放在列出 content_scripts 的数组之后。再次提醒,不要忘记数组后面的逗号。

启动推进器

首先,我们将向我们的背景页面添加所需的行为。在 background.js 中,添加以下代码:

chrome.extension.onConnect.addListener(function (port) {

    port.onMessage.addListener(function (msg) {

        if (msg.command === "getData") {

            var contacts = localStorage.getItem("webContacts")
|| '{ "message": "no contacts" }',
                  jsonContacts = JSON.parse(contacts);

            port.postMessage(jsonContacts);

        } else if (msg.command === "setData") {

          localStorage.setItem("webContacts", 
JSON.stringify({ 
              contacts: msg.contacts 
        }));

            port.postMessage({ message: "success" });
        }
    });
});

接下来,在 content.js 中,在我们将 data 对象推入 peopleData 数组之后,直接添加以下代码:

$("<a/>", {
    href: "#",
    "class": "app-save",
    text: "Save"
}).on("click", function (e) {
    e.preventDefault();

    var el = $(this),
          port = chrome.extension.connect(),
          contacts;

    if (!el.hasClass("app-saved")) {

        port.postMessage({ command: "getData" });
        port.onMessage.addListener(function (msg) {

            if (msg.message === "no contacts") {

                contacts = [peopleData[i]];

                port.postMessage({ 
                    command:"setData", 
                    contacts:contacts 
                });
            } else if (msg.contacts) {

                contacts = msg.contacts;
                contacts.push(peopleData[i]);

                port.postMessage({ 
                    command: "setData", 
                    contacts: contacts 
            });

        } else if (msg.message === "success") {

            el.addClass("app-saved")
               .text("Contact information saved");

        port.disconnect();

            }
        });
    }
}).appendTo(person);

最后,我们可以为我们刚刚添加的新保存链接添加一些样式。在 content.css 中,在文件底部添加以下代码:

.app-save { position:absolute; top:5px; right:5px; }
.app-saved { opacity:.5; cursor:default; }

目标完成 - 小型简报

在这个任务中,我们添加了相当多的代码,因为我们更新了几个不同的文件,以使扩展的不同部分进行通信。

添加通信模块

首先,我们更新了我们在任务开始时添加的行为页面。我们将使用 localStorage 来存储扩展收集的保存的联系人,但是只有运行在用户查看的网页上下文中的内容脚本才能访问给定页面的 localStorage 区域,但我们需要访问扩展本身的 localStorage 区域。

为了实现这一点,我们的 background.js 文件将充当一个中介,它将访问扩展的 localStorage 区域,并在内容脚本和扩展之间传递数据。

首先,我们添加了一个监听器到 onConnect 事件,我们可以通过 Chrome 的 extension 实用模块访问。当内容脚本与扩展建立连接时,浏览器将自动打开一个端口。表示此端口的对象将自动传递给我们的处理程序函数。

我们可以使用端口来添加一个消息事件的处理程序。与项目早期的简单 <iframe> 通信一样,此处理程序函数将自动传递触发事件的消息。

在消息处理程序内部,我们检查消息的command属性是否等于getData。如果是,我们首先创建一个contacts对象,该对象将由 localStorage getItem()方法获取的联系人或者仅包含消息no contacts的非常简单的 JSON 对象组成,我们可以手动创建。

一旦我们有了这两个 JSON 对象之一,我们就可以使用 Chrome 的原生 JSON parse()方法将其解析为一个真正的 JavaScript 对象。然后,我们可以使用postMessage()方法将此对象传回端口。每当建立一个新的连接时,一个新的端口将被打开,所以消息将自动传回到正确的端口,无需我们进行额外的配置。

如果msg对象的command属性不等于getData,它可能会等于setData。如果是,我们想要将一个或多个新的联系人存储到 localStorage。在这种情况下,我们将要存储的联系人作为msg对象的contacts属性中的对象传递,所以我们可以简单地在这个属性的对象上使用stringify()方法作为setItem()方法的第二个参数。

然后,我们再次使用port对象的postMessage()方法传回一条简短的消息,确认数据保存成功。

更新内容脚本

其次,我们更新了content.js文件,以便收集和存储访问者在网页上找到的任何联系信息。

我们首先添加一个新的<a>元素,该元素将用作保存联系信息的按钮,并且将添加到包含微数据的任何元素中。我们为新元素添加了一个简单的# href属性,一个用于样式目的的类名,以及文本保存

大多数新功能都包含在使用 jQuery 的on()方法创建新的<a>元素时直接附加到每个元素上的单击事件处理程序中。

在这个事件处理程序中,我们首先使用preventDefault()停止浏览器的默认行为,就像我们通常在将事件处理程序附加到<a>元素时一样。然后,我们通过将$(this)存储在一个名为el的变量中来缓存对当前<a>元素的引用。还使用extension模块的connect()方法打开一个新的端口来处理我们的通信需求。声明了一个名为contacts的变量,但没有立即定义。

代码的其余部分位于一个条件语句内,该条件语句检查元素是否已经具有类名app-saved,这将有助于防止同一页面上同一人的重复条目被保存到本地存储中。

在条件语句中,我们首先需要获取先前存储的联系人,因此我们通过向我们刚刚打开的端口发送消息来请求行为页面上的保存联系人。我们将一个具有command属性设置为getData的对象作为消息发送。

然后,我们使用addListener()方法对此消息的响应添加了一个处理程序,该方法在onMessage事件上。我们的其余代码位于此处理程序中,其中包含根据响应消息不同而有不同反应的另一个条件语句。

条件语句的第一个分支处理响应msgmessage属性包含字符串no contacts的情况。在这种情况下,我们创建一个新数组,其中包含从点击的保存链接中收集的联系人信息。我们已经在peopleData数组中有这些信息,并且由于我们仍处于更新每个人的循环中,因此我们可以使用i变量来存储正确的人员。

然后,我们可以将此数组发送到行为页面,以永久存储在扩展程序的本地存储区域中。

如果msg对象没有message属性,可能有contacts属性。此属性将包含先前存储的联系人数组,因此我们可以将数组保存到变量中,并在将更新后的数组发送回行为页面进行永久存储之前将新联系人添加到此数组中。

条件语句的最后一个分支处理了联系人成功保存的情况。在这种情况下,msg对象的message属性将包含success字符串。在这种情况下,我们将类名app-saved添加到<a>元素,并将文本更改为联系信息已保存。由于不再需要端口,我们可以使用port对象的disconnect()方法关闭它。

添加简单的样式

最后,我们为保存链接添加了一些非常简单的样式。一旦用户发起的操作完成,显示反馈非常重要。

在这个例子中,我们通过改变链接的文本简单地使用 CSS 使其更加不透明,使其看起来好像不再可点击,这是因为我们在脚本中使用的if语句的情况。

现在,我们应该能够浏览到包含微数据并保存联系信息的页面。当单击浏览器操作按钮时,我们将看到弹出窗口,其中应显示保存的联系人,如项目开始时的屏幕截图所示。

机密情报

在测试内容脚本时,重要的是要意识到每当内容文件更改时,这在本例中意味着 JavaScript 文件或样式表,都必须重新加载扩展程序。

要重新加载扩展程序,在 Chrome 的扩展程序页面中列出的扩展程序下方有一个重新加载Ctrl + R)链接。我们需要点击此链接以应用对任何内容文件所做的更改。扩展程序的其他部分,例如弹出窗口文件,不需要重新加载扩展程序。

扩展程序员的另一个有用工具是开发者工具,它可以专门打开以监视后台页面中的代码。在使用后台页面时,进行故障排除和脚本调试时,这可能非常有用。

任务完成

在这个项目中,我们涵盖了构建 Chrome 扩展的大部分基础知识。我们介绍了创建一个浏览器操作,当点击它时触发弹出窗口,以显示保存的联系人。

我们还了解了如何安全地对需要运行危险代码(如eval()new Function)的页面进行沙盒化,以保护我们的扩展不受 XSS 攻击的影响,并且我们如何使用简单的消息传递 API 向包含沙盒化页面的<iframe>元素发送消息并接收响应。

我们看到,除了定义在扩展上下文中运行的脚本之外,还可以添加在浏览器中显示的网页上下文中运行的内容脚本。我们还学会了如何使用manifest.json文件来指定扩展的这些不同区域。

我们还看到可以使用更高级的消息传递系统,允许我们打开允许进行更复杂双向消息传递的端口。通过端口通信,我们可以从扩展的不同区域发送并接收尽可能多的消息,以完成保存数据到扩展 localStorage 区域等特定任务。

我们还了解了可以使用Schema.org微数据描述的数据类型,以及可以添加到元素中进行描述的 HTML 属性。除了能描述人以外,还有用于描述地点、公司、电影等等的Schema.org格式。

我们学到了很多关于在 Chrome 中创建扩展,但是我们还使用了大量 jQuery 方法,以简化我们编写的脚本,以驱动扩展程序。

你准备好全力以赴了吗?一个热门挑战

当我们的扩展保存新联系人时,包含微数据的突出显示元素将被赋予新的 CSS 类名,并且会对它们进行一些非常简约的额外样式修改。

这样做是可以的,但确认成功的更好方法是利用 Chrome 的桌面通知系统,生成类似 Growl 风格的弹出式通知来确认成功。

访问developer.chrome.com/extensions/notifications.html查看通知文档,并查看是否可以更新扩展以包括此功能。

第七章:制作自己的 jQuery

在 jQuery 1.8 发布中,引入了一项全体设计希望已久的新功能-能够构建只包含特定任务所需功能的自定义版本的 jQuery。

任务简报

在这个项目中,我们将设置我们需要使用 jQuery 构建工具的环境。我们将看到我们需要使用的其他软件,如何运行构建工具本身,以及我们可以期望构建工具的输出。

为什么它很棒?

尽管有人通常会说他们在构建的每个网站中都使用 jQuery(对我来说通常是这样),但我期望很少有人会说他们在每个项目中都使用完全相同的 jQuery 方法,或者他们使用了大量可用方法和功能。

减少文件大小以满足移动空间的需求,以及诸如 Zepto 等微框架的兴起,它以更小的尺寸提供了大量 jQuery 功能,这促使 jQuery 提供了一种精简大小的方法。

从 jQuery 1.8 开始,我们现在可以使用官方 jQuery 构建工具来构建我们自己的定制版本的库,从而只选择我们所需的功能来最小化库的大小。

注意

有关 Zepto 的更多信息,请查看 zeptojs.com/.

你的顶尖目标

要成功完成这个项目,我们需要完成以下任务:

  • 安装 Git 和 Make

  • 安装 Node.js

  • 安装 Grunt.js

  • 配置环境

  • 构建自定义 jQuery

  • 运行 QUnit 单元测试

任务清单

我们将使用 Node.js 来运行构建工具,所以你现在应该下载一个副本。Node 网站(nodejs.org/download/)提供了 64 位和 32 位 Windows 的安装程序,以及 Mac OS X 的安装程序。它还为 Mac OS X、Linux 和 SunOS 提供了二进制文件。下载并安装适合你的操作系统的版本。

jQuery 的官方构建工具(尽管它除了构建 jQuery 之外还可以做很多其他事情)是 Grunt.js,由 Ben Alman 编写。我们不需要下载它,因为它是通过 Node Package ManagerNPM)安装的。我们将在项目后面详细看这个过程。

注意

要了解更多关于 Grunt.js 的信息,请访问官方网站 gruntjs.com.

首先,我们需要设置一个本地工作区。我们可以在根项目文件夹中创建一个名为 jquery-source 的文件夹。当我们克隆 jQuery Github 仓库时,我们会将 jQuery 源代码存储在这里,并且 Grunt 也会在这里构建最终版本的 jQuery。

安装 Git 和 Make

我们需要安装的第一件事是 Git,我们需要它来从 Github 存储库克隆 jQuery 源代码到我们自己的计算机,这样我们就可以处理源文件。我们还需要一个叫做 Make 的东西,但我们只需要在 Mac 平台上真正安装它,因为在 Windows 上安装 Git 时它会自动安装。

提示

因为我们将创建的文件仅供我们自己使用,并且我们不想通过将代码推送回存储库来为 jQuery 做出贡献,所以我们不需要担心在 Github 上创建账户。

准备起飞

首先,我们需要下载 Git 和 Make 的相关安装程序。根据你是在 Mac 还是 Windows 平台上开发,需要不同的应用程序。

Mac 开发者

Mac 用户可以访问git-scm.com/download/mac获取 Git。

接下来我们可以安装 Make。Mac 开发者可以通过安装 XCode 来获取。可以从developer.apple.com/xcode/下载。

Windows 开发者

Windows 用户可以安装msysgit,可以通过访问code.google.com/p/msysgit/downloads/detail?name=msysGit-fullinstall-1.8.0-preview20121022.exe获取。

启动推进器

下载完成安装程序后,运行它们来安装应用程序。安装程序默认选择的设置对这个任务来说应该是合适的。首先我们应该安装 Git(或者在 Windows 上安装 msysgit)。

Mac 开发者

Mac 开发者只需要运行 Git 的安装程序将其安装到系统中。安装完成后,我们可以安装 XCode。我们只需要运行安装程序,Make 以及一些其他工具将被安装并准备好。

Windows 开发者

msysgit 的完整安装程序完成后,你应该可以看到一个命令行界面(标题为 MINGW32),表明一切准备就绪,你可以开始进行编码。但是,在我们开始编码之前,我们需要编译 Git。

为了做到这一点,我们需要运行一个叫做initialize.sh的文件。在 MINGW32 窗口中,cdmsysgit目录。如果你允许它安装到默认位置,你可以使用以下命令:

cd C:\\msysgit\\msysgit\\share\\msysGit

一旦我们在正确的目录中,就可以在 CLI 中运行initialize.sh。和安装一样,这个过程可能需要一些时间,所以请耐心等待 CLI 返回$字符的闪烁光标。

注意

以这种方式编译 Git 需要互联网连接。

Windows 开发者需要确保Git.exe和 MINGW 资源可以通过系统的PATH变量访问。这可以通过转到控制面板 | 系统 | 高级系统设置 | 环境变量来更新。

在对话框的底部部分,双击路径,并将以下两个路径添加到位于您选择安装位置内的msysgit文件夹中的bin文件夹中的git.exe文件中:

  • ;C:\msysgit\msysgit\bin;

  • C:\msysgit\msysgit\mingw\bin;

提示

谨慎更新路径!

您必须确保Git.exe的路径与其余路径变量之间用分号分隔。如果在添加Git.exe路径之前路径不以分号结尾,请确保添加一个。错误地更新路径变量可能导致系统不稳定和/或数据丢失。我在上一个代码示例的开头显示了一个分号,以说明这一点。

路径更新后,我们应该能够使用常规命令提示符来运行 Git 命令。

安装后的任务

在终端或 Windows 命令提示符(我将两者简称为 CLI 以便简洁起见)窗口中,我们应该首先cd进入我们在项目开始时创建的jquery-source文件夹。根据您本地开发文件夹的位置不同,此命令看起来会像下面这样:

cd c:\jquery-hotshots\jquery-source

要克隆 jQuery 仓库,请在 CLI 中输入以下命令:

git clone git://github.com/jquery/jquery.git

同样,在 CLI 返回到闪烁的光标以指示进程完成之前,我们应该看到一些活动。

根据您所开发的平台不同,您应该会看到类似以下截图的内容:

安装后的任务

完成目标 - 迷你总结

我们安装了 Git,然后使用它克隆了 jQuery 的 Github 仓库到这个目录,以获取 jQuery 源代码的最新版本。如果您习惯于 SVN,克隆仓库的概念上与检出仓库是相同的。

再次说明,这些命令的语法在 Mac 和 Windows 系统上非常相似,但请注意,在 Windows 中使用路径时需要转义反斜杠。完成此操作后,我们应该会在jquery-source目录内看到一个名为jquery的新目录。

如果我们进入此目录,会看到一些更多的目录,包括:

  • build:此目录由构建工具用于构建 jQuery

  • speed:此目录包含基准测试

  • src:此目录包含编译为 jQuery 的所有单个源文件

  • 测试:此目录包含 jQuery 的所有单元测试

它还包含一系列各种文件,包括:

  • 授权和文档,包括 jQuery 的作者和项目贡献指南

  • Git 特定文件,如.gitignore.gitmodules

  • Grunt 特定文件,如 Gruntfile.js

  • JSHint 用于测试和代码质量目的

我们不需要直接使用 Make,但是当我们构建 jQuery 源代码时,Grunt 会使用它,因此它需要存在于我们的系统中。

安装 Node.js

Node.js 是一个用 JavaScript 构建的运行服务器端应用程序的平台。例如,可以轻松创建一个接收和响应 HTTP 请求的网络服务器实例,使用回调函数。

服务器端 JS 与更熟悉的客户端对应物并不完全相同,但在您所熟悉和喜爱的舒适语法中,您会发现许多相似之处。在这个项目中,我们实际上不会编写任何服务器端 JavaScript — 我们只需要 Node 来运行 Grunt.js 构建工具。

为起飞做准备

要获取适用于您平台的适当安装程序,请访问 Node.js 网站 nodejs.org 并点击下载按钮。如果支持的话,应该会自动检测到适合您平台的正确安装程序。

启动推进器

在 Windows 或 Mac 平台上,安装 Node 非常简单,因为两者都有安装程序。此任务将包括运行安装程序,这显然是简单的,并使用 CLI 测试安装。

在 Windows 或 Mac 平台上,运行安装程序,它将指导您完成安装过程。我发现在大多数情况下默认选项都很好。与之前一样,我们还需要更新Path变量以包括 Node 和 Node 的包管理器 NPM。这些目录的路径在不同平台上会有所不同。

Mac

Mac 开发者应检查 $PATH 变量是否包含对 usr/local/bin 的引用。我发现这已经在我的 $PATH 中了,但是如果您发现它不存在,您应该添加它。

注意

有关更新 $PATH 变量的更多信息,请参阅 www.tech-recipes.com/rx/2621/os_x_change_path_environment_variable/

Windows

Windows 开发者需要像以前一样更新Path变量,其中包括以下路径:

  • C:\Program Files\nodejs\;

  • C:\Users\Desktop\AppData\Roaming\npm;

注意

Windows 开发者可能会发现 Path 变量已经包含了一个 Node 条目,因此可能只需要添加 NPM 的路径。

目标完成 - 迷你总结

一旦安装了 Node,我们就需要使用 CLI 与其进行交互。要验证 Node 是否已正确安装,请在 CLI 中键入以下命令:

node -v

CLI 应该报告使用的版本,如下所示:

目标完成 - 迷你总结

我们可以通过运行以下命令来测试 NPM:

npm -v

安装 Grunt.js

在这个任务中,我们需要安装 Grunt.js,这个过程非常快速且简单,就像安装 Node 一样。我们甚至不需要手动下载任何东西,就像以前一样,相同的命令应该在 Mac 或 Windows 系统上都能工作,只需要非常小的调整。

启动推进器

我们需要使用Node 包管理器 NPM来安装它,可以通过运行以下命令来执行(注意,不能运行 Node 本身):

npm install -g grunt-cli

注意

Mac 用户可能需要在命令开头使用 superuser do

sudo –s npm install –g grunt

准备等待几分钟。同样,当 Grunt 需要的资源被下载和安装时,我们应该会看到大量活动。一旦安装完成,提示符将返回到闪烁的光标。CLI 应该会像以下截图一样显示,具体取决于您正在开发的平台:

启动推进器

完成目标 - 迷你总结

如果一切顺利(通常情况下应该如此,除非您的系统出现问题),那么在 Grunt 及其依赖项通过 NPM 全局下载和安装完成时,CLI 中将会看到大量活动,一旦完成,Grunt 将被安装并准备就绪。

提示

需要互联网连接才能使用 NPM 自动下载和安装软件包。

为了验证 Grunt 是否已正确安装,我们可以在 CLI 中输入以下命令:

grunt -version

这将输出当前 Grunt 的版本,并且应该可以从任何目录中运行,因为 Grunt 已经全局安装了。

机密情报

除了构建自定义版本的 jQuery 外,Grunt 还可以用于创建几种不同的常见项目。我们首先选择以下项目类型之一:

  • gruntfile

  • commonjs

  • jquery

  • node

我们可以运行内置的 init 任务,并指定其中一个项目,Grunt 将继续设置包含该项目常用资源的骨架项目。

例如,运行 jquery init 任务将设置一个工作目录,用于创建一个 jQuery 插件。在该目录中,Grunt 将创建源脚本文件和单元测试的文件夹,以及创建一系列文件,包括一个 package.json 文件。

很可能在某个时候,所有新的 jQuery 插件都需要按照 Grunt 创建此项目类型时的方式来构建结构,因此,对于任何 jQuery 插件开发者来说,Grunt 将成为一款不可或缺的、节省时间的工具。

配置环境

在我们准备构建自己的 jQuery 版本之前,还有一些事情需要做。我们还可以通过构建 jQuery 的完整版本来测试我们的安装和配置,以确保一切都按预期工作。

准备起飞

我们需要安装一些额外的 Grunt 依赖项,以便我们可以使用从 Github 克隆的源文件来创建 jQuery 脚本文件。项目还使用了一系列 NPM 模块,这些模块也需要安装。幸运的是,NPM 可以自动为我们安装所有内容。

启动推进器

在构建 jQuery 源码之前,我们需要在 jquery 源码文件夹中安装一些额外的 Grunt 依赖项。我们可以使用 NPM 来做到这一点,因此可以在 CLI 中输入以下命令:

npm install 

注意

在运行 install 命令之前,请确保您已经使用 cd 命令导航到 jquery 目录。

在运行 install 命令后,CLI 应该会有很多活动,而在进程结束时,CLI 应该会显示类似以下截图的内容:

启动推进器

为了测试一切是否按预期进行,我们可以构建 jQuery 的完整版本。只需在 CLI 中运行 grunt 命令:

grunt

注意

如果此时出现任何错误或警告,说明某些内容未安装或配置正确。失败的原因可能有很多,所以最好的做法是卸载我们安装的所有内容,然后重新开始整个过程,确保所有步骤都严格按照要求进行。

同样,我们应该会在 CLI 上看到很多活动,以表明事情正在发生:

启动推进器

目标完成 - 迷你总结

安装过程完成后,我们应该会发现 Node 依赖项已经安装到 jquery 目录中的一个名为 node_modules 的目录中。在这个文件夹中是 Grunt 针对这个特定项目所需要的任何其他文件。

为了测试一切,我们然后使用 grunt 命令运行 jQuery 的默认构建任务。此任务将执行以下操作:

  • 阅读所有 jQuery 源文件

  • 为任务的输出创建一个 /dist 目录

  • 构建 jquery.js 分发文件

  • 使用 jshint 对分发文件进行代码检查

  • 运行单元测试

  • 构建分发文件的源映射

  • 构建 jquery.min.js 分发文件

脚本文件应该是完整文件 230 KB,.min 文件为 81 KB,尽管随着 jQuery 版本号的增加,这些数字可能会有所不同。

构建自定义 jQuery

在这个任务中,我们将构建一个自定义版本的 jQuery,它不会包含构成 "完整" jQuery 的所有不同模块,这些模块会合并成一个文件,通常我们从 jQuery 站点下载,就像上一个任务结束时我们构建的文件一样,而是仅包含核心模块。

启动推进器

现在我们可以构建一个自定义版本的 jQuery。要构建一个精简版的 jQuery,省略所有非核心组件,我们可以在 CLI 中输入以下命令:

grunt custom:-ajax,-css,-deprecated,-dimensions,-effects,-offset

目标完成 - 迷你总结

一旦我们拥有源代码并配置好本地环境,我们就能够构建一个自定义版本的 jQuery,只包含核心组件,而省略了所有可选组件。

在这种情况下,我们排除了所有可选组件,但我们可以排除其中任何一个,或任意组合它们,以生成一个仅仅尽可能大的脚本文件。

如果此时检查 /dist 目录,我们应该会发现完整的脚本文件现在是 159 KB,而 .min 版本只有 57 KB,大约节省了文件大小的 30%;对于几分钟的工作来说,这还不错!

注意

项目功能或范围的变化可能需要重新构建源文件并包括以前排除的模块。一旦排除,就无法将可选模块添加到构建的文件中而不重新构建。

机密情报

随着 jQuery 的发展,特别是在 2.0 里程碑之后,越来越多的 jQuery 组件将被公开到构建工具作为可选组件,因此将有可能排除更广泛的库部分。

虽然在撰写时我们节省的文件大小可能会被我们的大多数访问者不会在其缓存中拥有我们的自定义版本的 jQuery 而需要下载的事实所抵消,但可能会有一天我们能够将文件大小缩小到这样的程度,以至于下载我们的超轻量级脚本文件仍然比从缓存中加载完整源文件更有效率。

使用 QUnit 运行单元测试

QUnit 是 jQuery 的官方测试套件,并包含在我们在项目早期从 Git 克隆的源代码中。如果我们在jquery文件夹内的测试文件夹中查找,我们应该会发现有很多单元测试,用于测试构成 jQuery 的不同组件。

我们可以针对 jQuery 的各个组件运行这些测试,以查看 QUnit 需要的环境,并查看使用它测试 JavaScript 文件有多容易。为此任务,我们需要安装一个 web 服务器和 PHP。

注意

有关 QUnit 的更多信息,请参阅qunitjs.com上的文档。

为起飞做好准备

Mac 开发者应该已经拥有运行 QUnit 所需的一切,因为 Mac 计算机已经预装了 Apache 和 PHP。然而,Windows 开发者可能需要做一些设置。

在这种情况下,web 服务器有两个选择,Apache 或者 IIS。两者都支持 PHP。那些希望使用 Apache 的开发者可以安装像WAMPWindows Apache Mysql PHP)这样的东西,以便安装和配置 Apache,并将 MySQL 和 PHP 安装为模块。

要下载并安装 WAMP,请访问 Wamp Server 网站的下载部分(www.wampserver.com/en/)。

选择适合您平台的安装程序并运行它。这应该会安装和配置一切必要的内容。

希望使用 IIS 的人可以通过控制面板中的程序和功能页面的添加/删除 Windows 组件区域安装它(在这种情况下需要 Windows 安装光盘),或者使用Web 平台安装程序WPI),可以从www.microsoft.com/web/downloads/platform.aspx下载。

下载并运行安装程序。一旦启动,搜索 IIS 并让应用程序安装它。安装完成后,也通过 WPI 搜索 PHP 并进行安装。

要使用 web 服务器和 PHP 运行 QUnit,你需要将项目文件夹内的jquery目录中的源文件复制到 web 服务器用于提供文件的目录中,或者配置 web 服务器以提供jquery目录中的文件。

在 Apache 上,我们可以通过编辑httpd.conf文件(在开始菜单中应该有一个条目)来配置默认目录(当浏览器请求时用于提供页面的目录)。向下阅读配置文件,直到找到默认目录的行,并更改它,使其指向项目文件夹中的jquery目录。

在 IIS 上,我们可以使用 IIS 管理器添加一个新网站。在左侧的连接窗格中右键单击站点,然后选择添加网站…。填写打开的对话框中的详细信息,我们就可以开始了。

启动推进器

要运行测试,我们只需要在浏览器中使用localhost:8080(或配置的任何主机名/端口号)访问/test目录:

localhost:8080/test

测试应该显示如下屏幕截图所示:

启动推进器

完成目标 - 小结

当在浏览器中访问测试套件的 URL 时,QUnit 将运行为 jQuery 编写的所有单元测试。目前对完整版本的 jQuery 有超过 6000 个测试,对所有可选模块都排除的自定义版本有约 4000 个测试。

你可能会发现一些测试失败。别担心,这是正常的,原因是我们从 Git 获取的默认 jQuery 版本将是最新的开发版本。就我写作时而言,当前版本的 jQuery 是 1.8.3,但从 Git 克隆的版本是 2.0.0pre。

要解决这个问题,我们可以切换到当前稳定分支,然后从那里进行构建。所以如果我想获取版本 1.8.3,我可以在 CLI 中使用以下命令:

git checkout 1.8.3

然后我们可以再次构建源码,运行 QUnit,所有测试应该都会通过。

注意

在检出 jQuery 源码的另一个版本后,我们需要在jquery目录中运行npm install来重新安装节点依赖项。

机密情报

单元测试并不总是被前端开发者严格遵循,但是一旦你的应用程序跨越了一定的规模和复杂度阈值,或者在团队环境中工作时,单元测试就变得对于维护至关重要,所以至少学习基础知识是最好的。

QUnit 使得编写 JavaScript 单元测试变得容易。它采用了围绕着用简单函数证明的断言概念的简单 API。QUnit 的 API 包括我们可以使用的方法来进行这些断言,包括:

  • equal()

  • notEqual()

  • ok()

这样可以轻松检查变量是否等于特定值,或者函数的返回值是否不等于特定值,等等。

在 QUnit 中,使用全局的 test() 方法构建测试,该方法接受两个参数:描述测试的字符串和执行测试的函数:

test("Test the return value of myCustomMethod()", function() {
    //test code here
});

在函数内部,我们可以使用一个或多个断言来检查我们正在测试的方法或函数执行的操作的结果:

var value = myCustomMethod();
equal(value, true, "This method should return true");

equal() 方法检查第一个和第二个参数是否相等,最后一个参数是描述我们期望发生的情况的字符串。

提示

如果打开 jquery/test/unit 目录中的一些脚本文件,可以很容易地看出如何构造测试。

QUnit 网站上的文档非常出色。它不仅清晰简洁地描述了 API,还提供了大量关于单元测试概念的信息,因此对于初学者来说是一个很好的起点。

在该网站上,您还可以找到在 Grunt 之外运行 QUnit 所需的源文件以及一个 HTML 模板页面,您可以在浏览器中运行测试套件。

任务完成

在这个任务中,我们不仅学会了如何通过排除不需要的组件来构建自定义版本的 jQuery,以及如何运行 jQuery 的单元测试套件,而且,也许更重要的是,我们学会了如何设置一个体面的构建环境,用于编写干净、无错的应用级 JavaScript。

你准备好了吗?挑战来了!

我们已经学会了如何构建我们自己的 jQuery,并排除了最大数量的组件,所以在撰写本文时,我们已经没有太多可以做的了。

如果您在 jQuery 1.9 版本发布后阅读本文,则可能会有更多的组件可以排除,或者其他构建 jQuery 的技术,因此,为了真正巩固您对构建过程的理解,请构建一个新的自定义构建,也排除任何新的可选组件。

如果没有任何新的可选组件,我建议您花些时间为您编写的任何自定义脚本编写 QUnit 测试。其思想是编写一个复制错误的测试。然后您可以修复错误并观察测试通过。

第八章:使用 jQuery 进行无限滚动

无限滚动是许多热门网站采用的一种技术,它最小化了页面最初加载的数据量,然后在用户滚动到页面底部时逐步加载更多数据。你可以在 Facebook 或 Twitter 的时间线上看到这种效果,等等。

任务简报

在本项目中,我们将使用 jQuery 构建一个无限滚动系统,模仿前述网站上看到的效果。我们将请求一些数据并在页面上显示它。一旦用户滚动到页面底部,我们将请求下一页的数据,依此类推,直到用户继续滚动。

一旦我们建立了无限滚动系统,我们应该得到类似以下截图的结果:

任务简报

为什么很棒?

如果您有大量数据要显示,并且它可以轻松按照时间顺序排列,那么使用无限滚动技术是最大程度地提高页面用户体验的简单方法,通过渐进式披露向用户逐渐展示更多内容。

首先可以显示一小部分数据,这样可以加快页面加载速度,同时防止您的访问者被大量数据所压倒,随着用户交互逐渐增加。

本项目将要消费的数据是 YouTube 上 TEDTalks 频道上传的视频列表,以 JSON 格式提供。

注意

请记住,JSON 是一种轻量级的基于文本的数据格式,非常适合在网络上进行传输。有关 JSON 的更多信息,请参阅 www.json.org/

在该频道上可以找到数千个视频,因此它是我们项目的一个很好的测试基础。按时间顺序排序的数据是一个无限滚动的绝佳基础。

注意

TEDTalks 频道可以直接在 YouTube 网站上查看,网址是 www.youtube.com/user/tedtalksdirector

您的热门目标

该项目将分解为以下任务:

  • 准备基础页面

  • 获取初始供稿

  • 显示初始结果集

  • 处理滚动到页面底部

任务清单

我们可以像在之前的一些示例中那样链接到 JsRender 的托管版本,但在这个项目中,我们将使用一个称为 imagesLoaded 的便捷小型 jQuery 插件,它允许我们在所选容器中的所有图像加载完成时触发回调函数。

imagesLoaded 插件可以从 github.com/desandro/imagesloaded 下载,并应保存在我们项目的 js 目录中。

准备基础页面

在此任务中,我们将设置我们在整个项目中要使用的文件,并准备我们的无限滚动页面的基础。

准备起飞

和往常一样,我们将为此项目使用自定义样式表和自定义脚本文件,所以让我们首先添加它们。创建一个名为infinite-scroller.js的新 JavaScript 文件,并将其保存在js目录中。然后创建一个名为infinite-scoller.css的新样式表,并将其保存在css目录中。最后,将template.html文件的副本保存在根项目文件夹中,并将其命名为infinite-scroller.html

启动推进器

示例页面使用的底层标记将是最小的 - 我们将使用的许多元素将由我们的模板动态生成,我们也可以在此任务中添加它们。

首先,我们应该将对新文件的引用添加到 HTML 页面中。首先,在infinite-scroller.html<head>中,直接在对common.css的链接之后添加一个<link>元素:

<link rel="stylesheet" href="css/infinite-scroller.css" />

接下来,我们可以链接到两个新的 JavaScript 文件。在 jQuery 之后直接添加以下<script>元素:

<script src="img/jsrender.js">
</script>
<scriptsrc="img/jquery.imagesloaded.min.js"></script>
<scriptsrc="img/infinite-scroller.js"></script>

我们还需要添加一个简单的容器来渲染我们的数据。将以下代码添加到页面的<body>中:

<div id="videoList"></div>

现在我们可以添加我们将要使用的模板了。在这个项目中,我们将使用两个模板 - 一个用于呈现外部容器和用户数据,它将被呈现一次,另一个用于呈现视频列表,我们可以根据需要重复使用。

与以前一样,它们将位于页面<body>中的<script>元素内。在现有的<script>元素之前,添加以下新模板:

<script id="containerTemplate" type="text/x-jsrender">
    <section>
        <header class="clearfix">
            <imgsrc="img/{{>avatar}}" alt="{{>name}}" />
            <hgroup>
                <h1>{{>name}}</h1>
                <h2>{{>summary.substring(19, 220)}}</h2>
            </hgroup>
        </header>
        <ul id="videos"></ul>
    </section>
</script>

现在轮到视频模板了:

<script id="videoTemplate" type="text/x-jsrender">
    <li>
        <article class="clearfix">
            <header>
                <a href="{{>content[5]}}" title="Watch video">
                    <imgsrc="img/{{>thumbnail.hqDefault}}" alt="{{>title}}" />
                </a>
                <cite>
                    <a href="{{>content[5]}}" 
                    title="Watch video">{{>title}}</a>
                </cite>
            </header>
            <p>
                {{>~Truncate(12, description)}}
                    <a class="button" href="{{>content[5]}}" 
                    title="Watch video">Watch video</a>
            </p>
            <div class="meta">
                <dl>
                    <dt>Duration:</dt>
                    <dd>{{>~FormatTime(duration)}}</dd>
                    <dt>Category:</dt>
                    <dd>{{>category}}</dd>
                    <dt>Comments:</dt>
                    <dd>{{>commentCount}}</dd>
                    <dt>Views:</dt>
                    <dd>{{>viewCount}}</dd>
                    <dt>Likes:</dt>
                    <dd>{{>likeCount}}</dd>
                </dl>
            </div>
        </article>
    </li>
</script>

现在我们也可以为这些元素添加样式了。在infinite-scroller.css中,添加以下选择器和规则:

section { width:960px; padding-top:20px; margin:auto; }
section { 
    width:960px; padding:2em 2.5em 0; 
    border-left:1px solid #ccc; border-right:1px solid #ccc; 
    margin:auto; background-color:#eee; 
}
section> header { 
    padding-bottom:2em; border-bottom:1px solid #ccc; 
}
img, hgroup, hgroup h1, hgroup h2 { float:left; }
hgroup { width:80%; }
headerimg { margin-right:2em; }
hgroup h1 { font-size:1.5em; }
hgroup h1, hgroup h2 { width:80%; }
hgroup h2 { 
    font-weight:normal; margin-bottom:0; font-size:1.25em;
    line-height:1.5em; 
}
ul { padding:0; }
li { 
    padding:2em 0; border-top:1px solid #fff; 
    border-bottom:1px solid #ccc; margin-bottom:0; 
    list-style-type:none; 
}
article header a { 
    display:block; width:27.5%; margin-right:2.5%; float:left; }
aimg { max-width:100%; }
article cite { 
    width:70%; margin-bottom:10px; float:left; 
    font-size:1.75em; 
}
article cite a { width:auto; margin-bottom:.5em; }
article p { 
    width:45%; padding-right:2.5%; 
    border-right:1px solid #ccc; margin:0 2.5% 2em 0;
    float:left; line-height:1.75em; 
}
article .button { display:block; width:90px; margin-top:1em; }
article dl { width:19%; float:left; }
article dt, article dd { 
    width:50%; float:left; font-size:1.15em; text-align:right; 
} 
article dt { margin:0 0 .5em; clear:both; font-weight:bold; }

li.loading{ height:100px; position:relative; }
li.loading span { 
    display:block; padding-top:3em; margin:-3em 0 0 -1em; 
    position:absolute; top:50%; left:50%; text-align:center;
    background:url(../img/ajax-loader.gif) no-repeat 50% 0; 
}

注意

此项目中使用的ajax-loader.gif图像可以在本书的附带代码下载中找到。

目标完成 - 小结

因此,实际上整个页面都是由我们添加到页面<body>中的模板构建的,除了一个空的<div>,它将为我们提供一个容器来渲染数据。该模板包含了用于视频列表的标记,以及用于显示视频作者信息的标记。

在第一个模板中,数据的外部容器是一个<section>元素。在其中是一个<header>,显示有关用户的信息,包括他/她的个人资料图片、姓名和简介。

YouTube 返回的实际简介可能相当长,因此我们将使用 JavaScript 的substring()函数返回此摘要的缩短版本。该函数传递两个参数;第一个是从哪个字符开始复制,第二个是结束字符。

在第二个模板中,实际的视频列表将显示在第一个模板中添加的<ul>元素中,每个视频占据一个<li>。在每个<li>内,我们有一个<article>元素,这是一个适当的独立内容单元的容器。

<article>中,我们有一个包含视频的一些关键信息的<header>,如标题和缩略图。在<header>之后,我们显示视频的简短摘要在<p>元素中。我们还使用我们的缩短帮助函数Truncate(),从第 12 个字符开始。

最后,我们使用<dl>显示关于视频的一些元信息,例如播放次数、点赞次数和视频的持续时间。

我们使用另一个辅助函数来显示视频中的持续时间,FormatTime()。YouTube 返回视频的长度(以秒为单位),所以我们可以将其转换为一个格式良好的时间字符串。

我们使用>字符来 HTML 编码我们插入到页面中的任何数据。这样做是为了安全考虑,始终是最佳选择。

添加的 CSS 纯粹是用于表现的;仅用于以列表格式布局页面,并使其看起来略有趣味和可呈现。请随意更改布局样式的任何方面,或者元素的主题。

机密情报

你们中注重 SEO 的人会意识到,一个几乎完全由 AJAX 传递的内容构建的页面不太可能在搜索结果中得到很好的位置。传统上,这几乎肯定是正确的,但现在我们可以使用 HTML History API 中令人惊叹的pushState()方法来提供一个完全可由搜索引擎索引的动态网站。

pushState()的完整描述超出了本书的范围,但有很多很好的示例和教程。被许多人认为是 History API 的权威指南的是 Mozilla 开发者网络上关于pushState()的文档,其中包括关于pushState()的部分。你可以在 developer.mozilla.org/en-US/docs/DOM/Manipulating_the_browser_history 上查看文档。

获取初始饲料

在这个任务中,我们将专注于获取初始数据集,以便在页面首次加载时创建页面。我们需要编写我们的代码,使得获取第一页数据的函数对于任何数据页都是可重用的,以便我们稍后在项目中可以使用它。

准备起飞

我们可以使用 jQuery 提供的标准document ready快捷方式,就像我们在许多之前的项目中所做的那样。我们可以通过将以下代码添加到我们之前创建的infinite-scroller.js文件中来做好准备:

$(function () {

    //rest of our code will go here...  

});

启动推进器

首先,我们可以添加从 YouTube 检索数据的代码。用以下内容替换前面代码段中的注释:

var data = {},
    startIndex = 1;

var getUser = function () {
    return $.getJSON("http://gdata.youtube.com/feeds/api/users/tedtalksdirector?callback=?", {
        v: 2,
        alt: "json"
    }, function (user) {
        data.userdata = user.entry;
    });
};

var getData = function () {
    return $.getJSON("https://gdata.youtube.com/feeds/api/videos?callback=?", {
        author: "tedtalksdirector",
        v: 2,
        alt: "jsonc",
        "start-index": startIndex
    }, function (videos) {
        data.videodata = videos.data.items;
    });
};

接下来,我们需要稍微处理一下响应。我们可以使用以下代码,在我们之前添加的代码之后直接添加,以执行回调函数,一旦两个 AJAX 请求都完成,就会执行该回调函数:

$.when(getUser(), getData()).done(function () {
    startIndex+=25;

    var ud = data.userdata,
        clean = {};

    clean.name = ud.yt$username.display;
    clean.avatar = ud.media$thumbnail.url;
    clean.summary = ud.summary.$t;
    data.userdata = clean;
});

目标完成 - 迷你总结

我们首先定义了几个变量。第一个是一个空对象,我们将用我们的 AJAX 请求的结果填充它。第二个是一个整数,表示我们希望获取的第一个视频的索引号。YouTube 视频不像常规的 JavaScript 数组那样从零开始,所以我们最初将变量定义为1

接下来,我们添加了我们将用于获取数据的两个函数。第一个是请求获取我们将要显示其 Feed 的用户的个人资料数据。我们只会在页面最初加载时使用此函数一次,但您将会看到为什么重要的是我们以这种方式将函数定义为变量。

第二个函数将被重用,因此将其存储在一个变量中是一个很好的方法,可以随时调用它以获取新的视频数据页面。重要的是这两个函数都返回getJSON()方法返回的jqXHR对象。

这两个请求都使用 jQuery 的getJSON()方法进行请求。在用户请求中,我们只需要设置valt查询参数,这些参数设置在传递给getJSON()的第二个参数中的对象中。我们想要获取其个人资料数据的用户实际上是我们正在进行请求的 URL 的一部分。

此请求的回调函数简单地将从请求接收到的user.entry对象的内容添加到我们的data对象的userdata属性中。

第二个请求需要稍微更多的配置。我们仍然使用v参数设置我们要使用的 API 版本,但这次我们将响应格式设置为jsonc而不是json。在此请求的回调函数中,我们将视频数组存储在我们的data对象的videodata属性中。

JSON-C 代表 json-in-script,是 Google 可以针对某些请求进行响应的格式。以 JSON-C 格式返回的数据通常比以 JSON 格式返回的相同响应更轻量级,更高效,这是由于 Google 的 API 已经进行了工程化。

当使用这种格式时,我们需要使用的属性只有在返回时才会返回。我们在请求用户数据时不使用它的唯一原因是因为该特定查询没有 JSON-C 响应。

有关从 Google 的 API 返回的 JSON-C 响应的更多信息,请参阅 developers.google.com/youtube/2.0/developers_guide_jsonc 上的文档。

接下来我们使用 jQuery 的when()方法来启动我们的两个请求,然后使用done()方法在两个jqXHR对象都已解析后执行回调函数。这就是为什么单独使用的getUser()函数以与可重用的getData()函数相同的方式结构化很重要的原因。

done()的回调函数内部,我们首先将startIndex变量增加 25,这样当我们发出另一个请求时,我们就会获得下一个包含 25 个视频的“页面”。现在我们已经有了第一页的数据,当我们稍后使用getData()函数时,我们将自动获得“下一页”的结果。

注意

when()done()方法是自 jQuery 1.5 以来处理异步操作的首选方法。

此时,我们只需要对我们的userdata对象进行一点处理。有一大堆我们不需要使用的数据,而我们需要使用的一些数据被埋在嵌套对象中,所以我们简单地创建一个名为clean的新对象,并直接在这个对象上设置我们需要的数据。

一旦完成了这个操作,我们就可以将我们的干净对象保存回我们的data对象,覆盖原始的userdata对象。这样做可以使对象在我们的模板中更容易处理。

显示初始结果集

现在我们已经从 YouTube 的 API 返回数据,我们可以渲染我们的模板了。然而,为了渲染我们的模板,我们需要添加用于格式化部分数据的辅助函数。在此任务中,我们可以添加这些辅助函数,然后渲染模板。

启动推进器

模板辅助函数不需要驻留在$.done()回调函数内部。我们可以直接在infinite-scroller.js中的此代码之前添加它们:

var truncate = function (start, summary) {
        return summary.substring(start,200) + "...";
    },
    formatTime = function (time) {
        var timeArr = [],
            hours = Math.floor(time / 3600),
            mins = Math.floor((time % 3600) / 60),
            secs= Math.floor(time % 60);

        if (hours> 0) {
            timeArr.push(hours);
        }

        if (mins< 10) {
            timeArr.push("0" + mins);
        } else {
            timeArr.push(mins);
        }

        if (secs< 10) {
            timeArr.push("0" + secs);
        } else {
            timeArr.push(secs);
        } 

        return timeArr.join(":");
    };

接下来,我们只需要注册这些辅助函数。在上一段代码后面直接添加以下内容:

$.views.helpers({
    Truncate: truncate, 
    FormatTime: formatTime
});

最后,我们可以渲染我们的模板。我们希望一个可以从代码的任何位置调用的函数,以备将来进行进一步的请求。在注册辅助函数后添加以下代码:

var renderer = function (renderOuter) {

    var vidList = $("#videoList");

    if (renderOuter) {
        vidList.append(
$("#containerTemplate").render(data.userdata));
    }
    vidList.find("#videos")
           .append($("#videoTemplate").render(data.videodata));
}

现在我们只需要在我们的$.done()回调函数的末尾调用这个函数:

renderer(true);

目标完成 - 小结

我们的第一个辅助函数,truncate()非常简单。我们只是返回该函数作为参数接收的字符串的缩短版本。substring()函数接受两个参数;第一个是在字符串中开始复制的位置,第二个参数是要复制的字符数,我们固定在200

为了显示字符串已经被缩短,我们还在返回的字符串末尾附加了一个省略号,这就是我们在这里使用辅助函数的原因,而不是像之前直接在模板中使用子字符串一样。

formatTime()辅助函数稍微复杂一些,但仍然相对简单。这个函数将接收以秒为单位的时间,我们希望将其格式化为稍微漂亮一些的字符串,显示小时(如果有的话)、分钟和秒。

我们首先创建一个空数组来存储字符串的不同组成部分。然后,我们创建一些变量来保存我们将要创建的时间字符串的小时、分钟和秒部分。

小时数通过将总秒数除以 3600(一小时的秒数)来计算。我们对其使用Math.floor(),以便只得到一个整数结果。我们需要稍微不同地计算分钟,因为我们需要考虑小时数。

在这里我们使用模数运算符(%)首先去除任何小时,然后将余数除以60,这将告诉我们总分钟数或在考虑小时后剩余的分钟数。要计算秒数,我们只需要再次使用模数运算符和值60

然后,我们使用一系列条件语句来确定要添加到数组中的变量。如果有任何小时数(这在视频的性质上是不太可能的),我们将它们推入数组中。

如果分钟数少于10,我们在分钟数前添加0,然后将其推入数组中。如果分钟数超过10,我们只需将mins变量推入数组中。在将其推入数组之前,对secs变量应用相同的逻辑。

这个函数通过将数组中的项目连接起来并使用冒号作为分隔符来返回一个格式良好的时间。字符串将以H:MM:SSMM:SS的格式呈现,具体取决于视频的长度。然后,我们使用 JsRender 的helpers对象向模板注册辅助函数,该对象本身嵌套在由模板库添加到 jQuery 的views对象中。我们希望添加的辅助函数被设置为对象文字中的值,其中键与模板中的函数调用匹配。

接下来,我们添加了一个函数,我们可以调用该函数来呈现我们的模板。renderer()函数接受一个布尔值参数,指定是否同时呈现容器模板和视频模板,或只呈现视频模板。在函数内部,我们首先缓存对视频列表的外部容器的引用。

如果renderOuter参数具有真值(也就是说,如果它具体保留了值true),我们就呈现containerTemplate并将其附加到页面的空<div>中。然后,我们呈现videoTemplate,将呈现的 HTML 附加到由containerTemplate添加的<ul>中。

最后,我们第一次调用我们的renderer()函数,将true作为参数传递,以同时呈现容器和初始视频列表。

处理滚动到页面底部

现在我们已经得到了第一页的视频,我们想添加一个处理程序,监视窗口的滚动事件,并检测页面是否已经滚动到底部。

启动推进器

首先,我们需要添加一些新的变量。修改文件顶部附近的第一组变量,使其显示如下:

var data = {},
    startIndex = 1,
    listHeight = 0,
    win = $(window),
    winHeight = win.height();

现在我们需要更新我们的renderer()函数,以便在模板被渲染后更新新的listHeight变量。在我们渲染videoTemplate后添加以下代码:

vidList.imagesLoaded(function () {
    listHeight = $("#videoList").height();
});

接下来,我们可以为滚动事件添加一个处理程序。在infinite-scroller.js中的when()方法后面,添加以下代码:

win.on("scroll", function () {

    if (win.scrollTop() + winHeight >= listHeight) {
        $("<li/>", {
            "class": "loading",
            html: "<span>Loading older videos...</span>"
        }).appendTo("#videos");

        $.when(getData()).done(function () {
            startIndex += 25;

            renderer();

            $("li.loading").remove();

        });
    }
}).on("resize", function() {
    winHeight = win.height();
});

我们正在使用一个旋转器来向用户显示正在检索更多数据的信息。我们需要一些额外的样式来处理旋转器的位置,所以我们也可以将以下代码添加到我们的infinite-scroller.css样式表的底部:

li.loading{ height:100px; position:relative; }
li.loading span { 
    display:block; padding-top:38px; margin:-25px 0 0 -16px; 
    position:absolute; top:50%; left:50%; text-align:center; 
    background:url(../img/ajax-loader.gif) no-repeat 50% 0;
}

目标完成 - 迷你总结

我们使用我们缓存的win对象和on()方法将处理程序附加到窗口。事件类型被指定为scroll。在回调函数内部,我们首先检查当前窗口的scrollTop属性加上视口的height是否大于或等于我们的videolist容器的height。我们需要这样做来知道页面何时滚动到底部。

如果两个高度相等,我们创建一个临时加载器,向用户提供视觉反馈,表明正在发生某些事情。我们将一个新的<li>元素附加到包含视频的<ul>中,并给它一个类名为loading,以便我们可以轻松地用一些 CSS 来定位它。我们将一个<span>元素设置为新列表项的内容。

我们可以使用 jQuery 的scrollTop()方法获取scrollTop属性的当前值。我们正在使用窗口height的缓存值。我们的滚动处理程序将相当密集,因为它将在用户滚动时被调用,因此使用窗口height的缓存值会使这个过程稍微更有效率一些。

但这意味着如果窗口被调整大小,这个值将不再准确。我们通过为窗口添加一个调整大小处理程序来解决这个问题,每当窗口调整大小时重新计算这个值。这是通过在滚动处理程序之后链接另一个对on()方法的调用来完成的,该方法查找window对象的调整大小事件,并相应地更新winHeight变量。

然后我们再次使用 jQuery 的when()方法,调用我们的getData()函数来检索下一个 25 个视频。我们还再次使用done()方法来在请求完成后执行回调函数。

在这个回调函数中,我们再次将我们的startIndex变量增加25,准备请求下一组视频。getData()函数将填充我们的data对象,新的视频数据,所以我们只需调用我们的renderer()函数来显示新的视频,然后移除临时加载器。

在这一点上,我们应该有一个完全功能的无限加载器,当用户滚动到页面底部时加载更多视频。当我们滚动到底部时,我们应该能够运行页面并看到类似以下的内容:

目标完成 - 迷你总结

任务完成

在这个项目中,我们编写的大部分代码都是关于获取我们想要显示的数据。实际上,添加无限滚动功能本身只需要一小部分代码 - 一个监视滚动事件并在文档滚动到底部时触发新数据请求的单个处理程序。

如你所见,这是一个非常容易作为附加层来修改现有功能的功能。这种技术最适合能够轻松按时间顺序排列的数据,新项目出现在顶部,旧项目出现在底部。

这并不一定是分页数据的完全替代,但在处理诸如新闻故事、博客文章、推文或状态更新等内容时,肯定是有意义的。它与社交数据配合得非常好。

你准备好大干一场了吗?一个高手挑战。

在这个项目中,我们只是为每个 YouTube 视频提供了回到全屏视频播放器的链接。所以,当访问者点击视频缩略图或标题时,他们将被送到 YouTube 实际观看视频。

虽然这样做并没有什么本质上的错,但更酷的做法是打开一个包含在<iframe>中嵌入的视频播放器的灯箱。这样访问者就可以在不离开您的网站的情况下观看视频。来自 YouTube 视频的响应包含一个可以用作<iframe>src属性的链接,那为什么不试试自己连接一下呢?

你会注意到,如果你滚动到页面底部,然后立即继续向下滚动,同一组视频将被多次请求。作为另一个任务,看看你是否可以通过仅在当前没有请求正在进行时才请求更多数据来防止这种情况发生。

这应该非常容易设置,只需在请求开始时设置一个标志,结束时删除标志。然后,只有在标志未被设置时才能发出请求。

第九章:一个 jQuery 热图

热图可以告诉您有关您的网站如何使用的很多信息。在分析领域,这是一种有价值的工具,可以告诉您网站的哪些功能被最多使用,以及哪些区域可能需要一些改进以真正吸引访问者。

任务简报

在这个项目中,我们将建立自己的热图,记录任何页面的哪些区域被点击最多。我们需要建立一种实际记录每次点击发生的位置以及将该信息传输到某个地方以便存储的方法。

我们实际上将构建整个热图的两个不同部分 - 客户端部分在访问者的浏览器中执行以捕获点击,并且一个管理控制台,向网站的所有者显示热图。

我们需要考虑不同的分辨率和设备,以便捕获尽可能多的信息,并确保我们的脚本足够高效地在后台运行而不被注意到。

当然,在客户端不会发生任何可见的事情(所有这部分将做的就是记录和存储点击),但是在项目结束时,我们将能够在管理控制台中显示有关页面上所有点击的数量和位置的详细信息,如以下屏幕截图所示:

任务简报

它为什么很棒?

所有的分析对网站的所有者都是有用的,并且可以提供有关访问网站的人的详细信息,包括他们的计算环境,他们进入网站的页面,他们离开的页面以及他们访问的页面数量。

从开发者的角度来看,热图同样具有信息量。您页面的哪些部分被点击最频繁?热图可以告诉您。

我们将构建的热图将适用于能够根据设备屏幕宽度改变其布局以适应的响应式网站。单个项目远远不足以涵盖响应式设计的所有方面,因为我们主要关注脚本本身,所以我们不会详细介绍它。

如果您已经使用过响应式技术,那么您将不需要额外的信息。如果您之前没有使用过响应式原理,那么这应该是一个关于该主题的温和介绍,应该作为该主题的入门手册。

您的热门目标

在这个项目中,我们将涵盖以下任务:

  • 确定并保存环境

  • 捕获访问者的点击

  • 保存点击数据

  • 添加管理控制台

  • 请求点击数据

  • 显示热图

  • 允许选择不同的布局

  • 显示每个布局的热图

任务清单

这是唯一一个我们不打算自己构建所需的 HTML 和 CSS 的项目。我们希望我们的热图能够与各种布局配合使用,测试这一点的最佳方法是使用响应式布局。如果我们自己编写代码,我们会在此项目的大部分时间里仅编写和讨论布局,甚至在开始制作热图之前。

我们将在这个项目中使用一个预先构建的响应式模板,这样我们就可以直接进入有趣的部分,而不会分心。我们将使用的模板称为 Simplex,但不幸的是,它已经不再在线上提供了。您需要使用本书附带下载的模板文件。只需将下载存档中的simplex文件夹复制到主jquery-hotshots项目目录中即可。我们需要做的就是在模板的每个 HTML 页面中添加几个脚本引用。应该更新的文件是:

  • contact.html

  • gallery.html

  • index.html

  • who-we-are.html

新的<script>元素可以放在每个页面的<body>底部。首先,我们需要 jQuery:

<script src="img/jquery-1.9.0.min.js"></script>

我们还将使用我们在上一个项目中使用的 imagesLoaded 插件:

<script src="img/jquery.imagesloaded.min.js"></script>

在这个项目中,我们将创建两个脚本,一个用于客户端,一个用于管理控制台。最初,我们将使用客户端脚本,因此我们应该在每个页面中添加以下内容:

<script src="img/heat-map-client.js"></script>

当然,这个文件还不存在,所以在我们进行设置时,我们可以先创建这个文件。它应该保存在js目录中,与我们的其他脚本一起。

确定并保存环境

在我们的第一个任务中,我们将存储一些关于当前浏览环境的信息,例如当前页面的 URL。我们还将解析任何附加的样式表,查找媒体查询。

准备升空

我们将像我们在大多数其他项目中所做的那样,从我们的document ready快捷方式开始。在heat-map-client.js文件中,添加以下代码:

$(function () {

});

我们添加到这个文件的所有附加代码都将放在此回调函数中。

启动推进器

我们首先设置一系列在整个脚本中将使用的变量。我们还需要解析任何附加的样式表,并查找媒体查询,以便我们可以确定为不同布局定义了哪些断点。

注意

媒体查询是一种在 CSS 中指定一组样式的方法,只有在满足某些条件时才会应用,例如屏幕的宽度。有关更多信息,请参阅en.wikipedia.org/wiki/Media_queries

将以下代码添加到我们刚刚添加的回调函数中:

var doc = $(document),
    clickStats = {
        url: document.location.href,
        clicks: []
    },
    layouts = [];

$.ajaxSetup({
    type: "POST",
    contentType: "application/json",
    dataType: "json"
});

$.each(doc[0].styleSheets, function (x, ss) {

  $.each(ss.rules, function (y, rule) {

    if (rule.media&&rule.media.length) {

      var jq = $,
          current = rule.media[0],
          mq = {
            min: (current.indexOf("min") !== -1) ? 
            jq.trim(current.split("min-width:")[1]
            .split("px")[0]) : 0,

            max: (current.indexOf("max") !== -1) ? 
            jq.trim(current.split("max-width:")[1]
            .split("px")[0]) : "none"
          };

      layouts.push(mq);
    }
  });
});

layouts.sort(function (a, b) {
    return a.min - b.min;
});

$.ajax({
    url: "/heat-map.asmx/saveLayouts",
    data: JSON.stringify({ url: url, layouts: layouts })
});

完成目标 - 迷你总结

我们首先定义了一系列变量。我们缓存了对document对象的引用,并使用 jQuery 功能对其进行了包装。然后我们创建了一个名为clickStats的对象,我们将用作会话的通用存储容器。

在对象内部,我们存储页面的 URL,并定义一个名为clicks的空数组,用于存储每次点击事件。最后,我们创建另一个数组,这次在我们的clickStats对象之外,我们将使用它来存储代表文档每个布局的对象。

我们还使用 jQuery 的ajaxSetup()方法为任何 AJAX 请求设置一些默认值,该方法接受包含要设置的选项的对象。我们将进行几个请求,因此设置在两个请求中都设置的任何选项的默认值是有意义的。在本例中,我们需要将type设置为POST,将contentType设置为application/json,并将dataType设置为json

我们的下一个代码块涉及解析通过<link>元素附加到文档的任何样式表,并提取其中定义的任何媒体查询。

我们首先使用 jQuery 的each()方法来迭代存储在document对象的StyleSheets集合中的样式表对象。对于每个样式表,集合中都会有一个对象,其中包含其所有选择器和规则,包括任何媒体查询。

我们正在迭代的集合由对象组成,因此我们传递给each()方法的回调函数将接收当前对象的索引(我们将其设置为x)和当前对象本身(我们将其设置为ss)作为参数。

在我们的回调函数内部,我们再次使用 jQuery 的each()方法。这次,我们正在迭代传递给回调函数的ss对象的rules集合。此集合将包含一系列对象。我们传递给该方法的回调函数将再次接收索引(这次设置为y)和当前对象(这次设置为rule)作为参数。

对象的类型将取决于其是什么。它可能是一个CSSImportRule,用于@import语句,一个CSSFontFaceRule,用于@font-face规则,一个CSSStyleRule,用于样式表定义的任何选择器,或者一个CSSMediaRule,用于任何媒体查询。

我们只对CSSMediaRule对象感兴趣,因此在嵌套的each()回调中,我们首先检查规则对象是否具有media属性,以及媒体属性是否具有length

只有CSSMediaRule对象会有一个media属性,但是此属性可能为空,因此我们可以在嵌套的回调中使用if条件检查此属性的存在并检查其是否具有length

如果这两个条件都为true(或者是真值),我们就知道我们找到了一个媒体查询。我们首先设置一些新变量。第一个变量是media集合的第一项,它将包含定义媒体查询的文本字符串,第二个是一个称为mq的对象,我们将使用它来存储媒体查询的断点。

我们设置了该对象的两个属性 - 媒体查询的minmax值。我们通过检查文本字符串是否包含单词min来设置min属性。如果是,我们首先在术语min-width:上拆分字符串,然后获取split()函数将返回的数组中的第二项,然后在结果字符串上拆分术语px并获取第一项。我们可以像这样链式调用split(),因为该函数返回一个数组,这也是它被调用的方式。

如果字符串不包含单词min,我们将值设置为0。如果存在max-width,我们也执行同样的操作来提取它。如果没有max-width,我们将其设置为字符串none。创建layout对象后,我们将其推送到layouts数组中。

最后,我们对我们的断点数组进行排序,以便按升序排列。我们可以通过向 JavaScript 的sort()方法传递一个排序函数来做到这一点,该方法在数组上调用。我们传递的函数将从我们正在排序的数组中接收两个项目。

如果第一个对象的min属性小于第二个对象bmin属性,则函数将返回一个负数,这会将较小的数字放在数组中较大的数字之前 - 这正是我们想要的。

因此,我们将得到一个数组,其中每个项目都是一个特定的断点,它在数组中逐渐增加,从而使稍后检查哪个断点正在应用变得更加容易。

最后,我们需要将这些数据发送到服务器,可能是为了保存。对于这个请求,我们需要设置的唯一选项是要发送请求的 URL,以及我们用来将页面的 URL 和媒体查询数组发送到服务器的data选项。当然,我们之前设置的 AJAX 默认值也会被使用。

分类情报

如果您已经熟悉媒体查询,请随意跳到下一个任务的开始;如果没有,我们在这里简要地看一下它们,以便我们都知道我们的脚本试图做什么。

媒体查询类似于 CSS 中的if条件语句。CSS 文件中的媒体查询将类似于以下代码片段:

@media screen and (max-width:320px) {
    css-selector { property: style; }
}

该语句以@media开头表示媒体查询。查询指定了一个媒介,例如screen,以及可选的附加条件,例如max-widthmin-width。只有在满足查询条件时,查询中包含的样式才会被应用。

媒体查询是响应式网页设计的主要组成部分之一,另一个是相对尺寸。通常,一个响应式构建的网页将有一个或多个媒体查询,允许我们为一系列屏幕尺寸指定不同的布局。

我们包含的每个媒体查询都将设置布局之间的断点。当断点超过时,例如在前一个媒体查询中设备的最大宽度小于320px时,布局会按照媒体查询指示进行更改。

捕获访客点击

在这个任务中,我们需要构建捕获页面上发生的任何点击的部分。在页面打开时,我们希望记录有关布局和点击本身的信息。

启动推进器

我们可以使用以下代码捕获点击并记录我们想要存储的其他信息,该代码应直接添加到上一个任务中我们添加到heat-map-client.js中的ajax()方法之后:

$.imagesLoaded(function() {

    doc.on("click.jqHeat", function (e) {

        var x = e.pageX,
              y = e.pageY,
             docWidth = doc.outerWidth(),
             docHeight = doc.outerHeight(),
             layout,
             click = {
                 url: url,
                 x: Math.ceil((x / docWidth) * 100),
                 y: Math.ceil((y / docHeight) * 100)
            };

        $.each(layouts, function (i, item) {

            var min = item.min || 0,
                  max = item.max || docWidth,
                  bp = i + 1;

            if (docWidth>= min &&docWidth<= max) {
                click.layout = bp;
            } else if (docWidth> max) {
                click.layout = bp + 1;
            }
        });

        clickStats.clicks.push(click);
    });
});

目标完成 - 小型总结

我们可以通过使用 jQuery 的on()方法添加处理程序来监听页面上的点击,我们还希望确保页面中的任何图像在我们开始捕获点击之前已完全加载,因为图像将影响文档的高度,进而影响我们的计算。因此,我们需要将我们的事件处理程序附加到imagesLoaded()方法的回调函数内。

我们将click指定为要监听的事件,但同时使用jqHeat对事件进行命名空间化。我们可能希望在一系列页面上使用此代码,每个页面可能具有自己的事件处理代码,我们不希望干扰此代码。

在事件处理程序中,我们首先需要设置一些变量。该函数将事件对象作为参数接收,我们使用它来设置我们的前两个变量,这些变量存储点击的xy位置。此数字将表示页面上的像素点。

我们然后存储文档的宽度和高度。我们每次点击都存储这个的原因是因为页面的宽度,以及因此文档的高度,在页面打开期间可能会发生变化。

有人说只有开发人员在测试响应式构建时调整浏览器大小,但这并不总是事实。根据正在使用的媒体查询定义的断点,设备方向的变化可能会影响文档的宽度和高度,这可能会在页面加载后的任何时间发生。

接下来我们定义layout变量,但我们暂时不为其分配值。我们还创建一个新对象来表示点击。在此对象中,我们最初将点击坐标存储为百分比。

将像素坐标转换为百分比坐标是一个微不足道的操作,只需将像素坐标除以文档的宽度(或高度),然后将该数字乘以100即可。我们使用 JavaScript 的Math.ceil()函数使数字向上舍入到下一个整数。

接下来,我们需要确定我们处于哪种布局中。我们可以再次使用 jQuery 的each()方法迭代我们的layouts数组。回调函数的第一个参数接收layouts数组中当前项目的索引,第二个参数是实际对象。

在回调函数内部,我们首先设置我们的变量。这次我们需要的变量是布局的最小宽度,我们将其设置为对象的min属性,如果没有定义min,则设置为零。我们还将max变量设置为当前项目的max属性,或者如果没有max属性,则设置为文档的宽度。

我们最后的变量只是将当前索引加1。索引是从零开始的,但是对于我们的布局来说,将其标记为1到布局数目比标记为0到布局数目更有意义。

然后,我们使用一个if条件来确定当前应用的是哪个布局。我们首先检查当前文档宽度是否大于或等于媒体查询的最小值,并且小于或等于最大值。如果是,我们就知道我们在当前布局内,因此将转换后的布局索引保存到我们的click对象中。

如果我们没有匹配到任何布局,那么浏览器的大小必须大于媒体查询定义的最大max-width值,所以我们将布局设置为转换后的布局再加一。最后,我们将创建的click对象添加到我们的clickStats对象的clicks数组中。

保存点击数据

有人访问了一个我们的热图客户端脚本正在运行的页面,他们点击了一些内容,到目前为止我们的脚本已记录了每次点击。现在呢?现在我们需要一种将这些信息传输到服务器以进行永久存储并在管理控制台中显示的方法。这就是我们将在本任务中看到的内容。

启动推进器

我们可以确保将捕获的任何点击都发送到服务器以进行永久存储,使用以下代码,应在imagesLoaded()回调函数之后添加:

window.onbeforeunload = function () {

    $.ajax({
        async: false,
        type: "POST",
        contentType: "application/json",
        url: "/heat-map.asmx/saveClicks",
        dataType: "json",
        data: JSON.stringify({ clicks: clicks })
    });
}

目标完成 - 迷你简报

我们为window对象附加了一个beforeunload事件处理程序,以便在离开页面之前将数据发送到服务器。不幸的是,这个事件并不总是被完全处理 - 有时它可能不会触发。

为了尽量将此功能减少到最小,我们直接将事件处理程序附加到原生的window对象上,而不是 jQuery 包装的对象,我们可以通过数组中的第一个项目访问该对象,该项目是 jQuery 对象。

使用任何 jQuery 方法,包括on(),都会增加额外开销,因为会调用 jQuery 方法以及底层的 JavaScript 函数。为了尽量减少这种开销,我们在这里避免使用 jQuery,并恢复到使用旧式方法来附加事件处理程序,即以on作为事件名的前缀,并将函数分配为它们的值。

在这个函数内部,我们需要做的就是将数据发送到服务器,以便将其插入到数据库中。我们使用 jQuery 的ajax()方法发起请求,并将async选项设置为false以使请求同步进行。

这很重要,并且将确保请求在 Chrome 中发出。无论如何,我们对服务器的响应不感兴趣 - 我们只需确保在页面卸载之前发出请求即可。

我们还将 type 设置为 POST,因为我们正在向服务器发送数据,并将 contentType 设置为 application/json,这将为请求设置适当的头,以确保服务器正确处理数据。

url 明显是我们要发送数据到的 Web 服务的 URL,并且我们将 dataType 设置为 json,这样可以更容易地在服务器上消耗数据。

最后,我们将 clicks 数组转换为字符串并使用浏览器的原生 JSON 引擎将其包装在对象中。我们使用 data 选项将字符串化的数据发送到服务器。

此时,当打开连接到该脚本的页面时,脚本将在后台静静运行,记录页面上点击的任何点的坐标。当用户离开页面时,他们生成的点击数据将被发送到服务器进行存储。

机密情报

不具有 JSON 引擎的浏览器,比如 Internet Explorer 的第 7 版及更低版本,将无法运行我们在此任务中添加的代码,尽管存在可在这些情况下使用的 polyfill 脚本。

更多信息请参阅 Github 上的 JSON 仓库(github.com/douglascrockford/JSON-js)。

添加管理控制台

我在项目开始时说过我们不需要编写任何 HTML 或 CSS。那是一个小小的夸张;我们将不得不自己构建管理控制台页面,但不用担心,我们不需要写太多代码 - 我们在页面上显示的大部分内容都将是动态创建的。

准备起飞

根据我们的标准模板文件创建一个名为 console.html 的新 HTML 页面,并将其保存在我们为此项目工作的 simplex 目录中。接下来创建一个名为 console.js 的新脚本文件,并将其保存在相同的文件夹中。最后,创建一个名为 console.css 的新样式表,并将其保存在 simplex 目录内的 css 文件夹中。

我们应该从新的 HTML 页面的 <head> 中链接到新样式表:

<link rel="stylesheet" href="css/console.css" />

我们还应该在 <body> 的底部链接到 jQuery 和我们的新脚本文件:

<script src="img/jquery-1.9.0.min.js"></script>
<script src="img/console.js"></script>

最后,我们应该将类名 jqheat 添加到 <body> 元素中:

<body class="jqheat">

启动推进器

页面将需要显示一个界面,用于选择要查看点击统计信息的页面。将以下代码添加到 console.html<body> 中:

<header>
    <h1>jqHeat Management Console</h1>
    <fieldset>
        <legend>jqHeat page loader</legend>
        <input placeholder="Enter URL" id="url" />
        <button id="load" type="button">Load page</button>
    </fieldset>
</header>
<section role="main">
    <iframe scrolling="no" id="page" />
</section>

我们还可以为这些元素添加一些非常基本的 CSS。将以下代码添加到 console.css 中:

.jqheat{ overflow-y:scroll; }
.jqheat header { 
    border-bottom:1px solid #707070; text-align:center; 
}
.jqheat h1 { display:inline-block; width:100%; margin:1em 0; }
.jqheat fieldset { 
    display:inline-block; width:100%; margin-bottom:3em; 
}
.jqheat legend { display:none; }
.jqheat input { 
    width:50%; height:34px; padding:0 5px; 
    border:1px solid #707070; border-radius:3px; 
}
.jqheat input.empty{ border-color:#ff0000; }
.jqheat button { padding:9px5px; }
.jqheat section {
    width:100%;margin:auto;
    position:relative;
}
.jqheat iframe, .jqheat canvas {
    Width:100%; height:100%; position:absolute; left:0; top:0;
}
.jqheat canvas { z-index:999; }

在此任务中,我们不会添加任何实际功能,但我们可以准备好我们的脚本文件,以便在下一个任务中使用通常的 document ready 处理程序。在 console.js 中,添加以下代码:

$(function () {

});

目标已完成 - 迷你总结

我们的页面首先包含一个包含<h1><fieldset>中页面标题的<header>元素。在<fieldset>内是必须的<legend>和一个非常简单的页面 UI,它包含一个<input>和一个<button>元素。<input><button>元素都有id属性,以便我们可以在脚本中轻松选择它们。

页面的主要内容区域由一个<section>元素组成,该元素具有role属性为main。使用此属性标记页面的主要内容区域是标准做法,有助于澄清该区域对辅助技术的意图。

<section>内部是一个<iframe>。我们将使用<iframe>来显示用户想要查看点击统计信息的页面。目前,它只有一个id属性,这样我们就可以轻松选择它,并且非标准的scrolling属性设置为no。我不太喜欢使用非标准属性,但在这种情况下,这是防止在加载内容文档时<iframe>出现无意义滚动条的最简单方法。

页面很可能会导致滚动条出现,而我们可以设置页面的<body>永久具有垂直滚动条,而不是在滚动条出现时发生的移动。除此之外,CSS 主要是一些定位相关的东西,我们不会深入研究。

机密情报

我们在<input>元素上使用了 HTML5 的placeholder属性,在支持的浏览器中,该属性的值会显示在<input>内部,作为内联标签。

这很有用,因为这意味着我们不必添加一个全新的元素来显示一个<label>,但是在撰写时,支持并不是 100%。幸运的是,有一些出色的polyfills可以在不支持的浏览器中提供合理的回退。

注意

Modernizr 团队推荐了一整套placeholder polyfills(还有许多其他推荐)。您可以通过访问github.com/Modernizr/Modernizr/wiki/HTML5-Cross-Browser-Polyfills来查看完整列表。

请求点击数据

控制台页面几乎为空,主要包含一个用于加载我们想要查看点击数据的页面的表单。在这个任务中,我们将看看如何加载该页面并从服务器请求其数据。

启动推进器

console.js中的空函数中添加以下代码:

var doc = $(document),
    input = doc.find("#url"),
    button = doc.find("#load"),
    iframe = doc.find("#page"),
    canvas = document.createElement("canvas");

$.ajaxSetup({
    type: "POST",
    contentType: "application/json",
    dataType: "json",
    converters: {
        "textjson": function (data) {
            var parsed = JSON.parse(data);

            return parsed.d || parsed;
        }
    }
});

然后,我们可以为<button>元素添加一个点击处理程序:

doc.on("click", "#load", function (e) {
    e.preventDefault();

    var url = input.val(),
        len;

    if (url) {
        input.removeClass("empty").data("url", url);
        button.prop("disabled", true);
        iframe.attr("src", url).load(function() {
          $(this).trigger("iframeloaded");
        });
    } else {
        input.addClass("empty");
        button.prop("disabled", false);
  }
});

最后,我们可以为自定义的iframeloaded事件添加事件处理程序:

doc.on("iframeloaded", function () {

    var url = input.data("url");

    $.ajax({
        type: "POST",
        contentType: "application/json",
        url: "/heat-map.asmx/getClicks",
        dataType: "json",
        data: JSON.stringify({ url:url, layout: 4 }),
        converters: {
          "textjson": function (data) {
              var parsed = JSON.parse(data);

              returnparsed.d || parsed;
          }
        }
    });
});

目标完成 - 小型总结

我们像往常一样开始,设置了一些变量。我们存储了一个包装在 jQuery 中的document对象的引用,我们可以使用这个引用作为起点在页面上选择任何元素,而无需每次选择元素或绑定事件处理程序时都创建一个新的 jQuery 对象。

我们还存储了一个包含页面 URL 的<input>元素的引用,一个紧挨着<input><button>的引用,以及我们将加载请求页面的<iframe>的引用。最后,我们设置了一个未定义的变量叫做canvas,我们将使用createElement()函数使用 JavaScript 创建一个<canvas>元素的引用。

当然,我们可以使用 jQuery 来创建这个元素,但我们只是创建一个单独的元素,而不是复杂的 DOM 结构,所以我们可以使用纯 JavaScript 同时获得性能提升。

与以前一样,我们可以使用ajaxSetup()方法来设置将发送到服务器的请求的typecontentTypedataType选项。我们还使用了一个转换器来转换服务器将返回的数据。

converters 选项接受一个对象,其中指定要用于数据类型的转换器的键,指定要用作转换器的函数的值。

一些服务器将返回包裹在属性d中的对象中的 JSON 数据,以增加安全性,而其他服务器不会这样做。通常,text json数据类型将使用 jQuery 的parseJSON()方法进行解析,但在这种情况下,我们的代码仍然需要从对象中提取实际数据,然后才能使用它。

相反,我们的转换器使用浏览器的原生 JSON 解析器解析 JSON,然后返回d的内容(如果存在)或解析的数据。这意味着处理数据的代码在数据是否包裹在对象中都是相同的。

虽然在这个特定的例子中并不是必需的,但转换器在代码分发和将在其上运行的平台事先未知的情况下,可以非常有用。

接下来,我们使用 jQuery 的on()方法在事件代理模式下向document添加了一个点击处理程序。为了添加一个代理处理程序,我们将处理程序附加到一个父元素,即document,并使用on()的第二个参数来提供事件应该被过滤的选择器。

事件从触发元素一直冒泡到外部的window对象。只有当触发元素与传递为第二个参数的选择器匹配时,处理程序才会被执行。第一个参数当然是事件类型,第三个参数是处理程序函数本身。

在函数内部,我们首先阻止事件的默认浏览器操作,然后将<input>元素的值存储在名为url的变量中。我们还设置了一个未定义的变量叫做len。我们现在不需要使用它,但以后会用到。

接下来,我们检查我们设置的 url 变量是否具有真值,比如长度不为零的字符串。如果是,则如果 <input> 元素具有 empty 类名,则删除它,然后使用 jQuery 的 data() 方法将 <input> 的内容设置为元素的数据。

以这种方式将 URL 关联到元素是一种很好的持久化数据的方法,这样可以从代码中的其他函数中获取数据,而这些函数无法访问事件处理程序的作用域。我们还禁用了 <button> 以防止重复请求。在热图绘制到屏幕上后,我们可以随后启用它。

然后,我们将从 <input> 元素获得的 URL 添加为 <inframe>src 属性,这会导致 <iframe> 加载该 URL 所指向的页面。我们为 <iframe> 添加了一个 load 事件的处理程序,一旦页面加载完成,该处理程序将被触发。在这个处理程序内部,我们使用 jQuery 的 trigger() 方法触发了一个自定义的 iframeloaded 事件。

如果 url 变量不包含真值,则将 empty 类添加到 <input> 中,并再次启用 <button>

最后,我们为自定义的 iframeloaded 事件添加了一个事件处理程序。自定义事件会像常规事件一样冒泡到 document,因此我们可以将处理程序附加到我们缓存的 <body> 元素,它仍然会在适当的时间被触发。

在这个处理程序中,我们通过回顾与 <input> 元素相关联的数据来获取已加载页面的 URL。然后,我们使用 jQuery 的 ajax() 方法向服务器发出请求。

我们已经再次使用 ajaxSetup() 设置了一些必需的 AJAX 选项为默认值,因此对于此请求,我们只设置了 urldata 选项。这次发送的数据是一个包含页面 URL 和获取点击数据的布局的字符串化对象。作为响应,我们期望收到一个 JSON 对象,其中包含一系列点击对象,每个对象包含指向页面上特定点的 xy 坐标。

请注意,此时我们正在硬编码要加载的布局,我们将其设置为 4。我们将在下一部分回来,并允许用户选择要查看的布局。

显示热图

我们已经准备好显示热图了。在这个任务中,我们将处理点击数据以生成热图,然后使用 <canvas> 元素显示在 <iframe> 上方。

启动推进器

首先,我们可以为上一个任务末尾所做的 AJAX 请求添加一个成功处理程序。我们可以通过将 done() 方法链接到 ajax() 方法来实现这一点:

}).done(function (clicks) {

    var loadedHeight = $("html", iframe[0].contentDocument)
.outerHeight();

    doc.find("section").height(loadedHeight);

    canvas.width = doc.width();
    canvas.height = loadedHeight;
    $(canvas).appendTo(doc.find("section"))
             .trigger("canvasready", { clicks: clicks });

});

接下来,我们可以为自定义的 canvasready 事件添加一个处理程序。这应该直接添加在 iframeloaded 事件处理程序之后:

doc.on("canvasready", function (e, clickdata) {

    var docWidth = canvas.width,
        docHeight = canvas.height,
        ctx = canvas.getContext("2d") || null;

    if (ctx) {

        ctx.fillStyle = "rgba(0,0,255,0.5)";

        $.each(clickdata.clicks, function (i, click) {

            var x = Math.ceil(click.x * docWidth / 100),
                y = Math.ceil(click.y * docHeight / 100);

            ctx.beginPath();
            ctx.arc(x, y, 10, 0, (Math.PI/180)*360, true);
            ctx.closePath();
            ctx.fill();

        });
    }

    button.prop("disabled", false);

});

目标完成 - 迷你总结

一旦 AJAX 请求完成,我们首先存储已在 <iframe> 中加载的文档的高度。jQuery 方法可以在选择器之后传递第二个参数,该参数设置应该被搜索以匹配选择器的上下文。我们可以将上下文设置为页面上第一个 <iframe>contentDocument 对象,我们可以使用 frame[0] 访问它。

设置 <section> 元素的 height 将自动使之前创建的 <iframe><canvas> 元素的 widthheight 等于 <section> 的宽度和高度,以便可以全屏查看页面。

接下来,我们设置了上一个任务中创建的 <canvas> 元素的 widthheight 属性。我们尚未设置 <canvas> 元素的 widthheight 属性,因此默认情况下,无论 CSS 设置的可见大小如何,它都只有 300 x 300 像素的大小。因此,我们将属性设置为正确的大小。

然后,我们可以将新的 <canvas> 添加到页面上的 <section> 元素中,然后触发自定义的 canvasready 事件。我们将要在此事件的事件处理程序中使用服务器传递的数据,因此我们使用 trigger() 方法的第二个参数将其传递给处理程序函数。

我们接着为 canvasready 事件添加了一个处理程序。该函数接收事件对象和点击数据作为参数。在函数内部,我们首先获取 <canvas> 元素的 widthheight。我们将点击数据存储为百分比,需要将其转换为像素值。

为了在 <canvas> 上绘制,我们需要获取一个上下文。我们可以使用 canvas 对象的 getContext() 函数获取 <canvas> 的 2D 上下文并将其存储在一个变量中。如果不支持 <canvas> 元素,则 ctx 变量将被设置为 null。因此,只有在上下文不为 null 时,我们才能继续与画布交互。

如果 ctx 不为 null,我们首先使用 canvas API 的 clearRect() 函数清除 <canvas>,然后设置我们将要在画布上绘制的颜色。我们可以将其设置为 RGBA(红、绿、蓝、透明度)字符串 0,0,255,.05,这是一种半透明的蓝色。这只需要设置一次。

然后,我们使用 jQuery 的 each() 方法迭代服务器返回的点击数据。迭代器函数将执行数组中项目的数量,传递当前项目在数组中的索引和 click 对象。

我们首先存储每个点击的像素的 xy 位置。这些数字目前是百分比,因此我们需要将它们转换回像素值。这只是在热力图的客户端部分执行的相反计算。我们只需将百分比乘以 <canvas>widthheight,然后将该数字除以 100

然后,我们可以在点击发生的地方在<canvas>上绘制一个点。我们通过使用 canvas 对象的beginPath()方法开始一个新路径来实现这一点。点是使用arc()方法绘制的,该方法传递了一些参数。前两个是圆弧中心的坐标,我们将其设置为刚计算的xy值。

第三个参数是圆的半径。如果我们将点设置为单个像素,数据将非常难以解释,因此使用大点而不是单个像素将大大提高热图的外观。

第三个和第四个参数是弧开始和结束的角度,以弧度而不是度表示。我们可以通过从零弧度开始,到约 6.5 弧度结束来绘制完整的圆。

定义了弧之后,我们可以使用closePath()方法关闭路径,并使用fill()方法填充弧形颜色。此时,我们应该能够在浏览器中运行控制台,输入模板页面之一的 URL,并看到对应于点击的点的页面。

允许选择不同的布局

在项目的这个任务中,我们需要允许用户选择页面支持的每个布局。我们可以通过使用<select>框来实现这一点,在页面加载时用不同的布局填充它。

启动推进器

首先,我们可以将<select>元素添加到页面中。这可以放在console.html顶部的搜索字段和按钮之间:

<select id="layouts"></select>

接下来,我们需要在页面加载时进行请求,为<select>元素填充每个不同布局的<option>。我们可以在之前在console.js中添加的<button>的点击处理程序中执行此操作。

它需要放在条件语句的第一个分支中,该条件语句检查是否已将 URL 输入到<input>中,直接在我们设置<iframe>src之前。

$.ajax({
    url: "/heat-map.asmx/getLayouts",
    data: JSON.stringify({ url: url })
}).done(function (layouts) {

    var option = $("<option/>"),
        max;

    len = layouts.length;

    function optText(type, i, min, max) {

        var s,
            t1 = "layout ";

        switch (type) {
            case "normal":
                s = [t1, i + 1, " (", min, "px - ", max, "px)"];
                break;
            case "lastNoMax":
                s = [t1, len + 1, " (", min, "px)"];
                break;
            case "lastWithMax":
                s = [t1, len + 1, " (", max, "px+)"];
                break;
        }

        return s.join("");
    }

    $.each(layouts, function (i, layout) {

        var lMin = layout.min,
            lMax = layout.max,
            text = optText("normal", i, lMin, lMax);

        if (i === len - 1) {
            if (lMax === "none") {
                text = optText("lastNoMax", null, lMin, null);
            } else {
                max = lMax;
            }
        }

        option.clone()
              .text(text)
              .val(i + 1)
              .appendTo("#layouts");
        });

        if (max) {

            var fText = optText("lastWithMax", null, null, max);

            option.clone()
                  .text(fText)
                  .val(len + 1)
                  .prop("selected",true)
                  .appendTo("#layouts");
  }
});

我们还可以为我们的新<select>元素添加一点 CSS。我们可以将这些内容放在console.css的底部:

.jqheat select { 
    width:175px; height:36px; padding:5px;
    margin:0 .25em 0 .5em; border:1px solid #707070;
    border-radius:3px;
}

目标完成 - 小型总结

首先,我们向服务器发出请求以获取布局信息。url设置为返回布局的 Web 服务,data是我们想要布局的页面的 URL。

我们使用done()方法设置了一个成功处理程序,这是向承诺对象添加成功处理程序的推荐技术,以便在它们解决时调用。在处理程序中,我们首先设置了一些变量。

我们创建一个<option>元素,因为我们每个布局都需要一个,所以可以使用clone()方法克隆它,需要多少次就可以克隆多少次。我们还更新了之前创建但未定义的len变量,将其更新为布局的数量,即函数将接收的数组的length,以及一个未定义的变量max

接下来,我们定义了一个名为optText()的函数,我们可以使用它来为我们创建的每个<option>元素生成文本。该函数将接受要创建的字符串类型、索引和minmax值。

在此函数中,我们设置了几个变量。第一个变量称为s,在这一点上是未定义的。第二个变量t1用于存储在字符串的每个变体中使用的一些简单文本。

然后,我们使用switch条件来确定要构建的字符串,该字符串基于类型确定,该类型将作为第一个参数传递到函数中,并将设置为normallastNoMaxlastWithMax,并应该考虑可能找到的不同类型的媒体查询。

在正常情况下,我们指定了minmax值。当没有max值时,我们使用min值构建字符串,当有max值时,我们使用max值构建字符串。

每个字符串都使用数组构造,然后在函数末尾,我们通过连接所创建的任一数组来返回一个字符串。

然后我们使用 jQuery 的each()方法来迭代服务器返回的layouts对象。与往常一样,迭代器函数会传入当前项的索引和当前项本身作为参数。

在迭代器函数内部,我们设置了变量,这些变量在这种情况下是当前布局对象的minmax属性值,以及文本字符串的普通变体,我们肯定会至少使用一次。我们调用我们的optText()函数并将结果存储供以后使用。

然后我们检查是否处于最后一次迭代,我们会在索引等于之前存储的layouts数组长度减去1时知道。如果我们处于最后一次迭代,我们会检查max值是否等于字符串none。如果是,我们再次调用我们的optText()函数,并将文本设置为lastNoMax类型,该类型为我们生成所需的文本字符串。如果不是,则将max变量设置为当前对象的max值,该变量最初被声明为未定义。最后,我们为layouts数组中的每个对象创建所需的<option>元素。给定我们设置的文本,以及索引加1的值。创建完成后,将<option>追加到<select>元素中。

最后,我们检查max变量是否有一个真值。如果是,我们再次调用我们的optText()函数,这次使用lastWithMax类型,并创建另一个<option>元素,将其设置为选定项。这是必需的,因为我们的布局比layouts数组中的对象多一个。

当我们在浏览器中运行页面时,我们应该发现,当我们在<input>中输入 URL 并点击加载页面时,<select>元素会填充一个<option>,每个布局对应一个选项。

机密情报

在我们的optText()函数中,switch语句中的中间caselastNoMax)实际上在这个示例中不会被使用,因为我们使用的模板中的媒体查询的结构如何。在这个示例中,最后一个断点的媒体查询是769px1024px。有时,媒体查询可能结构化,使得最后一个断点只包含min-width

我已经包含了switch的这个case,以使代码支持这种其他类型的媒体查询格式,因为这是相当常见的,当您自己使用媒体查询时,您可能会遇到它。

显示每个布局的热图

现在,我们在<select>元素中有每个布局后,我们可以将其连接起来,以便当所选布局更改时,页面更新为显示该布局的热图。

启动推进器

在这个任务中,我们需要修改先前任务中编写的一些代码。我们需要更改<button>的点击处理程序,以便布局不会硬编码到请求中。

首先,我们需要将len变量传递给iframeloaded事件的处理程序。我们可以通过向trigger()方法添加第二个参数来实现这一点:

$(this).trigger("iframeloaded", { len: len });

现在,我们需要更新回调函数,以便该对象由该函数接收:

doc.on("iframeloaded", function (e, maxLayouts) {

现在,我们可以修改硬编码的布局4的位,在向服务器请求点击数据时传递给服务器的数据中:

data: JSON.stringify({ url: url, layout: maxLayouts.len + 1 }),

现在我们准备好在<select>更改时更新热图了。在console.jscanvasready处理程序之后直接添加以下代码:

doc.on("change", "#layouts", function () {

    var url = input.data("url"),
          el = $(this),
          layout = el.val();

    $.ajax({
        url: "/heat-map.asmx/getClicks",
        data: JSON.stringify({ url: url, layout: layout })
    }).done(function (clicks) {

        doc.find("canvas").remove();

        var width,
              loadedHeight,
              opt = el.find("option").eq(layout - 1),
              text = opt.text(),
              min = text.split("(")[1].split("px")[0],
              section = doc.find("section"),
              newCanvas = document.createElement("canvas");

        if (parseInt(layout, 10) === el.children().length) {
            width = doc.width();
        } else if (parseInt(min, 10) > 0) {
            width = min; 
        } else {
            width = text.split("- ")[1].split("px")[0];
      }

        section.width(width);
        newCanvas.width = width;

        loadedHeight = $("html", 
        iframe[0].contentDocument).outerHeight();

        section.height(loadedHeight);
        newCanvas.height = loadedHeight;

        canvas = newCanvas;

        $(newCanvas).appendTo(section).trigger("canvasready", { 
            clicks: clicks });
        });
    });

完成目标 - 小结

我们首先委派我们的处理程序给文档,就像我们大多数其他事件处理程序一样。这次,我们正在监听由具有idlayouts的元素触发的change事件,这是我们在上一个任务中添加的<select>元素。

然后,我们继续遵循以前的形式,设置一些变量。我们获取保存为<input>元素的data的 URL。我们还缓存了<select>元素和所选<option>的值。

接下来,我们需要发起一个 AJAX 请求来获取所选布局的热图。我们将url设置为将返回此信息的 Web 服务,并将我们想要的热图的url和布局作为请求的一部分发送。不要忘记,此请求也将使用我们使用ajaxSetup()设置的默认值。

我们再次使用done()方法添加一个请求的成功处理程序。当收到响应时,我们首先从页面中删除现有的<canvas>元素,然后设置一些更多的变量。

前两个变量一开始是未定义的;我们马上会填充这些。我们存储了所选的<option>,以便我们可以获取其文本,该文本存储在下一个变量中。我们通过分割我们刚刚存储的文本来获取断点的最小宽度,然后缓存页面上的<section>的引用。最后,我们创建一个新的<canvas>元素来显示新的热图。

后续的条件 if 语句处理设置我们的第一个未定义变量 - width。第一个分支测试所请求的布局是否是最后一个布局,如果是,则将新的<canvas>设置为屏幕的宽度。

如果未请求最后一个布局,则条件的下一个分支检查布局的最小宽度是否大于0。如果是,则将width变量设置为最小断点。

当断点的最小宽度为0时,使用最终分隔<option>文本获得的最大断点width

然后,我们使用刚刚计算出的宽度来设置<section>元素和新的<canvas>元素的宽度。

接下来,我们可以定义我们的第二个未定义变量 - loadedHeight。这个变量的计算方式与之前相同,通过访问加载到<iframe>中的文档,并使用 jQuery 的outerHeight()方法获取其document对象的高度来获取,其中包括元素可能具有的任何填充。一旦我们有了这个值,我们就可以设置<section>元素和新的<canvas>元素的高度。

当我们消耗点击数据并生成热图时,我们将再次触发我们的canvasready事件。不过,在此之前,我们只需将新创建的<canvas>元素保存回我们在console.js顶部设置的canvas变量即可。

此时,我们应该能够加载 URL 的默认热图,然后使用<select>元素查看另一个布局的热图:

目标完成 - 小结

机密情报

我使用了MS SQL数据库来存储数据,并使用包含此项目所需的各种 Web 方法的C# Web 服务。在本书附带的代码下载中包含了数据库的备份和 Web 服务文件的副本,供您使用。

MS SQL express 是 SQL 服务器的免费版本,可以将数据库恢复到该版本,而免费的 Visual Studio 2012 for web 将愉快地通过其内置的开发服务器运行 Web 服务。

如果您没有安装这些产品,并且您可以访问 Windows 机器,我强烈建议您安装它们,这样您就可以看到此项目中使用的代码运行情况。也可以轻松地使用开源替代产品 PHP 和 MySQL,尽管您将需要自己编写此代码。

任务完成

在这个项目中,我们构建了一个简单的热图生成器,用于捕获使用响应式技术构建的网页上的点击数据。我们将热图生成器分为两部分——一些在网站访问者的浏览器中运行的代码,用于捕获屏幕上的每次点击,以及一个与之配合使用的简单管理控制台,可以在其中选择要为其生成热图的页面的 URL 和要显示的布局。

虽然我们必须允许一定的误差范围,以考虑像素到百分比的转换及其逆过程,不同的屏幕分辨率,以及不同断点之间的范围,但这个易于实现的热图仍然可以为我们提供有价值的信息,了解我们的网站如何使用,哪些功能受欢迎,哪些功能浪费了屏幕空间。

你准备好全力以赴了吗?挑战热血青年

我们还没有处理的一个问题是颜色。我们的热图由均匀蓝色的点构成。由于它们是半透明的,在密集区域出现更多点时会变暗,但是随着足够多的数据,我们应该尽量改变颜色,从红色、黄色一直到白色为最多点击的区域。看看你是否能自己添加这个功能,真正为项目锦上添花。

第十章:带有 Knockout.js 的可排序、分页表格

Knockout.js 是一个很棒的 JavaScript 模型-视图-视图模型MVVM)框架,可以帮助你在编写复杂的交互式用户界面时节省时间。它与 jQuery 配合得非常好,甚至还具有用于构建显示不同数据的重复元素的内置基本模板支持。

任务简报

在本项目中,我们将使用 jQuery 和 Knockout.js 从数据构建分页表格。客户端分页本身是一个很好的功能,但我们还将允许通过提供可点击的表头对表格进行排序,并添加一些附加功能,如根据特定属性过滤数据。

到此任务结束时,我们将建立如下屏幕截图所示的东西:

任务简报

为什么这很棒?

构建快速响应用户交互的复杂 UI 是困难的。这需要时间,而且应用程序越复杂或交互性越强,花费的时间就越长,需要的代码也越多。而应用程序需要的代码越多,就越难以保持组织和可维护性。

虽然 jQuery 擅长帮助我们编写简洁的代码,但它从未旨在构建大规模、动态和交互式应用程序。它功能强大,擅长自己的工作以及它被设计用来做的事情;只是它并没有被设计用来构建整个应用程序。

在构建大规模应用程序时需要其他东西,需要提供一个框架,可以在其中组织和维护代码。Knockout.js 就是这样一个旨在实现此目标的框架之一。

Knockout.js 被称为一个 MVVM 框架,它基于三个核心组件 - 模型视图视图模型。这类似于更为人熟知的 MVC 模式。这些和其他类似的模式的目的是提供清晰的应用程序可视部分和管理数据所需代码之间的分离。

模型 可以被认为是应用程序的数据。实际上,实际数据是模型的结果,但在客户端工作时,我们可以忽略数据是如何被服务器端代码访问的,因为通常我们只是发出 AJAX 请求,数据就会被传递给我们。

视图 是数据的可视化表示,实际的 HTML 和 CSS 用于向用户呈现模型。在使用 Knockout.js 时,应用程序的这一部分也可以包括绑定,将页面上的元素映射到特定的数据部分。

视图模型 位于模型和视图之间,实际上是视图的模型 - 视图状态的简化表示。它管理用户交互,生成并处理对数据的请求,然后将数据反馈到用户界面。

你的炫酷目标

完成此任务所需的任务如下:

  • 渲染初始表格

  • 对表格进行排序

  • 设置页面大小

  • 添加上一页和下一页链接

  • 添加数字页面链接

  • 管理类名

  • 重置页面

  • 过滤表格

任务清单

在这个项目中我们将使用 Knockout.js,所以现在你需要获取它的副本。这本书印刷时的最新版本为 2.2.1,可以从以下网址下载:[knockoutjs.com/downloads/index.html](http:// 
http://knockoutjs.com/downloads/index.html)。应将其保存在主jquery-hotshots项目文件夹内的js目录中,命名为knockout-2.2.1.js

我们还需要一些数据来完成这个项目。我们将需要使用一个相当大的数据集,其中包含可以按多种方式排序的数据。我们将使用元素周期表的 JSON 格式作为我们的数据源。

我已经提供了一个文件作为这个示例的一部分,名为table-data.js,其中包含一个名为elements的属性的对象。该属性的值是一个对象数组,其中每个对象表示一个元素。对象的格式如下:

{ 
    name: "Hydrogen", 
    number: 1, 
    symbol: "H", 
    weight: 1.00794, 
    discovered: 1766,
    state: "Gas"
}

渲染初始表格

在项目的第一个任务中,我们将构建一个超级简单的 ViewModel,添加一个基本的 View,并将 Model 渲染到一个裸的<table>中,没有任何增强或附加功能。这将使我们能够熟悉 Knockout 的一些基本原理,而不是直接投入到深水区。

准备起飞

此时我们创建项目中将要使用的文件。将模板文件另存为sortable-table.html,保存在根项目目录中。

我们还需要一个名为sortable-table.css的样式表,应将其保存在css文件夹中,并且一个名为sortable-table.js的 JavaScript 文件,当然应将其保存在js目录中。

HTML 文件应链接到每个资源,以及knockout-2.2.1.js文件。样式表应在common.css之后直接链接,我们迄今为止在本书中大部分项目中都使用了它,而knockout.jstable-data.js和这个项目的自定义脚本文件(sortable-table.js)应在链接到 jQuery 之后添加,按照这个顺序。

启动推进器

首先我们可以构建 ViewModel。在sortable-table.js中,添加以下代码:

$(function () {

    var vm = {
        elements: ko.observableArray(data.elements)
    }

    ko.applyBindings(vm);

});

接下来,我们可以添加 View,它由一些简单的 HTML 构建而成。将以下标记添加到sortable-table.html<body>中,位于<script>元素之前:

<table>
    <thead>
        <tr>
            <th>Name</th>
            <th>Atomic Number</th>
            <th>Symbol</th>
            <th>Atomic Weight</th>
            <th>Discovered</th>
        </tr>
    </thead>
    <tbody data-bind="foreach: elements">
        <tr>
            <td data-bind="text: name"></td>
            <td data-bind="text: number"></td>
            <td data-bind="text: symbol"></td>
            <td data-bind="text: weight"></td>
            <td data-bind="text: discovered"></td>
        </tr>
    </tbody>
</table>

最后,我们可以通过将以下代码添加到sortable-table.css来为我们的<table>及其内容添加一些基本样式:

table { 
    width:650px; margin:auto; border-collapse:collapse;
}
tbody { border-bottom:2px solid #000; }
tbodytr:nth-child(odd) td { background-color:#e6e6e6; }
th, td { 
    padding:10px 50px 10px 0; border:none; cursor:default;
}
th { 
    border-bottom:2px solid #000;cursor:pointer;
    position:relative;
}
td:first-child, th:first-child { padding-left:10px; }
td:last-child { padding-right:10px; }

目标完成 - 迷你简报

在我们的脚本中,首先添加了通常的回调函数,在文档加载时执行。在此之中,我们使用存储在变量vm中的对象字面量创建了 ViewModel。

此对象唯一的属性是elements,其值是使用 Knockout 方法设置的。Knockout 添加了一个全局的ko对象,我们可以使用它来调用方法。其中之一是observableArray()方法。该方法接受一个数组作为参数,并且传递给该方法的数组将变为可观察的。这就是我们应用程序的数据。

在 Knockout 中,诸如字符串或数字之类的基本类型可以是可观察的,这使它们能够在其值更改时通知订阅者。可观察数组类似,只是它们与数组一起使用。每当向可观察数组添加或删除值时,它都会通知任何订阅者。

定义了我们的 ViewModel 之后,我们需要应用可能存在于 View 中的任何绑定。我们马上就会看到这些绑定;暂时只需知道,在调用 Knockout 的 applyBindings() 方法之前,我们添加到 View 的任何绑定都不会生效。

我们添加的 HTML 几乎毫无特色,只是一个简单的<table>,每个元素的属性都有一个列。如果你查看table-data.js文件,你会看到数组中每个元素的属性与<th>元素匹配。

第一件有趣的事情是我们添加到<tbody>元素的data-bind属性。这是 Knockout 用于实现声明式绑定的机制。这是我们将 View 中的元素与 ViewModel 属性连接起来的方式。

data-bind属性的值由两部分组成 - 绑定和要连接到的 ViewModel 属性。第一部分是绑定,我们将其设置为foreach。这是 Knockout 的流程控制绑定之一,其行为方式类似于常规 JavaScript 中的标准for循环。

绑定的第二部分是要绑定到的 ViewModel 属性。我们目前的 ViewModel 只有一个属性,即elements,其中包含一个可观察数组。foreach绑定将映射到一个数组,然后为数组中的每个项渲染任何子元素。

此元素的子元素是一个<tr>和一系列<td>元素,因此我们将在elements数组中的每个项中获得一个表格行。为了将<td>元素填充内容,我们将使用另一个 Knockout 绑定 - text绑定。

text绑定绑定到单个可观察属性,因此我们有一个<td>绑定到elements数组中每个对象的每个属性。每个<td>的文本将设置为当前数组项中每个属性的值。

我们在任务结束时添加的 CSS 纯粹是为了表现目的,与 Knockout 或 jQuery 无关。此时,我们应该能够在浏览器中运行页面,并在一个整洁的<table>中看到来自table-data.js的数据显示出来。

机密情报

View 元素和 ViewModel 属性之间的绑定是 Knockout 的核心。ViewModel 是 UI 状态的简化版本。由于绑定,每当底层 ViewModel 发生更改时,视图将更新以反映这些更改。

因此,如果我们以编程方式向可观察数组添加一个新的元素对象,则<table>将立即更新以显示新元素。类似地,如果我们从 ViewModel 中的数组中删除一个项目,则相应的<tr>将立即被删除。

对表格进行排序

在这个任务中,我们可以更改<th>元素,使其可点击。当其中一个被点击时,我们可以按照被点击的列对表格行进行排序。

启动推进器

首先,我们可以更新sortable-table.html中包含的<tr><th>元素:

<tr data-bind="click: sort">
    <th data-bind="css: nameOrder">Name</th>
    <th data-bind="css: numberOrder">Atomic Number</th>
    <th data-bind="css: symbolOrder">Symbol</th>
    <th data-bind="css: weightOrder">Atomic Weight</th>
    <th data-bind="css: discoveredOrder">Discovered</th>
</tr>

接下来,我们可以在sortable-table.js中的 ViewModel 中添加一些新的可观察属性:

nameOrder: ko.observable("ascending"),
numberOrder: ko.observable("ascending"),
symbolOrder: ko.observable("ascending"),
weightOrder: ko.observable("ascending"),
discoveredOrder: ko.observable("ascending"),

我们还添加了一个名为sort的新方法:

sort: function (viewmodel, e) {

    var orderProp = $(e.target).attr("data-bind")
                               .split(" ")[1],

        orderVal = viewmodel[orderProp](),
        comparatorProp = orderProp.split("O")[0];

    viewmodel.elements.sort(function (a, b) {

        var propA = a[comparatorProp],
            propB = b[comparatorProp];

        if (typeof (propA) !== typeof (propB)) {

            propA = (typeof (propA) === "string") ? 0 :propA;
            propB = (typeof (propB) === "string") ? 0 :propB;
        }

        if (orderVal === "ascending") {
            return (propA === propB) ? 0 : (propA<propB) ? -1 : 1;

        } else {
            return (propA === propB) ? 0 : (propA<propB) ? 1 : -1;

        }

    });

    orderVal = (orderVal === "ascending") ? "descending" : "ascending";

    viewmodelorderProp;

    for (prop in viewmodel) {
        if (prop.indexOf("Order") !== -1 && prop !== orderProp) {
            viewmodelprop;
        }
    }
}

最后,我们可以添加一些额外的 CSS 来样式化我们可点击的<th>元素:

.ascending:hover:after { 
    content:""; display:block; border-width:7px; 
    border-style:solid; border-left-color:transparent; 
    border-right-color:transparent; border-top-color:#000;
    border-bottom:none; position:absolute; margin-top:-3px; 
    right:15px; top:50%; 
}
.descending:hover:after {
    content:""; display:block; border-width:7px; 
    border-style:solid; border-left-color:transparent; 
    border-right-color:transparent; border-bottom-color:#000; 
    border-top:none; position:absolute; margin-top:-3px; 
    right:15px; top:50%; 
}

目标完成 - 小结

首先,我们使用更多的绑定更新了我们的 HTML。首先,我们使用data-bind属性在父级<tr>上添加了click绑定。click绑定用于向任何 HTML 元素添加事件处理程序。

处理程序函数可以是 ViewModel 方法或任何常规 JavaScript 函数。在这个示例中,我们将处理程序绑定到一个名为sort的函数,它将是我们 ViewModel 的一个方法。

请注意,我们将绑定添加到父级<tr>而不是各个<th>元素。我们可以利用事件向上冒泡的特性来实现一种非常简单且计算成本低廉的事件委派形式。

我们还为每个<th>元素添加了css绑定。css绑定用于向元素添加类名。因此,元素获取的类名取决于它绑定到的 ViewModel 属性。我们的每个<th>元素都绑定到不同的 ViewModel 属性,并将用作我们排序的一部分。

接下来,我们对我们的脚本文件进行了一些更改。首先,我们添加了一系列新的可观察属性。我们添加了以下属性:

  • nameOrder

  • numberOrder

  • symbolOrder

  • weightOrder

  • discoveredOrder

这些属性中的每一个都是可观察的,这是必需的,以便当任何一个属性发生更改时,<th>元素的类名会自动更新。每个属性最初都设置为字符串ascending,因此每个<th>元素都将被赋予这个类名。

对数据进行排序

接下来,我们将我们的sort方法添加到 ViewModel 中。因为此方法是事件处理绑定的一部分(我们添加到<tr>click绑定),所以该方法将自动传递两个参数 - 第一个是 ViewModel,第二个是事件对象。我们可以在函数中使用这两个参数。

首先我们定义一些变量。我们使用 jQuery 选择被点击的任何<th>元素。我们可以使用事件对象的target属性来确定这一点,然后我们用 jQuery 包装它,以便我们可以在所选元素上调用 jQuery 方法。

我们可以使用 jQuery 的attr()方法获取元素的data-bind属性,然后根据绑定名称和绑定到的属性之间的空格拆分它。所以例如,如果我们在浏览器中点击包含Name<th>,我们的第一个变量orderProp将被设置为nameOrder

下一个变量orderVal被设置为 ViewModel 属性的当前值,orderProp变量指向的属性。Knockout 提供了一种简单的方法来以编程方式获取或设置任何 ViewModel 属性。

如果我们想获取属性的值,我们将其调用为函数,如下所示:

property();

如果我们想设置属性,我们仍然像调用函数一样调用它,但是我们将要设置的值作为参数传递:

property(value);

因此,继续上述点击包含Name<th>的例子,orderVal变量将具有值ascending,因为这是每个…Order属性的默认值。请注意我们如何使用orderProp变量和方括号表示法获取正确的值。

我们的最后一个变量comparatorProp很方便地存储我们将要根据其对elements数组中的对象进行排序的属性。我们的 ViewModel 属性在末尾有字符串Order,但是elements数组中的对象内部的属性没有。因此,为了获取正确的属性,我们只需要在大写O上拆分字符串,并从split()返回的数组中取第一个项目。

observableArray

接下来我们使用sort()方法进行排序。看起来我们在使用 JavaScript 的普通sort()函数,但实际上我们并不是。不要忘记,elements数组不只是一个普通数组;它是一个observableArray,因此虽然我们可以从元素的viewModel属性中获取基础数组,然后在其上调用普通的 JavaScriptsort()函数,但 Knockout 提供了更好的方法。

Knockout 提供了一系列可以在 observable 数组上调用的标准 JavaScript 数组函数。在很大程度上,这些函数的工作方式与它们的原始 JavaScript 对应函数非常相似,但是尽可能使用 Knockout 变体通常更好,因为它们在浏览器中得到了更好的支持,特别是传统浏览器,比原始 JavaScript 版本。一些 Knockout 方法还为我们提供了一些额外的功能或便利。

其中一个例子是使用 Knockout 的sort()方法。这并不是我们在这里使用该方法的原因,但这是 Knockout 如何改进原始 JavaScript 函数的一个例子。

JavaScript 内置的默认sort()函数对数字的排序效果不是很好,因为它会自动将数字转换为字符串,然后根据字符串而不是数字进行排序,导致我们得到意料之外的结果。

Knockout 的sort()方法不会自动对字符串或数字数组进行排序。在这一点上,我们不知道我们将排序字符串,数字,还是两者兼有,因为elements数组中的对象既包含字符串又包含数字,有时在同一个属性中。

就像 JavaScript 的sort()函数一样,传递给 Knockout 的sort()方法的函数将自动传递两个值,这两个值是当前要排序的项。与 JavaScript 的sort()函数一样,Knockout 的sort()方法应返回0,如果要比较的值相等,返回负数,如果第一个值较小,或者返回正数,如果第一个值较大。

在传递给sort()的函数中,我们首先从对象中获取我们将要比较的值。传递给函数的两个值都将是对象,但我们只想比较每个对象内部的一个属性,所以我们为了方便起见将要比较的属性存储在propApropB变量中。

比较不同类型的值

我之前提到有时我们可能会比较不同类型的值。这可能发生在我们按日期列排序时,其中可能包含形式为年份的数字,或者可能是字符串Antiquity,而这些对象中有一些包含这样的值。

所以我们使用 JavaScript 的typeof运算符和普通的if语句来检查要比较的两个值是否属于相同的类型。如果它们不是相同的类型,我们检查每个属性是否是字符串,如果是,就将其值转换为数字0。在if语句内部,我们使用 JavaScript 的三元运算符来简洁地表达。

检查顺序

然后,我们检查我们在一会儿设置的orderProp变量是否设置为 ascending。如果是,我们执行标准排序。我们检查两个值是否相等,如果是,返回0。如果两个值不相等,我们可以检查第一个值是否小于第二个值,如果是,返回-1,如果不是,返回1。为了将整个语句保持在一行上,我们可以使用复合的三元运算符。

如果顺序不是ascending,那么必须是descending,所以我们可以执行降序排序。这段代码几乎与之前的代码相同,只是如果第一个值小于第二个值,我们返回1,如果不是,我们返回-1,这与条件语句的第一个分支相反。

然后,我们需要更新我们刚刚排序过的列的…Order属性的值。这段代码的作用类似于一个简单的开关 - 如果值当前设置为ascending,我们将其设置为descending。如果它设置为descending,我们只需将其设置为ascending。这种行为允许的是,当单击<th>元素第一次时,它将执行默认的升序排序。如果再次单击它,它将执行降序排序。

最后,如果我们的 ViewModel 的其他…Order属性已更改,我们希望重置它们。我们使用一个简单的 JavaScript for in循环来迭代我们的 ViewModel 的属性。对于每个属性,我们检查它是否包含字符串Order,以及它是否不是我们刚刚更新的属性。

如果这两个条件都满足,我们将当前属性的值重置为默认值ascending

添加图标

我们添加的 CSS 用于在悬停时向每个<th>元素添加一个小的排序图标。我们可以利用 CSS 形状技术来创建一个向下指向的箭头,表示升序,和一个向上指向的箭头,表示降序。我们还使用:after CSS 伪选择器来避免硬编码非语义元素,比如<span>或类似的元素,来显示形状。显示哪个箭头取决于我们绑定到 ViewModel 的…Order属性的类名。

注意

如果您以前从未使用过 CSS 形状,我强烈建议您研究一下,因为它们是创建图标的绝佳方法,而无需非语义占位符元素或 HTTP 重的图像。有关更多信息,请查看 css-tricks.com/examples/ShapesOfCSS/ 上的 CSS 形状指南。

此时,我们应该能够在浏览器中运行页面,并单击任何一个标题,一次执行升序排序,或者点击两次执行降序排序:

添加图标

设置页面大小

所以我们添加的排序功能非常棒。但是<table>仍然相当大且笨重 - 实际上太大了,无法完整地在页面上显示。所以分页正好适用。

我们需要做的一件事是确定每页应包含多少项数据。我们可以在脚本中硬编码一个值,表示每页显示的项目数,但更好的方法是添加一个 UI 功能,让用户可以自己设置每页显示的项目数。这就是我们将在此任务中做的事情。

启动推进器

我们可以从添加一些额外的标记开始。直接在<tbody>元素之后添加以下元素:

<tfoot>
    <tr>
        <tdcolspan="5">
            <div id="paging" class="clearfix">
                <label for="perPage">Items per page:</label>
                <select id="perPage" data-bind="value: pageSize">
                    <option value="10">10</option>
                    <option value="30">30</option>
                    <option value="all">All</option>
                </select>
            </div>
        </td>
    </tr>
</tfoot>

我们还需要对<tbody>元素进行一些小改动。它目前具有对观察到的元素数组的foreach绑定。我们将在稍后为我们的 ViewModel 添加一个新属性,然后需要更新sortable-table.html中的绑定,以便它链接到这个新属性:

<tbody data-bind="foreach: elementsPaged">

接下来,我们可以在 sortable-table.js 中添加一些新的 ViewModel 属性:

pageSize: ko.observable(10),
currentPage: ko.observable(0),
elementsPaged: ko.observableArray(),

最后,我们可以添加一个特殊的新变量,称为 computed observable。这应该在 vm 变量之后出现:

vm.createPage = ko.computed(function () {

    if (this.pageSize() === "all") {
        this.elementsPaged(this.elements.slice(0));
    } else {
        var pagesize = parseInt(this.pageSize(), 10),
            startIndex = pagesize * this.currentPage(),
            endIndex = startIndex + pagesize;

        this.elementsPaged(this.elements.slice(startIndex,endIndex));
    }

}, vm);

完成目标 - 小结

我们从添加一个包含一个行和一个单元格的 <tfoot> 元素开始这项任务。单元格内是用于我们分页元素的容器。然后我们有一个 <label> 和一个 <select> 元素。

<select> 元素包含一些选项,用于显示不同数量的项目,包括一个查看所有数据的选项。它还使用 Knockout 的 value data-bind 属性将 <select> 元素的值链接到 ViewModel 上的一个名为 pageSize 的属性。这种绑定意味着每当 <select> 元素的值更改时,例如用户进行选择时,ViewModel 属性将自动更新。

此绑定是双向的,因此如果我们在脚本中以编程方式更新 pageSize 属性,则页面上的元素将自动更新。

然后,我们将 <tbody>foreach 绑定到我们的 ViewModel 上的一个新属性,称为 elementsPaged。我们将使用这个新属性来存储 elements 数组中项目的一个子集。该属性中的实际项目将构成数据的单个页面。

接下来,我们在存储在 vm 变量中的对象字面量中添加了一些新属性,也称为我们的 ViewModel。这些属性包括我们刚刚讨论的 currentPagepageSizeelementsPaged 属性。

我们最后要做的是添加一个名为 computed observable 的 Knockout 功能。这是一个非常有用的功能,它让我们监视一个或多个变量,并在任何可观察变量更改值时执行代码。

我们使用 ko.computed() 方法将计算的 observable 设置为 ViewModel 的一个方法,将函数作为第一个参数传入。ViewModel 作为第二个参数传入。现在我们不在一个附加到我们的 ViewModel 的方法中,所以我们需要将 ViewModel 传递给 computed() 方法,以便将其设置为 ViewModel。

在作为第一个参数传递的函数中,我们引用了刚刚添加的三个新 ViewModel 属性。在此函数中引用的任何 ViewModel 属性都将被监视变化,并在此发生时调用该函数。

此函数的全部功能是检查 pageSize() 属性是否等于字符串 all。如果是,则将元素数组中的所有对象简单地添加到 elementsPaged 数组中。它通过取 elements 数组的一个切片来实现这一点,该切片从第一个项目开始。当 slice() 与一个参数一起使用时,它将切片到数组的末尾,这正是我们需要获得整个数组的方式。

如果pageSize不等于字符串all,我们首先需要确保它是一个整数。因为这个 ViewModel 属性与页面上的<select>元素相关联,有时值可能是一个数字的字符串而不是实际的数字。我们可以通过在属性上使用parseInt() JavaScript 函数并将其存储在变量pagesize中,在函数的其余部分中使用它来确保它始终是一个数字。

接下来,我们需要确定传递给slice()作为第一个参数的起始索引应该是什么。要解决此问题,我们只需将pageSize属性的值乘以最初设置为0currentPage属性的值。

然后,我们可以使用elements数组的一个片段来填充elementsPaged数组,该片段从我们刚刚确定的startIndex值开始,到endIndex值结束,该值将是startIndex加上每页项目数。

当我们在浏览器中运行页面时,<select>框将最初设置为值 10,这将触发我们的计算可观察到的行为,选择elements数组中的前 10 个项目,并在<table>中显示它们。

我们应该发现,我们可以使用<select>来动态更改显示的条目数量。

机密情报

在此任务中,我们使用了slice() Knockout 方法。您可能认为我们使用的是 JavaScript 的原生Array.slice()方法,但实际上我们使用的是 Knockout 版本,而且有一种简单的方法来识别它。

通常,当我们想要获取可观察属性内部的值时,我们会像调用函数一样调用属性。因此,当我们想要获取 ViewModel 的pageSize属性时,我们使用了this.pageSize()

然而,当我们调用slice()方法时,我们没有像调用函数那样调用元素属性,因此实际数组在属性内部并未返回。slice()方法直接在可观察对象上调用。

Knockout 重新实现了一系列可以在数组上调用的原生方法,包括push()pop()unshift()shift()reverse()sort(),我们在上一个任务中使用了它们。

建议使用这些方法的 Knockout 版本而不是原生 JavaScript 版本,因为它们在 Knockout 支持的所有浏览器中都受到支持,从而保持了依赖跟踪并保持了应用程序的 UI 同步。

添加上一页和下一页链接

此时,我们的页面现在只显示前 10 个项目。我们需要添加一个界面,允许用户导航到其他数据页面。在此任务中,我们可以添加上一页下一页链接,以便以线性顺序查看页面。

启动推进器

我们将再次从添加此功能的 HTML 组件开始。在<tfoot>元素中的<select>元素之后直接添加以下新标记:

<nav>
    <a href="#" title="Previous page" 
    data-bind="click: goToPrevPage">&laquo;</a>

    <a href="#" title="Next page" 
    data-bind="click: goToNextPage">&raquo;</a>
</nav>

接下来,我们可以向我们的 ViewModel 添加一些新方法。这些可以直接添加到我们之前在sortable-table.js中添加的sort方法后面:

totalPages: function () {
    var totalPages = this.elements().length / this.pageSize() || 1;
        return Math.ceil(totalPages);
},
goToNextPage: function () {
    if (this.currentPage() < this.totalPages() - 1) {
        this.currentPage(this.currentPage() + 1);
    }
},
goToPrevPage: function () {
    if (this.currentPage() > 0) {
        this.currentPage(this.currentPage() - 1);
    }
}

最后,我们可以通过将以下代码添加到 sortable-table.css 来为此部分添加的新元素以及上一部分添加的元素添加一些 CSS 以进行整理:

tfoot label, tfoot select, tfootnav {
    margin-right:4px; float: left; line-height:24px; 
}
tfoot select { margin-right:20px; }
tfootnav a { 
    display:inline-block; font-size:30px; line-height:20px; 
    text-decoration:none; color:#000; 
}

目标完成 - 小结

我们首先通过向页面添加包含两个 <a> 元素的 <nav> 元素来开始,这些元素制作了上一页下一页链接。我们为链接添加了数据绑定,将上一页链接连接到 goToPrevPage() 方法,将下一页链接连接到 goToNextPage() 方法。

然后,我们添加了一个小的实用方法,以及这两个新方法到我们的 ViewModel。我们的方法不必像 sort() 方法那样接受参数,我们可以在方法中使用 this 访问我们的 ViewModel。

第一个方法 totalPages() 简单地通过将 elements 数组中的总项目数除以 pageSize 属性中保存的值来返回总页数。

有时 currentPage 属性将等于字符串 all,当在数学运算中使用时将返回 NaN,因此我们可以添加双竖线 OR (||) 来在这种情况下返回 1。我们还使用 Math.ceil() 来确保我们获得一个整数,因此当有 11.8 页的数据时(基于每页 10 个项目的默认值),该方法将返回 12。Ceil() 函数将总是向上舍入,因为我们不能有部分页面。

我们在上一个任务中添加的 createPage 计算的可观察对象实际上为我们做了大部分工作。接下来的两个方法只是更新了 currentPage 属性,这将自动触发 createPage() 计算的可观察对象。

goToNextPage() 方法中,我们首先检查我们是否已经在最后一页,只要我们不是,我们就将 currentPage 属性增加一。在我们检查是否在最后一页时,我们使用 totalPages() 方法。

goToPrevPage() 方法同样简单。这次我们检查我们是否已经在数据的第一页(如果 currentPage 等于 0),如果不是,我们将 currentPage 的值减去 1

我们添加的少量 CSS 只是整理了 <tfoot> 元素中的元素,使它们能够与彼此并排浮动,并使新链接比默认情况下稍大一些。

添加数字页面链接

现在,我们可以添加任意数量的链接,以便允许用户直接访问任何页面。这些是直接链接到每个单独页面的数字页面链接。

启动推进器

首先,我们需要在我们的 ViewModel 中的现有可观察属性之后直接添加一个新的可观察属性,在 sortable-table.js 中:

pages: ko.observableArray(),

在此之后,我们可以向我们的 ViewModel 中添加一个新方法。这可以添加在 goToPrevPage() 方法之后,位于 vm 对象字面量内部:

changePage: function (obj, e) {
    var el = $(e.target),
        newPage = parseInt(el.text(), 10) - 1;

    vm.currentPage(newPage);
}

不要忘记在goToPrevPage()方法后面加上逗号!然后我们可以添加一个新的计算可观察属性,方式与我们之前添加的一样。这可以直接放在我们在上一个任务中添加的createPage计算可观察属性之后:

vm.createPages = ko.computed(function () {

    var tmp = [];

    for (var x = 0; x < this.totalPages(); x++) {
        tmp.push({ num: x + 1 });
    }

    this.pages(tmp);

}, vm);

接下来,我们需要在 HTML 页面中添加一些新的标记。这应该在我们在上一个任务中添加的PreviousNext链接之间添加:

<ul id="pages" data-bind="foreach: pages">
    <li>
        <a href="#" data-bind="text: num, 
        click: $parent.changePage"></a>
    </li>
</ul>

最后,我们可以添加一点 CSS 来定位sortable-table.css中的新元素:

tfoot nav ul { margin:3px 0 0 10px; }
tfoot nav ul, tfootnav li { float:left; }
tfoot nav li { margin-right:10px; }
tfoot nav li a { font-size:20px; }

目标完成 - 小结。

首先,我们在 ViewModel 中添加了一个新的pages可观察数组。一开始我们没有给它一个数组;我们会在合适的时候动态添加。

我们添加的计算可观察属性createPages用于构建一个数组,其中数组中的每个项目表示数据的一个页面。我们可以像之前一样使用我们的totalPages()方法获取总页数。

一旦确定了这一点,也就是每当pageSize()可观察属性发生变化时,我们就可以填充刚刚添加的可观察数组。

添加到数组中的对象是使用简单的for循环创建的,以创建一个对象并将其推入数组中。一旦我们为每个页面构建了一个对象,我们就可以将数组设置为pages属性的值。

我们创建的每个对象都只有一个属性,称为num,其值是循环中使用的x计数器变量的当前值。

在 HTML 页面中,我们使用foreach数据绑定来迭代我们添加到pages数组中的数组。对于数组中的每个对象,我们创建一个<li>元素和一个<a>元素。<a>使用data-bind属性指定了两个绑定。

第一个是text绑定,它设置元素的文本。在这种情况下,我们将文本设置为每个对象具有的num属性的值。

第二个绑定是一个点击绑定,它调用一个名为changePage的方法。然而,在foreach绑定中,上下文被设置为pages数组中的当前对象,所以我们需要使用特殊的$parent上下文属性来访问 ViewModel 上的方法。

最后,我们添加了changePage方法,它被<a>元素使用。在这个简单的方法中,我们需要做的就是获取被点击元素的文本,从其值中减去1,因为实际的页码是从零开始的,并更新我们 ViewModel 的curentPage可观察属性。在这个方法中,由于某种原因,this的值并没有设置为被点击的元素,正如我们之前遇到的sort()方法所期望的那样。

因为触发changePage方法的<a>元素是在foreach绑定内创建的,所以传递给changePage的第一个参数将是pages数组中与<a>元素关联的对象。幸运的是,我们仍然可以使用变量vm访问 ViewModel。

我们添加的 CSS 简单地将列表项浮动在一起,稍微间隔开它们,并设置文本的颜色和大小。

机密情报

除了 $parent 上下文属性允许我们访问在 foreach 绑定中迭代的 ViewModel 属性的父对象之外,我们还可以利用 $data,它指向正在迭代的数组。

除此之外,还有一个 $index 属性,允许我们访问当前迭代的索引,我们可以在这个示例中使用它,而不是在每个对象上设置 num 属性。

管理类名

在这个任务中,我们可以向用户显示反馈,描述当前正在查看的页面。如果我们在数据的第一页或最后一页,我们也可以禁用 PreviousNext 链接。我们可以使用更多的脚本和一些简单的 CSS 来完成所有这些。

启动推进器

首先,我们需要在 sortable-table.js 中的现有方法后直接添加另一个方法到我们的 ViewModel 中:

manageClasses: function () {
    var nav = $("#paging").find("nav"),
        currentpage = this.currentPage();

    nav.find("a.active")
       .removeClass("active")
       .end()
       .find("a.disabled")
       .removeClass("disabled"); 

    if (currentpage === 0) {
       nav.children(":first-child").addClass("disabled");
    } else if (currentpage === this.totalPages() - 1) {
        nav.children(":last-child").addClass("disabled");
    }

    $("#pages").find("a")
               .eq(currentpage)
               .addClass("active");
}

然后,我们需要从我们现有的代码中的几个位置调用这个方法。首先,我们需要在 createPage()createPages() 计算观察函数的末尾调用它,通过在每个函数的最后一行(以 this 开头的行)添加以下代码:

.manageClasses();

然后,为了在与表格交互之前添加初始类名,我们需要在 ViewModel 之后的 applyBindings() 方法之后调用它:

vm.manageClasses();

最后,我们可以添加任务介绍中提到的额外 CSS:

tfoot nav a.disabled, tfoot nav a.disabled:hover { 
    opacity: .25; cursor: default; color:#aaa;
}
tfoot nav li a.active, tfoot a:hover { color:#aaa; }

目标完成 - 小结

在这个任务中,我们首先向我们的 ViewModel 添加了一个新方法 - manageClasses() 方法。该方法负责向 PreviousNext 链接添加或移除 disabled 类,并向当前页对应的数字链接添加活动类。

在方法内部,我们首先缓存包含 <nav> 元素的选择器,以便我们能够尽可能高效地访问需要更新的元素。我们还获取 curentPage ViewModel 属性,因为我们将多次比较其值。

然后,我们找到具有 disabledactive 类的元素,并将它们移除。注意我们在移除 active 类后如何使用 jQuery 的 end() 方法返回到原始的 <nav> 选择。

现在我们只需要将类重新放回适当的元素上。如果 currentPage0,我们使用 jQuery 的 :first-child 选择器与 children() 方法一起将 disabled 类添加到 <nav> 中的第一个链接。

或者,如果我们在最后一页,我们将 disabled 类添加到 <nav> 的最后一个子元素,这次使用 :last-child 选择器。

使用 jQuery 的 eq() 方法轻松地选择要应用 active 类的元素,该方法将元素的选择减少到作为指定索引的单个元素。我们使用 currentpage 作为要在选择中保留的元素的索引。

CSS 仅用于为具有不同样式的类名的元素添加样式,因此可以轻松地看到类何时添加和删除。

现在在浏览器中运行页面时,我们应该发现上一页链接一开始是禁用的,并且数字1是活动的。如果我们访问任何页面,该数字将获得 active 类。

重置页面

现在我们已经连接了我们的数字分页链接,一个问题变得明显起来。有时,在更改每页项目数时,将显示空表格。

我们可以通过向 <select> 元素添加另一个绑定来修复此问题,该绑定在 <select> 元素的 value 更改时重置当前页面。

启动推进器

首先,我们可以将新的绑定添加到 HTML 中。将 <select> 元素更改为以下内容:

<select id="perPage" data-bind="value: pageSize, event: { 
 change: goToFirstPage
}">

现在我们可以将 goToFirstPage() 方法添加到 ViewModel 中:

goToFirstPage: function () {
    this.currentPage(0);
}

目标完成 - 迷你总结

首先,我们将 event 绑定添加为 <select> 元素的第二个绑定,负责设置每页项的数量。此绑定的格式与我们在此项目中使用的其他绑定略有不同。

在绑定的名称之后,event 在本例中,我们在大括号内指定事件的名称和事件发生时要调用的处理程序。之所以使用此格式是因为如果需要,我们可以在括号内指定多个事件和处理程序。

然后,我们将新的事件处理程序 goToFirstPage() 添加为 ViewModel 的方法。在处理程序中,我们只需要将 currentPage 可观察值设置为 0,这将自动将我们移回到结果的第一页。每当 <select> 元素的值发生变化时,都会发生这种情况。

对表进行过滤

为了完成项目,我们可以添加过滤器,以便可以显示不同类型的元素。表的数据包含我们尚未使用的列——元素的 state(实际物理元素,而不是 HTML 元素!)

在此任务中,我们可以添加一个 <select> 元素,以允许我们根据其状态对元素进行过滤。

启动推进器

首先,我们需要向 ViewModel 添加一个新的可观察数组,该数组将用于存储表示元素可能的不同状态的对象:

states: ko.observableArray(),

我们还可以向 ViewModel 添加一个简单的非可观察属性:

originalElements: null,

接下来,我们需要填充新数组。我们可以在调用 vm.manageClasses() 之后直接执行此操作:

var tmpArr = [],
      refObj = {};

tmpArr.push({ state: "Filter by..." });

$.each(vm.elements(), function(i, item) {

    var state = item.state;

    if (!refObj.hasOwnProperty(state)) {

        var tmpObj = {state: state};
        refObj[state] = state;
        tmpArr.push(tmpObj);
    }
});

vm.states(tmpArr);

然后,我们可以添加新的 HTML,该 HTML 将创建用于过滤 <table> 数据的 <select> 元素:

<div class="filter clearfix">
    <label for="states">Filter by:</label>
    <select id="states" data-bind="foreach: states, event: { 
        change: filterStates
    }">
        <option data-bind="value: state, text: state">
        </option>
    </select>
</div>

现在我们需要向 ViewModel 添加一个最终方法,该方法在进行选择时实际过滤数据:

filterStates: function (obj, e) {

    if (e.originalEvent.target.selectedIndex !== 0) {

        var vm = this,
            tmpArr = [],
            state = e.originalEvent.target.value;

        vm.originalElements = vm.elements();

        $.each(vm.elements(), function (i, item) {
            if (item.state === state) {
                tmpArr.push(item);
            }
        });

        vm.elements(tmpArr).currentPage(0);

        var label = $("<span/>", {
            "class": "filter-label",
            text: state
        });
        $("<a/>", {
            text: "x",
            href: "#",
            title: "Remove this filter"
        }).appendTo(label).on("click", function () {

            $(this).parent().remove();
            $("#states").show().prop("selectedIndex", 0);
            vm.elements(vm.originalElements).currentPage(0);

        });

        label.insertBefore("#states").next().hide();
    }
}

最后,我们可以向sortable-table.css添加一点 CSS,只是为了整理新元素:

tfoot .filter { float:right; }
tfoot .filter label { 
    display:inline-block; height:0; line-height:0; 
    text-indent:-9999em; overflow:hidden; 
}
tfoot .filter select { margin-right:0; float:right; }
tfoot .filter span { 
    display:block; padding:0 7px; border:1px solid #abadb3;
    border-radius:3px; float:right; line-height:24px;
}
tfoot .filter span a { 
    display:inline-block; margin-left:4px; color:#ff0000;
    text-decoration:none; font-weight:bold;
}

完成目标 - 小结

首先,我们添加了一个名为states的新的可观察数组,该数组将用于包含构成我们数据的元素的不同状态。这些状态是固体、液体、气体或未知状态。

我们还向 ViewModel 添加了一个简单的属性,称为originalElements,它将用于存储完整的元素集合。该属性只是一个常规对象属性,因为我们不需要观察其值。

填充状态数组

接下来,我们将状态数组填充为数据中找到的所有唯一状态。我们只需要填充一次这个数组,所以它可以出现在 ViewModel 之外。我们首先创建一个空数组和一个空对象。

然后,我们向数组添加一个单个项目,该项目将用于<select>元素中的第一个<option>元素,并在与<select>框交互之前作为标签起作用。

然后,我们可以使用 jQuery 的each()方法迭代elements数组。对于数组中的每个项目(如果您记得的话,它将是表示单个元素的对象),我们获取其state并检查这是否存储在引用对象中。我们可以使用hasOwnProperty()JavaScript 函数来检查这一点。

如果状态在对象中不存在,我们将其添加。如果已经存在,则我们不需要做任何事情。如果对象不包含该状态,我们还将状态推入空数组。

一旦each()循环结束,我们应该有一个数组,其中包含数据中找到的每个state的单个实例,因此我们可以将此数组添加为states可观察数组的值。

构建<select>

过滤功能的底层标记非常简单。我们添加了一个带有几个类名的容器<div>,一个<label>和一个<select><label>类名只是为了可访问性而添加的,我们不会显示它,因为<select>元素的第一个<option>将作为标签。

<select>元素有几个 Knockout 绑定。我们使用了foreach绑定,它连接到状态数组,因此一旦这个数组被填充,<select><option>元素就会自动添加。

我们还一次使用了event绑定,为change事件添加了一个处理程序,每当与<select>框交互时就会触发。

<select>元素内部,我们为<option>元素添加了一个模板。每个选项将被赋予states数组中当前对象的state属性的textvalue

过滤数据

然后,我们添加了负责过滤<table>中显示的数据的 ViewModel 的方法。在方法中,我们首先检查第一个<option>是否未被选中,因为这只是一个标签,不对应任何状态。

我们可以通过查看target元素(<select>)的selectedIndex属性来确定这一点,该属性在originalEvent对象中可用。这本身是自动传递给我们的事件处理程序的事件对象的一部分。

因为我们将要更改elements可观察数组(以触发对过滤元素的分页),所以我们希望稍后存储原始元素。我们可以将它们存储在 ViewModel 的originalElements属性中。

接下来,我们需要构建一个新数组,其中仅包含具有在<select>元素中选择的state的元素。为此,我们可以创建一个空数组,然后迭代elements数组并检查每个元素的state。如果匹配,则将其推入新数组。

我们可以再次使用传递给我们的事件处理程序的事件对象来获取从<select>元素中选择的state。这次我们在originalEvent对象中使用target元素的value属性。

一旦新数组被填充,我们就更新elements数组,使其仅包含我们刚刚创建的新数组,然后将currentPage设置为0

我们添加的过滤器是互斥的,因此一次只能应用一个过滤器。选择过滤器后,我们希望隐藏<select>框,以便无法选择另一个过滤器。

我们还可以创建一个标签,显示当前正在应用的过滤器。此标签由一个<span>元素制成,显示过滤器的文本,并且还包含一个<a>元素,可用于删除过滤器并将<table>返回到其最初显示所有元素的状态。

我们可以使用 jQuery 的on()方法在创建并附加到页面后立即附加<a>元素的处理程序。在处理程序中,我们只需将 ViewModel 的elements属性设置回保存在originalEvents属性中的数组,并将<table>重新设置为第一页,方法是将currentPage属性设置为0

现在我们应该发现,我们可以在<select>框中选择其中一个选项,仅查看过滤后的数据和过滤标签,然后单击过滤标签中的红色叉号以返回初始的<table>。以下是数据的筛选选择和筛选标签的截图:

数据过滤

任务完成

我们的应用程序主要依赖 Knockout 功能运行,它允许我们轻松地将动态元素填充到内容中,添加事件处理程序,并通常管理应用程序的状态。我们也使用 jQuery,主要是在 DOM 选择容量方面,还偶尔使用它来使用实用程序,例如我们多次利用的$.each()方法。

完全可以纯粹使用 jQuery 构建此应用程序,而不使用 Knockout;但是,jQuery 本身从未被设计或打算成为构建复杂动态应用程序的完整解决方案。

当我们尝试仅使用 jQuery 构建复杂动态应用程序时,通常会发现我们的脚本很快变成一堆事件处理程序的混乱代码,既不容易阅读,也不易于维护或在将来更新。

使用 Knockout 来处理应用程序状态的维护,并使用 jQuery 来实现它的预期角色,为我们提供了使用非常少的代码构建高度动态、数据驱动的复杂应用程序的理想工具集。

在整个示例中,我尽量使各个方法尽可能简单,并且让它们只做一件事情。以这种方式将功能单元保持隔离有助于保持代码的可维护性,因为很容易看到每个现有函数的功能,也很容易添加新功能而不会破坏已有的内容。

你准备好全力以赴了吗?挑战热门的高手?

Knockout 可以轻松地从数据数组中构建一个<table>,由于数据是动态的,因此很容易编辑它或向其添加新项目,并使应用程序中的数据得以更新。尽管在此示例中数据是存储在本地文件中的,但将数据存储在服务器上并在页面加载时使用简单的 AJAX 函数填充我们的元素数组是很简单的。

如果你想进一步学习这个示例,这将是首要任务。完成这个任务后,为什么不试试使表格单元格可编辑,以便可以更改它们的值,或添加一个允许你插入新行到<table>的功能。完成这些后,你会想把新数据发送回服务器,以便永久存储。

posted @ 2024-05-19 20:13  绝不原创的飞龙  阅读(3)  评论(0编辑  收藏  举报