HTML5-Web-应用开发示例-全-

HTML5 Web 应用开发示例(全)

原文:zh.annas-archive.org/md5/F338796025D212EF3B95DC40480B4CAD

译者:飞龙

协议:CC BY-NC-SA 4.0

前言

现在是开始使用 HTML5 的时候了。HTML5 为编写在 Web 浏览器中运行的功能齐全的应用程序提供了完整的应用程序开发框架。尽管 HTML5 规范尚未完全完成,但几乎每个现代浏览器都已广泛支持最受欢迎的功能,从台式机到平板电脑再到智能手机上运行。这意味着你可以编写一次应用程序,然后在几乎任何设备上运行。

如果你想开始编写 HTML5 Web 应用程序,但不知道从哪里开始,那么这本书适合你。我们将从构建 Web 应用程序的基础知识开始,然后通过构建真正的工作应用程序来学习 HTML5、CSS3 和 JavaScript。这不是一本参考书。我们将尽量减少理论知识,最大限度地进行实际编码。

就在几年前,在浏览器中编写功能齐全的应用程序需要其他技术,比如作为浏览器插件运行的 Flash 或 Java 小程序。和大多数人一样,我只用 JavaScript 编写简单的客户端验证脚本。我甚至都没想过可以用 JavaScript 编写真正的应用程序。一切开始改变是因为发生了几件事情。

首先,我发现了 jQuery。这是一个库,通过抽象浏览器的特殊性,使得编写 JavaScript 变得更加容易,并且非常容易操作网页的元素。此外,它还可以帮助我们执行一些很酷的操作,比如动画元素。然后大约三年前,我在寻找一种在网页上直接绘制图形原语的方法时了解到了 HTML5。从那时起,我看到 HTML5 发展成为一个完整的框架,能够用来编写无需插件的真正应用程序。

这本书是过去三年几乎每天写 JavaScript 的结晶,学到了什么有效,什么无效。可以说是技术上的大脑倾泻。目标是写一本我在刚开始时希望能读到的书。

HTML5 Web 应用程序开发的未来看起来很光明。在 Web 浏览器开发领域,所有大公司都全力支持 HTML5 和 JavaScript。HTML5 是 Web 应用程序开发的未来!

本书涵盖的内容

第一章,“手边的任务”,将通过构建一个模板来教你 JavaScript 应用程序的基本组件,该模板可用于开始编写新的应用程序。然后我们将创建一个任务列表应用程序,学习如何操作 DOM 以及如何使用 HTML5 Web 存储来保存应用程序的状态。

第二章,“时尚起来”,将展示如何使用新的 CSS3 功能为你的 Web 应用程序添加专业外观的样式,包括圆角、阴影和渐变。我们还将学习如何使用 CSS 精灵使图像加载更加高效。

第三章,“细节决定成败”,将通过向任务列表应用程序添加详细信息部分来教你关于新的 HTML5 表单输入类型。我们还将学习自定义数据属性,并学习如何使用它们将视图中的数据绑定到模型。

第四章,“一块空白画布”,将展示如何使用新的 Canvas 元素和 API 直接在网页上绘制,创建一个绘图应用程序。我们还将学习如何处理来自触摸屏设备的触摸事件。

第五章,“不再是空白画布”,将继续教授有关画布的知识,向你展示如何使用新的文件 API 从画布中导出图像,并将图像加载到画布中。然后我们将深入到像素级别,学习如何直接操作画布图像数据。

第六章,Piano Man,将教你如何使用音频元素和 API 在网页中播放声音。我们将创建一个虚拟钢琴,在点击键时播放声音。

第七章,Piano Hero,将把前一章的虚拟钢琴变成一个游戏,玩家必须在正确的时间弹奏正确的音符以获得积分。在这个过程中,我们将学习如何使用 JavaScript 定时器和动画元素。

第八章,A Change in the Weather,将向你展示如何从服务器获取数据并使用 Ajax 与 Web 服务通信。我们将构建一个天气小部件,使用地理位置 API 获取用户的位置,并显示来自 Web 服务的本地天气报告。

第九章,Web Workers Unite,将教你如何使用 HTML5 web workers 在单独的线程中执行长时间运行的进程,以使你的应用程序更具响应性。我们将创建一个应用程序,使用 web worker 在画布上绘制 Mandelbrot 分形。

第十章,Releasing an App into the Wild,将教你如何在发布应用程序到世界之前使用 JavaScript 压缩器来合并和压缩应用程序的 JavaScript 文件。我们还将学习如何使用 HTML5 应用程序缓存创建可以离线使用的应用程序。

本书所需的内容

HTML5 的好处在于使用它是没有成本的。你不需要任何特殊的工具或许可证来开发 Web 应用程序。然而,使用一个好的代码编辑器会对你有所帮助,特别是在刚开始的时候。没有什么比自动建议更能帮助你记住 JavaScript 函数、元素名称和样式选项。而语法高亮对于使代码更易于阅读是至关重要的。

也就是说,如果你还没有一个源代码编辑器,我可以建议几个。Notepad++是一个免费的编辑器,具有 JavaScript、HTML 和 CSS 语法高亮显示和一些基本的自动建议,没有太多的开销。我用它来写这本书中的所有代码。在高端,Microsoft Visual Studio 提供非常好的自动建议功能,但比基本文本编辑器的开销更大。另一个很好的选择是 NetBeans,一个用 Java 编写的开源 IDE,具有良好的 Web 开发支持。

你还需要一个支持 HTML5 的 Web 浏览器和开发者工具。大多数浏览器的最新版本都支持本书中使用的 HTML5 功能。你使用的浏览器应该取决于你最喜欢的开发者工具。我使用 Chrome,因为它内置了很棒的开发者工具。安装了 Firebug 插件的 Firefox 也非常好。在这本书中,我使用 Chrome 作为首选的浏览器。Internet Explorer 9 并不完全支持我们将要学习的所有 HTML5 功能,而且开发者工具也不如其他浏览器好,所以我建议不要用它进行开发。

你可能还需要一个像 IIS 或 Apache 这样的 Web 服务器。在开发时,大多数情况下你可以直接从文件系统中打开你的 Web 应用程序。然而,一些 HTML5 功能只能通过 Web 服务器工作。我已经在本书中指出了这种情况。

这本书适合谁

这本书是为那些在其他语言有经验并想要开始编写 HTML5 Web 应用程序的程序员而写的。您应该对 HTML、CSS 和 JavaScript 有一些基本的了解。例如,您应该知道如何编写简单的 HTML 文档。您还应该了解如何使用 CSS 选择器的基础知识,因为它们对于使用 jQuery 很重要。您不需要知道如何使用 jQuery,因为本书将简要介绍基础知识,但这可能会有所帮助。只要您能理解并编写简单的 JavaScript 代码,那就足以让您开始了。我们将从基础知识开始,通过大量示例逐步深入。

约定

在本书中,您会经常看到几个标题。

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

行动时间 - 标题

  1. 行动 1

  2. 行动 2

  3. 行动 3

说明通常需要一些额外的解释,以便理解,因此它们后面跟着:

刚刚发生了什么?

这个标题解释了您刚刚完成的任务或说明的工作原理。

您还会在书中找到其他一些学习辅助工具,包括:

小测验 - 标题

这些是旨在帮助您测试自己理解的简短的多项选择题。

试试看 - 标题

这些实际挑战为您提供了尝试所学内容的想法。

您还会发现一些文本样式,用于区分不同类型的信息。以下是一些这些样式的示例,以及它们的含义解释。

文本中的代码单词、数据库表名、文件夹名、文件名、文件扩展名、路径名、虚拟 URL、用户输入和 Twitter 句柄显示如下:"接下来,我们将向Canvas2D对象添加一个drawText()方法。"

代码块设置如下:

this.drawText = function(text, point, fill)
{
    if (fill)
    {
        context.fillText(text, point.x, point.y);
    }
    else
    {
        context.strokeText(text, point.x, point.y);
    }
};

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

switch (action.tool)
{
    // code not shown...
    case "text":
 canvas2d.drawText(action.text, action.points[0],
 action.fill);
 break;
}

术语重要 单词以粗体显示。您在屏幕上看到的单词,比如菜单或对话框中的单词,会以这样的方式出现在文本中:"当单击保存按钮时,它将获取数据 URL,然后打开它。"

注意

警告或重要说明会以这样的方式出现在一个框中。

提示

技巧和窍门会以这种方式出现。

第一章:手头的任务

“我渴望完成一项伟大而崇高的任务,但我的首要任务是完成小任务,就像它们是伟大而崇高的一样。”

  • 海伦·凯勒

在本章中,我们将学习创建 HTML5 应用程序的基础知识。我们将创建一个应用程序模板,用作快速构建新应用程序的起点,并且付出最小的努力。然后,我们将使用该模板创建一个简单的任务列表应用程序。在此过程中,我们将发现如何与用户交互并操作应用程序的用户界面。我们还将了解我们的第一个新 HTML5 功能,Web 存储 API。

在本章中,我们将学习:

  • HTML5 应用程序的三个基本组件,HTML,CSS 和 JavaScript

  • 对于那些不熟悉 JavaScript 库的 jQuery 基础知识

  • 如何初始化应用程序并处理用户交互

  • 如何操作 DOM 以添加、删除、更改和移动元素

  • 如何创建可重用的 HTML 模板

  • 如何使用 HTML5 Web 存储 API 存储和检索应用程序的状态

HTML5 应用程序的组件

在开始构建我们的第一个应用程序之前,我们需要了解一些 HTML5 应用程序基础知识。HTML5 应用程序类似于使用任何其他编程语言编写的应用程序。在我们开始进行有趣的部分之前,需要放置一定数量的基础设施和管道。

当涉及到搭建项目时,Web 应用程序非常好。您可以每次开始新应用程序时都从头开始。但是随着您编写越来越多的应用程序,您会注意到每次开始时都在做相同的基本事情,因此创建应用程序模板以快速启动而不必每次重新发明轮子是有意义的。

为了了解 HTML5 应用程序是如何构建的,我们将从头开始构建自己的应用程序模板,我们可以在创建新应用程序时使用。我们将使用此模板作为本书中构建的所有应用程序的基础。

每个 Web 应用程序都以三个组件开始:HTML,CSS 和 JavaScript。您可以将它们全部放在一个文件中,对于非常简单的应用程序可能是可以接受的,但是我们在这里学习如何构建真正的应用程序。因此,我们将首先创建三个文件,每个组件一个文件,并将它们放在名为template的文件夹中。它们将被命名为app.htmlapp.cssapp.js

以下图表是对 HTML5 应用程序及其组件的解释。我们的应用程序是建立在 HTML,CSS 和 JavaScript 之上的。这些又建立在 CSS3 和 HTML5 框架之上,其中包括新的标记元素和 JavaScript API。

HTML5 应用程序的组件

让我们看看我们应用程序的文件夹结构。我们将把我们创建的所有文件放在应用程序文件夹的根目录下。我们还将添加一个名为lib的文件夹,其中包含应用程序可能需要的任何第三方 JavaScript 库。由于我们将始终使用 jQuery 库,因此我们将在其中放置一个副本。如果有任何其他资产,例如图像或音频文件,我们将分别将它们放在imagesaudio文件夹中:

HTML5 应用程序的组件

注意

我们可以从在线内容交付网络(CDN)引用 jQuery 库,但这要求您始终具有互联网连接。相信我,您永远不知道何时会在某个地方结束而无法连接并发现无法完成任何工作。

行动时间-创建 HTML 文件

我们将构建的第一个组件是我们的基本 HTML 文件app.html。我们将尽可能保持我们的 HTML 干净。它应该只包含标记。不应该混入任何样式或脚本块。保持标记、样式和行为分开将使您的应用程序更容易调试和维护。例如,如果某些东西的外观有问题,我们将知道问题在 CSS 文件中而不是 JavaScript 文件中。另一个好处是,您可以通过更改 CSS 完全重新设计应用程序的用户界面,而不必触及其功能。

这是我们基本 HTML 文件的标记。它只包括我们的 CSS 和 JavaScript 以及 jQuery 库,并定义了大多数应用程序将使用的简单 body 结构。这是我们将要编写的应用程序的一个很好的起点。

<!DOCTYPE html>
<html>
  <head>
    <title>App</title>
    <link href="app.css" rel="StyleSheet" />
    <script src="img/jquery-1.8.1.min.js"></script>
    <script src="img/app.js"></script>
  </head>
  <body>
    <div id="app">
      <header>App</header>
      <div id="main"></div>
      <footer></footer>
    </div>
  </body>
</html>

提示

下载示例代码

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

HTML5 标记和以前版本的 HTML 之间的一个主要区别是文档类型声明已经大大简化。正如你可能记得的那样,HTML5 之前的文档类型声明非常冗长,普通人根本记不住。它们看起来像这样:

<!DOCTYPE HTML PUBLIC "-//W3C//DTD HTML 4.01//EN" "http://www.w3.org/TR/html4/strict.dtd">

现在让我们来看看新的改进的 HTML5 文档类型声明。它简单、优雅,最重要的是易于记忆:

<!DOCTYPE html>

您可能注意到的另一个区别是<header><footer>元素。这些是 HTML5 中的新语义元素,本质上与<div>元素相同。HTML5 实际上有一整套新的语义元素,旨在为 HTML 标记提供比仅仅将所有内容包装在<div>标记中更多的含义。

由于我们在这里构建的是应用程序,而不是编写内容页面,我们不会过多关注这些语义元素。大多数时候,我们将使用普通的<div>元素。但为了让您熟悉它们,这里是一些最有用的新语义元素的概述:

  • <article>:定义文档中的一篇文章

  • <aside>:定义页面内容以外的内容

  • <footer>:定义文档中某个部分的页脚

  • <header>:定义文档中某个部分的标题

  • <nav>:包含页面导航链接

  • <section>:定义文档中的一个部分

在 HTML5 中,以前版本的 HTML 中存在的一些元素和属性现在已经不再存在。这些主要是与布局和字体有关的元素,包括<big><center><font><strike><u>。过时的元素,如<frame><applet>也已经淘汰。

现在让我们来看看我们标记中<body>元素的内容。首先是一个<div id="app">元素。这将包装应用程序的整个标记。其他标记,如站点导航或与应用程序无关的任何其他内容,可以放在此元素之外。

app元素内部,我们还有三个元素。在这里,我们使用了一些新的语义元素。首先,我们在应用程序中有一个<header>元素,它将包含应用程序的名称,比如标题栏(不要与文档<head>部分中的<title>元素混淆)。<div id="main">元素是应用程序主要部分的标记所在的地方。我们在它下面添加一个<footer>元素,它将被用作状态栏来显示应用程序的状态。

行动时间-创建 CSS 文件

接下来我们将创建名为app.css的基本 CSS 文件。这将包含所有应用程序将使用的基本样式,如默认字体和颜色。CSS 文件的第一部分包含一些文档范围的元素样式,设置了基本的外观和感觉。

body
{
    font: 1em Verdana, Geneva, sans-serif;
    padding: 0;
    margin: 5px;
    color: Black;
    background-color: WhiteSmoke;
}
div
{
    padding: 0;
    margin: 0;
}
button
{
    cursor: pointer;
}
.hidden
{
    display: none;
}

首先,我们设置要应用于 body 的样式,这将传递到其他元素。我喜欢将字体大小设置为1em,而不是固定的像素大小,这样它就会使用浏览器的默认字体大小。然后,您可以使用 em 或百分比基于此进行其他测量,以便为您提供更具反应性的布局,并使以后更改应用程序外观更容易。当您始终需要某些东西保持相同大小时,常数像素大小很好,或者用于边框和边距等小值。

注意

通常,在大多数浏览器中,默认情况下 1em 等于 16px。

接下来,我们确保所有div元素的填充和边距都被移除,所以我们将它们归零。当用户悬停在按钮上时,将光标更改为指针也是很好的,所以我们也会在这里设置。最后,有一个.hidden类选择器,可以添加到任何元素中,以将其隐藏不显示。

我们将用一些样式来完成 CSS 的appmain元素。在这一点上,我们所设置的只是边距、填充和颜色:

#app
{
    margin: 4px;
    background-color: #bbc;
}
#app>header
{
    padding: 0 0.5em;
    font-size: 1.5em;
    color: WhiteSmoke;
    background-color: #006;
}
#app>footer
{
    padding: 0.25em;
    color: WhiteSmoke;
    background-color: #006;
}
#main
{
    margin: 1em;
}

行动时间-创建 JavaScript 文件

让我们继续进行 JavaScript 文件app.js。在这里,我们将为我们的应用程序模板勾画出一个基本的轮廓。如果您不知道美元符号是用来做什么的,它们是 jQuery 库的别名。我们将在一会儿讨论一些 jQuery 基础知识。

"use strict";

function MyApp()
{
    var version = "v1.0";

    function setStatus(message)
    {
        $("#app>footer").text(message);
    }

    this.start = function()
    {
        $("#app>header").append(version);
        setStatus("ready");
    };
}

从顶部开始,我们将在我们的 JavaScript 文件中包含"use strict"。这通知 JavaScript 运行时在运行我们的代码时使用更新和更严格的标准。例如,在旧版本的 JavaScript 中,完全可以在不使用var关键字先声明变量名的情况下使用它。这会导致它成为附加到window对象的全局变量。当定义"use strict"时,如果尝试这样做,将会收到错误。它可以帮助您找到可能导致程序中出现错误的糟糕编码错误。

注意

如果您使用一些不适用于严格模式的较旧的 JavaScript 库,可以在函数声明中添加"use strict",以使仅该代码块使用严格模式。

function strict()
{
    "use strict";
    // Everything inside here will use strict
// mode
}

接下来我们定义主应用程序对象myApp。在 JavaScript 中,有许多定义对象的方法,包括使用对象字面量和构造函数。对象字面量是定义对象的最简单方法,但这些对象通常在 JavaScript 加载后立即创建,通常在 DOM 准备就绪之前。以下是我们的对象作为对象字面量的样子:

var myApp = {
    version: "v1.0",
    setStatus: function(message)
    {
        $("#app>footer").text(message);
    },
    start: function()
    {
        $("#app>header").append(this.version);
        this.setStatus("ready");
    };
};

由于我们的应用程序正在操作文档对象模型(DOM),我们不希望在 DOM 准备就绪之前创建对象。这就是为什么我们将使用函数构造函数形式来创建对象。

DOM,或文档对象模型,是 HTML 标记的内部表示。它是一个对象的分层树,表示 HTML 元素。

使用对象字面量的另一个问题是,其中定义的所有内容都是对象的成员,因此必须使用this关键字访问。请注意,在前面的对象字面量形式中,我们必须使用this来访问versionsetStatus()。然而,当使用构造函数创建对象时,我们可以在构造函数中定义函数和变量,而不使它们成为对象的成员。由于它们不是成员,您不必使用this关键字来访问它们。

那么使用this有什么问题呢?在您使用 JavaScript 编程一段时间后,您会意识到this关键字可能会引起很多混乱,因为它在不同的时间可能会有不同的含义。在其他语言中,比如 C#和 Java,this总是指向您所在的对象。在 JavaScript 中,this是指向调用函数的对象的指针,对于事件处理程序来说,通常是window对象。因此,我们尽量避免使用它,越少用越好。

使用构造函数的另一个优点是能够定义私有和公共方法。请注意,setStatus()方法是使用普通函数声明定义的。这将使它成为一个私有方法,只能从封闭它的对象内部访问,并且不需要使用this来调用它。另一方面,start()方法是使用this分配给对象的。这将使start()成为一个公共方法,只能从对象的实例中访问。我们将在整个 JavaScript 中使用这种范式来实现对象的私有和公共成员。

我们需要的最后一件事是一个文档准备好的事件处理程序。文档准备好的事件在页面加载完成并且 DOM 层次结构已完全构建后触发。使用 jQuery 添加此事件处理程序有两种方法。第一种更冗长的方式是您所期望的:

$(document).ready(handler);

然而,由于它可能是您需要实现的最基本和重要的事件,jQuery 提供了一种简写形式,就是这么简单:

$(handler);

这是我们的文档准备好的事件处理程序:

$(function() {
    window.app = new MyApp();
    window.app.start();
});

这是一个重要的代码片段。它定义了我们应用程序的起点。它相当于其他语言(如 C、C++、C#和 Java)中的main()函数。

在这里,我们创建了我们的主应用程序对象的一个实例,然后将其分配给一个名为app的全局变量,通过将其附加到window对象。我们将它设置为global,这样它就可以在整个应用程序中访问。最后但同样重要的是,我们调用我们的应用程序对象的start()方法来启动应用程序。

发生了什么?

我们刚刚创建了一个模板,可以用来开始编写新的应用程序,启动时间最短。它由 HTML、CSS 和 JavaScript 文件组成。在这一点上,我们的模板已经完成,我们已经拥有了开始编写新的 HTML5 应用程序所需的基础知识。

美元符号标识符

您可能已经注意到我们的 JavaScript 代码中到处都是美元符号。美元符号只不过是 jQuery 对象的别名。您可以用 jQuery 替换所有美元符号,效果是一样的,只是要多输入一些。如果您已经了解 jQuery,您可能想要跳过。否则,我将简要概述一下 jQuery。

jQuery 是一个流行的 JavaScript 库,它在最基本的层面上提供了访问和操作 DOM 的功能。它还提供了许多其他有用的功能,如事件处理、动画和 AJAX 支持。此外,它隐藏了许多不同浏览器之间的差异,因此您可以专注于编程,而不是如何使您的代码在每个浏览器中都能正常工作。它使编写 JavaScript 应用程序变得可忍受,甚至可以说是有趣的。我不会想在没有它的情况下编写 HTML5 应用程序。它对 JavaScript 来说就像 System 库对 Java 和 C#一样。

在大多数情况下,jQuery 使用与 CSS 相同的查询语法来选择元素。典型的模式是选择一个或多个元素,然后对它们执行某些操作,或者从中检索数据。因此,例如,这是一个 jQuery 选择,用于获取 DOM 中的所有div元素:

$("div")

以下查询将给出具有 ID 为main的元素:

$("#main")

与 CSS 一样,井号选择具有特定 ID 的元素,点选择具有特定类的元素。您还可以使用复合搜索条件。下一个查询将返回所有具有 ID 为main的元素的后代,并具有selected类的元素:

$(#main .selected")

在选择了一个或多个元素之后,您可以对它们执行一些操作。jQuery 选择返回一个类似数组的 jQuery 对象,但也有很多内置函数可以做各种事情,我们将在本书中逐步学习。例如,以下代码行将隐藏从前一个选择返回的所有元素(将它们的 CSS display属性设置为none):

$(#main .selected").hide()

简单而强大。那么美元符号到底是怎么回事呢?有些人认为这是 jQuery 可以使用美元符号作为别名的一种魔法。但显然,美元符号是 JavaScript 中一个有效的字符,可以作为变量或函数名称的开头。

创建我们的第一个应用程序

在本章和接下来的几章中,我们将构建一个使用 HTML5 和 CSS3 的任务列表应用程序。在开始之前,我们应该明确我们应用程序的规格,这样我们就知道我们想要构建什么。

  • 我们的任务列表应用程序应该允许用户快速输入一个或多个任务名称,并在列表中显示它们。

  • 用户应该能够通过编辑、删除或上下移动任务来轻松操作任务。

  • 应用程序应该记住输入的任务,所以当用户回到应用程序时,他们可以继续之前的工作。

  • UI 应该是响应式的,这样它可以在许多不同的设备上使用,具有不同的屏幕尺寸。

  • 我们将从简单的开始,并随着进展逐步构建。在整个过程中,我们将构建一些 JavaScript 库,可以在后续项目中使用,这样我们就可以快速上手。

行动时间-创建任务列表

现在我们已经掌握了基础知识,让我们开始任务列表应用程序。我们将称我们的应用程序为Task at Hand,或者Task@Hand以时髦一点。首先复制我们的模板文件夹,并将其重命名为taskAtHand。还要将.html.css.js文件重命名为taskAtHand。现在我们准备开始我们的第一个 HTML5 应用程序。您可以在第一章/示例 1.1中找到本节的代码。

我们需要做的第一件事是进入 HTML 文件,并在<head>元素中更改标题和 CSS 和 JS 文件的名称为taskAtHand

<head>
  <title>Task@Hand</title>
  <link href="taskAtHand.css" rel="StyleSheet" />
  <script src="img/jquery-1.8.1.min.js"></script>
  <script src="img/strong>"></script>
</head>

接下来我们转到 body。首先我们在<header>元素中更改应用程序的名称。然后进入<div id="app">元素,并添加一个文本输入字段,用户可以在其中输入任务的名称。最后,我们添加一个空列表来保存我们的任务列表。因为我们正在构建一个列表,所以我们将使用无序列表<ul>元素。

<body>
  <div id="app">
    <header>Task@Hand</header>
    <div id="main">
      <div id="add-task">
        <label for="new-task-name">Add a task</label>
        <input type="text" id="new-task-name" title="Enter a task name" placeholder="Enter a task name"/>
      </div>
      <ul id="task-list">
      </ul>
    </div>
    <footer>
    </footer>
  </div>
</body>

这是我们现在需要的所有标记。这里有一件事要指出,这是 HTML5 中的新内容。输入元素有一个新的属性叫做placeholder,它会在用户开始输入之前在字段中显示一些文本。这给用户一个提示,告诉他们应该在字段中输入什么。这对允许用户输入文本的输入元素是有效的。

让我们进入 JavaScript 文件并开始编码。我们要做的第一件事是将应用程序对象重命名为TaskAtHandApp

function TaskAtHandApp()
{
    // code not shown…
}
$(function() {
    window.app = new TaskAtHandApp();
    window.app.start();
});

注意

在 JavaScript 中的一个标准是,只有需要一个新语句(即对象构造函数)的东西才应该以大写字母开头。这有助于区分需要使用new关键字创建的内容。其他所有内容,包括变量和函数名称,都应该以小写字母开头。

当用户输入完任务名称并按下Enter键时,我们希望创建一个新的列表项元素并将其添加到列表中。我们首先需要在文本字段中添加一个事件处理程序,以便在按下键时得到通知。我们将在应用程序对象的start()方法中添加这个。

this.start = function()
{
    $("#new-task-name").keypress(function(e) {
        if (e.which == 13) // Enter key
        {
            addTask();
            return false;
        }
    })
    .focus();

    $("#app header").append(version);
    setStatus("ready");
};

首先,我们通过对其 ID 进行 jQuery 选择来获取文本字段,即new-task-name。然后,我们向该元素添加一个keypress()事件处理程序,传入一个函数,以便在每次触发事件时执行。jQuery 向事件处理程序函数传递一个参数,即keypress事件对象。事件对象包含一个名为which的字段,其中包含按下的键的字符代码。我们感兴趣的是Enter键,它的代码是13

当用户按下Enter键时,我们调用addTask()方法(下面定义),然后它返回false。我们在这里返回false的原因是告诉系统我们处理了按键事件,并且不希望它执行默认操作。一些浏览器在按下Enter键时会执行其他操作。

接下来,我们在keypress()处理程序的末尾添加另一个函数调用,将焦点设置回文本字段。此时,您可能会问自己,这是如何工作的,调用一个函数的函数?这称为函数链接,可能是 jQuery 最有用的功能之一。jQuery 的大多数方法都返回对象本身的指针,因此我们可以在一行代码中执行多个操作。

现在我们将编写addTask()方法。此方法将获取任务的名称,并将新的列表项添加到我们 HTML 中的<ul>元素中:

function addTask()
{
    var taskName = $("#new-task-name").val();
    if (taskName)
    {
        addTaskElement(taskName);
        // Reset the text field
        $("#new-task-name").val("").focus();
    }
}
function addTaskElement(taskName)
{
    var $task = $("<li></li>");
    $task.text(taskName);
    $("#task-list").append($task);
}

首先,我们使用 jQuery 的val()方法获取new-task-name文本字段的值,该方法用于获取输入字段的值。只是为了确保用户实际输入了内容,我们测试taskName变量是否为"真值",在这种情况下意味着它不是空字符串。

接下来我们调用addTaskElement()方法。在那里,我们创建一个新的<li>元素。您可以通过传入元素定义而不是选择到 jQuery 来创建一个新元素。在这种情况下,我们使用"<li></li>"来创建一个新的空列表项元素,然后将其分配给$task变量。然后,我们立即使用text()方法填充该元素的任务名称。

注意

将 jQuery 对象分配给变量时,最好的做法是以$开头的变量名,这样您就知道它引用了一个 jQuery 对象。

现在我们有了新元素,我们需要将其添加到文档的正确位置,即<ul id="task-list">元素内。这是通过选择task-list元素并调用append()方法来完成的。这将我们的新<li>元素添加到任务列表的末尾。

我们在addTask()方法中做的最后一件事是清除文本输入字段的值,并将焦点重新设置在它上面,以便用户可以立即输入另一个任务。我们在这里使用函数链接来在一条语句中完成两个操作。请注意,我们在设置和获取文本字段的值时都使用了 jQuery 的val()方法。如果传入一个值,它会设置控件的值;否则,它会返回控件的值。您会发现很多 jQuery 方法都是这样工作的。例如,text()方法将在元素内设置文本,或者如果没有传入值,则返回文本。

刚刚发生了什么?

我们创建了一个任务列表应用程序,用户可以在其中输入任务名称并构建任务列表。让我们在浏览器中打开应用程序,看看我们目前有什么:

刚刚发生了什么?

行动时间-从列表中删除任务

现在我们可以向列表中添加任务了,让我们添加删除任务的功能。为此,我们需要为列表中的每个任务添加一个删除按钮。我们将在addTaskElement()方法中添加创建按钮的代码。您可以在第一章/example1.2中找到此部分的代码。

function addTaskElement(taskName)
{
    var $task = $("<li></li>");
    var $delete = $("<button class='delete'>X</button>");
    $task.append($delete)
         .append("<span class='task-name'>" + taskName +
                 "</span>"); 
    $delete.click(function() { $task.remove(); });
}

这个方法的第一件事是创建一个带有delete类的新<button>元素。然后,它创建了列表项元素,就像我们之前做的那样,只是首先附加了删除按钮,然后附加了任务名称。请注意,我们现在将任务名称包装在一个<span class='task-name'>元素中,以帮助我们跟踪它。最后,我们为删除按钮添加了一个点击事件处理程序。要从列表元素中删除任务,我们只需调用remove()方法将其从 DOM 中删除。哇,它就消失了!

行动时间-在列表中移动任务

顺便说一句,让我们为列表中的任务添加上移和下移按钮。为此,我们将向addTaskElement()方法添加一些代码。首先,我们需要创建move-upmove-down按钮,然后将它们与delete按钮一起添加到列表元素中。

function addTaskElement(taskName)
{
    var $task = $("<li></li>");
    var $delete = $("<button class='delete'>X</button>");
    var $moveUp = $("<button class='move-up'>^</button>");
    var $moveDown = $("<button class='move-up'>v</button>");
    $task.append($delete)
        .append($moveUp)
        .append($moveDown)
        .append("<span class='task-name'>" + taskName +
                "</span>");
    $("#task-list").append($task);

    $delete.click(function() { $task.remove(); });
    $moveUp.click(function() {
        $task.insertBefore($task.prev());
    });
    $moveDown.click(function() {
        $task.insertAfter($task.next());
    });
}

当单击向上移动向下移动按钮时,它使用prev()next()方法找到前一个或下一个任务元素。然后它使用 jQuery 的insertBefore()insertAfter()方法将任务元素向上或向下移动到任务列表中。

刚刚发生了什么?

我们为每个任务元素添加了按钮,以便可以删除它们或将它们上下移动到列表的顺序中。我们学会了如何使用 jQuery 的remove()insertBefore()insertAfter()方法来修改 DOM。

HTML 模板

正如您所看到的,我们的addTaskElement()方法有点混乱。我们在 JavaScript 中以编程方式创建了一堆元素,并手动将它们添加到 DOM 中。如果我们只需在 HTML 文件中定义任务元素的结构,并使用它来创建新任务,那不是更容易吗?好吧,我们可以,而且我们将这样做。在本节中,我们将创建一个 HTML 模板,以便轻松创建新任务。

注意

有很多 JavaScript 库可以用来实现 HTML 模板,它们具有很多强大的功能,但对于我们的应用程序,我们只需要一些简单的东西,所以我们将自己实现。

行动时间-实施模板

首先,我们需要一个放置模板标记的地方。因此,我们将在 HTML 文件中的app元素之外添加一个<div id="templates">,并给它一个hidden类。正如您可能还记得的,从我们的 CSS 中,hidden类为元素设置displaynone。这将隐藏模板标记,使用户永远看不到它。现在让我们定义模板:

<div id="app">
  …
</div>
<div id="templates" class="hidden">
 <ul id="task-template">
 <li class="task">
 <div class="tools">
 <button class="delete" title="Delete">X</button>
 <button class="move-up" title="Up">^</button>
 <button class="move-down" title="Down">v</button>
 </div>
 <span class="task-name"></span>
 </li>
 </ul>
</div>

我不知道你怎么想,但对我来说,这比在代码中构建任务元素要容易得多。这样做也更容易阅读、添加和维护。你可能已经注意到,还添加了一些其他元素和属性,如果要以编程方式添加,那将是非常痛苦的。在按钮周围放置了一个<div class="tools">,将它们组合在一起,并为每个按钮添加了一个title属性,它将显示为浏览器中的工具提示。

请注意,我们在任务元素中没有使用任何 ID 属性。相反,我们使用类属性来标识不同的元素。这样做的原因是,ID 唯一地标识一个元素,因此它应该只被使用一次。如果我们创建一个具有一堆 ID 的模板并开始复制它,我们将会有重复的 ID。如果您多次使用 ID,那么 ID 对于唯一标识元素就毫无价值了。

在继续之前,我们需要为按钮及其容器在 CSS 中添加一些样式。我们希望按钮保持与任务名称在同一行,但它们的容器<div>是一个块级元素。让我们将它更改为inline-block,这样它就不会断行:

#task-list .task .tools
{
    display: inline-block;
}

我们还希望从按钮中移除边框,使它们都是相同的大小,并移除填充和边距,使其更加紧凑:

#task-list .task .tools button
{
    margin: 0;
    padding: 0;
    width: 1.25em;
    height: 1.25em;
    border: none;
}

所以,现在我们有了一个任务模板,我们该怎么办呢?这里再次用到了 jQuery。我们所要做的就是获取模板元素,并使用clone()方法来复制它。然后将复制的内容插入到 DOM 中的任何位置。下面是我们新的addTaskElement()方法的样子:

function addTaskElement(taskName)
{
    var $task = $("#task-template .task").clone();
 $("span.task-name", $task).text(taskName);

    $("#task-list").append($task);

    $("button.delete", $task).click(function() {
        $task.remove();
    });
    $("button.move-up", $task).click(function() { 
        $task.insertBefore($task.prev());
    });
    $("button.move-down", $task).click(function() {
        $task.insertAfter($task.next());
    });
}

我们用一行代码替换了所有创建元素的代码行,它获取了任务模板元素,并使用clone()方法对其进行复制。第二行将任务名称填入了我们设置好的<span class="task-name">元素中。如果你仔细看,你会发现我们现在在选择时向 jQuery 传递了第二个参数。这告诉 jQuery 只搜索task元素的后代元素。否则它会在整个文档中找到每个任务名称元素并更改它们。在选择按钮时,我们也是用相同的方法来识别它们,使用它们的类名来连接点击事件处理程序。

刚刚发生了什么?

我们实现了一个 HTML 模板,允许我们删除所有动态生成任务元素的代码,并用 jQuery 的clone()方法来替换它。这使得我们更容易在 HTML 中更新和维护元素结构,而不是在 JavaScript 中。

行动时间-编辑列表中的任务

到目前为止,我们有一个任务列表,可以向其中添加任务,从中删除任务,并更改任务的顺序。让我们添加一些功能,允许用户更改任务的名称。当用户点击任务名称时,我们将把它更改为文本输入字段。为此,我们需要在任务元素模板中的任务名称后面添加一个文本输入字段:

<li class="task">
    <div class="tools">
        <button class="delete" title="Delete">X</button>
        <button class="move-up" title="Up">^</button>
        <button class="move-down" title="Down">v</button>
    </div>
    <span class="task-name"></span>
    <input type="text" class="task-name hidden"/>
</li>

我们给它一个task-name的类来标识它,并且还添加了隐藏类,所以默认情况下它是不可见的。我们只想在用户点击任务名称时显示它。所以让我们进入 JavaScript 文件,并在addTaskElement()方法的末尾添加一个<span>元素的事件处理程序:

$("span.task-name", $task).click(function() {
    onEditTaskName($(this));
});

让我们来分解一下。首先,我们获取了任务元素的子元素,类名为task-name的 span。然后,我们添加了一个点击事件处理程序,调用onEditTaskName()方法。onEditTaskName()方法以<span>元素的引用作为参数。当你在 jQuery 事件处理程序函数中时,this指的是事件的源元素。因此,$(this)创建了一个包装<span>元素的 jQuery 对象,这样我们就可以在其上调用 jQuery 方法:

function onEditTaskName($span)
{
    $span.hide()
        .siblings("input.task-name")
        .val($span.text())
        .show()
        .focus();
}

尽管onEditTaskName()方法在技术上只包含一行代码,但其中发生了很多事情。它使用函数链接在一个紧凑的语句中完成了很多工作。首先,它隐藏了<span>元素。然后,它通过查找<span>元素的兄弟元素,即类名为task-name<input>元素,获取了文本输入字段。然后,它使用 jQuery 的text()方法从<span>元素中获取任务名称并设置文本字段的值。最后,它使文本字段可见,并将焦点设置在它上面。

当用户点击任务名称时,它似乎会在他们眼前变成一个可编辑的文本字段。现在我们只需要一种方法,在用户完成编辑名称后将其改回来。为此,我们将以下内容添加到addTaskElement()方法的末尾:

$("input.task-name", $task).change(function() {
    onChangeTaskName($(this));
});

这与任务名称点击事件处理程序的工作方式相同。我们将调用一个名为onChangeTaskName()的方法,并传递一个包装文本字段输入元素的 jQuery 对象:

function onChangeTaskName($input)
{
    $input.hide();
    var $span = $input.siblings("span.task-name");
    if ($input.val())
    {
        $span.text($input.val());
    }
    $span.show();
}

首先,我们隐藏文本输入字段,然后获取任务名称<span>元素并将其存储在一个变量中。在更新名称之前,我们检查用户是否确实输入了内容。如果是,我们就更新任务名称。最后,我们调用show()来使任务名称再次可见。用户会看到文本字段再次变成静态文本。

最后还有一件事要做。如果用户在不更改任何内容的情况下点击字段,我们将不会收到更改事件,并且文本字段将不会被隐藏。但是,当发生这种情况时,我们可以获得blur事件。因此,让我们向文本字段添加一个blur事件处理程序,以隐藏它并显示静态任务名称<span>元素:

$("input.task-name", $task).change(function() {
    onChangeTaskName($(this));
})
.blur(function() {
 $(this).hide().siblings("span.task-name").show();
});

发生了什么?

我们在任务模板中添加了一个文本字段,当用户点击任务名称时,它会显示出来,以便他们可以编辑任务名称。当任务名称文本字段更改时,它会更新任务名称标签。

发生了什么?

保存应用程序的状态

现在我们有一个非常实用的任务列表应用程序。我们可以添加、删除和移动任务。甚至可以编辑现有任务的名称。只有一个问题。由于我们动态向 DOM 添加了所有这些任务元素,所以下次用户返回应用程序时,它们将不会存在。我们需要一种方法来保存任务列表,这样用户下次返回应用程序时,任务仍将存在。否则,这有什么意义呢?

HTML5 刚好有这样的东西-Web Storage。Web Storage 是 HTML5 中的一个新 API,允许您在客户端上存储信息。过去,客户端上唯一可用的存储方式是 cookie。但是 cookie 并不是在客户端存储数据的好方法。它们仅限于几千字节的数据,并且还包含在 HTTP 请求中,增加了它们的大小。

另一方面,Web Storage 允许我们保存更多的数据(在大多数浏览器中最多可达 5MB),并且不会增加 HTTP 请求的内容。它由两个具有相同接口的全局对象组成,localStoragesessionStorage。两者之间唯一的区别是存储在sessionStorage中的数据在关闭浏览器时会消失,而存储在localStorage中的数据不会。由于我们希望在会话之间保存应用程序数据,因此我们只会使用localStorage

数据以键/值对的形式存储。您可以使用setItem()方法设置值,并使用getItem()检索值,如下所示:

localStorage.setItem("myKey", "myValue");
var value = localStorage.getItem("myKey") // returns "myValue"

如果尝试使用在localStorage中不存在的键获取值,它将返回null。如果尝试向localStorage添加值并且内存不足,将会收到QUOTA_EXCEEDED_ERR异常。

localStorage有一些限制:

  • 用户不一定可以访问存储在其中的任何内容(尽管可以通过浏览器的开发人员工具访问)。

  • 它由域中的所有应用程序共享,因此存储限制在所有应用程序之间共享。这也意味着在所有应用程序中,所有键都必须是唯一的。如果两个应用程序使用相同的键,它们最终会覆盖彼此的数据。

  • 键和值都必须是字符串。如果要存储的内容不是字符串,必须先将其转换为字符串。当您从存储中取出该值时,必须将其从字符串转换回您期望的类型。

幸运的是,JavaScript 有一个叫做 JSON 的实用对象,它提供了将值转换为字符串和从字符串转换回值的函数。JSON代表JavaScript 对象表示法,是以可读格式表示值的标准。它是 JavaScript 中对象文字表示法的子集,因此如果您知道如何定义对象文字,您就知道 JSON。JSON 对象有两种方法; JSON.stringify()将值转换为字符串,JSON.parse()将字符串转换回值。

行动时间-创建一个 localStorage 包装器

为了帮助解决localStorage的一些限制,我们将创建一个名为AppStorage的对象,它提供了对localStorage对象的包装。AppStorage对象将帮助我们避免键冲突,并提供一种简单的方法来存储非字符串值。让我们在一个名为appStorage.js的新文件中定义这个对象,这样我们可以在所有应用程序中重用它。您可以在第一章/示例 1.3中找到这一部分的代码。

function AppStorage(appName)
{
    var prefix = (appName ? appName + "." : "");

构造函数以应用程序名称作为参数。下一行设置了一个名为prefix的私有变量,它将用于为所有键添加应用程序名称前缀,以避免冲突。如果未提供appName参数,则不会使用前缀,这对于在所有应用程序之间共享数据可能很有用。如果我们将"myApp"传递给构造函数,我们应用程序的所有键将以"myApp"开头(例如,myApp.settingsmyApp.data)。

这一行创建了一个公共变量,用于确定浏览器是否支持localStorage。它只是检查全局localStorage对象是否存在:

this.localStorageSupported = (('localStorage' in window) && window['localStorage']);

让我们首先实现setValue()方法,用于在本地存储中设置值:

this.setValue = function(key, val)
{
    if (this.localStorageSupported)
        localStorage.setItem(prefix + key, JSON.stringify(val));
    return this;
};

setValue()方法接受一个键和一个要放入本地存储的值。它在键前面添加应用程序前缀,以避免命名冲突。由于您只能将字符串放入本地存储,我们使用JSON.stringify()方法将值转换为字符串,然后调用localStorage.setItem()进行存储。

现在让我们实现getValue()方法来从localStorage中获取值:

this.getValue = function(key)
{
    if (this.localStorageSupported)
        return JSON.parse(localStorage.getItem(prefix + key));
    else return null;
};

getValue()方法接受一个键,将前缀添加到它,并返回与之在localStorage中关联的字符串值。它使用JSON.parse()将从localStorage中检索到的字符串解析为值。如果键不存在或不支持本地存储,这些方法将返回null

我们需要的下一步是删除项目的方法。让我们实现removeValue()方法来做到这一点。它只是调用localStorage.removeItem(),传入带前缀的键:

this.removeValue = function(key)
{
    if (this.localStorageSupported)
        localStorage.removeItem(prefix + key);
    return this;
};

在这个过程中,让我们添加一个方法来删除应用程序的所有键。localStorage确实有一个clear()方法,但这会完全清空您域中的localStorage,而不仅仅是我们应用程序的值。因此,我们需要获取我们应用程序的所有键,然后逐个删除它们:

this.removeAll = function()
{
    var keys = this.getKeys();
    for (var i in keys)
    {
        this.remove(keys[i]);
    }
    return this;
};

removeAll()方法引用了一个getKeys()方法。这个方法将返回应用程序的所有键名数组。我们将制作getKeys()方法,这样用户也可以传入一个过滤函数,以便根据自己的标准进一步过滤结果:

this.getKeys = function(filter)
{
    var keys = [];
    if (this.localStorageSupported)
    {
        for (var key in localStorage)
        {
            if (isAppKey(key))
            {
                // Remove the prefix from the key
                if (prefix) key = key.slice(prefix.length);
                // Check the filter
                if (!filter || filter(key))
                {
                    keys.push(key);
                }
            }
        }
    }
    return keys;
};
function isAppKey(key)
{
    if (prefix)
    {
        return key.indexOf(prefix) === 0;
    }
    return true;
};

这个方法通过循环遍历localStorage中的所有键来工作,你可以通过实现使用in关键字的循环来获取对象或数组中的所有键,它调用私有方法isAppKey()来确定键是否属于我们的应用程序。如果是,它会从键中移除应用程序前缀。最后,如果没有定义过滤器或过滤器函数返回true,则将键添加到要返回的键数组中。

私有的isAppKey()方法以键名作为参数,并在键属于我们的应用程序时返回true。如果未定义应用程序名称前缀,则没有要检查的内容。否则,我们检查键是否以应用程序前缀开头。

我们需要编写最后一个公共方法。contains()方法将确定与键关联的值是否存在。它只是尝试获取与键关联的值并检查是否存在:

this.contains = function(key)
{
    return this.get(key) !== null;
};

刚发生了什么?

我们创建了一个名为AppStorage的包装对象,它包装了 HTML5localStorage对象。它封装了与localStorage交互和保存 JavaScript 对象的所有行为。现在我们可以将任何类型的数据保存到localStorage中,然后检索它。

行动时间-存储任务列表

让我们回到任务列表应用程序。首先在我们的 HTML 文件中添加对appStorage.js的引用:

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

接下来,我们将在TaskAtHandApp对象中添加一个私有的appStorage变量,并将应用程序的名称传递给构造函数:

function TaskAtHandApp()
{
    var version = "v1.3",
        appStorage = new AppStorage("taskAtHand");
    //…
}

现在让我们添加一个私有方法,可以在每次更改时调用以保存任务:

function saveTaskList()
{
    var tasks = [];
    $("#task-list .task span.task-name").each(function() {
        tasks.push($(this).text())
    });
    appStorage.setValue("taskList", tasks);
}

saveTaskList()方法查找列表中每个任务的任务名称<span>元素。然后调用 jQuery 的each()方法,用于迭代由选择找到的元素。each()方法接受一个函数作为参数,并为每个元素调用该函数。我们的函数只是将任务名称推送到任务数组的末尾。然后我们调用appStorage.setValue(),告诉它使用键"taskList"存储任务数组。

现在我们需要在列表更改时每次调用saveTaskList()。这将在addTask()onChangeTaskName()方法中进行。此外,在addTaskElement()中,我们需要在deletemove-upmove-down的按钮点击事件处理程序中调用它。为了使事情更容易阅读,让我们通过将内联处理程序代码移出到私有方法中进行一些重构:

function addTaskElement(taskName)
{
    // code not shown…
    $("button.delete", $task).click(function() {
        removeTask($task);
    });
    $("button.move-up", $task).click(function() {
        moveTask($task, true);
    });
    $("button.move-down", $task).click(function() {
        moveTask($task, false);
    });
    //…
}
function removeTask($task)
{
    $task.remove();
    saveTaskList();
}
function moveTask($task, moveUp)
{
    if (moveUp)
    {
        $task.insertBefore($task.prev());
    }
    else
    {
        $task.insertAfter($task.next());
    }
    saveTaskList();
}

现在让我们在 Chrome 中看一下这个。继续添加一些任务,然后按F12打开开发者工具。如果您点击窗口顶部的资源图标,您将在左窗格中看到资源列表。展开本地存储项目,然后单击其下的项目。您应该在右窗格中看到存储在本地存储中的域中的所有数据:

行动时间-存储任务列表

Key列中,您应该找到taskAtHand.taskList,并在Value列中看到代表我们任务列表的 JSON,正如您可能记得的那样,它存储为数组。

现在继续玩一下。尝试添加、删除、编辑和移动任务。您应该在每次更改后看到本地存储中的值更新。我们现在有一个持久的任务列表。

当使用file://协议时,一些浏览器不允许访问localStorage(也就是说,您直接从文件系统打开文件到浏览器)。如果您的localStorage不起作用,请尝试在另一个网络浏览器中使用,或者通过诸如 IIS 或 Apache 之类的网络服务器访问您的应用程序。

行动时间-加载任务列表

我们已经保存了任务列表。但如果我们无法加载它,那对我们来说没有太大用处。所以让我们添加一个名为loadTaskList()的新私有方法:

function loadTaskList()
{
    var tasks = appStorage.getObject("taskList");
    if (tasks)
    {
        for (var i in tasks)
        {
            addTaskElement(tasks[i]);
        }
    }
}

此方法调用appStorage.getValue(),传入我们任务列表的键。然后检查确保我们得到了一些东西。如果是这样,它会遍历数组中的所有任务,为每个任务调用addTaskElement()方法。

唯一剩下的事情是在start()方法中添加一个调用loadTaskList(),这样在应用程序启动时加载列表:

this.start = function()
{
    // Code not shown…
    loadTaskList();
    setStatus("ready");
};

刚才发生了什么?

在我们的任务列表应用程序中,我们使用AppStorage对象将任务列表存储到localStorage中,每当有变化时,然后在用户返回时检索它并构建任务列表。

尝试一下

编写一个本地存储浏览器应用程序,用于查看域中每个应用程序的数据。在顶层,列出所有应用程序。当您深入到应用程序时,它会显示所有本地存储项。当您单击一个项目时,它会显示该项目的内容。

快速测验

Q1. HTML5 应用程序的三个基本组件是什么?

  1. jQuery、模板和本地存储

  2. 文档、对象和模型

  3. 标签、元素和属性

  4. HTML、CSS 和 JavaScript

Q2. 本地存储可以存储哪些类型的数据?

  1. 任何类型

  2. 对象

  3. 数字

  4. 字符串

总结

就是这样。我们现在已经完成了我们的第一个 HTML5 应用程序。一个任务列表,我们可以添加、删除和编辑任务。任务是持久的,所以当用户返回应用程序时,他们可以从他们离开的地方继续。在本章中,我们涵盖了以下概念:

  • 我们学会了构建 HTML5 应用程序及其三个组件,HTML、CSS 和 JS 的基础知识。

  • 我们创建了一个应用程序模板,以帮助我们快速启动新应用程序。

  • 我们学会了如何使用 jQuery 来访问和操作 DOM。

  • 我们学会了如何初始化一个 Web 应用程序并处理用户交互。

  • 我们学会了如何创建 HTML 模板,以便我们可以在标记中定义可重用的元素结构。

  • 我们学会了如何使用 Web Storage 来保存和检索应用程序的状态,并创建了一个AppStorage对象来帮助我们访问localStorage

现在我们已经学会了创建 HTML5 应用程序的基础知识,并且我们的任务列表应用程序正在运行,我们准备开始一些样式设计。在下一章中,我们将学习一些新的 CSS3 功能,这些功能将使我们的应用程序看起来和任何桌面应用程序一样好,甚至更好。

第二章:让我们时尚起来

“在风格问题上,随波逐流;在原则问题上,坚如磐石。”- 托马斯·杰斐逊

在本章中,我们将戴上我们的平面设计师帽子,进行一些样式设计。现在我们在第一章中创建的任务列表应用程序可以工作,但看起来像是 2005 年的东西。我们将使用 CSS3 使其现代化,并使用最新的 CSS3 功能使其看起来干净、现代。我们将添加圆角、阴影、渐变和过渡效果。我们还将使用 CSS 精灵为任务列表按钮添加一些图像。

在本章中,我们将学习:

  • 在 CSS3 中指定颜色的新方法和设置透明度

  • 如何向元素添加圆角

  • 如何向元素和文本添加阴影

  • 如何在元素背景中绘制渐变

  • 新的 CSS3 背景属性

  • 如何在应用程序中使用 CSS 精灵

  • 如何使用过渡和变换为用户界面添加效果

  • 如何动态加载样式表以创建可定制的用户界面

CSS3 概述

CSS3 不是 HTML5 规范的一部分,但它是编写 HTML5 应用程序的一个重要部分。CSS3 与 HTML5 并行开发,并提供许多新的样式,使网页的外观和功能比以往更好。曾经是 Photoshop 的领域,如渐变和阴影,现在可以通过样式轻松添加。使用这些新的图形功能将使您的应用程序看起来现代,并为您的应用程序增添特色。

CSS 的一些最令人兴奋的新增功能之一是能够向元素添加渐变和阴影。圆角是每个人都希望在网页中拥有的功能,曾经是许多 HTML hack 的领域,现在可以轻松添加。从未有过如此简单地使网页和应用程序看起来好,而无需下载额外的图像和代码来支持它们。

您可以在chapter2/css3-examples/css3-examples.html中看到所有以下 CSS3 样式的示例。

CSS3 颜色

在开始新效果之前,让我们讨论一下颜色。CSS3 有新的定义颜色的方式,允许您设置透明度并以 HSL 格式定义颜色。当然,您仍然可以使用旧的十六进制值标准、任何 CSS 颜色名称和rgb()指定符。

已添加了一个新的rgba()指定符,允许设置颜色的 alpha 或不透明度。与rgb()一样,前三个参数设置红色、绿色和蓝色的数量,取值范围为0255。第四个参数 alpha 是一个浮点值,范围从01,其中0是完全透明,1是完全不透明。以下声明了一个红色背景颜色,透明度为 50%:

background-color: rgba(255, 0, 0, 0.5);

尽管大多数浏览器支持rgba(),但最好通过在其前面以rgb()格式定义颜色来为不支持它的浏览器指定一个回退,如下所示:

background-color: rgb(255, 0, 0);
background-color: rgba(255, 0, 0, 0.5);

这是一个重叠三个元素的示例,所有元素的 alpha 值均为0.5,颜色分别为红色、绿色和蓝色(是的,您可以绘制圆形元素,我们将在下一节中看到)。

CSS3 颜色

除了 RGB 颜色,CSS3 还支持HSL颜色,它代表色调饱和度亮度。HSL 基于一个颜色轮,边缘是全彩色,中心渐变为灰色。现在将该轮延伸为一个圆柱体,底部是黑色,顶部是白色,中间是全彩色。这就是 HSL 颜色的理论。

它是使用hsl(h, s, l)指定的。色调是从0360的值,对应于颜色轮上的角度。0是红色,120是绿色,240是蓝色,360又回到红色。饱和度是颜色的百分比,其中0%是完全灰色,100%是全彩色。亮度是亮度的百分比,其中0%是黑色,50%是全彩色,100%是白色。您可以像rgb()一样指定它,也可以不带 alpha 值,如下所示:

hsl(240, 100%, 50%);
hsla(240, 100%, 50%, 0.5);

大多数人不会考虑 HSL 中的颜色,但它确实存在,以防您想要使用它。如果您想尝试一下,可以在hslpicker.com找到一个不错的 HSL 选择器。

圆角

我们将要看的第一个 CSS3 效果是圆角,因为在 CSS3 之前这是一个非常受欢迎的功能。过去,如果您想要圆角,只有一些非最佳的解决方案可用。您可以加载四个图像,每个角一个,然后添加一些额外的标记来使它们对齐(并尝试使其在所有浏览器中工作)。或者使用多个div标签来“绘制”圆角边框的某种黑客方式。或者其他半打方法之一。最终,它们都不是很好的解决方案。那么为什么我们要如此努力地在 CSS3 之前使圆角边框起作用呢?因为人们被它们吸引,它们似乎让您的设计看起来更自然。

使用 CSS3 的新border-radius属性非常容易地向元素添加圆角。如果您希望每个角具有相同的边框半径,只需给出一个值,如下所示:

border-radius: 0.5em;

如果要将边框的每个角设置为不同的半径,也可以这样做。值按照 CSS 属性的标准顺序,顺时针从左上角开始:左上,右上,右下和左下。

border-radius: 1px 4px 8px 12px;

您可以设置一个、两个、三个或四个值。一和四是不言自明的。

  • 如果设置了两个值,则第一个值适用于左上和右下,第二个值适用于右上和左下。因此,它是相对的角。

  • 如果设置了三个值,则第二个值适用于右上和左下。第一个适用于左上,第三个适用于右下。

您还可以单独定义每个角的半径,如下所示:

border-top-left-radius: 1px;
border-top-right-radius: 4px;
border-bottom-right-radius: 8px;
border-bottom-left-radius: 12px;

注意

想要创建圆形或椭圆形?将border-radius值设置为50%

Rounded corners

阴影

在 CSS3 中,向元素和文本添加阴影非常简单。使用阴影使某些元素真正脱颖而出,并为您的 UI 赋予更自然的外观。有许多选项可用于添加阴影,例如大小、位置和颜色。阴影不一定总是在元素和文本后面;它们也可以为它们提供框架、突出显示和添加效果。

盒子阴影

除了圆角,您还可以使用新的 CSS3 box-shadow属性为元素添加阴影。box-shadow属性接受一些参数,告诉它如何绘制阴影:

box-shadow: h-offset v-offset blur-radius spread-radius color;

以下是参数的解释:

  • h-offset:阴影的水平偏移。负值将阴影放在元素的左侧。

  • v-offset:阴影的垂直偏移。负值将阴影放在元素上方。

  • blur-radius:确定模糊量;数字越高,模糊越多(可选)。

  • spread-radius:阴影的大小。如果为零,则与模糊大小相同(可选)。

  • color:阴影的颜色(可选)。

  • inset:添加inset以将阴影从外部更改为内部(可选)。

注意

您可以使用box-shadow属性为元素添加除阴影之外的一些有趣效果。通过将offset值设置为零并调整模糊和扩展(请参见前两个示例),您可以为元素设置内部或外部发光。

Box shadows

文本阴影

除了盒子阴影,CSS3 还支持使用text-shadow属性添加文本阴影。它的工作方式几乎与box-shadow相同,并且使用几乎相同的参数:

text-shadow: h-offset v-offset blur-radius color;

box-shadow一样,您可以产生一些有趣的效果,例如发光文本:

Text shadows

行动时间-样式行动

让我们在任务列表应用程序中充分利用border-radiusbox-shadow效果。首先,我们将在页面上居中显示任务列表。然后我们将在每个任务周围放一个有圆角和阴影的框。让我们打开taskAtHand.css并进行一些更改。您可以在chapter2/example2.1中找到此部分的代码。

首先,我们将更改包含task-name文本字段和任务列表的<div id="main">元素的样式。让我们给这个部分设置一个最小宽度为9em,最大宽度为25em。我们不希望任务列表变得太宽或太小,以便更容易阅读。这将为我们提供一个反应式布局的开端。我们还将将上下边距设置为1em,将左右边距设置为auto以使其在页面上居中。

注意

一个反应式布局是根据其环境调整其布局以适应其显示的设备的布局。通过使用反应式布局,您可以确保您的应用程序在任何设备上都能正常工作和显示良好,从手机到桌面设备。

#main
{
    max-width: 25em;
    min-width: 9em;
    margin: 1em auto;
}

我们还想通过将其width属性设置为98%来将task-name文本输入字段的样式更改为占据主区域的整个宽度。这将为文本框的边框留出一些余地;100%会让它爆炸:

#task-name
{
    font-size: 1em;
    display: block;
    width: 98%;
}

现在让我们来处理task-list项目。我们将给它们设置背景颜色,圆角和阴影。我们将使阴影变黑并且给它一些透明度,这样背景颜色就会透过来。我们还将把position属性设置为relative,这样我们就可以在其中定位任务按钮(见下一个屏幕截图):

#task-list .task
{
    position: relative;
    list-style: none;
    padding: 0.25em;
    margin: 0.25em;
    background-color: beige;
    border-radius: 4px;
    box-shadow: 2px 2px 3px rgba(0, 0, 0, 0.6);
}

让我们还在任务按钮周围添加一个边框来对它们进行分组,并使用绝对定位将它们移到task元素的右上方。我们也可以在这里将其浮动到右侧,但是绝对定位可以给我们更多的控制:

#task-list .task .tools
{
    position: absolute;
    top: 0.25em;
    right: 0.25em;
    border: 1px solid black;
    border-radius: 2px;
}

注意

在使用绝对定位时,元素相对于最近的已定位的父元素进行定位。在这种情况下,那将是task元素。这就是为什么我们将其position属性设置为relative的原因。

刚刚发生了什么?

如果你在浏览器中查看应用程序,你会注意到我们的任务列表看起来更加自然。阴影确实让任务项目从页面中凸显出来,并赋予它们深度。这使它们成为应用程序的亮点。通过将任务按钮移到右侧并且远离,我们真的让任务名称脱颖而出:

刚刚发生了什么?

调整浏览器窗口大小,看看列表的反应。这是相同的布局调整为更小的样子,就像你在手机或其他移动设备上看到的一样:

刚刚发生了什么?

背景

有许多新的样式用于设置元素的背景样式。现在您可以轻松地绘制渐变而不使用图像。您可以改变背景图像的大小和原点,甚至在背景中使用多个图像。

渐变为元素绘制了从一种颜色到一种或多种其他颜色的背景。它们为您的页面增添了深度,并增添了更加自然的外观。您可以在 CSS3 中指定两种不同类型的渐变:线性径向。线性渐变是线性的。它们从一种颜色直线流向另一种颜色。径向渐变从中心点向外扩散。

线性渐变

线性渐变是使用background属性上的linear-gradient指定符来定义的。对于最简单的形式,你可以使用我们在颜色部分讨论过的任何color指定符来指定起始和结束颜色,它会从元素的顶部到底部绘制渐变。以下是从红色到蓝色的渐变:

background: linear-gradient(#FF0000, #0000FF);

尽管渐变目前受到几乎所有浏览器的支持,但您仍然必须使用特定于浏览器的前缀才能使其工作。这意味着至少要指定四次才能覆盖大多数浏览器。请记住,始终将非专有版本指定为最后一个,如下面的 CSS 片段所示,这样它将在可用时覆盖特定于浏览器的版本:

background: -webkit-linear-gradient(#FF0000, #0000FF);
background: -moz-linear-gradient(#FF0000, #0000FF);
background: -ms-linear-gradient(#FF0000, #0000FF);
background: linear-gradient(#FF0000, #0000FF);

如果要使渐变从顶部开始,可以指定第一个参数,该参数可以是要从其开始的侧面的名称或旋转的量。侧面有topbottomleftright。您可以指定从-360deg360deg的度数,或从-6.28rad6.28rad的弧度。0left相同。正数逆时针旋转,负数顺时针旋转。以下是从leftright绘制渐变的示例:

background: linear-gradient(left, #FF0000, #0000FF);

以下是以45度角绘制的渐变,即从左下角开始:

background: linear-gradient(45deg, #FF0000, #0000FF);

如果愿意,您还可以添加多于两个的颜色停止。以下是从红色到蓝色到绿色的45度角渐变:

background: linear-gradient(45deg, #FF0000, #0000FF, #00FF00);

以下是这些代码片段的显示方式:

线性渐变

径向渐变

径向渐变在其使用的参数上几乎与线性渐变相同。默认情况下,从元素的中心到边缘绘制渐变:

background: radial-gradient(#FF0000, #0000FF);

您还可以指定位置,例如使用预定义位置或从顶部左侧角的偏移点作为渐变的中心:

background: radial-gradient(top, #FF0000, #0000FF);

以下是以距离左上角20像素和20像素处为中心绘制的渐变:

background: radial-gradient(20px 20px, #FF0000, #0000FF);

您还可以为径向渐变添加多于两个的颜色停止。以下是从红色到蓝色到绿色的渐变,中心位于距左侧20像素和下方20像素的位置:

background: radial-gradient(20px 20px, #FF0000, #0000FF, #00FF00);

以下是这些代码片段的显示方式:

径向渐变

您可以指定许多其他设置来实现一些有趣的渐变效果,但这超出了本书的范围。如果发现自己创建自己的渐变太难,可以在www.colorzilla.com/gradient-editor/找到一个出色的渐变生成器。

背景图片

您可以将背景图像的大小设置为固定像素量或元素区域的百分比。图像将被缩放以适应指定的区域。background-size属性接受两个值:水平大小和垂直大小。如果要使背景图像填充元素的整个背景,可以使用以下方法:

background-size: 100% 100%;

您可以通过用逗号分隔它们来指定多个背景图像。列表中的第一张图像将绘制在顶部,最后一张将绘制在底部。以下是绘制两个背景图像的示例:

background: url(bg-front.png),
            url(bg-back.png);

背景图片

还有一个新的background-origin属性,用于确定背景图像的绘制位置。可能的值如下:

  • content-box:仅在元素的内容区域中绘制背景图像

  • padding-box:将背景图像绘制到元素的填充区域

  • border-box:将背景图像一直绘制到元素的边框

以下是一个示例:

background-origin: content-box;

以下是输出:

背景图片

CSS 精灵

我们接下来要讨论的概念是 CSS 精灵。这种技术对于 CSS3 来说并不新鲜,但在编写 HTML5 应用程序时,了解如何使用它是很重要的。CSS 精灵允许您将应用程序中的所有图像放入单个图像文件中,然后使用 CSS 将单个图像切片到元素中。这种技术节省了下载多个图像所需的时间和网络资源。如果您的应用程序有很多小图像,这种技术尤其有用。

要实现 CSS 精灵,将所有图像放入单个图像文件中,称为精灵表。然后按照以下步骤将精灵表中的图像放入页面上的元素中:

  1. 使元素与要显示的图像大小相同。

  2. 将元素的背景图像设置为精灵表图像。

  3. 调整精灵表的背景位置,使要查看的图像位于元素的左上角。

让我们看一个例子。以下精灵表有 16 张图片,每张图片宽 10 像素,高 10 像素。首先,我们将元素的widthheight属性设置为10像素。接下来,我们将背景图像设置为sprite-sheet.png精灵表。如果我们现在停下来,我们只会在我们的元素中看到第一张图片。

但是我们想要在我们的元素中显示第七张图片。因此,我们需要将精灵表的背景位置向左移动 20 像素,向上移动 10 像素。您必须使用负偏移来将正确的图像放置在位置上,因为您正在移动背景图像,而不是元素:

#seven
{
    Width: 10px;
    height: 10px;
    background-image: url(sprite-sheet.png);
    background-position: -20px -10px;
}

这是结果:

CSS 精灵

注意

将其视为在网页上切割一个洞,然后在洞后面滑动精灵表,直到正确的图像显示在洞中。

行动时间 - 添加渐变和按钮图像

让我们利用我们对渐变和背景图像的了解,使我们的应用程序看起来更有趣。首先,我们将在我们的任务列表应用程序的背景中添加一个渐变。我们将在<div id="app">元素上添加一个线性渐变。它将从顶部开始,渐变为底部的深蓝色。请注意,我们保留旧的背景颜色作为不支持渐变的浏览器的回退:

#app
{
    margin: 4px;
    background-color: #bbc;
    background: -webkit-linear-gradient(top, #bbc, #558);
    background: -moz-linear-gradient(top, #bbc, #558);
    background: -ms-linear-gradient(top, #bbc, #558);
    background: linear-gradient(top, #bbc, #558);
}

这就是它的样子:

行动时间 - 添加渐变和按钮图像

现在让我们使用 CSS 精灵将图像添加到任务列表应用程序中的按钮。我们需要删除、向上移动和向下移动的图像。我们的按钮将是 16x16 像素,因此我们的图像也需要是相同的大小。由于我们有三张图片,我们将创建一个 48 像素宽、16 像素高的精灵表。我们将把名为icons.png的精灵表图像文件放入images文件夹中。

行动时间 - 添加渐变和按钮图像

现在让我们打开taskAtHand.css并添加样式,将图像从精灵表中提取到按钮中。首先,我们将更改适用于所有任务按钮的样式,将大小设置为 16x16 像素,并将背景图像设置为我们的精灵表。这样,我们只需要指定一次精灵表图像,它就会应用到我们所有的按钮上:

#task-list .task .tools button
{
    margin: 0;
    padding: 0;
    border: none;
    color: transparent;
 width: 16px;
 height: 16px;
 background: url(images/icons.png);
}

现在我们所有的按钮都将使用icons.png作为它们的背景。我们现在所要做的就是设置每个按钮的背景位置,使它们与正确的图像对齐:

#task-list .task .tools button.delete
{
    background-position: 0 0;
}
#task-list .task .tools button.move-up
{
    background-position: -16px 0;
}
#task-list .task .tools button.move-down
{
    background-position: -32px 0;
}

刚刚发生了什么?

现在在浏览器中查看应用程序。我们添加了渐变,所以它不再那么沉闷和单调。现在它看起来现代而时尚。我们使用 CSS 精灵向按钮添加图像,从一个精灵表图像中提取图像。有了真正的按钮图标,这样看起来不是更好吗?

刚刚发生了什么?

过渡

现在我们有一个相当不错的 UI,但是我们可以通过一些过渡效果使其变得更好。CSS3 过渡在元素样式改变时为元素添加动画效果。例如,如果我们改变元素的大小,它将逐渐从较小的大小变为较大的大小,从而为用户提供视觉反馈。当事物逐渐改变时,它比突然出现在页面上的东西更容易引起我们的注意。

CSS3 的transition属性允许我们在元素上指定过渡。它的格式如下:

transition: property duration timing-function delay

以下是参数的解释:

  • property:要添加过渡的 CSS 属性。例如,widthcolor。使用all将过渡应用于所有属性。

  • duration:过渡所需的时间长度。例如,0.5s需要半秒钟来完成过渡。

  • timing-function:确定过渡在持续时间内的进展方式:

  • linear:从开始到结束的速度相同

  • ease:开始缓慢,然后加速,然后结束缓慢

  • ease-in:开始缓慢然后加速

  • ease-out:开始快然后减慢

  • ease-in-out:先缓慢,然后加速

  • cubic-bezier(): 如果你不喜欢预定义的函数,你可以构建自己的

  • delay: 开始过渡之前等待的时间。

cubic-bezier函数接受四个参数,这些参数是从01的数字。以下产生与ease函数相同的效果:

transition: all 1s cubic-bezier(0.25, 0.1, 0.25, 1);

构建自己的cubic-bezier函数并不是大多数人可以凭空做到的。如果你想探索创建自己的时间函数,请访问cubic-bezier.com/

与渐变一样,过渡得到了广泛的支持,但在声明时仍应使用特定于浏览器的前缀:

-webkit-transition: all 1s ease;
-moz-transition: all 1s ease;
-o-transition: all  1s ease;
transition: all 1s ease;

应用过渡的最简单方法是与 CSS 的hover选择器结合使用。当用户将鼠标移动到元素上时,以下内容将使元素的背景颜色从白色渐变到蓝色,用时 0.25 秒:

#some-element
{
    background-color: White;
    transition: all 0.25s ease;
}
#some-element:hover
{
    background-color: Blue;
}

变换

CSS3 变换提供了更复杂的效果。有 2D 和 3D 变换可用。我们将在这里讨论一些 2D 变换。变换可以与过渡一起使用,提供一些有趣的效果。这是transform属性的基本形式:

transform: function();

有一些不同的 2Dtransform函数。我们首先看的是translate()。它将一个元素从当前位置移动到一个新位置。它以 x 和 y 位置作为参数。你可以使用负值向上和向左移动。以下将使一个元素向右移动10像素,向上移动25像素:

transform: translate(10px, -25px);

rotate()函数按给定的角度旋转元素。旋转量可以用度或弧度来指定。使用负值逆时针旋转,正值顺时针旋转:

transform: rotate(45deg);

scale()函数通过某个因子调整元素的大小。它接受一个或两个参数。如果只提供一个参数,它将按该量进行缩放。如果指定了两个参数,它将分别缩放水平和垂直轴。以下示例将元素的宽度加倍,高度减半:

transform: scale(2, 0.5);

我们将看一下skew()函数。这个函数扭曲或拉伸一个元素。它接受两个参数,即旋转 x 和 y 轴的量。角度的指定方式与rotate()函数相同:

transform: skew(45deg, 10deg);

变换还需要特定于浏览器的前缀:

-webkit-transform: rotate(45deg);
-moz-transform: rotate(45deg);
-o-transform: rotate(45deg);
-ms-transform: rotate(45deg);
transform: rotate(45deg);

以下是变换在浏览器中的样子:

Transforms

行动时间-效果在行动

让我们给任务列表添加一些效果。首先,我们将添加选择列表中任务的能力。当点击一个任务时,它将增大并获得一个有颜色的边框,这样就很容易看出它被选中了。我们还将为任务添加悬停效果,这样当用户将鼠标移动到一个任务上时,任务的操作按钮就会显示出来。当鼠标移出任务时,按钮将淡出。你可以在chapter2/example2.2中找到这一部分的代码。

我们需要做的第一件事是回到taskAtHand.js,并在addTaskElement()方法中创建task元素后添加一个click事件处理程序:

$task.click(function() { onSelectTask($task); });

当点击一个任务时,它调用onSelectTask()方法。在这个方法中,我们将通过给它一个selected类名来标记一个task元素为选定。我们还将从先前选定的任务元素中删除selected类:

function onSelectTask($task)
{
    if ($task)
    {
        // Unselect other tasks
        $task.siblings(".selected").removeClass("selected");
        // Select this task
        $task.addClass("selected");
    }
}

现在让我们在taskAtHand.css中为选定的任务添加样式。我们将增加填充以使元素更大,添加边框以突出显示它,并改变背景颜色:

#task-list .task.selected
{
    padding: 0.6em 0.5em;
    border: 2px solid orange;
    border-radius: 6px;
    background-color: white;
}

这很好,但我们可以通过添加过渡来使它更好。我们将在.task类中添加transition属性。它将在 0.25 秒内平稳地改变所有属性。当用户选择一个任务时,这将为用户提供一些良好的视觉反馈:

#task-list .task
{
    /* Not shown... */
    -webkit-transition: all 0.25s ease;
    -moz-transition: all 0.25s ease;
    -o-transition: all 0.25s ease;
    transition: all 0.25s ease;
}

在此期间,让我们再添加一个过渡效果。我们将隐藏任务操作按钮,直到用户将鼠标移动到任务上或选择任务。为此,我们只需要添加一些额外的 CSS。首先,我们将通过将其opacity属性设置为0来隐藏任务按钮的容器元素,使其变为透明。然后,我们添加与之前相同的transition属性:

#task-list .task .tools
{
    position: absolute;
    top: 0.25em;
    right: 0.25em;
    border: 1px solid black;
    border-radius: 2px;
 opacity: 0;

 -webkit-transition: all 0.25s ease;
 -moz-transition: all 0.25s ease;
 -o-transition: all 0.25s ease;
 transition: all 0.25s ease;
}

现在我们为task元素添加一个hover选择器,将opacity属性设置为1,使其不透明。这个选择器和过渡一起,将使任务按钮在用户悬停在任务上时出现淡入效果。我们还添加了一个选择器,使任务按钮在选择任务时显示出来(以下片段中的第二行):

#task-list .task:hover .tools,
#task-list .task.selected .tools
{
    opacity: 1;
}

在 CSS3 之前,您可以使用 jQuery 的fadeIn()fadeOut()方法以及一些鼠标事件来使用 JavaScript 做同样的事情,但这需要更多的代码。

刚刚发生了什么?

我们在任务列表中添加了一些 CSS3 过渡效果,使任务项目按钮淡入淡出,并在单击时使选定的任务项目变大。我们已经看到,只需几行 CSS 代码,我们就可以为我们的应用程序添加一些不错的效果。现在我们的任务列表看起来是这样的,Task 2被选中:

刚刚发生了什么?

动态样式表

让我们为我们的应用程序添加一个新功能,允许用户选择颜色方案或主题,以便他们可以自定义应用程序的外观和感觉。我们可以通过动态加载 CSS 文件来改变网页的外观,以覆盖默认样式表中的现有样式。为了实现这一点,我们将在应用程序中添加一个主题的下拉列表供用户选择。当他们改变主题时,它将改变样式表,从而改变页面的颜色。

行动时间-添加主题选择器

首先,我们需要一个放置主题选择器的地方。因此,让我们在taskAtHand.html中的任务列表应用程序的标记中添加一个工具栏。我们将它插入在<header><div id="main">元素之间。工具栏将包含一个<label>和一个<select>下拉列表。列表将包含四种不同的颜色主题:bluegreenmagentared。您可以在chapter2/example2.3中找到此部分的代码:

<div id="app">
  <header>Task@Hand</header>
 <div id="toolbar">
 <label for="theme">Theme</label>
 <select id="theme" title="Select theme">
 <option value="blue">Blue</option>
 <option value="green">Green</option>

 <option value="magenta">Magenta</option>
 <option value="red">Red</option>
 </select>
 </div>
  <div id="main">

现在让我们设计工具栏。我们将使字体比页面的其他部分稍微小一些,并将背景颜色设置为黑色,并带有一些透明度,以便它后面的颜色透过来:

#toolbar
{
    padding: 0.25em;
    font-size: 0.8em;
    color: WhiteSmoke;
    background-color: rgba(0, 0, 0, 0.4);
}

接下来,我们必须实现不同的主题。因此,让我们创建一些新的 CSS 文件,每个主题一个文件。我们将把它们放在一个名为themes的文件夹中,以便将它们分组在一起。CSS 文件将与<option>值具有相同的名称:blue.cssgreen.cssmagenta.cssred.css。让我们来看一下green.css

#app
{
    background-color: #bcb;
    background: -webkit-linear-gradient(top, #bcb, #585);
    background: -moz-linear-gradient(top, #bcb, #585);
    background: -ms-linear-gradient(top, #bcb, #585);
    background: linear-gradient(top, #bcb, #585);
}
#app>header,
#app>footer
{
    background-color: #060;
}

从顶部开始,我们覆盖app元素的背景渐变,使它们成为绿色,而不是蓝色。我们还将headerfooter元素改为绿色。其他 CSS 文件将与此文件完全相同,只是它们的颜色会有所不同。

现在让我们在 HTML 文件的<header>元素中添加一个样式表<link>元素,用于主题 CSS 文件。由于蓝色主题是默认的,我们将设置它加载blue.css

<link href="taskAtHand.css" rel="StyleSheet" />
<link id="theme-style" href="themes/blue.css" rel="StyleSheet" />

请注意,我们在基本样式表之后包含主题样式表。这将允许我们覆盖默认样式。还要注意,我们给<link>元素一个ID属性,这样我们以后就可以在 JavaScript 中访问它。

我们需要添加的其余代码在taskAtHand.js中。首先,我们将在TaskAtHand.start()方法中为主题选择器添加一个change事件处理程序:

$("#theme").change(onChangeTheme);

当用户选择新主题时,它将调用onChangeTheme()私有方法:

function onChangeTheme()
{
    var theme = $("#theme>option").filter(":selected").val();
    setTheme(theme);
    appStorage.setValue("theme", theme);
}

这个方法通过获取其<option>元素并使用 jQuery 的:selected选择器在filter()方法内找到选定的选项,从列表中获取所选选项。然后调用setTheme()方法,接下来我们将实现。最后,我们将所选主题保存到localStorage,这样下次用户返回应用程序时就可以设置它。

setTheme()方法接受主题名称作为参数。它获取<link id="theme-style">元素,并将其href属性更改为新样式表的 URL:

function setTheme(theme)
{
    $("#theme-style").attr("href", "themes/" + theme + ".css");
}

当这发生时,页面将加载新的样式表,并将其样式应用于现有样式。就像这样,页面的颜色发生了变化。

等等,我们还没有完成。还记得我们是如何将主题保存到localStorage的吗?现在当用户返回我们的应用程序时,我们需要将其取出。我们将创建一个loadTheme()方法来做到这一点:

function loadTheme()
{
    var theme = appStorage.getValue("theme");
    if (theme)
    {
        setTheme(theme);
        $("#theme>option[value=" + theme + "]")
            .attr("selected","selected");
    }
}

这个方法从localStorage获取主题名称。如果找到了一个,它就调用setTheme()来设置它。然后通过在列表中找到具有其值为主题名称的<option>,并在其上设置selected属性,来选择该主题。最后一件事是从start()方法中添加对loadTheme()的调用,然后我们就完成了。

注意

我们的主题样式更改非常简单,但是您可以完全改变应用程序的外观和感觉使用这种技术。

刚刚发生了什么?

我们添加了一个主题选择器,可以更改主题样式表,这会导致页面使用不同的颜色来绘制背景。我们将所选主题保存到本地存储中,因此当用户返回应用程序时,设置将被记住。

填充窗口

在我们离开 CSS 章节之前,还有一件事情我们需要重新设计。让我们使应用程序填满整个窗口的空间。现在随着列表的增长,背景渐变也在增长,页脚也在下移。如果渐变覆盖整个窗口,页脚始终位于底部会更好。

行动时间-扩展应用程序

我们可以使用绝对定位来填充浏览器窗口。让我们为<div id="app">元素的样式添加以下内容:

#app
{
 position: absolute;
 top: 0;
 bottom: 0;
 left: 0;
 right: 0;
 overflow: auto;
    /* Code not shown… */
}

首先,它将元素的定位设置为绝对定位,这样我们就可以将元素的位置设置为我们想要的位置。然后我们将所有的position属性设置为0。这样就可以拉伸元素,使其填满整个窗口空间。最后,我们将overflow属性设置为auto。这将使滚动条出现,并且如果任务列表超出窗口的高度,渐变会延伸到窗口底部以下。

我们还需要重新定位页脚,使其固定在窗口底部。我们可以通过将position设置为absolutebottom设置为0来实现。请注意,我们没有将right设置为0,因此页脚不会占据整个宽度。否则,它可能会干扰任务列表:

#app>footer
{
    position: absolute;
    bottom: 0;
    /* Code not shown… */
}

刚刚发生了什么?

我们扩展了主应用程序元素,使其占据整个浏览器窗口的空间,并将页脚移动到底部。让我们看看我们的应用程序现在在浏览器中的样子:

刚刚发生了什么?

试试看

想出并实现更多的主题。使用我们学到的一些 CSS3 特性,如径向渐变、背景图片,甚至一些盒子阴影来制作一些看起来有趣的主题。

快速测验

Q1. 渐变可以有多少个颜色停?

  1. 一个

  2. 任意数量

Q2. 过渡是什么作用?

  1. 将 CSS 属性从一个值过渡到另一个值

  2. 将元素从一种类型过渡到另一种类型

  3. 从一个类过渡到另一个类

  4. 从一个视图过渡到另一个视图

总结

在本章中,我们学习了一些新的 CSS3 功能,可以用来使您的应用程序更加突出,并为用户提供大量的视觉反馈。我们通过向任务元素添加圆角和阴影,并向任务工具按钮添加图像来更新了我们的任务列表应用程序。我们为背景添加了渐变和主题选择器,允许用户更改颜色方案。我们还添加了一些过渡效果,使变化看起来更加自然。

在本章中,我们涵盖了以下概念。

  • 如何在 CSS3 中定义带有透明度的颜色

  • 如何给元素添加圆角

  • 如何向元素和文本添加阴影

  • 如何创建线性和径向渐变

  • 如何使用 CSS3 过渡和变换来创建视觉效果

  • 如何使用 CSS 精灵来减少应用程序的网络印记

  • 如何动态加载样式表

  • 如何使您的应用程序填满整个窗口

在我们继续之前,让我给你一个警告。仅仅因为 CSS3 拥有所有这些出色的效果,并不意味着你必须在应用程序中全部使用它们。每个文本并不都需要阴影,你不需要让你的背景具有彩虹般的渐变,也不需要让每个元素旋转 30 度。谨慎地使用这些效果将使您的应用程序看起来更专业;过度使用将使它们看起来滑稽。

在下一章中,我们将通过为每个任务添加一个详细部分,使用一些新的 HTML5 输入类型,将我们的任务列表应用程序提升到一个新的水平。我们还将学习如何使用自定义数据属性将数据模型绑定到输入元素。

第三章:细节决定成败

“希望了解世界的人必须从具体细节中了解它。”

—赫拉克利特

本章主要介绍新的 HTML5 输入类型以及如何使用 JavaScript 与其进行交互。在第一章中,手头的任务,我们创建了一个任务列表应用程序,在第二章中,时尚起来,我们使用了新的 CSS3 样式对其进行了美化。在本章中,我们将继续改进它,通过添加新的 HTML5 输入类型的任务详细信息部分。然后,我们将使用自定义数据属性自动将视图中的值绑定到应用程序中的数据模型。我们还将添加一些 jQuery 动画,使 UI 过渡更加流畅。

我们将在本章中学习以下主题:

  • 新的 HTML5 输入类型及其提供的好处

  • 自定义数据属性及其用途

  • 如何使用自定义数据属性将数据模型绑定到输入元素

  • 使用 jQuery 动画方法隐藏和显示元素

  • 使用定时器将保存到 localStorage 的内容排队

HTML5 输入类型

HTML5 带来了一系列新的输入类型。这些新类型旨在提供格式化、验证,有时还提供选择器。对于触摸设备,其中一些类型为键盘提供了不同的按键。并非所有新的输入类型都得到了所有浏览器的支持。幸运的是,如果浏览器不支持某种类型,它将只是将其显示为普通文本字段。不幸的是,如果不支持的类型只显示为文本字段,您不能依赖浏览器提供正确格式化的数据。因此,如果您要使用它们,请确保您有备用方案。

以下是一些更有用的新输入类型,以及 Chrome 支持的其中一些类型的图像。

注意

请参见第三章/输入类型/input-types.html中的示例。

颜色

color输入类型用于选择颜色。单击时通常会显示颜色选择器。值是十六进制颜色指示符(例如,#FF0000)。目前这个控件的支持范围有限,因此请谨慎使用。

<input type="color" value="#FF0000"/>

颜色

日期

date输入类型用于选择日期。单击时通常会显示日期选择器。值是 yyyy-mm-dd 格式的日期字符串(例如,2013-01-23)。您还可以以相同的日期格式指定minmax属性,以限制日期范围:

<input type="date" value="2013-01-23" min="2013-01-01"/>

日期

电子邮件

email输入类型用于输入电子邮件地址。它的外观和行为类似于标准文本字段。在触摸设备上,键盘通常会更改以提供电子邮件符号,如@符号和.com

<input type="email" value="foo@bar.com"/>

数字

number输入类型用于输入数字。通常会显示带有上下按钮(微调控件)的形式,单击时会按step的量更改值。在触摸设备上,键盘可能会更改为数字键盘。您可以使用一些属性来限制字段:

  • min:指定允许的最小值

  • max:指定允许的最大值

  • step:指定单击上下微调按钮时值更改的量

<input type="number" value="23" min="0" max="100" step="1"/>

数字

范围

range输入类型用于从一系列值中选择一个值。这几乎与number输入类型相同,只是通常显示为滑块控件。它具有与number输入类型相同的属性。

<input type="range" value="20" min="0" max="100" step="10"/>

范围

时间

time输入类型用于选择时间。单击时,它可能会显示时间选择器,或者您可以使用微调器设置时间。值是 24 小时格式的时间字符串,格式为 hh:mm:ss(例如,13:35:15)。

<input type="time" value="13:35:15"/>

时间

URL

url输入类型用于输入 URL。与email类型一样,触摸设备通常会显示为优化输入 URL 的键盘。

<input type="url" value="http://www.worldtreesoftware.com"/>

数据列表

除了这些新的输入类型,HTML5 中还添加了一个新的<datalist>表单元素。它用于为文本字段添加一个下拉提示列表。当用户开始在文本字段中输入时,与正在输入的字母匹配的所有列表选项将显示在字段下的下拉菜单中。用户可以选择其中一个选项来自动填写字段。

你可以通过在<datalist>元素上设置一个 ID,并在<input>元素的list属性中引用它,将<datalist>元素与文本字段关联起来。

<input type="text" list="color-list"/>
<datalist id="color-list">
    <option value="Red"/>
    <option value="Orange"/>
    <option value="Yellow"/>
    <option value="Green"/>
    <option value="Blue"/>
    <option value="Purple"/>
</datalist>

数据列表

注意

由于新输入类型的实现在这个时候还不够完善,所以在使用它们时要小心。如果不支持使用number字段不会引起太多问题;用户仍然可以在文本字段中输入数字。但是如果像color字段这样的东西不被支持,它会显示为一个文本字段。在这种情况下,你的用户愿意输入十六进制代码的颜色吗?

自动聚焦

HTML5 输入元素还有一个有用的补充。新增了一个autofocus属性,用于在页面首次加载时设置焦点在特定的<input>元素上。以前我们在应用程序中通过调用 jQuery 的focus()方法来为<input id="new-task-name">元素设置焦点。在 HTML5 中,我们可以通过添加autofocus属性来实现相同的功能。

<input type="text" autofocus id="new-task-name".../>

任务详细信息

让我们在我们的任务列表应用程序中充分利用一些新的输入类型。目前我们只有一个任务名称输入字段。我们对此无能为力。所以让我们为每个任务添加一些字段,让用户可以定义更多关于它们的细节。你可以在Chapter 3\example3.1中找到这一部分的源代码。

行动时间 - 添加任务详细信息

我们将为每个任务添加以下新字段:

  • 开始日期:任务应该开始的日期。输入类型为date

  • 截止日期:任务应该完成的日期。输入类型为date

  • 状态:下拉列表<select>,选项为未开始已开始已完成

  • 优先级:下拉列表<select>,选项为正常

  • % 完成:输入类型为number,有效范围为0100

让我们在taskAtHand.html中的任务模板标记中定义这些字段。每个任务的详细信息将显示在任务名称下的一个部分中。我们的模板现在看起来像以下的代码片段:

<li class="task">
    <span class="task-name"></span>
    <input type="text" class="task-name hidden"/>
    <div class="tools">
        <button class="delete" title="Delete">X</button>
        <button class="move-up" title="Up">^</button>
        <button class="move-down" title="Down">v</button>
    </div>
    <div class="details">
 <label>Start date:</label>
 <input type="date"/><br/>
 <label>Due date:</label>
 <input type="date"/><br/>
 <label>Status:</label>
 <select>
 <option value="0">None</option>
 <option value="1">Not Started</option>
 <option value="2">Started</option>
 <option value="3">Completed</option>
 </select><br/>
 <label>Priority:</label>
 <select>
 <option value="0">None</option>
 <option value="1">Low</option>
 <option value="2">Normal</option>
 <option value="3">High</option>
 </select><br/>
 <label>%&nbsp;Complete:</label>
 <input type="number min="0" max="100" step="10" value="0"/>
 </div>
</li>

首先,我们添加了一个新的<div class="details">元素来包含新的详细字段。这样我们可以将详细信息与任务名称分开以便以不同的样式进行设置。然后我们向其中添加了标签和字段。请注意,对于% 完成,我们设置了number字段的minmax属性,以限制数字在 0 到 100 之间。

接下来我们需要为详细信息部分设置样式。我们将给它一个灰色的背景和圆角。我们将所有标签设置为相同的宽度,并将它们右对齐,以便所有输入字段对齐。然后我们将状态优先级<select>元素设置为固定宽度,以便它们也对齐。

#task-list .task .details
{
    display: block;
    background-color: gray;
    color: white;
    border-radius: 4px;
    margin-top: 0.5em;
    padding: 0.25em;
    overflow: auto;
}
#task-list .task .details label
{
    width: 8em;
    text-align: right;
    display: inline-block;
    vertical-align: top;
    font-size: 0.8em;
}
#task-list .task .details select
{
    width: 8em;
}

刚刚发生了什么?

我们使用一些新的 HTML5 输入类型为我们的任务添加了一个任务详细信息部分。以下截图显示了现在的任务项目是什么样子的,有一个详细信息部分:

刚刚发生了什么?

行动时间 - 隐藏任务详细信息

看起来不错,但它也占用了很多空间。如果列表中的每个任务都这么长,它很快就会滚动到页面之外,我们将无法很好地看到任务列表的概述。由于任务详细信息是可选字段,我们可以通过不显示详细信息来使我们的列表更加紧凑,直到用户想要查看它们为止。我们将通过隐藏详细信息部分并在任务名称旁边添加一个切换按钮来实现这一点,以便在单击时显示或隐藏详细信息。

首先让我们在我们的任务模板中的任务名称旁边添加一个切换详细信息按钮,并给它一个名为toggle-details的类。

<li class="task">
    <button class="toggle-details">+</button>
    <span class="task-name"></span>
    <!—- Not shown… -->
</li>

现在让我们在 JavaScript 代码中实现切换按钮。首先,在addTaskElement()方法中为切换按钮添加一个单击事件处理程序,该处理程序调用toggleDetails()方法:

$("button.toggle-details", $task).click(function() {
    toggleDetails($task);
});

然后我们实现toggleDetails()方法:

function toggleDetails($task)
{
    $(".details", $task).slideToggle();
    $("button.toggle-details", $task).toggleClass("expanded");
}

toggleDetails()方法使用了一些我们尚未见过的新的 jQuery 方法。它使用slideToggle()来切换任务详情的可见性,并使用toggleClass()来切换按钮上的expanded类。toggleClass()方法会在元素没有该类时向元素添加一个类,并在元素有该类时将其删除。

slideToggle()方法是一个动画函数,用于切换元素的可见性。它通过向下滑动的方式使元素可见,将其下面的元素推下。要隐藏元素,它会将其向上滑动,缩小直到隐藏。还有一个用于淡入淡出元素的方法,称为fadeToggle()。但是,当元素在变得可见时,滑动提供了更平滑的过渡,因为它会将其他元素推开。

注意

一般来说,当元素在变得可见时,滑动效果看起来更好,因为它会将下面的元素推下。它还适用于类似菜单的行为。当您使一个元素变得可见并显示在其他元素的顶部时,淡入通常看起来最好。

现在让我们为按钮添加一些样式。当然,我们想要一些漂亮的图标,就像我们的其他任务按钮一样,所以让我们将它们添加到我们的精灵表文件icons.png中。我们需要一个图像来显示任务属性折叠时的情况,另一个图像用于显示它们展开时的情况。让我们为这两个图标创建第二行图像。

进行操作-隐藏任务详情

我们在样式表中需要做的第一件事是将详情的display设置为none,以便它们默认情况下是隐藏的:

#task-list .task .details
{
 display: none;
    /* Not shown… */
}

然后我们为toggle-details按钮添加样式。由于我们使用与任务工具按钮相同的精灵表,因此我们将通过将其添加到 CSS 选择器中,使用相同的样式来为我们的新按钮添加样式。然后,我们将添加选择器以使用背景位置偏移将图像放入按钮中:

#task-list .task .tools button,
#task-list .task button.toggle-details
{
    /* Not shown… */
    background: url(images/icons.png);
}
#task-list .task button.toggle-details
{
    background-position: 0 -16px;
}
#task-list .task button.toggle-details.expanded
{
    background-position: -16px -16px;
}

我们的toggle-details图像的垂直偏移量为-16px,因为它们位于精灵表的第二行。请注意,第二个图像与expanded类匹配。当详情可见时,我们将expanded类添加到按钮上。

刚刚发生了什么?

我们为每个任务添加了一个切换按钮,当单击时隐藏或显示任务详情。在浏览器中打开它,看看我们现在有什么。您可以打开和关闭任务详情,并且它们会平稳地展开和关闭。相当酷。

刚刚发生了什么?

自定义数据属性

HTML5 的另一个新功能是自定义数据属性。自定义数据属性允许您将自定义数据存储为 DOM 中任何元素的属性。您只需使用data-作为属性名称的前缀。名称应全部为小写字母。您可以为属性分配任何字符串值。

例如,假设我们有一个产品列表,我们想要存储有关产品的信息,例如产品 ID 和类别。我们只需向相关元素添加data-product-iddata-category属性:

<ul id="product-list">
  <li data-product-id="d1e0ddde" data-category="widgets">
    Basic Widget
  </li>
  <li data-product-id="e6b2c03f" data-category="widgets">
    Super Widget
  </li>
</ul>

现在我们有了自定义属性,我们可以使用 JavaScript 从元素中提取自定义数据,并在我们的应用程序中使用它。jQuery 碰巧有一个专门为此目的设计的data()方法。您只需给出自定义属性的名称,减去data-,它就会返回与其关联的值。

继续上一个示例,假设我们希望允许用户单击列表中的产品,然后对其进行一些处理。以下的setSelectedProduct()方法使用data()方法从被单击的元素中提取产品 ID 和类别:

$("#product-list li").click(function() {
    var $product = $(this);
    var productId = $product.data("product-id");
    var category = $product.data("category");
    // Do something...
});

使用自定义属性进行数据绑定

自定义数据属性的一个很好的用途是实现数据绑定。数据绑定允许我们将 DOM 中的用户控件映射到数据模型中的字段,以便在用户更改它们时自动更新。使用这种技术,我们可以消除许多无聊的重复代码,这些代码只是处理事件并将视图中的字段映射到模型中的字段。

注意

有一些出色的数据绑定库可用于 JavaScript,包括Backbone.jsKnockout。我们将在这里实现自己简单的数据绑定,以学习如何使用自定义属性。如果你发现自己正在构建一个大量数据的应用程序,你可能会考虑使用其中一个库。

行动时间-构建数据模型

在我们开始实现数据绑定之前,我们需要一个数据模型进行绑定。如果你还记得,我们只是将任务名称保存到localStorage。我们的数据模型只是一个字符串数组。现在每个任务都有多个详细字段,我们需要一些更实质的东西来保存所有这些数据。你可以在Chapter 3\example3.2中找到本节的源代码。

让我们从为我们的数据模型定义一个任务对象开始。我们将创建一个新文件taskList.js来放置它。

function Task(name)
{
    this.name = name;
    this.id = Task.nextTaskId++;
    this.created = new Date();
    this.priority = Task.priorities.normal;
    this.status = Task.statuses.notStarted;
    this.pctComplete = 0;
    this.startDate = null;
    this.dueDate = null;
}
// Define some "static variables" on the Task object
Task.nextTaskId = 1;
Task.priorities = {
    none: 0,
    low: 1,
    normal: 2,
    high: 3
};
Task.statuses = {
    none: 0,
    notStarted: 1,
    started: 2,
    completed: 3
};

从头开始,我们的构造函数接受一个参数-任务名称。它用于设置对象中的名称字段。每个任务都有一个唯一的任务 ID,每次创建任务时都会递增。其余成员设置为默认值。

我们将Task.nextTaskId字段附加到Task对象构造函数,以跟踪下一个唯一任务 ID 应该是什么。这样做可以让我们定义我们在具有类的语言中定义静态或类变量的内容,比如 Java 或 C#(在这些语言中,它们使用静态变量定义)。nextTaskId字段在更改时将保存到localStorage,以便我们知道用户返回应用程序时我们离开的位置。

注意prioritystatus使用枚举。我们将这些实现为静态对象(因为 JavaScript 没有枚举),附加到Task构造函数。

我们需要的下一件事是一个列表,用于存储Task对象。为了更容易管理这段代码,我们将创建一个TaskList对象,它基本上是一个数组的包装器。它提供了添加、删除和获取任务的方法:

function TaskList(tasks)
{
    tasks = tasks || [];

构造函数接受一个可选参数,即Task对象的数组。构造函数的第一行检查是否传入了一个数组。如果没有,则使用空方括号([])创建一个新的空数组。

注意

在 JavaScript 中,逻辑或运算符(||)可以充当空值合并运算符。如果操作数是“真值”,它将返回左操作数;否则返回右操作数。在这种情况下,真值意味着传入了tasks参数并且不是nullundefined。这是定义默认值的非常有用的范例。

现在我们添加一个公共的getTasks()方法,它简单地返回数组。我们以后需要访问它来保存任务:

    this.getTasks = function()
    {
        return tasks;
    };

接下来我们添加一个公共的addTask()方法,它接受一个Task对象并将其追加到数组的末尾:

    this.addTask = function(task)
    {
        tasks.push(task);
        return this;
    };

公共的removeTask()方法以任务 ID 作为参数,并从列表中删除相关的任务:

    this.removeTask = function(taskId)
    {
        var i = getTaskIndex(taskId);
        if (i >= 0)
        {
            var task = tasks[i];
            tasks.splice(i, 1);
            return task;
        }
        return null;
    };

它通过调用getTaskIndex()获取任务的索引,然后使用array.splice()方法从tasks数组中删除它。getTaskIndex()方法是一个私有方法,它以任务 ID 作为参数,并通过数组搜索找到具有该 ID 的任务。如果找到任务,则返回它。否则返回-1

    function getTaskIndex(taskId)
    {
        for (var i in tasks)
        {
            if (tasks[i].id == taskId)
            {
                return parseInt(i);
            }
        }
        // Not found
        return -1;
    }

接下来是公共的getTask()方法。它以任务 ID 作为参数,并使用getTaskIndex()方法来查找它。它返回相关的Task对象,如果不存在则返回null

    this.getTask = function(taskId)
    {
        var index = getTaskIndex(taskId);
        return (index >= 0 ? tasks[index] : null);
    };

我们要添加的最后一个公共方法称为each()。它将callback函数的引用作为参数。它循环遍历任务数组,并对数组中的每个任务执行callback函数。此方法可用于遍历列表中的所有任务:

    this.each = function(callback)
    {
        for (var i in tasks)
        {
            callback(tasks[i]);
        }
    };
}

行动时间-实现绑定

让我们回到 HTML 文件中的任务模板,并添加一些自定义数据属性。我们将为所有任务详细信息的<input><select>元素添加自定义属性。数据属性的名称将是data-field,属性值将是元素在Task对象中匹配的字段的名称。我们稍后将在 JavaScript 中使用这些属性来将 DOM 元素和数据模型连接在一起:

<div class="details">
    <label>Start date:</label>
    <input type="date" data-field="startDate"/><br/>
    <label>Due date:</label>
    <input type="date" data-field="dueDate"/><br/>
    <label>Status:</label>
    <select data-field="status">
        <!— options removed... -->
    </select><br/>
    <label>Priority:</label>
    <select data-field="priority">
        <!— options removed... -->
    </select><br/>
    <label>%&nbsp;Complete:</label>
    <input type="number" data-field="pctComplete"
        min="0" max="100" step="10" value="0"/>
</div>

现在我们有了一个数据模型,我们需要进入taskAtHand.js中的TaskAtHandApp对象,并更新它以使用该模型。首先,我们将添加一个taskList变量,并将其初始化为TaskList对象的实例:

function TaskAtHandApp()
{
    var version = "v3.2",
        appStorage = new AppStorage("taskAtHand"),
 taskList = new TaskList();

然后,我们将进入addTask()方法,并添加代码来创建一个新的Task对象,并将其添加到任务列表中。这也是在将nextTaskId值递增后将其保存到localStorage中的地方:

function addTask()
{
    var taskName = $("#new-task-name").val();
    if (taskName)
    {
 var task = new Task(taskName);
 taskList.addTask(task);
 appStorage.setValue("nextTaskId", Task.nextTaskId);
        addTaskElement(task);
        saveTaskList();
        // Reset the field
        $("#new-task-name").val("").focus();
    }
}

请注意,我们还更改了addTaskElement()方法的参数,以传入Task对象。因此,让我们更新addTaskElement()方法,以将Task对象作为参数而不是任务名称:

function addTaskElement(task)
{
    var $task = $("#task-template .task").clone();
 $task.data("task-id", task.id);
    $("span.task-name", $task).text(task.name);

在 DOM 中创建新任务元素后,我们使用名为task-id的自定义数据属性在其上设置任务 ID。这是通过 jQuery 的data()方法完成的,该方法将数据属性名称和值作为参数。接下来,我们将任务名称设置到task.name字段的<span>属性中。

现在我们将实现数据绑定的第一部分。下面的代码块使用了我们之前添加到标记中的数据属性,将Task对象的值设置到详细部分中关联的<input><select>元素中:

    // Populate all of the details fields
    $(".details input, .details select", $task).each(function() {
        var $input = $(this);
        var fieldName = $input.data("field");
        $input.val(task[fieldName]);
    });

它的工作原理如下:

  1. 首先,它查找任务元素内的所有<input><select>元素。

  2. 然后调用 jQuery 的each()方法,用于遍历所选元素集,传入一个callback函数。

  3. callback函数中,this指向当前元素。因此,我们首先将元素包装在 jQuery 对象中。

  4. 然后,我们使用data()方法获取data-field自定义属性的值,这是与元素关联的Task对象中字段的名称。

  5. 最后,我们将用户控件的值设置为Task对象中字段的值。我们使用方括号从Task对象中获取值。请记住,在 JavaScript 中,object["field"]object.field是相同的。

注意

您可以将使用方括号访问对象字段视为类似于在 C#或 Java 中使用反射在运行时动态访问对象中的值。

现在,我们需要添加代码来实现双向绑定。每当用户更改表单控件的值时,我们希望自动将其保存回数据模型。因此,让我们为每个详细表单控件添加一个 change 事件处理程序:

$(".details input, .details select", $task).change(function() {
    onChangeTaskDetails(task.id, $(this));
});

这调用onChangeTaskDetails()方法,传入任务 ID 和更改的表单控件元素,该元素包装在 jQuery 对象中。让我们实现该方法:

function onChangeTaskDetails(taskId, $input)
{
    var task = taskList.getTask(taskId)
    if (task)
    {
        var fieldName = $input.data("field");
        task[fieldName] = $input.val();
        saveTaskList();
    }
}

让我们分解一下,看看它是如何工作的:

  1. 首先,它从具有指定 ID 的任务列表中获取Task对象。

  2. 确保我们得到了一个对象(你永远不知道,所以总是检查),我们从元素的data-field属性中获取Task对象字段名称。

  3. 然后,我们将Task对象上的字段值设置为表单控件元素的值,再次使用方括号动态访问它。

  4. 最后,我们调用saveTaskList()来提交对localStorage的更改。

提醒一下,我们需要重写saveTaskList()方法来保存我们的新TaskList对象。这很容易。我们只需调用任务列表的getTasks()方法来获取Task对象的数组。然后我们将数组保存到localStorage

function saveTaskList()
{
    appStorage.setValue("taskList", taskList.getTasks());
}

注意

如果您有来自先前示例的旧任务列表数据,则需要在使用新数据模型之前将其删除。在 Chrome 开发者工具中,您可以单击该项目,然后按删除键将其删除。

刚刚发生了什么?

首先,我们创建了一个数据模型来保存所有任务数据。然后,我们使用自定义数据属性向我们的应用程序添加了数据绑定,以在页面更改字段时自动更新数据模型。然后我们将任务列表保存到本地存储。

行动时间-加载任务列表

现在我们已将新数据模型保存到localStorage,我们需要更新loadTaskList()方法来加载数据:

function loadTaskList()
{
    var tasks = appStorage.getValue("taskList");
    taskList = new TaskList(tasks);
    rebuildTaskList();
}

首先,我们从localStorage获取任务数组,并将其作为参数传递给TaskList对象的构造函数。然后,我们调用一个新方法rebuildTaskList()来在 DOM 中创建任务元素:

function rebuildTaskList()
{
    // Remove any old task elements
    $("#task-list").empty();
    // Create DOM elements for each task
    taskList.each(function(task)
    {
        addTaskElement(task);
    });
}

首先,我们使用 jQuery 的empty()方法从任务列表元素中删除任何旧元素。然后,我们使用我们在TaskList对象中实现的each()方法来迭代任务,并为每个任务调用addTaskElement()来构建任务元素。

排队更改

现在,我们已将用户控件绑定到数据模型,并在每次进行更改时自动保存。不过,这样做有一个问题。像numbertime类型的输入控件,这些控件与微调按钮相关联,每次单击微调按钮时都会触发change事件。如果用户按住微调按钮,它将以惊人的速度触发change事件。这将反过来在非常短的时间内重复将任务列表保存到localStorage。这似乎不是一件非常有效的事情,特别是如果您有大量数据。

行动时间-延迟保存

注意

请参阅第三章\示例 3.3中的代码。

我们可以通过延迟保存到localStorage一段时间来缓解这个问题,以等待所有用户交互完成。使用 JavaScript 的setTimeout()函数很容易实现这一点。我们将在saveTaskList()方法中进行此更改,但首先我们需要在TaskAtHandApp对象中设置一个全局变量,以跟踪setTimeout()返回的超时 ID:

function TaskAtHandApp()
{
    var version = "v3.3",
        appStorage = new AppStorage("taskAtHand"),
        taskList = new TaskList(),
        timeoutId = 0;

当更改待保存时,我们希望在页面底部的状态元素中显示消息,以便用户知道他们的更改将被保存。当实际保存发生时,我们将更新消息并使其淡出,以便用户知道保存已完成。为此,我们需要重写setStatus()方法:

function setStatus(msg, noFade)
{
    $("#app>footer").text(msg).show();
    if (!noFade)
    {
        $("#app>footer").fadeOut(1000);
    }
}

我们添加了一个可选的noFade参数。当设置为true时,消息将不会淡出。否则,我们使用 jQuery 的fadeOut()方法在 1000 毫秒或一秒内逐渐淡出消息。现在让我们更新saveTaskList()方法:

function saveTaskList()
{
    if (timeoutId) clearTimeout(timeoutId);
    setStatus("saving changes...", true);
    timeoutId = setTimeout(function()
    {
        appStorage.setValue("taskList", taskList.getTasks());
        timeoutId = 0;
        setStatus("changes saved.");
    },
    2000);
}

我们首先检查timeoutId变量是否有值,以查看是否已经有保存待处理。如果有,我们将使用 JavaScript 的clearTimeout()函数取消超时。这样做的效果是,如果用户在保存待处理时进行其他更改,所有更改将被排队并一次性保存。

接下来,我们使用setTimeout()设置一个新的超时。setTimeout()函数接受要执行的函数和等待执行该函数的毫秒数。它返回一个超时 ID,我们将其存储在timeoutId变量中,以防以后需要取消超时。

在 2000 毫秒或两秒的不活动后,任务列表将被保存。然后我们重置timeoutId变量,因为我们的超时已经结束。最后,我们调用setStatus()告诉用户更改已保存。

刚刚发生了什么?

我们使用 JavaScript 的setTimeout()函数来有效地排队更改,这样当值快速变化时,我们不会不断保存任务列表。

尝试一下

就这样;我们的任务列表应用程序已经完成,至少在这本书中是这样。现在去添加你自己的功能,使它变得更好。例如,添加更多的任务字段,比如一个文本区域来输入备注。也许在工具栏中添加一个选项来隐藏已完成的任务。尝试添加一个排序选项,按名称、状态或日期对列表进行排序。

弹出测验

Q1. 如果浏览器不支持新的 HTML5 输入类型会发生什么?

  1. 输入字段未显示。

  2. 该字段显示为文本字段。

  3. 该字段设置为只读。

  4. 浏览器显示错误消息。

Q2. 自定义数据属性可以用在什么样的元素上?

  1. 只有表单输入元素。

  2. 只有块级元素。

  3. 只有内联元素。

  4. 任何元素。

总结

在本章中,我们看了一些更有用的 HTML5 输入类型。我们使用这些输入类型为每个任务创建了一个可折叠的任务详情部分。然后我们使用自定义数据属性来实现简单的数据绑定,将视图中的输入字段映射到数据模型。

在本章中,我们涵盖了以下概念:

  • 如何以及何时使用新的 HTML5 输入类型

  • 如何使用自定义数据属性在 DOM 中存储私有数据

  • 如何使用自定义数据属性实现数据绑定,将数据模型绑定到表单控件

  • 如何使用 jQuery 动画方法隐藏和显示元素

  • 如何使用定时器延迟保存到localStorage,使应用程序更加响应

在下一章中,我们将朝着一个全新的方向前进。我们将看看 HTML5 画布元素和 API,并编写一个使用它的全新应用程序。

第四章:一块空白画布

“站在一块空白的画布前,既美好又可怕。”

—保罗·塞尚

在本章中,我们将朝着一个全新的方向前进。我们将学习如何使用新的 HTML5 画布元素和 API,创建一个简单的绘图应用程序。我们的应用程序将使用画布基础知识,如笔画、路径、线条和形状。我们将使用在上一章中学到的自定义数据属性创建一个工具栏,将菜单项绑定到我们代码中的操作。

我们将在本章中学习以下内容:

  • 画布元素及其绘图 API

  • 如何获取画布上下文及其全局属性

  • 如何绘制线条、矩形和其他形状

  • 如何获取画布元素内鼠标的位置

  • 如何创建包含下拉菜单的工具栏

  • 如何使用自定义数据属性将工具栏操作绑定到 JavaScript 代码

HTML5 画布

HTML5 最令人兴奋的新功能之一可能就是画布。您可以使用它在网页的任何位置创建绘图。以前唯一的方法是使用其他技术,如 Flash、SVG 或其他浏览器插件。

HTML5 画布既是一个元素,也是一个 API。<canvas>元素定义了网页的一个矩形区域,可以在其中绘制图形。画布 API 与<canvas>元素一起工作,提供了在画布上绘制的 JavaScript 接口。它是一组用于绘制线条、矩形、圆形和其他图形基元的低级函数。

<canvas>元素本身非常简单。您必须设置widthheight属性来指定其大小。您还可以选择将内容放在<canvas>元素内,以便在不支持它的浏览器中显示。好消息是,几乎每个现代浏览器都广泛支持 HTML5 的<canvas>元素。以下代码创建一个宽度为 600 像素,高度为 400 像素的画布元素:

<canvas width="600" height="400">
  Sorry, your browser doesn't support canvas.
</canvas>

注意

如果您在 CSS 中将<canvas>元素的宽度和高度设置为元素上指定的大小之外的值,它将拉伸或缩小画布中的绘图以适应,这可能会影响图像质量。

获取上下文

可以通过画布上下文对象访问画布 API。通过调用<canvas>元素的getContext()方法,传入一个字符串参数来定义您想要的上下文类型,来获取上下文:

var context = $("canvas")[0].getContext("2d");

您目前可以传递给getContext()的唯一有效上下文类型参数是"2d"。这引出了一个问题,“是否有 3D 上下文?”答案是否定的。但我们总是可以期待未来会有一个。

画布基础知识

在本节中,我们将学习如何使用画布 API 的一些基础知识。现在我们有了上下文,我们可以调用其方法来绘制线条和形状。API 具有一系列方法,让您可以绘制从最基本的线条到形状,甚至位图图像的一切。

您可以在chapter4/canvas-examples/canvas-examples.html中找到此部分的源代码。

清除画布

画布的背景是透明的。您在 CSS 中为画布元素指定的任何背景颜色都会显示出来。您可以使用上下文的clearRect()方法清除画布或其部分。它接受xy、宽度和高度参数,并清除画布的那一部分。

context.clearRect(0, 0, canvas.width, canvas.height);

上下文属性

默认情况下,当您在画布上绘制时,线条宽度为一像素,颜色为黑色。您可以通过在context对象上设置全局属性来更改这些属性。

  • penWidth:此属性设置绘制线条的宽度。它可以是任何小数。例如,您可以有一条宽度为 1.5 像素的线。

  • strokeStyle:此属性设置用于绘制线条的颜色。它可以是 CSS 颜色规范符之一。例如,要用红色绘制,您可以使用red#FF0000rgb(255, 0, 0)rgba(255, 0, 0, 1)

  • fillStyle:此属性设置用于填充形状的颜色。与strokeStyle一样,它可以是任何 CSS 颜色规范。

  • globalAlpha:此属性设置要绘制的 alpha 或透明度量。它可以是从 0 到 1 的任何数字,其中 0 是完全透明的,1 是完全不透明的。

  • lineCap:此属性确定线的端点如何绘制。它可以是以下之一:

  • butt绘制一个平角

  • round绘制一个圆形的末端

  • square绘制一个方形的末端

square看起来类似于butt,只是它在末端多画了一个矩形,使它更长。

上下文属性

  • lineJoin:此属性确定两条线相交的地方如何绘制角。它可以是以下之一:

  • bevel绘制一个斜角或平角

  • round绘制圆角

  • miter绘制一个尖角

上下文属性

Canvas pad

现在我们已经了解了画布 API 的基础知识,让我们利用我们新获得的知识来创建一个名为canvas pad的绘图应用程序。我们将从一个绘制黑色线条的应用程序开始,就像在纸上用笔画一样。然后我们将添加一个工具栏和菜单,以便用户可以更改选项,如宽度、不透明度、颜色,并选择不同的绘图工具。

行动时间 - 创建画布垫

您可以在chapter4/example4.1中找到本节的源代码。让我们从第一章创建的应用程序模板中复制,并将文件名更改为canvasPad.htmlcanvasPad.csscanvasPad.js。然后我们进入并更改 HTML 中这些文件的链接。最后,我们将 JavaScript 中的主应用程序对象更改为CanvasPadApp

现在让我们在 HTML 中的<div id="main">元素内部添加一个<canvas>元素,并将其大小设置为 600 乘以 400:

<div id="main">
  <canvas width="600" height="400">
    Sorry, your browser doesn't support canvas.
  </canvas>
</div>

接下来,我们将在 CSS 中添加一些样式,将画布居中显示在页面上,并给它一个白色背景。我们还将使用box-shadow元素使其突出显示:

#main
{
    text-align: center;
}
#main>canvas
{
    cursor: crosshair;
    margin: 1em auto;
    background-color: white;
    box-shadow: 0 0 8px 2px #555;
}

为了封装我们与画布的交互,我们将创建一个名为Canvas2D的新对象,并将其放在一个名为canvas2d.js的文件中。在这个对象中,我们将创建一些更高级的绘图函数。这个对象的构造函数以一个包装在 jQuery 对象中的<canvas>元素作为参数:

function Canvas2D($canvas)
{
    var context = $canvas[0].getContext("2d"),
        width = $canvas[0].width,
        height = $canvas[0].height;
}

构造函数的第一件事是设置一些私有变量。我们从$canvas jQuery 对象中获取上下文、宽度和高度。

注意

您可以通过使用方括号(如数组)访问 jQuery 对象包装的基础元素。因此,在这种情况下,$canvas[0]给我们第一个(也是唯一的)<canvas>元素。

刚刚发生了什么?

我们从我们的模板中创建了一个新的画布垫应用程序,并向其添加了一个画布。我们将画布居中显示在页面上,并给它一个全面的阴影来框定它,并使其看起来浮在页面的顶部。最后,我们创建了一个Canvas2D对象来封装与画布的交互。

行动时间 - 显示坐标

我们在Canvas2D对象中要实现的第一件事是将页面坐标转换为画布坐标的方法。然后我们将使用它来在用户在画布上移动鼠标时在页面上显示鼠标坐标。

鼠标坐标的问题在于它们总是相对于网页的左上角偏移。为了获得画布坐标,我们需要找到页面上<canvas>元素的偏移量,并从页面坐标中减去它。

首先,我们需要一个名为pageOffset的变量来保存画布元素的偏移量。我们将使用 jQuery 的offset()方法来设置它的值,该方法获取元素的页面偏移量。它返回一个带有lefttop字段的对象:

var pageOffset = $canvas.offset();

现在我们添加一个getCanvasPoint()方法。它接受pageXpageY参数,减去画布元素的偏移量,并返回一个新对象,其中包含xy字段来保存调整后的坐标:

this.getCanvasPoint = function(pageX, pageY)
{
    return {
        x: pageX - pageOffset.left,
        y: pageY - pageOffset.top
    }
};

由于我们的画布位于页面中心,每当窗口大小发生变化时,画布的偏移量也会发生变化。因此,我们需要向窗口添加一个resize事件处理程序,以便在其发生变化时更新pageOffset变量:

$(window).resize(function() { pageOffset = $canvas.offset(); });

现在让我们添加代码,当用户在画布上移动鼠标时,在状态栏中显示鼠标坐标。首先,我们需要在我们应用程序的主类CanvasPadApp中创建Canvas2D对象的实例。我们将把它赋给一个名为canvas2d的私有变量:

function CanvasPadApp()
{
    var version = "4.1",
 canvas2d = new Canvas2D($("#main>canvas"));
    // ...

我们将在画布下方的<footer>元素中显示坐标。让我们在页脚中添加一个<span>来保存坐标:

<footer>
 <span id="coords">0, 0</span>
</footer>

接下来,在start()方法中为<canvas>元素添加一个mousemove事件处理程序。当鼠标移动时,它将调用onMouseMove

this.start = function()
{
    $("#app header").append(version);
    $("#main>canvas").mousemove(onMouseMove);
}

onMouseMove事件处理程序调用canvas2d.getCanvasPoint()方法,传入鼠标事件的页面坐标。它返回画布上鼠标的位置,并将其传递给showCoordinates()方法以在页脚中显示它们:

function onMouseMove(e)
{
    var canvasPoint = canvas2d.getCanvasPoint(e.pageX, e.pageY);
    showCoordinates(canvasPoint);
}
function showCoordinates(point)
{
    $("#coords").text(point.x + ", " + point.y);
}

showCoordinates()方法使用 jQuery 的text()方法将坐标放入页脚。现在,如果您在页面上的画布上移动鼠标,您将看到坐标变化。当您将鼠标移动到左上角时,它应该显示(0, 0)

刚刚发生了什么?

我们通过从鼠标坐标中减去画布的位置来计算鼠标在画布上的页面偏移。然后我们添加了一个mousemove事件处理程序,以在用户在画布上移动鼠标时在页脚显示坐标。

绘制线条

我们要实现的第一件事是让用户绘制简单的线条,或者在画布上涂鸦。为此,我们需要在用户按下鼠标按钮并移动鼠标时获取点,并在它们之间绘制线条。所以让我们学习如何在画布上绘制。

路径和描边

在画布上绘制的最原始的方法是定义路径,然后描边或绘制它们。可以将其视为在脑海中规划要绘制的内容,然后将笔放在纸上,并实际绘制出来。

要创建路径,您需要使用moveTo()lineTo()方法指定两个或更多点来定义它。然后通过调用stroke()方法将其绘制到画布上。有四种基本方法可用于定义和绘制路径。

  • beginPath():此方法开始一个新路径。

  • moveTo(x, y):此方法将笔移动到新位置而不绘制。

  • lineTo(x, y):此方法从上一个位置绘制一条线到新位置。

  • stroke():此方法将路径绘制到画布上。重要的是要注意,直到调用stroke()之前,实际上没有任何东西被绘制到画布上。

以下代码从点(10, 10)绘制一条线到(80, 100):

context.beginPath();
context.moveTo(10, 10);
context.lineTo(80, 100);
context.stroke();

beginPath()stroke()之间,您可以调用任意次moveTo()lineTo()方法。这允许您排队多个绘图命令,然后一次性将它们提交到画布上。如果您希望路径形成一个封闭的形状,可以调用closePath()方法以从最后一个点绘制一条线到第一个点。例如,以下代码绘制了一个三角形:

context.beginPath();
context.moveTo(100, 10);
context.lineTo(150, 90);
context.lineTo(200, 20);
context.closePath();
context.stroke();

还可以通过调用上下文的fill()方法而不是stroke()来填充形状。实际上,如果您希望形状以一种颜色轮廓并以另一种颜色填充,可以同时调用fill()stroke()

context.beginPath();
context.moveTo(100, 10);
context.lineTo(150, 90);
context.lineTo(200, 20);
context.closePath();
context.fill();
context.stroke();

路径和描边

行动时间-使用鼠标绘制

我们需要做的第一件事是捕获鼠标事件。让我们进入CanvasPadApp对象,并在start()方法中添加代码来检查它们。您可能还记得,我们已经添加了mousemove事件处理程序。现在我们将为mousedownmouseupmouseout事件添加处理程序:

$("#main>canvas").mousemove(onMouseMove)
    .mousedown(onMouseDown)
    .mouseup(onMouseUp)
    .mouseout(onMouseUp);

不,mouseout中没有错误。我们希望mouseout事件与mouseup事件以相同的方式处理,因此它们都会停止绘图过程。当鼠标离开<canvas>元素时,将触发mouseout事件。当这种情况发生时,我们将无法再获取mousemove事件,因此无法再跟踪笔的位置。

在我们实现事件处理程序之前,我们需要一些新变量来跟踪事物。我们需要一个布尔值来跟踪我们何时在绘制,一个数组来跟踪当前的点集,以及一个数组来跟踪所有的点集(我们将它们称为动作):

var version = "4.1",
canvas2d = new Canvas2D($("#main>canvas")),
drawing = false,
 points = [],
 actions = [];

注意

请注意,如果您给全局对象变量设置默认值,它将使具有自动完成功能的代码编辑器更容易确定变量的类型,并为您提供适当的建议。

首先让我们实现onMouseDown(),因为这会启动绘图过程。它接受一个参数,即鼠标事件对象:

function onMouseDown(e)
{
 e.preventDefault();
    penDown(e.pageX, e.pageY);
}
function penDown(pageX, pageY)
{
    drawing = true;
    points = [];
    points.push(canvas2d.getCanvasPoint(pageX, pageY));
    actions.push(points);
}

onMouseDown()方法中的第一件事是在鼠标事件对象上调用preventDefault()。这将阻止系统执行默认的鼠标按下行为,其中的一部分是更改鼠标光标图标。我们希望它保持为十字光标,这是我们之前在 CSS 中设置的。然后我们调用penDown(),传入鼠标的页面坐标,这些坐标是从鼠标事件中获取的。

penDown()方法中,我们初始化了绘图过程。首先,我们将drawing标志设置为true。然后我们创建一个新数组来存放当前的绘图点。然后我们将第一个点添加到数组中,通过调用getCanvasPoint()将其从页面坐标转换为画布坐标。我们做的最后一件事是将当前的points数组添加到actions数组中。

绘图过程中的下一步是处理mousemove事件,所以让我们重写onMouseMove()方法:

function onMouseMove(e)
{
    penMoved(e.pageX, e.pageY);
}
function penMoved(pageX, pageY)
{
    var canvasPoint = canvas2d.getCanvasPoint(pageX, pageY);
    showCoordinates(canvasPoint);

    if (drawing)
    {
        points.push(canvasPoint);
        redraw();
    }
}

现在onMouseMove()调用penMoved(),传递鼠标坐标。penMoved()方法首先转换坐标,然后像以前一样调用showCoordinates()。然后我们检查drawing标志是否已设置。这是在penDown()方法中设置的,所以我们知道鼠标按钮已按下。如果用户正在绘制,那么我们将当前点添加到点数组中并调用redraw(),接下来我们将实现它:

function redraw()
{
    canvas2d.clear();
    for (var i in actions)
    {
        canvas2d.drawPoints(actions[i]);
    }
}

redraw()方法首先通过调用canvas2d.clear()清除画布,接下来我们将编写它,然后遍历所有的动作并调用drawPoints(),传入每个动作的点集。

现在让我们进入我们的Canvas2D对象并添加clear()drawPoints()方法。首先,我们的clear()方法调用context.clearRect()方法,传入我们在Canvas2D构造函数中定义的画布widthheight变量:

this.clear = function()
{
    context.clearRect(0, 0, width, height);
    return this;
};

接下来,drawPoints()方法接受一个点数组并在它们之间绘制线条:

this.drawPoints = function(points)
{
    context.beginPath();
    context.moveTo(points[0].x, points[0].y);
    for (var i = 1; i < points.length; i++)
    {
        context.lineTo(points[i].x, points[i].y);
    }
    context.stroke();
    return this;
};

在开始新路径后,它调用moveTo()将笔移动到数组中的第一个点。然后它遍历数组中的其余点,为每个点调用lineTo()。完成后,它调用stroke()将其绘制到画布上。

注意

对于 Canvas2D 中那些通常不返回值的方法,我们将返回this,以便我们可以进行函数链接。

我们需要实现的最后一件事是onMouseUp()事件处理程序。我们在这里需要做的就是将drawing标志设置回false

function onMouseUp(e)
{
    penUp();
}
function penUp()
{
    drawing = false;
}

刚刚发生了什么?

我们使用鼠标事件来捕获和存储绘图动作到缓冲区中。然后我们使用画布 API 从这些点绘制线条到画布上。现在让我们在浏览器中打开我们的应用程序并检查一下。我们可以使用鼠标在画布上涂鸦并创建简单的线条图。

刚刚发生了什么?

更改上下文属性

让我们通过允许用户更改笔属性,如颜色、不透明度和宽度,将我们的应用程序提升到下一个级别。

行动时间 - 添加上下文属性

首先让我们在我们的Canvas2D对象中添加一些代码,以允许我们更改全局上下文绘图属性。让我们在构造函数中设置一些默认值。我们将笔的颜色设置为黑色,宽度为4,并通过将globalAlpha设置为1使其完全不透明。我们将线连接和端点设置为圆形,使我们的线看起来更加平滑:

context.lineWidth = 4;
context.strokeStyle = "black";
context.fillStyle = "black";
context.globalAlpha = 1.0;
context.lineJoin = "round";
context.lineCap = "round";

接下来,我们将添加公共属性访问器方法,以允许我们设置和获取颜色、不透明度和宽度属性的值。如果参数被传递到属性方法中(即arguments.length不是0),它将设置属性的值,然后返回this,这样我们就可以进行函数链接。否则,它将返回属性的值:

this.penWidth = function(newWidth)
{
    if (arguments.length)
    {
        context.lineWidth = newWidth;
        return this;
    }
    return context.lineWidth;
};
this.penColor = function(newColor)
{
    if (arguments.length)
    {
        context.strokeStyle = newColor;
        context.fillStyle = newColor;
        return this;
    }
    return context.strokeStyle;
};
this.penOpacity = function(newOpacity)
{
    if (arguments.length)
    {
        context.globalAlpha = newOpacity;
        return this;
    }
    return context
};

现在我们需要一种方式让用户从应用程序中更改这些设置,所以下一步我们将实现的是一个工具栏。

创建工具栏

我们的工具栏将需要以下按钮。前三个将用于更改上下文的属性。最后两个将允许我们撤消和清除画布。

  • 颜色:这个按钮显示一个下拉菜单,用户可以选择笔的颜色。

  • 不透明度:这个按钮显示一个下拉菜单,用户可以选择笔的不透明度。

  • 宽度:这个按钮显示一个下拉菜单,用户可以选择笔的宽度。

  • 撤消:这个按钮移除最后一次绘图操作

  • 清除:这个按钮清除画布和所有绘图操作,重新开始

自定义数据属性,我们在上一章中介绍过,将在整个工具栏中使用,用于定义工具栏按钮的操作和菜单选项。我们稍后将在 JavaScript 中使用这些属性来确定所选的操作或选项。现在添加一些额外的标记将使我们免于以后编写大量重复的代码。

行动时间 - 创建工具栏

您可以在chapter4/example4.2中找到本节的代码。

我们将在 HTML 文件中的主要元素内部定义工具栏,并放在画布的上方:

<div id="toolbar">
  <div class="dropdown-menu">
    <button data-action="menu">Color</button>
    <ul id="color-menu"data-option="penColor" class="menu">
      <li data-value="red"></li>
      <li data-value="orange"></li>
      <li data-value="yellow"></li>
      <li data-value="green"></li>
      <li data-value="blue"></li>
      <li data-value="purple"></li>
      <li data-value="black" class="selected"></li>
      <li data-value="white"></li>
    </ul>
  </div>
<div class="dropdown-menu">
    <button data-action="menu">Opacity</button>
      <ul data-option="penOpacity" class="menu">
        <li data-value=".1">10%</li>
        <li data-value=".2">20%</li>
        <li data-value=".3">30%</li>
        <li data-value=".4">40%</li>
        <li data-value=".5">50%</li>
        <li data-value=".6">60%</li>
        <li data-value=".7">70%</li>
        <li data-value=".8">80%</li>
        <li data-value=".9">90%</li>
        <li data-value="1" class="selected">100%</li>
      </ul>
  </div>
  <div class="dropdown-menu">
    <button data-action="menu">Width</button>
      <ul id="width-menu" data-option="penWidth" class="menu">
        <li data-value="1">1</li>
        <li data-value="2">2</li>
        <li data-value="4" class="selected">4</li>
        <li data-value="6">6</li>
        <li data-value="8">8</li>
        <li data-value="10">10</li>
        <li data-value="12">12</li>
        <li data-value="14">14</li>
        <li data-value="16">16</li>
      </ul>
  </div> |
  <button data-action="undo">Undo</button> |
  <button data-action="clear">Clear</button>
</div>

每个工具栏按钮都有一个data-action自定义属性。这将在 JavaScript 中用于确定单击按钮时要执行的操作。对于带有下拉菜单的按钮,我们将data-action设置为"menu"撤消清除按钮各自有自己独特的操作值。

由于颜色、不透明度和宽度的工具栏项目是下拉菜单,我们将它们包装在<div class="dropdown-menu">元素中。这将工具栏按钮和菜单分组在一起,当单击按钮时显示菜单。菜单使用无序列表定义。每个<ul>元素都被赋予一个menu类和一个data-option自定义属性。这个属性的值与Canvas2D对象中的属性方法的名称相匹配,例如penColor()

菜单项使用<li>元素定义。每个菜单项都有一个data-value自定义属性。这个属性设置为将传递到菜单上的data-option属性定义的属性方法中的值。

现在让我们在 CSS 中为工具栏设置样式:

#toolbar
{
    padding: 2px;
    background-color: rgba(0, 0, 0, 0.5);
}
#toolbar button
{
    border: none;
    background-color: transparent;
    color: white;
    font-size: 1em;
}

首先,我们将工具栏的颜色设置为黑色,不透明度为 50%,这样背景颜色会透过来。然后我们将样式按钮,去掉边框和背景颜色,并将文本颜色设置为白色。现在让我们为下拉菜单设置样式:

#toolbar .dropdown-menu
{
    display: inline-block;
    position: relative;
}
#toolbar ul.menu
{
    display: none;
    position: absolute;
    top: 100%;
    left: 0;
    margin: 0;
    padding-left: 1.5em;
    border: 1px solid black;
    box-shadow: 2px 2px 8px 1px rgba(0, 0, 0, 0.5);
    background-color: silver;
    color: black;
    list-style-type: none;
}

我们将<div class="dropdown-menu">包装元素设置为inline-block显示,并将position设置为relative,这样我们可以绝对定位菜单在它们下方。

对于<ul>菜单元素,首先我们将display设置为none,这样它们默认是隐藏的。然后我们将position设置为absolute,这样它们在页面中不占用任何空间。为了使它们出现在按钮下方而不是覆盖在按钮上方,我们将top设置为100%。然后我们给它添加阴影,以营造深度的错觉。最后,我们通过将list-style-type设置为none来去掉列表的项目符号。

最后让我们为菜单项设置样式:

#toolbar ul.menu>li
{
    margin: 0;
    min-width: 4em;
    height: 2em;
    border-width: 0;
    background-color: WhiteSmoke;
    font-size: .75em;
    cursor: pointer;
}
#toolbar ul.menu>li.selected
{
    list-style-type: circle;
    background-color: lightblue;
}

我们给菜单项设置了最小宽度,以防它们变得太小。我们还指定了选定菜单项的样式,使用list-style-type显示一个圆圈,并将背景颜色设置为浅蓝色。

刚刚发生了什么?

我们在 HTML 文件中创建了一个工具栏,其中包含颜色、宽度和不透明度的菜单项。我们使用自定义数据属性来定义将在 JavaScript 中实现的自定义操作。最后,我们在 CSS 文件中对菜单进行了样式设置,使它们与工具栏按钮对齐。

行动时间-实现可重用的工具栏

现在让我们创建一个新的可重用的Toolbar对象,封装工具栏的代码。这样我们以后也可以在其他应用程序中使用它。我们将把它放在一个名为toolbar.js的新文件中。构造函数将接受包装在 jQuery 对象中的工具栏的根元素:

function Toolbar($toolbar)
{
    var _this = this;

还记得我在第一章中说过的吗,手头的任务this指针在使用公共方法的事件处理程序时可能会引起问题?为了解决这个问题,我们将创建一个全局的_this变量,并将其设置为对象的this,这样它就始终可用。

首先我们将实现公共方法。我们有两个方法,用于通知应用程序工具栏按钮或菜单项已被单击。在这个对象中,它们只是占位符。客户端应用程序将覆盖它们以实现自定义行为:

this.toolbarButtonClicked = function(action)
{
    return false;
};
this.menuItemClicked = function(option, value)
{
    return false;
};

toolbarButtonClicked()方法将按钮的data-action属性作为参数。menuItemClicked()方法将菜单的data-option和菜单项的data-value属性作为参数。

我们还需要一个名为hideMenus()的公共方法,以隐藏工具栏的所有下拉菜单。它只是找到所有菜单元素并隐藏它们:

this.hideMenus = function()
{
    $(".menu", $toolbar).hide();
}

接下来我们将为所有工具栏按钮添加事件处理程序:

$("button", $toolbar).click(function(e) {
  onToolbarButtonClicked($(this));
});

当用户单击工具栏中的按钮时,它调用私有的onToolbarButtonClicked()方法,将被单击的按钮包装在 jQuery 对象中传递给它。现在让我们实现这个处理程序:

function onToolbarButtonClicked($button)
{
    var action = $button.data("action");
    if (!_this.toolbarButtonClicked(action))
    {
        if (action == "menu")
        {
            showMenu($button.siblings("ul.menu"));
        }
        else
        {
            _this.hideMenus();
        }
    }
}

该方法从按钮中获取data-action自定义属性的值。然后将其传递给公共的toolbarButtonClicked()方法。请注意,它必须使用_this来调用公共方法,因为this当前指向window对象。如果toolbarButtonClicked()返回true,这意味着客户端处理了操作,没有其他事情要做。否则,它检查操作是否为"menu",如果是,则调用showMenu(),并传入菜单元素,该元素是按钮的兄弟元素。如果不是菜单操作,则隐藏所有菜单。

现在让我们编写私有的showMenu()方法:

function showMenu($menu)
{
    if ($menu.is(":visible"))
    {
        $menu.fadeOut("fast");
    }
    else
    {
        // Hide any open menus
        _this.hideMenus();
        // Show this menu
        $menu.fadeIn("fast");
    }
}

我们使用 jQuery 的is()方法,传入:visible过滤器来确定菜单是否已经显示。如果是,它会淡出菜单以隐藏它。否则,它会隐藏工具栏中的所有菜单,以防其他菜单已经打开,然后淡入菜单以显示它。

接下来,我们为所有菜单项添加点击事件处理程序:

$(".menu>li", $toolbar).click(function(e) {
  onMenuItemClicked($(this));
});

当用户在工具栏中单击菜单项时,它调用onMenuItemClicked(),并将被单击的菜单项包装在 jQuery 对象中传递给它:

function onMenuItemClicked($item)
{
    var $menu = $item.parent();
    var option = $menu.data("option");
    var value = $item.data("value");
    if (!_this.menuItemClicked(option, value))
    {
        $item.addClass("selected")
             .siblings().removeClass("selected");
        $menu.fadeOut("fast");
    }
}

首先我们获取菜单的父元素。然后我们从中获取data-option属性。接下来我们从菜单项本身获取data-value属性。我们将这些值作为参数传递给公共的menuItemClicked()方法。如果该方法返回true,这意味着客户端处理了操作,没有其他事情要做。否则,我们向菜单项添加一个"selected"类来突出显示它,并从所有其他菜单项中删除该类。然后我们淡出菜单以隐藏它。

刚刚发生了什么?

我们创建了一个可重用的对象,封装了工具栏行为,包括按钮和下拉菜单。它使用自定义数据属性来定义工具栏按钮和菜单项的操作。我们可以在需要工具栏的应用程序中使用这个对象。

添加工具栏

现在我们有了一个Toolbar对象和我们的工具栏和菜单的 HTML 定义,我们可以在我们的绘图应用程序中连接事件以处理用户交互。

行动时间-添加工具栏对象

让我们将Toolbar对象添加到我们的应用程序中。首先,我们向CanvasPadApp添加一个toolbar变量,并将其设置为Toolbar对象的新实例。我们将工具栏的根<div>元素作为参数传递给构造函数:

var version = "4.2",
canvas2d = new Canvas2D($("#main>canvas")),
toolbar = new Toolbar($("#toolbar")),
        // code not shown...

start()中,我们重写toolbar对象的toolbarButtonClicked()menuItemClicked()方法,将它们设置为我们自己的实现来处理这些事件:

toolbar.toolbarButtonClicked = toolbarButtonClicked;
toolbar.menuItemClicked = menuItemClicked;

首先让我们实现我们的CanvasPadApp.toolbarButtonClicked()方法:

function toolbarButtonClicked(action)
{
    switch (action)
    {
        case "clear":
            if (confirm("Clear the canvas?"))
            {
                actions = [];
                redraw();
            }
            break;
        case "undo":
            actions.pop();
            redraw();
            break;
    }
}

当用户单击清除按钮时,我们确认他们是否要清除画布。如果是,我们将actions数组设置为一个新数组以清除所有内容,然后调用redraw(),这将清除画布。

当用户单击撤消按钮时,它会从actions数组中删除最后一个绘图操作,然后调用redraw()

现在让我们实现menuItemClicked()方法。它接受两个参数;菜单选项名称和所选菜单项的值:

function menuItemClicked(option, value)
{
    canvas2doption;
}

如果您还记得之前的实例,data-option属性是用于在Canvas2D对象中设置属性的方法的名称。我们使用方括号方法访问对象中的该方法,然后我们执行它,将菜单项的data-value属性传递给它。

例如,如果用户在颜色菜单中单击红色菜单项,则data-option将是"penColor"data-value将是"red"。因此,在这种情况下,语句canvas2doption将等同于调用canvas2d.penColor("red"

刚刚发生了什么?

我们将我们在上一节中创建的可重用的Toolbar对象添加到我们的应用程序中,并添加事件处理程序来处理工具栏按钮和菜单事件。然后我们实现了撤消和清除操作。

行动时间-初始化菜单项

接下来,我们将初始化颜色菜单,将每个项目的背景颜色设置为它所代表的颜色。我们可以在 CSS 中做到这一点,但这将很麻烦。相反,我们将编写一个 JavaScript 方法,只需一点点代码就可以设置它们全部:

function initColorMenu()
{
    $("#color-menu li").each(function(i, e) {
        $(e).css("background-color", $(e).data("value"));
    });
}

这会获取所有颜色菜单项,并使用 jQuery 的each()方法对它们进行迭代。对于每个项目,它使用 jQuery 的css()方法将背景颜色设置为data-value自定义属性的值,这是一个 CSS 颜色名称。就像这样,我们有了一个颜色菜单。

我们希望对宽度菜单的项目执行类似的操作,只是我们将底部边框设置为data-value自定义属性中的宽度,以便用户了解线条的大小:

function initWidthMenu()
{
    $("#width-menu li").each(function(i, e) {
        $(e).css("border-bottom",
                 $(e).data("value") + "px solid black");
    });
}

当我们初始化应用程序时,我们将从start()方法中调用这两种方法。

刚刚发生了什么?

我们更改了颜色和宽度菜单项的样式,分别为它们设置颜色和宽度,以便用户可以更好地看到他们从菜单中选择了什么。

现在,如果您在浏览器中打开应用程序,您可以更改笔的属性。继续画几条线。如果单击撤消,最后一行将被擦除。当您单击清除时,整个图纸都将被擦除。

添加绘图操作

您可能已经注意到,当您更改选项时,下次绘制时,选项将应用于以前绘制的所有线条。这不是一个很好的用户体验。用户期望当他们更改笔选项时,它只会应用于他们绘制的下一件事,而不是所有事情。

为了使其正常工作,我们需要为每个操作添加更多数据,而不仅仅是一系列点。我们还需要知道颜色,宽度和不透明度以绘制点。为此,我们需要一个对象来保存所有这些值。

行动时间-创建绘图操作

我们将使用一个工厂方法来创建这个对象。让我们在CanvasPadApp中添加一个newAction()方法,用当前的绘图选项设置创建动作对象:

function newAction(tool)
{
    return {
        tool: tool,
        color: canvas2d.penColor(),
        width: canvas2d.penWidth(),
        opacity: canvas2d.penOpacity(),
        points: []
    };
}

newAction()方法接受一个参数,即动作将使用的绘图工具的名称。接下来,它使用大括号定义一个新的对象字面量。该对象将保存工具、上下文属性值和该动作的点。它从我们的Canvas2D对象中获取当前颜色、宽度和不透明度设置。

我们需要做的下一件事是从CanvasPadApp对象中删除全局的points变量,并将其替换为一个curAction变量,用于保存由newAction()创建的当前动作对象。让我们还添加一个curTool变量来保存当前工具,并将其设置为"pen"

varversion = "4.2",
    // code not shown...
  curTool = "pen",
  curAction = newAction(curTool),
    actions = [];

现在,无论我们以前在哪里使用points变量,我们都需要将其更改为使用curAction.points。第一个地方是penDown()方法:

function penDown(pageX, pageY)
{
    drawing = true;
 curAction = newAction(curTool);
 curAction.points.push(
 canvas2d.getCanvasPoint(pageX, pageY));
 actions.push(curAction);
}

首先我们将curAction设置为一个新的动作对象,然后将第一个点添加到curAction对象的points数组中。然后我们将curAction添加到actions数组中。

下一步是penMoved()方法。在那里,我们将下一个点添加到动作的points数组中:

function penMoved(pageX, pageY)
{
    var canvasPoint = canvas2d.getCanvasPoint(pageX, pageY);
    showCoordinates(canvasPoint);
    if (drawing)
    {
        curAction.points.push(canvasPoint);
        redraw();
    }
}

我们还需要更新penUp()方法:

function penUp()
{
    if (drawing)
    {
        drawing = false;
        if (curAction.points.length < 2)
        {
            actions.pop();
        }
    }
}

首先,我们检查drawing变量,确保我们确实在绘制。如果是这样,我们通过将其设置为false来关闭drawing标志。接下来,我们需要确保动作的points数组中至少有两个点。如果用户按下鼠标按钮但没有移动它,那么只会有一个点。我们不能在没有两个点的情况下绘制任何东西,所以我们将使用pop()actions数组中移除该动作。

最后,我们将更新redraw()方法。这里我们需要做一些重大的改变:

function redraw()
{
    canvas2d.clear();
 canvas2d.savePen();

    for (var i in actions)
    {
 var action = actions[i];
 canvas2d.penColor(action.color)
 .penWidth(action.width)
 .penOpacity(action.opacity);

        canvas2d.drawPoints(action.points);
    }

 canvas2d.restorePen();
}

首先注意Canvas2D对象中对savePen()restorePen()的调用。它们将在我们开始绘制所有动作之前保存当前上下文属性,然后在完成后恢复它们。我们将马上实现它们。接下来,我们遍历所有动作,为每个动作设置笔的颜色、宽度和不透明度(使用函数链接),然后绘制点。

刚刚发生了什么?

我们添加了一个绘图动作对象来跟踪工具、笔属性和每个绘图动作的点。现在当我们更改绘图属性时,它们不会影响以前的动作。

行动时间-保存和恢复

现在,关于savePen()restorePen()方法。让我们去canvas2d.js,并将它们添加到Canvas2D对象中。我们可以自己跟踪当前属性,但画布 API 提供了一种更简单的方法。

画布 API 包含save()restore()方法。每当需要保存上下文的状态时,调用save(),它会将上下文的状态推送到堆栈上。当您想要恢复上下文状态时,调用restore(),它会将状态从堆栈中弹出到上下文中。这允许您多次递归保存和恢复状态。

这对于可能在运行时以任何顺序绘制的绘图函数库非常有效。每个方法在开始更改上下文属性之前都可以调用save(),并在完成后调用restore()。这样,当方法完成时,上下文的状态与调用方法之前的状态相同:

this.savePen = function()
{
    context.save();
    return this;
};
this.restorePen = function()
{
    context.restore();
    return this;
};

刚刚发生了什么?

我们学会了如何保存上下文并恢复它,以便不会丢失上下文的当前属性。

让我们在浏览器中打开应用程序并查看一下。现在我们可以用各种不同的颜色、宽度和不透明度绘制。如果出错,您可以单击撤消来擦除它。如果您想重新开始,可以单击清除

刚刚发生了什么?

添加绘图工具

此时,我们的应用程序可以绘制简单的线条,比如笔,但如果我们能绘制一些形状,比如直线、矩形和圆形,那将会很好。在本节中,我们将添加一个工具菜单,允许用户选择不同的形状进行绘制。

行动时间-添加线条工具

您可以在chapter4/example4.3中找到本节的代码。

目前我们可以绘制自由线条,但是我们没有办法从一个点到另一个点画一条直线。所以让我们添加一个线条绘制工具。为了允许用户选择不同的工具,我们需要一个新的下拉菜单工具栏选项。让我们把它添加到我们的 HTML 中:

<div id="toolbar">
  <div class="dropdown-menu">
    <button data-action="menu">Tool</button>
      <uldata-option="drawingTool" class="menu">
        <li data-value="pen" class="selected">Pen</li>
        <li data-value="line">Line</li>
      </ul>
    </div>

对于这个菜单,我们将data-option属性设置为drawingTool。我们为工具添加了菜单项,我们目前已经有了,以及线条工具,我们现在正在实现。由于drawingTool不是Canvas2D对象的属性,我们需要添加代码来检查menuItemClicked()中的属性。

function menuItemClicked(option, value)
{
    switch (option)
    {
        case "drawingTool":
            curTool = value;
            break;
        default;
            canvas2doption;
    } 
}

首先,我们检查选择了哪个选项。如果是"drawingTool",我们只需将当前工具设置为所选菜单项的值。否则,我们将执行设置Canvas2D属性为所选值的默认行为。

接下来我们将更改penMoved()方法。我们需要检查当前使用的工具。如果是笔,我们将向points数组添加另一个点。否则,我们只想更改points数组中的第二个点,因为我们正在画一条直线,而一条直线只有两个点:

function penMoved(pageX, pageY)
{
    var canvasPoint = canvas2d.getCanvasPoint(pageX, pageY);
    showCoordinates(canvasPoint);

    if (drawing)
    {
 if (curTool == "pen")
 {
 // Add another point
 curAction.points.push(canvasPoint);
 }
 else
 {
 // Change the second point
 curAction.points[1] = canvasPoint;
 }
        redraw();
    }
}

最后,我们需要对redraw()方法进行一些更改。在循环内,我们将检查操作的工具。如果是笔,我们调用canvas2d.drawPoints(),就像以前一样。如果是线条工具,我们调用canvas2d.drawLine(),传入这两个点:

function redraw()
{
    canvas2d.clear();
    canvas2d.savePen();

    for (var i in actions)
    {
        var action = actions[i];
        canvas2d.penColor(action.color)
                .penWidth(action.width)
                .penOpacity(action.opacity);

 switch (action.tool)
        {
 case "pen":
 canvas2d.drawPoints(action.points);
 break;
 case "line":
 canvas2d.drawLine(action.points[0], 
 action.points[1]);
 break;
 }
    }
    canvas2d.restorePen();
}

等一下!我们的Canvas2D对象中还没有drawLine()方法。所以让我们去添加它:

this.drawLine = function(point1, point2)
{
    context.beginPath();
    context.moveTo(point1.x, point1.y);
 context.lineTo(point2.x, point2.y);
    context.stroke();
    return this;
};

drawLine()方法将线的起点和终点作为参数。在开始新路径后,它移动到第一个点,画一条线到第二个点,然后描边。就是这样。现在我们可以画直线了。

刚刚发生了什么?

我们在工具栏中添加了一个工具菜单,用户可以选择不同的绘图工具。除了我们已经有的笔工具,我们还添加了一条线条绘制工具,用于在应用程序中绘制直线。

绘制矩形

您可以使用路径来绘制矩形,但是画布 API 有一些内置方法来实现这一点;drawRect()fillRect()。它们都接受相同的参数;xy,宽度和高度。drawRect()使用strokeStyle来绘制线条,而fillRect()使用fillStyle来填充。

以下是从点(350,10)开始的矩形,宽度为50,高度为90

context.strokeRect(350, 10, 50, 90);

这个例子画了一个从点(425,10)开始的填充矩形,宽度为50,高度为90

context.fillRect(425, 10, 50, 90);

绘制矩形

行动时间-添加矩形工具

让我们添加一个绘制矩形的工具。我们将首先向工具下拉菜单添加一个菜单项,其data-value属性设置为"rect"

<li data-value="rect">Rectangle</li>

让我们在Canvas2D中实现drawRect()方法:

this.drawRect = function(point1, point2, fill)
{
    var w = point2.x - point1.x,
        h = point2.y - point1.y;
    if (fill) context.fillRect(point1.x, point1.y, w, h);
    else context.strokeRect(point1.x, point1.y, w, h);
    return this;
};

我们的drawRect()方法接受三个参数;定义左上角和右下角坐标的两个点,以及一个布尔值来确定矩形是否应该填充。由于fillRect()strokeRect()都需要宽度和高度参数,我们需要通过从point2变量的坐标中减去point1变量的坐标来计算它们。

在编写drawRect()之前,我们需要处理一件事。我们的drawRect()方法可以绘制轮廓或填充矩形,因此我们需要一种方法让用户选择他们想要的选项。让我们在工具栏中添加另一个下拉菜单,命名为填充,允许用户设置此选项:

<div class="dropdown-menu">
  <button data-action="menu">Fill</button>
    <ul data-option="fillShapes" class="menu">
      <li data-value="true" class="selected">Yes</li>
      <li data-value="false">No</li>
    </ul>
</div>

下拉菜单只有两个选项:。在我们的CanvasPadApp对象中,我们需要一个全局的fillShapes布尔变量来跟踪当前的设置。让我们在对象的顶部添加这个变量,以及其他变量:

var version = "4.3",
    // code not shown...
  fillShapes = true;

我们还需要在newAction()方法的 action 对象中添加它。我们将添加一个名为fill的字段,并将其设置为fillShapes的当前值:

function newAction(tool)
{
    return {
        tool: tool,
        color: canvas2d.penColor(),
        width: canvas2d.penWidth(),
        opacity: canvas2d.penOpacity(),
 fill: fillShapes,
        points: []
    };
  }

接下来,我们需要在menuItemClicked()方法中添加一些额外的代码,以检查选项是否为填充菜单选项,如果是,则将fillShapes变量设置为其data-value。由于值要么是"true"要么是"false",我们可以直接将其转换为布尔值:

function menuItemClicked(option, value)
{
    switch (option)
    {
        case "drawingTool":
            curTool = value;
            break;
 case "fillShapes":
 fillShapes = Boolean(value);
 break;
        default:
            canvas2doption;
    }
}

好了,填充选项就是这样。现在我们可以在redraw()方法中添加代码,检查矩形工具并通过调用drawRect()来绘制它。我们将传入矩形的两个点和action.fill的值,告诉它是否填充矩形:

switch (action.tool)
{
    // code not shown...
 case "rect":
 canvas2d.drawRect(action.points[0],
 action.points[1],
 action.fill);
 break;
}

刚刚发生了什么?

我们在工具菜单中添加了一个矩形工具。我们还添加了一个新的工具栏菜单来选择是否填充形状。我们用它来确定是绘制填充还是轮廓矩形。

弧和圆

除了直线,您还可以使用上下文的arc()方法绘制弧线或圆的部分。它需要以下参数:

arc(centerX, centerY, radius, startAngle, endAngle, clockwise)
  • centerX: 此参数指定中心点的水平位置。

  • centerY: 此参数指定中心点的垂直位置。

  • radius: 此参数指定弧的半径。

  • startAngle: 此参数指定以弧度表示的弧的起始角度。它可以是0之间的任何值。超出此范围的数字将自动归一化为其中。

  • endAngle: 此参数指定以弧度表示的弧的结束角度。它可以是0之间的任何值。

  • counterclockwise: 这是一个Boolean参数,指定从起始角度到结束角度绘制弧的方向。如果为 false,则顺时针绘制,如果为 true,则逆时针绘制。

弧实际上是路径,因此您必须使用beginPath()stroke()来绘制它们。以下代码绘制了一个圆的右下角。中心点在(100, 200)处。它的半径为40。角度从0开始,到π/2弧度或 90 度结束。并且是顺时针绘制的:

context.beginPath();
context.arc(100, 200, 40, 0, Math.PI / 2, false);
context.stroke();

您也可以使用arc()方法绘制一个完整的圆。一个圆只是从0弧度或 360 度绘制的完整弧:

context.beginPath();
context.arc(100, 200, 40, 0, 2 * Math.PI, false);
context.stroke();

如果您不熟悉弧度,让我简要介绍一下。弧度只是指定角度的另一种方式。它基于圆的周长公式;C = 2 * π *半径。通过将半径设置为1,我们可以使用该公式来测量从圆上的一个点到圆周上的另一个点的弧长。如果您测量整个圆,您将得到弧度。因此,弧度等于 360 度。圆的一半是π弧度,等于 180 度。圆的四分之一是π/2弧度或 90 度。

弧和圆

如果您更喜欢使用度数,您可以始终使用此转换函数将度数转换为弧度:

function toRadians(deg)
{
    return deg * Math.PI / 180;
}

以下是使用不同参数的弧的一些示例。弧 1 和 2 使用相同的起始和结束角度,只是以不同的方向绘制。弧 3 和 4 也是如此。弧 5 绘制一个完整的圆:

  • context.arc(100, 200, 40, 0, toRadians(90), true);

  • context.arc(200, 200, 40, 0, toRadians(90), false);

  • context.arc(300, 200, 40, 0, toRadians(180), true);

  • context.arc(400, 200, 40, 0, toRadians(180), false);

  • context.arc(500, 200, 40, 0, toRadians(360), false);

弧和圆

行动时间-添加圆形工具

让我们在我们的工具菜单中添加一个圆形菜单项:

<li data-value="circle">Circle</li>

现在让我们继续添加一个drawCircle()方法到Canvas2D。我们的方法将接受中心点、半径和一个布尔值来确定是否应该填充圆:

this.drawCircle = function(center, radius, fill)
{
    context.beginPath();
    context.arc(center.x, center.y, radius, 0, 2 * Math.PI, true)
    if (fill) context.fill();
    else context.stroke();
    return this;
};

如果 fill 参数设置为 true,我们在调用arc()后调用context.fill()。否则,我们只使用context.stroke()来绘制轮廓。

最后让我们添加代码到redraw()来绘制圆。这里我们需要做一些工作来找到传递到drawCircle()的半径。首先我们找到第一个点和第二个点之间的x的差值,然后找到y的差值。无论哪个更小,我们将使用它作为我们的半径:

switch (action.tool)
{
    // code not shown...
 case "circle":
 var dx = Math.abs(action.points[1].x – 
 action.points[0].x);
 var dy = Math.abs(action.points[1].y – 
 action.points[0].y);
 var radius = Math.min(dx, dy);
 canvas2d.drawRect(action.points[0], radius, 
 action.fill);
 break;
}

刚刚发生了什么?

我们在工具菜单中添加了一个新的菜单项,使用上下文的arc()方法来绘制圆。

打开应用程序并试一试。现在我们的应用程序中有一个相当不错的绘图工具集合。我们可以用各种颜色和不透明度制作一些更复杂的绘画,而不仅仅是黑色涂鸦。

刚刚发生了什么?

试一试

尝试添加自己的绘图工具,比如三角形或其他形状。在Canvas2D对象中实现形状的绘制,然后在工具栏中添加一个菜单项。

快速测验

Q1. 绘制弧时使用什么单位来定义角度?

  1. 单位

  2. 弧度

Q2. 用于将路径绘制到画布的上下文方法是什么?

  1. drawPath()

  2. stroke()

  3. draw()

  4. endPath()

总结

在本章中,我们创建了一个名为 canvas pad 的绘图应用程序,可以用来制作简单的绘画。在这个过程中,我们学习了如何使用 HTML5 画布元素和 API。我们还学习了如何实现一个可重用的工具栏,其中菜单项通过自定义数据属性绑定到操作。现在我们有一个可重用的工具栏,可以在其他应用程序中使用。

本章中我们涵盖了以下概念:

  • 如何使用<canvas>元素和 canvas API

  • 如何获取画布上下文并更改全局绘图属性,如宽度、颜色和不透明度

  • 如何使用路径绘制自由线条和形状

  • 如何绘制线条、矩形和圆形

  • 如何获取画布元素内鼠标的位置

  • 如何创建可重用的工具栏并实现下拉菜单

  • 如何使用自定义数据属性将操作绑定到菜单项

在下一章中,我们将继续探索画布。我们将学习一些更高级的画布功能,如变换和旋转。我们还将看到如何加载图像并从画布中导出它们,同时涉及文件 API。然后我们将深入到画布的单个像素,进行一些图像处理。

第五章:并不是空白画布

这个世界只是我们想象的画布。

  • 亨利·大卫·梭罗

在上一章中,我们学习了使用 HTML5 画布的基础知识。我们创建了一个名为 Canvas Pad 的绘图应用程序,其中包含用于以各种颜色和大小绘制线条和形状的工具。在本章中,我们将通过扩展 Canvas Pad 来添加更多工具来继续探索 Canvas API。然后,我们将创建一个名为 Photo Pad 的新应用程序,我们将学习如何使用 File API 加载图像,并通过访问和修改画布的单个像素来执行图像处理。

在本章中我们将学习:

  • 如何获取文本输入并将其绘制到画布上

  • 如何使用 Canvas API 变换函数来改变在画布上绘制项目的方式

  • 如何导出画布图像以保存它

  • 如何使用 HTML5 文件 API 加载图像

  • 如何将位图图像绘制到画布上

  • 如何获取画布中每个像素的数据,操纵它,并将其放回

绘制文本

在画布上有两种可用的绘制文本的方法:strokeText()fillText()strokeText()使用当前的lineWidthstrokeStyle绘制轮廓文本,而fillText()使用当前的fillStyle进行绘制。两者都接受相同的参数:要绘制的文本以及 x 和 y 坐标。上下文对象有一个全局的字体属性来定义要使用的font。您可以像在 CSS 中定义字体时一样设置它的值。在我们在上一章中使用 Canvas Pad 应用程序结束的地方继续,我们将添加一个新的文本绘制工具。您可以在第五章/example5.1中找到本节的源代码。

行动时间-添加文本工具

让我们首先在工具下拉菜单中添加一个新项目,用于文本工具:

<li data-value="text">Text</li>

接下来,我们将在Canvas2D对象中添加一个drawText()方法。它将接受要绘制的文本、从哪里绘制文本的点以及一个布尔值,指示是填充文本还是仅仅轮廓它。如果filltrue,它使用fillText()来绘制文本,否则它使用strokeText()

this.drawText = function(text, point, fill)
{
    if (fill)
    {
        context.fillText(text, point.x, point.y);
    }
    else
    {
        context.strokeText(text, point.x, point.y);
    }
};

现在我们需要一种方法,允许用户输入他/她想要绘制的文本。我们需要一个文本输入字段,我们将保持隐藏,直到用户想要添加一些文本。当用户选择文本工具并点击画布时,我们将把文本字段定位在他/她点击的位置,并等待他/她输入文本。当用户按下Enter键时,我们将隐藏文本字段并将文本绘制到画布上。

为了让用户看起来像是在画布上输入,我们需要在画布上下文中设置更多属性以用于字体。我们将在构造函数中设置fonttextBaseline属性。基线告诉上下文在哪里相对于位置绘制文本。我们将其设置为"top",这样它将在 y 位置绘制文本的顶部,这与我们的文本字段所在的位置相同。其他常见的基线值是"bottom""middle"

context.font = "24px Verdana, Geneva, sans-serif";
context.textBaseline = "top";

现在我们需要一个文本字段,让用户输入文本。让我们将它添加到我们的 HTML 文件底部,在页脚元素之后:

<div id="text-input">
    <input type="text" />
</div>

接下来让我们进入 CSS 并定义text-input元素的样式。我们将display设置为none,这样它就被隐藏了,并将position设置为absolute,这样我们就可以在页面上任意位置放置它。我们还将字体大小改为 24 像素,因为这是我们在上下文中设置的字体大小:

#text-input
{
    display: none;
    position: absolute;
    width: 8em; 
}
#text-input>input
{
    font-size: 24px;
}

现在让我们在CanvasPadApppenDown()方法中添加一些 JavaScript 代码,以便当用户点击鼠标时显示文本输入字段:

function penDown(pageX, pageY)
{
    if (curTool == "text")
 {
 // Check if it's already visible
 if ($("#text-input").is(":visible")) return;
 showTextInput(pageX, pageY);
 }
    else
    {
        drawing = true;
    }

    // code not shown...
}

首先检查当前的工具。如果是文本工具,它会检查文本字段是否已经可见,如果是,则无需继续。否则,它调用showTextInput()并传入鼠标坐标。请注意,在这种情况下,我们不会将drawing设置为true,因为我们不需要跟踪鼠标。

showTextInput()方法获取鼠标坐标并将text-input元素移动到用户在画布上单击鼠标的位置:

function showTextInput(pageX, pageY)
{
    $("#text-input").css("top", pageY)
                    .css("left", pageX)
                    .fadeIn("fast");
    $("#text-input input").val("").focus();
}

首先我们设置topleft CSS 属性来移动元素到用户单击的位置,然后淡入。然后重置文本字段的值并将焦点设置在上面,这样用户就可以开始输入。这将使用户看起来好像在画布上输入。

当用户输入完成后,他/她可以按Enter键完成文本。我们需要在文本字段中添加一个keydown事件处理程序来检查这一点。我们将在start()方法中添加这个。

$("#text-input input").keydown(function(e) { 
    checkTextInput(e.which);
});

处理程序调用checkTextInput(),传入按下的键的键码。键码在事件对象的which字段中找到:

function checkTextInput(key)
{
    if (key == 13) // Enter key
    {
        curAction.text =  $("#text-input input").val();
        $("#text-input").hide();
        redraw();
    }
    else if (key == 27) // Escape
    {
        actions.pop();
        $("#text-input").hide();
    }
}

checkTextInput()方法查看键码以确定要执行什么操作。如果用户按下Enter键,即键码为 13,它将把文本设置到当前操作对象中,隐藏文本输入,然后调用redraw()。如果键码是 27,即Escape键,它将通过移除操作然后隐藏文本输入来取消文本。

实现的最后一部分是对redraw()的更改。我们需要将文本操作添加到我们的switch语句中。它传入文本、绘制位置以及是否填充:

switch (action.tool)
{
    // code not shown...
    case "text":
 canvas2d.drawText(action.text, action.points[0],
 action.fill);
 break;
}

刚刚发生了什么?

我们在应用程序中添加了一个文本工具,允许用户在画布上输入文本并绘制填充或轮廓。

试试看

尝试为用户添加一个工具栏菜单,以选择不同的字体大小。你需要在画布上下文中改变字体大小,以及文本输入字段的样式。

变换

Canvas API 包含四种方法来转换画布上的绘图方式。它们改变了画布的坐标系,使得当你绘制东西时,它会在一个不同的位置绘制。可以把它想象成在绘制之前移动或旋转一张纸。

  • translate(x, y): 这将画布上绘制的任何东西平移指定的值。这些值可以是任何小数。负数向上和向左平移。通常你会使用translate()将形状平移到中心,然后对其应用其他变换。

  • scale(x, y): 这将画布上绘制的任何东西按指定的值进行缩放。参数可以是任何正的小数。如果你想要一切都是一半大小,你会使用 scale(0.5, 0.5)。如果你想要加倍大小,使用 scale(2, 2)。

  • rotate(angle): 这将以一个角度旋转画布。角度以弧度从 0 到 2π指定。负数将逆时针旋转。

  • transform(a, b, c, d, e, f): 如果其他变换方法对你不起作用,你可以使用transform()来创建自己的变换。我不建议这样做,除非你知道如何使用变换矩阵。

行动时间-添加椭圆工具

让我们使用一些变换来在 Canvas Pad 中绘制一个椭圆。椭圆基本上是一个扁平的圆。我们可以使用scale()方法在绘制圆之前改变 x 或 y 轴的比例,将其压扁成椭圆。让我们在Canvas2D对象中添加一个drawEllipse()方法。它需要一个中心点、一个终点和一个布尔值来确定是否应该填充:

this.drawEllipse = function(center, endPoint, fill)
{
    var rx = Math.abs(endPoint.x - center.x);
    var ry = Math.abs(endPoint.y - center.y);
    var radius = Math.max(rx, ry);
    var scaleX = rx / radius;
    var scaleY = ry / radius;

    context.save();
    context.translate(center.x, center.y);
    context.scale(scaleX, scaleY);
    context.beginPath();
    context.arc(0, 0, radius, 0, Math.PI * 2, true);
    context.closePath();
    if (fill) context.fill();
    else context.stroke();
    context.restore();

    return this;
};

这里有很多事情要做,所以让我们来分解一下:

  1. 首先我们通过计算终点和中心点坐标之间的距离来找到水平和垂直半径(rx 和 ry)。其中较大的那个将是椭圆的半径。

  2. 接下来我们通过将半径除以最大半径来找到水平和垂直比例。由于其中一个半径是最大半径,所以该比例将为 1。另一个将比 1 小。

  3. 接下来我们调用save()来保存上下文的状态,然后开始变换它。

  4. 现在我们进行变换。首先,我们将平移至椭圆的中心,这样它将围绕形状的中心进行变换。然后,我们按照之前计算的量进行缩放。

  5. 然后,我们使用beginPath()arc()closePath()来绘制圆。由于画布在一个轴上被缩放,圆将被压扁成椭圆。

  6. 然后,根据fill参数调用fill()stroke()来将圆绘制到画布上。

  7. 最后,我们调用restore()来恢复上下文到应用变换之前的状态,然后就完成了。

现在我们有了一个绘制椭圆的方法,我们可以在 HTML 中的工具菜单中添加一个椭圆菜单项:

<li data-value="ellipse">Ellipse</li>

唯一剩下的事情就是在redraw()switch语句中为椭圆工具添加一个选项,然后我们就完成了:

switch (action.tool)
{
    // code not shown...
    case "ellipse":
        canvas2d.drawEllipse(action.points[0], action.points[1], 
            action.fill);
        break;
}

刚刚发生了什么?

我们向应用程序添加了一个椭圆工具,并实现了一个使用变换在画布上绘制椭圆的方法,以便在一个轴上压扁圆。

行动时间-导出图像

我们可以使用 Canvas Pad 应用程序绘制图片,但如果我们不能保存它们,那有什么意义呢?由于安全风险,HTML5 无法直接将文件保存到用户的文件系统中。因此,我们在客户端的选择相当有限。我们可以将数据保存到localStorage中,或者我们可以在新的浏览器窗口中打开图像,用户可以使用浏览器的保存选项保存图像。我们将选择后者,因为它允许用户获得一个真正的图像文件。

您可以通过在画布元素本身(而不是上下文)上调用toDataURL()方法来将图像数据作为 URL 从画布中获取。然后,您可以使用window.open()在另一个窗口中打开图像 URL。让我们在工具栏中添加一个保存按钮,并将data-action属性设置为"save"

<button data-action="save">Save</button>

接下来,让我们在toolbarButtonClicked()方法的switch语句中添加对操作的检查。当单击保存按钮时,它将获取数据 URL,然后打开它:

switch (action.tool)
{
    // code not shown...
    case "save":
        var url = $("#main>canvas")[0].toDataURL();
        window.open(url, "CanvasPadImage");
        break;
}

刚刚发生了什么?

现在,我们可以使用上下文的toDataUrl()方法从画布中导出图像,并在另一个浏览器窗口中打开它们,以便用户可以保存图像。

刚刚发生了什么?

处理触摸事件

HTML5 的一个伟大之处在于您可以编写一个应用程序,它将在许多不同的设备上运行。Canvas Pad 作为一个桌面应用程序非常出色,因为它支持鼠标事件。但是它在触摸屏设备上同样表现出色。因此,让我们为应用程序添加对触摸事件的支持。

触摸事件类似于鼠标事件。一个区别是用户可以用多个手指触摸屏幕,因此触摸事件可能包含多个点。因此,在处理它们时,我们必须考虑到这一点。

浏览器支持三种基本的触摸事件。

  • touchstart:当用户触摸屏幕时,我们会收到此事件。这相当于mousedown事件。

  • touchmove:在touchstart之后,当用户在屏幕上移动手指时,我们会收到这些事件。这相当于mousemove事件。

  • touchend:当用户从屏幕上抬起手指时,我们会收到此事件。这相当于mouseup事件。

传递给事件处理程序的触摸事件对象包含一个名为touches的数组。该数组包含所有被触摸的点。touches数组中的每个对象都有一个pageX和一个pageY字段,就像鼠标事件一样。

您可以通过检查文档元素是否具有ontouchstart方法来测试是否支持触摸事件。

var touchSupported = "ontouchstart" in document.documentElement;

jQuery 不包括对触摸事件的支持,但如果我们可以使用相同的 jQuery 机制来为元素添加触摸事件处理程序,那将会很好。因此,让我们编写一个 jQuery 扩展来添加它。我们将创建一个名为touchEvents.js的新文件,以便将我们的扩展放入其中,以便在其他应用程序中重用它。

行动时间-添加触摸事件处理程序

扩展 jQuery 库实际上非常容易。首先,我们将我们的扩展包装在一个立即调用的函数表达式中,并将 jQuery 对象传递给它。这是一个最佳实践,以确保美元符号确实映射到 jQuery,而不是被其他东西使用。然后,我们通过将它们添加到 jQuery 的内部$.fn对象来定义我们的扩展方法:

(function($)
{
    $.fn.touchstart = function(handler)
    {
        this.each(function(i, e) { 
            e.addEventListener("touchstart", handler); });
        return this;
    };
    $.fn.touchmove = function(handler)
    {
        this.each(function(i, e) { 
            e.addEventListener("touchmove", handler); });
        return this;
    };

    $.fn.touchend = function(handler)
    {
        this.each(function(i, e) { 
            e.addEventListener("touchend", handler); });
        return this;
    };

    $.isTouchSupported =
        ("ontouchstart" in document.documentElement);
})(jQuery);

注意

请注意,在扩展方法的上下文中,this指针指的是包装所选元素的 jQuery 对象。因此,this.each()会迭代选择的每个元素。

touchstarttouchmovetouchend方法都以相同的方式工作。它们遍历元素,并为每个元素调用addEventListener()

我们还在 jQuery 对象上直接定义了一个全局的isTouchSupported变量。它使用之前描述的方法来检查触摸支持。我们将使用它来确定我们的应用程序是否应该使用触摸或鼠标事件。

注意

您可以在 jQuery 网站上了解更多关于编写 jQuery 扩展的信息(jquery.com)。

我们的扩展已经完成,所以让我们回到CanvasPadApp,并在我们的应用程序中添加处理触摸事件的代码。首先在start()方法中,我们需要检查是否支持触摸,并连接正确的事件:

if ($.isTouchSupported)
{
    $("#main>canvas").touchstart(onTouchStart)
        .touchmove(onTouchMove)
        .touchend(onTouchEnd);
}
else
{
    $("#main>canvas").mousedown(onMouseDown)
        .mousemove(onMouseMove)
        .mouseup(onMouseUp)
        .mouseout(onMouseUp);
}

onTouchStart()事件处理程序方法必须在事件对象上调用stopPropagation()preventDefault(),以防止它执行默认行为。否则它可能会尝试拖动屏幕而不是在画布上绘制:

function onTouchStart(e)
{
    e.stopPropagation();
    e.preventDefault();
    penDown(e.touches[0].pageX, e.touches[0].pageY);
}

接下来,我们提取用户触摸的点。可能有多个点,但我们只对touches数组中的第一个点感兴趣。我们从中提取pageXpageY字段,并将它们传递给penDown()方法。

onTouchMove()处理程序的工作方式相同,只是调用penMoved()

function onTouchMove(e)
{
    e.stopPropagation();
    e.preventDefault();
    penMoved(e.touches[0].pageX, e.touches[0].pageY);
}

onTouchEnd()处理程序简单地调用penUp(),与onMouseUp()一样。

function onTouchEnd(e)
{
    penUp();
}

刚刚发生了什么?

我们创建了一个可重用的 jQuery 扩展,以向任何元素添加触摸事件,并向我们的应用程序添加了触摸支持。我们现在有一个可以用于在桌面和移动设备上绘制的绘图应用程序。

有了这个,我们的 Canvas Pad 应用程序就完成了,但我们还没有完成学习有关画布的知识。现在我们将转向我们的下一个应用程序 Photo Pad,在那里我们将学习一些更高级的画布功能和文件 API。

Photo Pad

我们接下来要编写的应用程序叫做 Photo Pad。它看起来很像 Canvas Pad,并且重用了工具栏和菜单的相同代码。但它不是一个绘图应用程序,而是一个照片处理应用程序。用户将能够加载图像并从几种不同的效果中选择,例如反转、黑白或棕褐色,然后应用到图像上。

行动时间-创建 Photo Pad

让我们像往常一样,首先复制我们在第一章中创建的应用程序模板,然后将文件重命名为photoPad.htmlphotoPad.cssphotoPad.js。在 HTML 文件中,我们将添加一个带有加载、保存和效果按钮的工具栏。您可以在第五章/example5.2中找到此部分的代码:

<body>
    <div id="app">
        <header>Photo Pad </header>
        <div id="main">
            <div id="toolbar">
                <div class="dropdown-menu">
                    <button data-action="menu">Load</button>
                    <ul id="load-menu" data-option="file-picker"
                        class="file-picker menu">
                        <li data-value="file-picker">
                            <input type="file" />
                        </li>
                    </ul>
                </div>
                <button data-action="save">Save</button>
                <div class="dropdown-menu">
                    <button data-action="menu">Effects</button>
                    <ul data-option="applyEffect" class="menu">
                        <li data-value="invert">Invert</li>
                    </ul>
                </div>
            </div>
            <canvas width="0" height="0">
                Sorry, your browser doesn't support canvas.
            </canvas>
        </div>
        <footer>Click load to choose a file</footer>
    </div>
</body>

加载工具栏项有一个下拉菜单,但里面没有菜单项,而是有一个文件输入控件,用户可以在其中选择要加载的文件。效果项目有一个效果的下拉菜单。目前我们只有一个,即反转,但以后我们会添加更多。

对于我们的 CSS,我们将把canvasPad.css中的所有内容复制到photoPad.css中,这样我们就可以获得工具栏和菜单的所有相同样式。我们还将在toolbar.js中使用Toolbar对象。

在我们的 JavaScript 文件中,我们将应用程序对象名称更改为PhotoPadApp。我们还需要在PhotoPadApp中定义一些变量。我们将canvas变量设置为<canvas>元素,将context变量设置为画布的上下文,并定义一个$img变量来保存我们将要显示的图像。在这里,我们使用 jQuery 将其初始化为一个新的<img>元素:

function PhotoPadApp()
{
    var version = "5.2",
        canvas = $("#main>canvas")[0],
        context = canvas.getContext("2d"),
        $img = $("<img>");

我们将要实现的第一个工具栏操作是保存按钮,因为我们已经从 Canvas Pad 中拥有了该代码。我们在toolbarButtonClicked()中检查操作是否为"save",如果是,我们获取数据 URL 并在新的浏览器窗口中打开它:

function toolbarButtonClicked(action)
{
    switch (action)
    {
        case "save":
            var url = canvas.toDataURL();
            window.open(url, "PhotoPadImage");
            break;
    }
}

刚刚发生了什么?

我们使用工具栏项目为 Photo Pad 应用程序创建了脚手架,包括加载、保存和效果。我们实现了与 Canvas Pad 相同的保存功能。

接下来,我们将要实现的是加载下拉菜单,因为我们需要一个图像来操作。当单击加载工具栏按钮时,它将显示带有我们之前定义的文件输入控件的下拉菜单。所有这些都是免费的,因为它只是工具栏中的另一个下拉菜单。

但在此之前,我们需要了解 HTML5 文件 API。

文件 API

我们可能无法直接将文件保存到用户的文件系统,但我们可以使用 HTML5 的文件 API 访问文件。文件 API 允许您获取有关用户选择的文件的信息并加载文件的内容。用户可以使用类型为file的输入元素选择文件。加载文件的过程如下:

  1. 用户使用<input type="file">元素选择一个或多个文件。

  2. 我们从输入元素的files属性中获取文件列表。该列表是一个包含 File 对象的FileList对象。

  3. 您可以枚举文件列表并像访问数组一样访问文件。

File对象包含三个字段。

  • name: 这是文件名。它不包括路径信息。

  • size: 这是文件的大小(以字节为单位)。

  • type: 这是 MIME 类型,如果可以确定的话。

  1. 使用FileReader对象读取文件的数据。文件是异步加载的。文件读取后,它将调用onload事件处理程序。FileReader有许多用于读取文件的方法,这些方法接受一个File对象并返回文件内容。
  • readAsArrayBuffer(): 此方法将文件内容读入ArrayBuffer对象中。

  • readAsBinaryString(): 此方法将文件内容作为二进制数据读入字符串中。

  • readAsText(): 此方法将文件内容作为文本读入字符串中。

  • readAsDataURL(): 此方法将文件内容读入数据 URL 字符串。您可以将其用作加载图像的 URL。

行动时间-加载图像文件

让我们在应用程序的start()方法中添加一些代码来检查文件 API 是否可用。您可以通过检查FileFileReader对象是否存在来确定浏览器是否支持文件 API:

this.start = function()
{
    // code not shown...
    if (window.File && window.FileReader)
    {
        $("#load-menu input[type=file]").change(function(e) {
            onLoadFile($(this));
        });
    }
    else
    {
        loadImage("images/default.jpg");
    }
}

首先,我们检查window对象中是否有FileFileReader对象。如果有,我们将为文件输入控件连接一个 change 事件处理程序,以调用onLoadFile()方法并传入用 jQuery 对象包装的<input>元素。如果文件 API 不可用,我们将通过调用loadImage()来加载默认图像,稍后我们将编写该方法。

让我们实现onLoadFile()事件处理程序方法:

function onLoadFile($input)
{
    var file = $input[0].files[0];
    if (file.type.match("image.*"))
    {
        var reader = new FileReader();
        reader.onload = function() { loadImage(reader.result); };
        reader.readAsDataURL(file);        
    }
    else
    {
        alert("Not a valid image type: " + file.type);
        setStatus("Error loading image!");
    }
}

在这里,我们通过查看文件输入的files数组并取第一个来获取所选的文件。接下来,我们检查文件类型,即 MIME 类型,以确保它是图像。我们使用String对象的正则表达式match()方法来检查它是否以"image"开头。

如果是图像,我们将创建FileReader对象的一个新实例。然后,我们将设置onload事件处理程序以调用loadImage()方法,并传入FileReader对象的result字段,其中包含文件的内容。最后,我们调用FileReader对象的readAsDataURL()方法,传入File对象以异步开始加载文件。

如果不是图像文件,我们将显示一个带有错误消息的警报对话框,并通过调用setStatus()在页脚显示错误消息。

文件读取完成后,将调用loadImage()方法。在这里,我们将使用从FileReader对象的result字段获得的数据 URL 将图像绘制到画布中:

function loadImage(url)
{
    setStatus("Loading image");
    $img.attr("src", url);
    $img[0].onload = function()
    {
        // Here "this" is the image
        canvas.width = this.width;
        canvas.height = this.height;
        context.drawImage(this, 0, 0);
        setStatus("Choose an effect");
    }
    $img[0].onerror = function()
    {
        setStatus("Error loading image!");
    }
}

首先,我们将图像元素的src属性设置为文件加载后获得的数据 URL。这将导致图像元素加载新图像。

接下来,我们为图像定义了onload事件处理程序,以便在图像加载时收到通知。请注意,当我们在onload事件处理程序内部时,this指向<image>元素。首先,我们将画布的宽度和高度更改为图像的宽度和高度。然后,我们使用上下文的drawImage()方法在画布上绘制图像。它接受要绘制的图像以及要绘制的 x 和 y 坐标。在这种情况下,我们在画布的左上角(0,0)绘制它。

最后,我们为图像设置了一个onerror事件处理程序。如果加载图像时发生错误,我们将在页脚显示错误消息。

刚刚发生了什么?

我们学习了如何使用文件 API 从用户的文件系统加载图像文件。在加载图像后,我们调整了画布的大小以适应图像的大小,并将图像绘制到画布上。

添加效果

现在让我们向效果菜单添加一些效果。我们将首先实现的是颜色反转。它将获取画布中的图像并反转颜色,使图像看起来像旧的底片(还记得那些吗?)。我们可以通过迭代图像中的每个像素并反转它们的颜色来实现这一点。

您可以使用上下文的getImageData()方法从画布中获取像素。它获取画布的矩形区域的像素。您传递它区域的位置和大小:

var data = context.getImageData(0, 0, width, height);

getImageData()方法返回一个字节数组,每个像素有四个字节,代表每个像素的颜色。第一个字节是红色量,第二个是绿色量,第三个是蓝色量,第四个是 alpha 量。所有值都在 0 到 255 之间。数组中的字节总数为4 宽度高度

在获取图像数据之后,您可以访问和更改数组中的任何值。请注意,这只会更改内存中的图像。更改图像数据后,您可以使用putImageData()方法将其写回到画布。此方法接受要绘制的图像数据和要绘制的位置的参数。

context.putImageData(data, 0, 0);

行动时间-图像效果对象

现在,我们将创建一个名为imageEffects的新对象,将所有图像效果的代码封装在一个新文件imageEffects.js中。imageEffects对象将是使用揭示模块模式定义的全局静态对象。

注意

使用揭示模块模式,您在私有范围内定义一组函数,然后返回一个匿名对象,该对象公开了您想要公开的这些方法。这对于定义静态对象很有效。

让我们首先定义imageEffects对象,并添加两个保持私有的辅助函数。它们用于获取和设置整个画布的图像数据:

var imageEffects = function()
{
    function getImageData(canvas)
    {
        return canvas.getContext("2d").getImageData(0, 0,
            canvas.width, canvas.height)
    }

    function putImageData(canvas, imageData)
    {
        canvas.getContext("2d").putImageData(imageData, 0, 0);
    }

getImageData()方法获取画布并返回整个画布的图像数据。putImageData()方法接受画布和图像数据作为参数,并将图像数据放回画布。

让我们实现我们的第一个效果;反转图像的颜色。invert()方法以画布作为参数。反转颜色非常简单。我们只需取每个像素的每个颜色通道并从 255 的最大颜色值中减去它的值:

    function invert(canvas)
    {
        var imageData = getImageData(canvas);
        var data = imageData.data;
        for (var i = 0; i < data.length; i += 4)
        {
            data[i]   = 255 - data[i];   //red
            data[i+1] = 255 - data[i+1]; //green
            data[i+2] = 255 - data[i+2]; //blue
            //data[i+3] is alpha
        }

        putImageData(canvas, imageData);
    }

首先,我们获取画布的图像数据,然后循环遍历字节,每次递增四个,因为每个像素有四个字节。每个颜色通道值都被反转并设置回字节中。Alpha 值保持不变。然后我们将图像数据放回画布。

现在让我们完成imageEffects对象。我们需要返回一个匿名对象,定义我们想要公开的所有方法。到目前为止,我们只有invert()方法:

    return {
        invert: invert
    };
}();

请注意,我们在函数声明的末尾有开括号和闭括号。这立即执行函数,并将返回的匿名对象分配给imageEffects变量。所以现在我们有一个imageEffects对象,其中有一个invert()公共方法。

现在我们需要将 Effects 菜单项与imageEffects对象连接起来。我们可以在PhotoPadAppmenuItemClicked()方法中进行这样的操作。之前,我们给菜单元素设置了一个data-option自定义属性,值为"applyEffect"。所以我们将检查这个属性:

function menuItemClicked(option, value)
{
    if (option == "applyEffect")
    {
        imageEffectsvalue;
    }
}

我们给 Invert 菜单项元素设置了一个data-value自定义属性,值为"invert"。我们将使用这个值来动态访问imageEffects对象中的invert()方法,就像我们在第三章中进行数据绑定一样。我们将canvas对象作为参数传递。对于"invert",这相当于调用imageEffects.invert(canvas)。我们将以这种方式实现所有菜单项,以便它们自动绑定到imageEffects对象中的方法。

刚刚发生了什么?

我们创建了一个imageEffects对象来保存所有的图像效果算法。我们实现了一个反转图像颜色的效果。我们使用自定义数据属性将 Effects 菜单与imageEffects对象中的方法绑定起来。

现在让我们在浏览器中打开我们的应用程序并尝试一下。加载图像后,从 Effects 菜单中选择Invert,您应该看到反转后的图像:

刚刚发生了什么?

黑白时间行动

好的,invert()方法非常简单。让我们尝试一些更具挑战性的东西,但不是太多。我们将实现一个将彩色图像转换为黑白的效果。让我们在imageEffects对象中实现一个toBlackAnWhite()方法:

function toBlackAndWhite(canvas)
{
    var imageData = getImageData(canvas);
    var data = imageData.data;
    for (var i = 0; i < data.length; i += 4)
    {
        var grayscale = (data[i] * 0.3) +
            (data[i + 1] * .59) +
            (data[i + 2] * .11);
        data[i]   = grayscale;
        data[i+1] = grayscale;
        data[i+2] = grayscale;
    }

    putImageData(canvas, imageData);
}

对于每个像素,我们通过取每个颜色通道的百分比并将它们相加来计算灰度值;30%红色,59%绿色和 11%蓝色。然后我们将每个颜色通道设置为该灰度值。

现在让我们在 Effects 菜单中添加一个黑白菜单项。data-value属性设置为我们之前创建的方法toBlackAndWhite

<li data-value="toBlackAndWhite">B&amp;W</li>

刚刚发生了什么?

我们创建了一个过滤器,将每个像素更改为其灰度值,并将其设置回图像数据中。现在我们可以将彩色图像转换为黑白:

刚刚发生了什么?

古铜时间行动

让我们实现另一个简单的效果。这次我们将图像转换为古铜色,给它一种老式照片的外观。古铜色与黑白色非常相似,只是略微温暖。首先让我们为它添加菜单项,并将data-value属性设置为toSepia

<li data-value="toSpeia">Sepia</li>

现在让我们在imageEffects对象中添加一个toSepia()方法。

function toSepia(canvas, depth, intensity)
{
    depth = depth || 20;
    intensity = intensity || 10;

    var imageData = getImageData(canvas);
    var data = imageData.data;
    for (var i = 0; i < data.length; i += 4)
    {
        var grayscale = (data[i] * 0.3) +
            (data[i + 1] * .59) +
            (data[i + 2] * .11);
        data[i]   = Math.min(255, grayscale + (depth * 2));
        data[i+1] = Math.min(255, grayscale + depth);
        data[i+2] = Math.max(0, grayscale - intensity);
    }

    putImageData(canvas, imageData);
}

尽管toSepia()有三个参数,但我们只会传入一个参数,即画布,这样我们就可以使用我们的默认 Effects 菜单处理代码,并将其余设置为默认值。该方法的前两行设置了depthintensity参数的默认值。depth用于调整红色和绿色通道,intensity用于调整蓝色通道,以便更精细调整最终结果。

要将像素转换为它的棕褐色调,我们首先以与黑白相同的方式获取灰度值。然后,我们根据通道调整这些值,而不仅仅是为所有颜色通道设置灰度。红色增强最多,这解释了棕褐色的红色调。绿色也增强,增强的程度是红色的一半。蓝色按强度值减少。我们使用Math.max()min()函数来确保我们不会设置超出范围的值。

刚刚发生了什么?

我们创建了一个滤镜,通过找到灰度并独立调整颜色通道的固定数量来将彩色图像转换为棕褐色,这个数量可以作为参数传入或默认值:

刚刚发生了什么?

试试看

尝试在计算灰度值时使用不同百分比的红色、绿色和蓝色,看看它对图像有什么影响。尝试传入不同的深度和强度值,看看它对棕褐色调有什么影响。

图像失真

接下来,我们将添加一个更高级的效果。我们将采用图像并使用波浪进行扭曲,使其看起来像是水中的倒影。我们可以使用Math.sin()方法来偏移像素位置,使其呈波浪状。因此,这一次我们不是改变颜色通道,而是移动像素。

行动时间-制造波浪

让我们为我们的波浪效果添加菜单项。我们给它一个data-value自定义属性,设置为makeWaves

<li data-value="makeWaves">Waves</li>

现在我们将编写makeWaves()方法。它将有四个参数;canvasamplitudefrequencyphaseamplitude确定波浪的大小,frequency确定有多少波浪,phase确定波浪从哪里开始。与toSepia()方法一样,我们只会传入canvas参数,但您可以尝试不同的参数,看看它们有什么影响:

function makeWaves(canvas, amplitude, frequency, phase)
{
    amplitude = amplitude || 10;
    frequency = frequency || 4;
    phase = phase || 0;

    var data = getImageData(canvas).data;
    var newImageData = getImageData(canvas);
    var newData = newImageData.data;
    var width = newImageData.width;
    var height = newImageData.height;

    // Adjust frequency to height of image
    frequency = frequency * 2 * Math.PI / height;

    for (var y = 0; y < height; y++)
    {
        var xoff = 4 * Math.floor(amplitude *
            Math.sin(y * frequency + phase));
        var yoff = y * 4 * width;

        for (var x = 0; x < width; x++)
        {
            var pos = yoff + x * 4;
            newData[pos + xoff]     = data[pos];
            newData[pos + xoff + 1] = data[pos+1];
            newData[pos + xoff + 2] = data[pos+2];
            newData[pos + xoff + 3] = data[pos+3];
        }
    }

    putImageData(canvas, newImageData);
}

我们要做的第一件事是设置参数的默认值。然后设置一些变量。这一次我们将需要两组图像数据。一个是我们的原始图像,另一个newImageData是我们将要更改并最终写回画布的工作集。

接下来,我们调整频率值,使其相对于图像的高度。这样,如果我们想要频率为四,图像从顶部到底部将有四个波浪。

现在是时候迭代像素了。在外部循环中,我们迭代图像的行。对于每一行,我们通过计算该行的正弦值并将其乘以 4(每个像素的颜色通道数)来计算 x 偏移量。这给我们提供了偏移量,以字节为单位,进入图像数据数组。我们还计算 y 偏移量,这是当前行数组的字节偏移量。

接下来,我们迭代每一行中的每个像素。在这个循环内,我们将像素数据从原始图像数据复制到工作图像数据数组中,偏移位置。应用正弦波以获取像素偏移量会给我们一个波浪般的图案:

行动时间-制造波浪

刚刚发生了什么?

我们创建了一个失真效果,使用正弦波使图像看起来波浪起伏。它通过计算从原始图像的偏移量并将像素复制到新图像中来实现这一点。

试试看

尝试想出自己的效果并将其添加到 Photo Pad 应用程序中。例如,您可以使图像变暗或变亮。对于更高级的效果,请尝试通过计算像素及其相邻像素的平均颜色来模糊图像(如果您想看看如何实现,我已经在本节的示例代码中实现了它)。

小测验

Q1. 触摸事件与鼠标事件有何不同?

  1. 触摸事件可以有任意数量的点

  2. 触摸事件没有任何点

  3. 触摸事件没有preventDefault()方法

  4. 没有区别

Q2. 画布图像数据中每个像素有多少字节?

总结

在本章中,我们继续使用 Canvas Pad 应用程序。我们学习了在画布上绘制文本和通过绘制椭圆来进行变换。我们通过添加对触摸事件的支持使 Canvas Pad 具备了触摸功能。然后我们创建了一个名为 Photo Pad 的新应用程序,在那里我们学习了如何使用 HTML5 文件 API 从用户文件系统加载文件。我们进行了一些图像处理,以学习如何直接访问和操纵画布上的像素。

在本章中,我们涵盖了以下概念:

  • 如何在画布上绘制文本

  • 如何使用 Canvas API 的变换来进行平移、旋转、缩放等操作,以改变画布上的绘制方式

  • 如何创建一个 jQuery 插件来检查触摸设备并为元素添加触摸事件

  • 如何使用文件 API 访问用户文件系统中的文件,并使用FileReader对象将它们读入内存

  • 如何加载图像文件并将其绘制到画布上

  • 如何访问画布的像素并操纵它们的颜色以实现一些图像处理滤镜

在下一章中,我们将再次开启全新的方向。我们将通过构建一个虚拟钢琴来学习 HTML5 <audio>元素和音频 API。

第六章:钢琴人

“音乐不仅是艺术,不仅是文学,它是普遍可及的。” – 比利·乔尔

在本章中,我们将通过创建一个虚拟钢琴应用程序来学习如何使用音频。首先,我们将学习 HTML5 音频元素和 API。然后,我们将创建一个音频管理器,以异步加载音频文件并缓存它们以供以后播放。我们将使用 HTML 元素创建一个键盘,并使用 CSS 进行样式设置。

在本章中,我们将学习以下内容:

  • HTML5 <audio> 元素及其属性

  • 如何使用音频 API 来控制应用程序中的音频

  • 如何动态加载音频文件

  • 如何处理键盘事件,将计算机键盘转换为钢琴键盘

  • 如何使用范围输入来控制音频元素的音量

  • 如何检查您的浏览器是否支持范围输入类型

HTML5 音频概述

在我们开始编写钢琴应用程序之前,我们需要学习如何使用 HTML5 音频的基础知识。因此,让我们从 <audio> 元素及其 API 的概述开始。

HTML5

HTML5 <audio> 元素用于定义在网页或应用程序中播放的音频文件。audio 元素可以在页面上具有可见控件,也可以保持隐藏并且可以通过 JavaScript 进行控制。以下是它支持的一些最有用的属性:

  • src: 要加载的音频文件的 URL。

  • autoplay: 用于指定文件在加载后立即开始播放。

  • controls: 告诉浏览器在页面上显示音频控件。否则,元素不会显示任何内容。

  • loop: 指定音频将循环播放。

  • muted: 指定音频将被静音。

  • preload: 定义音频文件的加载方式。

  • auto: 页面加载时加载音频文件。这是默认设置。

  • none: 不预加载文件,等待播放。

  • metadata: 页面加载时仅加载有关文件的元数据。

以下在页面加载后自动播放 audioFile.mp3 并在页面上显示音频控件:

<audio src="img/audioFile.mp3" autoplay controls>
    Your browser doesn't support audio.
</audio>

在 Chrome 上显示在页面上时的样子如下:

HTML5  元素

如果浏览器不支持 <audio> 元素,它将显示元素内的任何内容。

虽然您可以使用 src 属性指定要加载的文件,但不建议这样做。不同的浏览器支持不同的文件类型,因此如果您只指定一个文件,它可能在所有浏览器上都无法工作。相反,您应该在 <audio> 元素内指定 <source> 子元素,以定义要使用的不同音频文件的列表。浏览器将使用它支持的第一个文件:

<audio controls>
    <source src="img/audioFile.mp3">
    <source src="img/audioFile.ogg">
    <source src="img/audioFile.wav">
</audio>

支持的三种主要音频类型是 MP3、Ogg 和 WAV。您至少应提供 MP3 和 Ogg 文件,因为所有主要浏览器都支持其中一种。如果您还想包括 WAV 文件,请将其放在列表的最后,因为 WAV 文件未经压缩,因此需要大量带宽来下载。

HTML5 音频 API

如果您只能使用 HTML5 音频在网页上放置一个元素让用户听音乐,那将会很无聊,这一章将结束。但是像 <canvas> 元素一样,<audio> 元素有一个完整的 API 支持它。我们可以使用音频 API 来控制何时以及如何从 JavaScript 播放音频剪辑。

音频 API 包含大量的方法和属性。以下是其中一些最有用的方法:

  • play(): 开始播放音频剪辑。

  • pause(): 暂停音频剪辑的播放。

  • canPlayType(type): 用于确定浏览器是否支持某种音频类型。传入音频 MIME 类型,如 "audio/ogg""audio/mpeg"。它返回以下值之一:

  • "probably": 很可能支持

  • "maybe": 浏览器可能能够播放它

  • ""(空字符串):不支持

  • currentTime:用于获取或设置当前播放时间(以秒为单位)。这使我们能够在播放之前将声音定位到某个特定点。通常我们会将其设置为0以重新开始播放声音。

  • volume:用于获取或设置音量。可以是01之间的任何值。

  • ended:用于确定声音是否已完全播放。

注意

请注意,<audio><video>元素都共享相同的 API。因此,如果你知道如何使用 HTML 音频,你也知道如何使用视频。

我们可以使用音频 API 来做一些有趣的事情。在本章中,我们将创建一个虚拟钢琴,用户可以通过在屏幕上点击钢琴键来在网页上演奏。

加载音频文件

你可以通过在 HTML 文件中为每个音频文件添加<audio>元素来定义应用程序的所有音频文件。但是,我们也可以从 JavaScript 动态加载音频文件,以控制它们的加载方式和时间。我们可以像在上一章中动态加载图像文件一样加载它们。首先,我们创建一个新的<audio>元素,并将src属性设置为音频文件的名称:

var audio = $("<audio>")[0];
audio.src = "2C.mp3";

接下来,我们添加一个事件处理程序,以便在音频文件加载完成时收到通知。我们可以使用两个事件。canplay事件在浏览器有足够的数据开始播放音频时触发。canplaythrough事件在文件完全加载后触发:

audio.addEventListener("canplaythrough", function()
{
    audio.play();
});

行动时间 - 创建 AudioManager 对象

让我们将加载音频文件封装到一个可重用的对象中。我们将创建一个名为AudioManager的新对象,并将其放在名为audioManager.js的文件中。该对象将抽象出加载、缓存和访问音频文件所需的所有代码。

我们对象的构造函数接受一个名为audioPath的参数,这是存储音频文件的路径:

function AudioManager(audioPath)
{
    audioPath = audioPath || "";
    var audios = {},
        audioExt = getSupportedFileTypeExt();

如果未定义audioPath,我们将其默认为一个空字符串。然后我们添加一个名为audios的变量,它是一个对象,将用于缓存所有已加载的<audio>元素。最后,我们定义一个变量来保存浏览器支持的音频文件扩展名,我们将通过调用getSupportedFileTypeExt()方法来确定:

    function getSupportedFileTypeExt()
    {
        var audio = $("<audio>")[0];
        if (audio.canPlayType("audio/ogg")) return ".ogg";
        if (audio.canPlayType("audio/mpeg")) return ".mp3";
        if (audio.canPlayType("audio/wav")) return ".wav";
        return "";
    };

首先,我们在内存中创建一个新的<audio>元素,并使用它调用canPlayType()方法来确定浏览器支持的文件类型。然后我们返回该类型的文件扩展名。

接下来,我们需要一种从AudioManager对象获取音频文件的方法。让我们添加一个公共的getAudio()方法:

    this.getAudio = function(name, onLoaded, onError)
    {
        var audio = audios[name];
        if (!audio)
        {
            audio = createAudio(name, onLoaded, onError);
            // Add to cache
            audios[name] = audio;
        }
        else if (onLoaded)
        {
            onLoaded(audio);
        }
        return audio;
    };

getAudio()方法接受三个参数。第一个是没有扩展名的音频文件的名称。在加载文件时,我们稍后将为其添加音频路径和默认扩展名。接下来的两个参数是可选的。第二个参数是在文件加载完成时将被调用的函数。第三个是在加载文件时将被调用的函数。

getAudio()的第一件事是检查audios对象,看看我们是否已经加载并缓存了该文件。在这种情况下,audios对象被用作关联数组,其中键是文件名,值是音频元素。这样可以很容易地通过名称查找<audio>元素。

如果文件尚未添加到缓存中,那么我们将创建一个新的audio元素,并通过调用createAudio()方法来加载它,接下来我们将实现。然后将新元素添加到audios对象中以进行缓存。

如果文件名已经在缓存中,那么我们立即调用传递的onLoaded()处理程序函数,因为文件已加载。

现在让我们编写私有的createAudio()方法。它接受与上一个方法相同的参数:

    function createAudio(name, onLoaded, onError)
    {
        var audio = $("<audio>")[0];
        audio.addEventListener("canplaythrough", function()
        {
            if (onLoaded) onLoaded(audio);
            audio.removeEventListener("canplaythrough",
                arguments.callee);
        });
        audio.onerror = function()
        {
            if (onError) onError(audio);
        };
        audio.src = audioPath + "/" + name + audioExt;
        return audio;
    }
}

首先,我们使用 jQuery 创建一个新的<audio>元素。然后我们为canplaythrough添加一个事件监听器。当事件触发时,我们检查方法中是否传入了onLoaded函数。如果是,我们调用它并传递新的<audio>元素。我们还需要删除事件监听器,因为有些浏览器会在每次播放音频时调用它。

我们还为<audio>元素添加了一个onerror处理程序,以检查加载文件时是否出现错误。如果出现错误,它将调用onError函数(如果已定义)。

接下来,我们将<audio>元素的src属性设置为音频文件的 URL。我们通过组合audioPath、名称参数和audioExt来构建 URL。这将导致音频文件开始加载。最后,我们返回新的<audio>元素。

刚刚发生了什么?

我们创建了一个名为AudioManager的对象来加载和缓存音频文件。当我们第一次请求音频文件时,它会被加载和缓存。下一次它将使用缓存的音频。例如,如果我们的浏览器支持 Ogg 文件,以下代码将加载audio/2C.ogg音频文件:

var audioManager = new AudioManager("audio");
var audio = audioManager.getAudio("2C");

HTML5 钢琴应用程序

现在让我们创建我们的 HTML5 钢琴应用程序。我们将拥有两个八度的钢琴键,包括黑色和白色,并且我们将使用一些样式使其看起来像一个真正的键盘。当用户用鼠标点击键时,它将播放相应的音符,该音符在音频文件中定义。

您可以在chapter6/example6.1中找到此部分的代码。

行动时间-创建虚拟钢琴

我们将像往常一样,复制我们在第一章中创建的应用程序模板,手头的任务,并将文件重命名为piano.htmlpiano.csspiano.js。我们还需要touchEvents.js,这是我们在上一章中创建的。

piano.js中,我们将应用程序对象更改为PianoApp

function PianoApp()
{
    var version = "6.1",
        audioManager = new AudioManager("audio");

我们创建了一个AudioManager的实例,并传入了我们音频文件的路径,这将是audio文件夹。现在让我们打开我们的 HTML 文件并添加所有的钢琴键:

<div id="keyboard">
    <div id="backboard"></div>
    <div class="keys">
        <div data-note="2C" class="piano-key white"></div>
        <div data-note="2C#" class="piano-key black"></div>
        <div data-note="2D" class="piano-key white"></div>
        <div data-note="2D#" class="piano-key black"></div>
        <div data-note="2E" class="piano-key white"></div>
        <div data-note="2F" class="piano-key white"></div>
        <div data-note="2F#" class="piano-key black"></div>
        <div data-note="2G" class="piano-key white"></div>
        <div data-note="2G#" class="piano-key black"></div>
        <div data-note="2A" class="piano-key white"></div>
        <div data-note="2A#" class="piano-key black"></div>
        <div data-note="2B" class="piano-key white"></div>
        <!-- third octave not shown -->
        <div data-note="4C" class="piano-key white"></div>
    </div>
</div>

在“main”元素内,我们添加一个<div>标签,id设置为keyboard。在里面,我们有一个<div>标签,它将成为背板,以及一个包含所有键的<div>标签。每个键由一个包含piano-key类和whiteblack类的元素定义,具体取决于键的颜色。每个键元素还有一个data-note自定义数据属性。这将设置为钢琴键音符的名称,也将是匹配音频文件的名称。

我们的钢琴有两个完整的八度钢琴键。每个键都有自己的音频文件。由于每个八度有 12 个音符,并且我们在键盘末尾有一个额外的 C 音符,我们将有 25 个音频文件,命名为2C4C。我们希望提供 Ogg 和 MP3 格式的音频文件以支持所有浏览器,因此总共有 50 个音频文件:

行动时间-创建虚拟钢琴

让我们打开piano.css并为应用程序设置样式。首先,我们将通过将position设置为absolute并将所有position值设置为0来使应用程序占据整个浏览器窗口。我们将给它一个从白色到蓝色的线性渐变:

#app
{
    position: absolute;
    top: 0;
    bottom: 0;
    left: 0;
    right: 0;
    margin: 4px;
    background-color: #999;
    /* browser specific gradients not shown */
    background: linear-gradient(top, white, #003);
}

我们还将footer选择器的position属性设置为absolutebottom设置为0,这样它就贴在窗口底部了:

#app>footer
{
    position: absolute;
    bottom: 0;
    padding: 0.25em;
    color: WhiteSmoke;
}

在主要部分,我们将text-align设置为center,这样键盘就居中在页面上了:

#main
{
    padding: 4px;
    text-align: center;
}

现在让我们为键盘设置样式,使其看起来像一个真正的钢琴键盘。首先,我们给整个键盘一个从深棕色到浅棕色的渐变和一个阴影,使其具有一定的深度:

#keyboard
{
    padding-bottom: 6px;
    background-color: saddlebrown;
    /* browser specific gradients not shown */
    background: linear-gradient(top, #2A1506, saddlebrown);
    box-shadow: 3px 3px 4px 1px rgba(0, 0, 0, 0.9);
}

接下来,我们样式化背板,隐藏键的顶部。我们给它一个深棕色,使其高度为32像素,并给它一个阴影以增加深度。为了使阴影绘制在钢琴键上方,我们需要将position设置为relative

#backboard
{
    position: relative;
    height: 32px;
    background-color: #2A1506;
    border-bottom: 2px solid black;
    box-shadow: 3px 3px 4px 1px rgba(0, 0, 0, 0.9);
}

所有钢琴键共享一些基本样式,这些样式是使用piano-key类定义的。首先,我们将display设置为inline-block,这样它们就可以保持在同一行,并且具有宽度和高度。然后我们给底部设置了边框半径,使它们看起来圆润。我们还将cursor属性设置为pointer,这样用户就可以知道它们可以被点击:

#keyboard .piano-key
{
    display: inline-block;
    border-bottom-right-radius: 4px;
    border-bottom-left-radius: 4px;
    cursor: pointer;
}

最后,我们来到黑白键的样式。白键比黑键稍微宽一些,高一些。我们还给它们一个象牙色和阴影。最后,我们需要将z-index设置为1,因为它们需要显示在黑键的后面:

#keyboard .piano-key.white
{
    width: 50px;
    height: 300px;
    background-color: Ivory;
    box-shadow: 3px 3px 4px 1px rgba(0, 0, 0, 0.7);
    z-index: 1;
}

黑键比白键小一点。为了使黑键显示在白键的上方,我们将z-index设置为2。为了使它们看起来在白键之间,我们将它们的position属性设置为relative,并使用负left偏移将它们移动到白键的上方。我们还需要一个负的right-margin值,这样下一个白键就会被拉到它的上方和下方:

#keyboard .piano-key.black
{
    position: relative;
    width: 40px;
    height: 200px;
    left: -23px;
    margin-right: -46px;
    vertical-align: top;
    background-color: black;
    box-shadow: 2px 2px 3px 1px rgba(0, 0, 0, 0.6);
    z-index: 2;
}

这就是我们的钢琴会是什么样子的:

行动时间-创建虚拟钢琴

第一张图片显示了没有设置边距的键。看起来不太像一个真正的键盘,是吧?下一张图片显示了设置了left边距的样子。它变得更好了,但是白键还没有移动过来。设置右边距就解决了这个问题。

刚刚发生了什么?

我们从我们的应用程序模板开始创建了一个新的 HTML5 钢琴应用程序。我们在 HTML 中定义了所有的键,然后使用负偏移和边距对它们进行了样式化,使键能够像真正的键盘一样排列。

就是这样!我们现在有一个看起来非常逼真的两个八度键盘:

刚刚发生了什么?

行动时间-加载音符

我们有一个键盘,但还没有声音。让我们回到 JavaScript,加载所有的音频文件。我们将创建一个名为loadAudio()的新方法,并从应用程序的start()方法中调用它。

我们可以通过两种方式加载所有文件。我们可以通过为每个文件调用audioManager.getAudio()来一次加载它们,这将非常冗长并且需要大量输入。或者我们可以迭代所有的piano-key元素,并从它们的data-note属性中获取文件名。通过使用这种方法,我们可以在 HTML 中添加更多的钢琴键,甚至不需要触及 JavaScript:

function loadAudio()
{
    var count = 0,
        loaded = 0,
        error = false;

    $(".keyboard .piano-key").each(function()
    {
        count++;
        var noteName = escape($(this).data("note"));
        audioManager.getAudio(noteName,
            function()
            {
                if (error) return;
                if (++loaded == count) setStatus("Ready.");
                else setStatus("Loading " +
                        Math.floor(100 * loaded / count) + "%");
            },
            function(audio)
            {
                error = true;
                setStatus("Error loading: " + audio.src);
            }
        );
    });
}

我们要做的第一件事是定义一些变量来跟踪正在加载的音频文件的数量和已加载的数量。我们将使用它们来计算完成百分比。我们还需要一个变量来设置如果加载文件时出现错误。

接下来,我们要使用 jQuery 选择所有的piano-key元素,并调用each()来对它们进行迭代。对于每一个,我们要做以下事情:

  1. count变量加 1,以跟踪文件的总数。

  2. data-note属性中获取音符名称,这也是文件名。请注意,我们必须使用escape()函数,因为一些音符包含 sharp 符号#,这在 URL 中是非法的。

  3. 调用audioManager.getAudio(),传入音符名称。这将导致音频文件被加载和缓存。下次我们为这个音符调用getAudio()时,它将被加载并准备好播放。

  4. getAudio()的第二个参数是一个在每个文件成功加载完成时调用的函数。在这个函数中,我们增加了加载变量。然后我们检查是否所有文件都已加载,如果是,则显示准备好的消息。否则,我们通过调用setStatus()计算加载文件的完成百分比并显示在页脚中。

  5. getAudio()的最后一个参数是一个在加载文件时出错时调用的函数。当发生这种情况时,我们将error变量设置为true,并显示一个显示无法加载的文件的消息。

注意

请注意,如果您通过 IIS 等 Web 服务器运行此应用程序,您可能需要将.ogg文件类型添加到站点的 MIME 类型列表中(.oggaudio/ogg)。否则,您将收到文件未找到的错误。

刚刚发生了什么?

我们使用AudioManager对象动态加载每个键盘键的所有声音,使用它们的data-note属性作为文件名。现在我们已经加载、缓存并准备好播放所有的音频文件。

行动时间-播放音符

接下来我们需要做的是为钢琴键添加事件处理程序,当点击或触摸钢琴键时播放<audio>元素。我们将为所有的钢琴键连接事件处理程序,并在它们被触发时播放相关的音符。

注意

在撰写本文时,移动设备上的音频状态并不是很好。尽管触摸设备非常适合钢琴应用,但由于移动浏览器缓存音频的方式(或者没有缓存),声音并不总是正确播放。

让我们创建一个名为initKeyboard()的方法,它将从应用程序的start()方法中调用:

function initKeyboard()
{
    var $keys = $(".keyboard .piano-key");
    if ($.isTouchSupported)
    {
        $keys.touchstart(function(e) {
            e.stopPropagation();
            e.preventDefault();
            keyDown($(this));
        })
        .touchend(function() { keyUp($(this)); })
    }
    else
    {
        $keys.mousedown(function() {
            keyDown($(this));
            return false;
        })
        .mouseup(function() { keyUp($(this)); })
        .mouseleave(function() { keyUp($(this)); });
    }
}

首先,我们使用 jQuery 选择键盘上所有的piano-key元素。然后,我们使用触摸事件的 jQuery 扩展来检查浏览器是否支持触摸事件。如果是,我们将触摸事件处理程序连接到钢琴键。否则,我们将连接鼠标事件处理程序。

当按下键或点击鼠标时,它调用keyDown()方法,传入用 jQuery 对象包装的键元素。

注意

请注意,在这种情况下,this是被点击的元素。当键被释放或鼠标释放,或鼠标离开元素时,我们调用keyUp()方法。

让我们首先编写keyDown()方法:

function keyDown($key)
{
    if (!$key.hasClass("down"))
    {
        $key.addClass("down");
        var noteName = $key.data("note");
        var audio = audioManager.getAudio(escape(noteName));
        audio.currentTime = 0;
        audio.play();
    }
}

keyDown()方法中,我们首先检查键是否已经被按下,通过检查它是否具有down类。如果没有,我们将down类添加到键元素。我们将使用这个来为键添加样式,使其看起来像是被按下。然后,我们从data-note自定义属性中获取键的音符名称。我们将其传递给audioManager.getAudio()方法以获取<audio>元素。为了开始播放音频剪辑,我们首先将currentTime属性设置为0,以在开始时排队声音。然后,我们调用 Audio API 的play()方法来开始播放它。

function keyUp($key)
{
    $key.removeClass("down");
}

keyUp()方法只是从元素中移除down类,这样键就不会再以按下状态进行样式设置。

我们需要做的最后一件事是为按下状态添加样式。我们将使用渐变来使其看起来像是按下了键的末端。我们还会使阴影变小一点,因为按下时键不会那么高:

.keyboard .piano-key.white.down
{
    background-color: #F1F1F0;
    /* Browser-specific gradients not shown */
    background: linear-gradient(top, Ivory, #D5D5D0);
    box-shadow: 2px 2px 3px 1px rgba(0, 0, 0, 0.6);
}
.keyboard .piano-key.black.down
{
    background-color: #111;
    /* Browser-specific gradients not shown */
    background: linear-gradient(top, Black, #222);
    box-shadow: 1px 1px 2px 1px rgba(0, 0, 0, 0.6);
}

刚刚发生了什么?

我们连接了事件处理程序到钢琴键,当它们被鼠标点击或在触摸设备上被触摸时,播放相关的音符。我们添加了一些样式来给出视觉指示,表明键被按下。现在我们有一个使用 HTML5 音频的功能钢琴。请在浏览器中打开它,并弹奏一些曲调。

刚刚发生了什么?

键盘事件

在我们的钢琴上使用鼠标弹奏音符效果还可以,但如果我们可以同时播放多个音符会更好。为此,我们可以使用计算机键盘来弹奏音符。为此,我们将在 JavaScript 中向 DOMdocument添加键盘事件处理程序,并将键盘键映射到钢琴键。

键盘的前两行将用于第一个八度,后两行将用于第二个八度。例如,按下Q键将播放最低的 C 音符。按下2键将播放 C#,W将播放 D,依此类推。对于第二个八度,按下Z将播放中央 C,S将播放 C#,依此类推:

键盘事件

您可以在chapter6/example6.2中找到本节的代码。

行动时间-添加键盘事件

我们需要做的第一件事是将keycodes.js添加到我们的应用程序中。该文件包含一个名为keyCodes的全局静态对象,将键盘上的键映射到它们关联的键码。例如,keyCodes.ENTER等于13。使用这个将使我们的代码比使用键码数字更易读。

我们需要做的下一件事是打开 HTML 并向piano-key元素添加一个新的自定义数据属性。我们将其称为data-keycode,并将其设置为我们想要与钢琴键关联的keyCode对象中的值:

<div data-note="2C" data-keycode="Q" class="piano-key white" title="C2"></div>
<!—elements not shown -->
<div data-note="4C" data-keycode="COMMA" class="piano-key white" title="C4"></div>

现在我们需要将按键代码映射到音符。我们将在我们的应用程序中添加一个名为keyCodesToNotes的对象来保存我们的映射。我们将在initKeyboard()方法中对其进行初始化:

function initKeyboard()
{
    // Code not shown...
    $keys.each(function() {
        var $key = $(this);
        var keyCode = keyCodes[$key.data("keycode")];
        keyCodesToNotes[keyCode] = $key.data("note");
    });
}

在这里,我们遍历所有piano-key元素,获取每个元素的data-keycode自定义属性,并使用它来从keyCodes对象中获取键码。然后,我们通过将其设置为元素的data-note自定义属性来将映射添加到keyCodesToNotes中。例如,Q键的键码为 81,关联的钢琴键音符为 2C。因此,keyCodesToNotes[81]将设置为2C

现在让我们添加键盘事件处理程序。在检查按下、释放或按下事件时,您需要将事件处理程序附加到 HTML 文档上。让我们在应用程序的start()方法中添加keydownkeyup事件处理程序:

this.start = function()
{
  // Code not shown... 
    $(document).keydown(onKeyDown)
               .keyup(onKeyUp);
}

keydown事件处理程序调用onKeyDown()方法。keyup处理程序调用onKeyUp()

function onKeyDown(e)
{
    var note = keyCodesToNotes[e.which];
    if (note)
    {
        pressPianoKey(note);
    }
}

onKeyDown()方法中,我们使用keyCodesToNotes对象查找按下的键对应的音符。jQuery 在键事件对象上定义了一个which字段,其中包含键码。如果键码与我们键盘上的音符匹配,那么我们调用pressPianoKey()方法,将note参数传递给它:

function onKeyUp(e)
{
    var note = keyCodesToNotes[e.which];
    if (note)
    {
        releasePianoKey(note);
    }
}

onKeyUp()方法的工作方式相同,只是调用了releasePianoKey()方法。

function pressPianoKey(note)
{
    var $key = getPianoKeyElement(note);
    keyDown($key);
}

pressPianoKey()方法中,我们将要播放的音符名称作为参数。然后,我们调用getPianoKeyElement()来获取与该音符相关联的钢琴键元素。最后,我们将该元素传递给我们在添加鼠标和触摸事件时已经实现的keyDown()方法。通过这种方式,我们模拟了用户在屏幕上点击钢琴键元素。

function releasePianoKey(note)
{
    var $key = getPianoKeyElement(note);
    keyUp($key);
}

releasePianoKey()方法的工作方式完全相同,只是调用了现有的keyUp()方法。

function getPianoKeyElement(note)
{
    return $(".keyboard .piano-key[data-note=" + note + "]");
}

getPianoKeyElement()方法中,我们通过使用 jQuery 选择器匹配data-note自定义属性来找到与音符相关联的piano-key元素。

刚刚发生了什么?

我们在应用程序的 HTML 文档中添加了键盘按键事件处理程序。当按下键盘上的键时,我们将键码映射到钢琴键,以便用户可以按下键盘上的键来弹奏钢琴。通过将piano-key元素传递给keyDown()keyUp(),我们模拟了用户点击这些键。它们被添加了down类,看起来就像它们真的被按下了。

自己试一试。尝试同时按下两个或三个键,弹奏一些和弦。

音量和延音控制

让我们在钢琴上添加一些控件,允许用户更改音量和延音。你可能还记得,audio元素的音量可以设置为01.0之间的任何值。我们将使用一个范围输入控件,允许用户通过滑块来控制音量。

延音控制允许音符在释放钢琴键后继续播放。当关闭延音时,音符将在释放键时立即停止播放。我们将添加一个复选框来打开和关闭这个功能。

您可以在chapter6/example6.3中找到本节的源代码。

行动时间-添加延音控制

让我们继续在应用程序中添加一个延音控件。我们将使用复选框输入控件来打开和关闭延音。在我们的 HTML 文件中,我们将在键盘下方添加一个带有controls类的新<div>元素来容纳我们的控件:

<div id="main">
    <!-- keyboard not shown... -->
    <div class="controls">
        <label for="sustain">Sustain: </label>
        <input type="checkbox" id="sustain" checked /><br />
    </div>
</div>

我们使用id属性定义一个标签和一个复选框,名称为sustain。我们还将其默认设置为选中状态。

现在让我们在PianoApp应用程序对象中实现复选框的代码。首先,我们需要添加一个名为sustain的变量,并将其设置为true

function PianoApp()
{
    var version = "6.3",
    // Code not shown...
    sustain = true;

接下来,我们将添加一个change事件处理程序,以便在复选框更改时收到通知。我们将在应用程序的start()方法中执行此操作:

$("#sustain").change(function() { sustain = $(this).is(":checked"); });

复选框更改时,我们使用 jQuery 的is()过滤器方法来确定它是否被选中,传递给它:checked过滤器。如果选中,sustain变量将设置为true

现在我们需要对keyUp()方法进行一些更改。该方法现在的作用只是从piano-key元素中移除down类。我们需要添加代码来检查sustain变量,并且如果该变量设置为true,则停止播放声音:

function keyUp($key)
{
    $key.removeClass("down");
    if (!sustain)
    {
        var noteName = $key.data("note");
        var audio = audioManager.getAudio(escape(noteName));
        audio.pause();
    }
}

删除down类后,我们检查sustain变量。如果未设置延音,我们从piano-key元素的data-note自定义属性中获取音符名称,并使用它来从audioManager对象中获取<audio>元素。然后我们调用pause()方法来停止播放声音。

刚刚发生了什么?

我们添加了一个复选框,允许用户打开和关闭延音控制。当延音关闭并且用户释放钢琴键时,我们调用音频 API 的pause()方法来停止播放音符。

行动时间-添加音量控制

回到 HTML 中,让我们添加一个范围输入控件,允许用户更改音量。我们将它放在刚刚添加的延音标签和控件下面:

<label for="volume">Volume: </label>
<input type="range" id="volume" min="1" max="100" value="100" step="1" />

我们使用volume属性定义一个标签和一个范围输入。我们将控件的范围设置为1100,步长值为1。我们还将默认值设置为100

回到我们的PianoApp对象中,我们添加了另一个名为volume的全局变量,并将其默认设置为1.0,即最大音量:

function PianoApp()
{
    var version = "6.3",
    // Code not shown...
    sustain = true,
    volume = 1.0;

sustain复选框一样,我们需要为应用程序的start()方法添加一个change事件处理程序,用于范围控制:

$("#volume").change(function() {
    volume = parseInt($(this).val()) / 100;
});

您可能已经注意到,我们的范围输入控件的范围为1100,而audio元素的音量定义为01.0。因此,在我们的事件处理程序中,我们将volume变量设置为范围控件的值除以100

现在我们只需要在keyDown()方法中添加一行代码,以在播放之前设置audio元素的volume属性:

audio.currentTime = 0;
audio.volume = volume;
audio.play();

现在让我们在 CSS 中为页面的controls部分进行一些样式设置:

.controls
{
    margin-top: 2em;
    color: white; 
}
.controls input
{
    vertical-align: middle;
}
.controls input[type=range]
{
    width: 10em;
}

我们设置顶部边距,为控件留出一些空间,为控件设置垂直对齐,使标签居中对齐,并设置音量范围控件的宽度。

我们还应该做一件事,使我们的应用程序更加动态。范围输入控件并不被所有浏览器广泛支持,因此让我们添加一些代码来检查它是否被支持。我们将添加一个isInputTypeSupported()方法:

function isInputTypeSupported(type)
{
    var $test = $("<input>");
    // Set input element to the type we're testing for
    $test.attr("type", type);
    return ($test[0].type == type);
}

首先,我们在内存中创建一个新的<input>元素。然后我们将type属性设置为我们正在测试的类型。在我们的情况下,那将是range。然后我们检查type属性,看它是否被固定。如果元素保留了该类型,则表示浏览器支持它。

start()方法中,我们将添加一个检查范围类型的检查。如果您还记得第三章中的内容,细节中的魔鬼,如果一个输入类型不受支持,它将显示为文本输入字段。因此,如果范围类型不受支持,我们将更改字段的宽度,使其变小。我们不希望一个宽度为10em的文本输入字段输入从0100的数字:

if (!isInputTypeSupported("range")) $("#volume").css("width", "3em");

刚刚发生了什么?

我们添加了一个范围输入控件,允许用户使用滑块更改声音的音量。在播放声音之前,我们将音量设置为用户选择的值。我们还编写了一个方法,用于检查浏览器是否支持某些 HTML5 输入类型。以下是我们创建的内容:

刚刚发生了什么?

尝试一下

<audio>元素创建一个包装器对象,该对象将元素作为构造函数,并包含公共方法来访问音频 API 方法。添加一些便利方法,例如rewind(),它设置audio.currentTime = 0,或stop(),它调用pause()rewind()

快速测验

Q1. <audio>元素支持哪种音频类型?

  1. Ogg

  2. MP3

  3. Wav

  4. 以上所有内容

Q2. 你将键盘事件附加到哪个对象?

  1. 窗口

  2. 文档

  3. div

  4. 音频

音频工具

在我们离开本章之前,我想告诉你一些免费音频工具,你可以用它们来获取和处理应用程序的音频文件。

FreeSound.org

FreeSound.org是一个网站,你可以在那里获取以知识共享许可发布的音频文件。这意味着你可以在各种使用限制下免费使用它们。有一些公共领域的声音,你可以无需做任何事情就可以使用。还有一些声音,只要你给作者以信用,你就可以做任何事情。还有一些声音,你可以用于任何目的,除了商业用途。FreeSound 数据库庞大,具有出色的搜索和浏览功能。你几乎可以在这个网站上找到任何你需要的声音。

Audacity

Audacity 是一个免费的开源音频编辑器,用于录制、切割和混合音频,可在许多不同的操作系统上运行。Audacity 非常适合在不同文件类型之间转换,这对我们来说非常重要,因为我们需要支持不同浏览器的不同音频类型。它支持主要网络浏览器使用的所有主要音频类型,包括 Ogg、MP3 和 WAV。

总结

在本章中,我们学习了如何使用 HTML5 的audio元素和 API 来为 Web 应用程序添加声音。我们看到了如何通过创建可重用的音频管理器对象来加载和缓存音频文件。然后我们使用 HTML5 音频在网页中创建了一个虚拟钢琴应用程序。我们使用键盘事件允许用户通过键盘弹奏钢琴键。我们添加了控件来改变音量和延长音符。

在本章中,我们涵盖了以下概念:

  • 如何将 HTML5 的<audio>元素添加到网页中并使用其属性来控制它

  • 使用 JavaScript 从音频 API 来编程控制音频元素的播放

  • 如何加载音频文件并缓存以供以后播放

  • 如何播放、暂停和重置音频文件

  • 如何将键盘事件连接到文档并在我们的应用程序中处理它们

  • 如何使用范围输入控件改变audio元素的音量

  • 如何检查浏览器是否支持任何 HTML5 输入类型

在下一章中,我们将把我们的钢琴应用程序变成一个叫做钢琴英雄的游戏。我们将学习关于时间、动画元素和通过创建音频序列器播放音乐。

第七章:钢琴英雄

"音乐的一大好处是,当它打动你时,你感觉不到痛苦。"

  • 鲍勃·马利

在本章中,我们将把上一章的钢琴应用程序转变成一个游戏,玩家必须在音符按下屏幕时以正确的时间演奏歌曲的音符。我们将创建一个启动页面,用于跟踪图像加载并允许玩家选择游戏选项。我们将创建一个音频序列以播放音乐数据中的歌曲。在游戏过程中,我们将收集钢琴键盘输入并验证以确定玩家的得分。

在本章中我们将学到以下内容:

  • 如何使用 HTML5 进度条元素跟踪资源的加载

  • 如何使用 JavaScript 定时器来控制音频播放以播放歌曲

  • 如何使用 DOM 元素动画来移动它们在屏幕上

  • 如何在游戏状态之间过渡

  • 如何获取用户输入并验证它

创建钢琴英雄

我们的钢琴英雄游戏将从我们在上一章中构建的 HTML5 钢琴应用程序开始。我们将添加一个音频序列到其中以播放预先录制的歌曲。为了得分,玩家需要跟着演奏歌曲的音符,并在正确的时间演奏。还将有一个练习模式,只播放歌曲,以便玩家能听到它。

我们的游戏将有两个不同的主面板。第一个将是启动面板,这是游戏的起点。当应用程序首次启动时,它将显示一个进度条,因为音频正在加载。加载完成后,它将显示游戏的选项。当玩家点击播放按钮时,他们将转到游戏面板。

游戏面板包含钢琴键盘和一个显示要演奏的音符从上面掉下来的区域。如果用户在正确的时间演奏了正确的音符,他们会得到积分。在歌曲结束时,玩家的得分和一些统计数据将被显示。游戏结束后,应用程序将转回到启动面板,用户可以选择选项并再次游戏。

通常有助于绘制一个流程图,显示游戏如何从一个状态过渡到另一个状态。

创建钢琴英雄

行动时间-创建启动面板

让我们从上一章创建的钢琴应用程序开始,并将文件重命名为pinaoHero.htmlpianoHero.jspianoHero.css。我们还将主应用程序对象重命名为PianoHeroApp。您可以在第七章/example7.1中找到本节的代码。

现在让我们创建启动面板。首先我们将在pianoHero.html中定义 HTML。我们将在键盘元素上方添加一个新的<div>元素来容纳启动面板:

<div id="splash">
    <h1>Piano Hero</h1>
    <section class="loading">
        Loading audio...<br/>
        <progress max="100" value="0"></progress>
    </section>

首先,我们添加一个带有"loading"类的部分,显示应用程序首次启动时加载音频的状态。请注意,我们正在使用新的 HTML5<progress>元素。该元素用于在应用程序中实现进度条。它有一个max属性,定义最大值,和一个value属性来设置当前值。由于我们显示百分比完成,我们将max设置为100。随着音频文件的加载,我们将从 JavaScript 更新value属性。

然后我们添加一个带有"error"类的部分,如果加载音频时出错将显示错误消息。否则它将被隐藏:

    <section class="error">
        There was an error loading the audio.
    </section>

最后,我们添加一个显示游戏选项和按钮的部分。这个面板在所有音频加载完成后显示:

    <section class="loaded hidden">
        <label>Choose a song</label>
        <select id="select-song">
            <option value="rowBoat">Row Your Boat</option>
            <option value="littleStar">
              Twinkle, Twinkle, Little Star</option>
            <option value="londonBridge">London Bridge</option>
            <option value="furElise">Fur Elise</option>
        </select><br/>
        <label>Choose difficulty</label>
        <select id="select-rate">
            <option value="0.5">Slow (60bpm)</option>
            <option value="1" selected>Normal (120bpm)</option>
            <option value="1.5">Fast (180bpm)</option>
        </select>
        <p>
            <button id="start-game">Start Game</button>
            <button id="start-song">Play Song</button>
        </p>
    </section>
</div>

在这里,用户从下拉列表中选择歌曲和难度。难度是以歌曲播放速度的比率来表示。值为 1 是默认速度,即每分钟 120 拍。小于 1 的值是更慢的,大于 1 的值是更快的。

现在我们需要为启动面板设置样式。请查看所有样式的源代码。一个值得注意的样式是PIANO HERO标题,我们将其放在<h1>标题元素中:

#splash h1
{
    font-size: 6em;
    color: #003;
    text-transform: uppercase;
    text-shadow: 3px 3px 0px #fff, 5px 5px 0px #003;
}

我们将文本的颜色设置为深蓝色。然后我们使用text-shadow来产生有趣的块文本效果。在使用text-shadow时,您可以通过逗号分隔指定任意数量的阴影。阴影将按照从后到前的顺序绘制。所以在这种情况下,我们首先绘制一个偏移为 5 像素的深蓝色阴影,然后是一个偏移为 3 像素的白色阴影,最后深蓝色文本将被绘制在其上方:

行动时间-创建闪屏面板

现在让我们创建一个名为splashPanel.js的新 JavaScript 文件,并在其中定义一个名为SplashPanel的新对象,该对象将包含控制闪屏面板的所有代码。构造函数将接受一个参数,即对audioManager的引用:

function SplashPanel(audioManager)
{
    var $div = $("#splash"),
    error = false;

我们定义了一个$div对象来保存对闪屏面板根<div>元素的引用,并设置了一个error变量来设置是否在加载音频时出现错误。接下来,我们定义了公共的show()hide()方法。这些方法将由主应用程序对象调用以显示或隐藏面板。

    this.show = function()
    {
        $div.fadeIn();
        return this;
    };
    this.hide = function()
    {
        $div.hide();
        return this;
    };
}

接下来,我们将loadAudio()方法从PianoHeroApp移动到SplashPanel。在这个方法中,我们需要对audioManager.getAudio()的调用进行一些小的更改:

audioManager.getAudio(noteName,
    function()
    {
        if (error) return;
        if (++loaded == count) showOptions();
        else updateProgress(loaded, count);
    },
    function(audio) { showError(audio); }
);

在我们每次加载音频文件时调用的函数中,我们首先检查是否有错误,如果有,则将其取出。然后我们检查是否已加载所有音频文件(loaded == count),如果是,则调用showOptions()方法。否则,我们调用updateProgress()方法来更新进度条:

function updateProgress(loadedCount, totalCount)
{
    var pctComplete = parseInt(100 * loadedCount / totalCount);
    $("progress", $div)
        .val(pctComplete)
        .text(pctComplete + "%");
}

updateProgress()方法将加载计数和总计数作为参数。我们计算完成的百分比,并使用它来更新<progress>元素的值。我们还设置了<progress>元素的内部文本。这只会在不支持<progress>元素的浏览器中显示。

function showOptions()
{
    $(".loading", $div).hide();
    $(".options", $div).fadeIn();
}

在加载完所有音频后,将调用showOptions()方法。首先隐藏具有"loading"类的元素,然后淡入具有"options"类的元素。这将隐藏进度部分并显示包含游戏选项的部分。

我们的错误处理程序调用showError(),将失败的音频元素传递给它:

function showError(audio)
{
    error = true;
    $(".loading", $div).hide();
    $(".error", $div)
        .append("<div>" + audio.src + "<div>")
        .show();
}

showError()方法中,我们将error标志设置为true,以便我们知道不要在getAudio()调用中继续。首先隐藏加载部分,然后将失败的文件名附加到错误消息中,并显示错误部分。

我们闪屏面板中的最后一件事是将事件处理程序连接到按钮。有两个按钮,开始游戏播放歌曲。它们之间唯一的区别是播放歌曲按钮会播放歌曲而不计分,因此用户可以听歌曲并练习:

$(".options button", $div).click(function()
{
    var songName = $("#select-song>option:selected", $div).val();
    var rate = Number($("#select-rate>option:selected", $div).val());
    var playGame = ($(this).attr("id") == "start-game");
    app.startGame(songName, rate, playGame);
});

我们为两个按钮使用相同的事件处理程序。首先获取用户选择的选项,包括歌曲和播放速率。您可以使用 jQuery 的:selected选择器找到所选的<option>元素。我们通过查看按钮的id属性来确定用户按下了哪个按钮。然后我们在全局app对象上调用startGame()方法,传入所选的选项。我们稍后将编写该方法。

行动时间-创建闪屏面板

刚刚发生了什么?

我们创建了一个闪屏面板,使用 HTML5 的<progress>元素显示音频文件的加载进度。加载完成后,它会显示游戏选项,然后等待用户选择选项并开始游戏。

行动时间-创建游戏面板

接下来,我们将创建游戏面板。我们已经有了钢琴键盘,它将是其中的一部分。我们还需要在其上方添加一个区域来显示下降的音符,并在游戏结束时显示结果的地方。让我们将这些添加到我们的 HTML 文件中的game元素内部和键盘上方:

<div id="game">
    <div id="notes-panel">
        <div class="title">PIANO HERO</div>
    </div>

<div id="notes-panel">元素将用于容纳代表要演奏的音符的元素。现在它是空的。在游戏进行时,note元素将动态添加到这个元素中。它有一个带有标题的<div>元素,将显示在音符的后面。

    <div id="results-panel">
        <h1>Score: <span class="score"></span></h1>
        <p>
            You got <span class="correct"></span>
            out of <span class="count"></span> notes correct.
        </p>
        <p>
            Note accuracy: <span class="note-accuracy"></span>%<br/>
            Timing accuracy: <span class="timing-accuracy"></span>%
        </p>
    </div>

<div id="results-panel">元素将在游戏完成时显示。我们添加<span>占位符来显示得分,音符的总数以及正确的数量,以及一些准确度统计。

    <div class="keyboard">
        <div class="keys">
            <!-- Code not shown... -->
        </div>
        <div class="controls">
            <button id="stop-button">Stop</button>
            <button id="restart-button">Restart</button>
            <button id="quit-button">Quit</button><br/>
            <label for="sustain">Sustain: </label>
            <input type="checkbox" id="sustain" checked /><br />
            <label for="volume">Volume: </label>
            <input type="range" id="volume" min="1" max="100"
                value="100" step="1" />
        </div>
    </div>
</div>

我们还在键盘下方的<div class="controls">元素中添加了一些按钮。停止按钮将停止游戏,重新开始将从头开始播放当前歌曲,退出将把玩家带回到启动面板。

现在让我们在一个名为gamePanel.js的文件中创建一个GamePanel对象,以包含实现游戏所需的所有代码。构造函数将接受对audioManager对象的引用:

function GamePanel(audioManager)
{
    var $panel = $("#game"),
        $notesPanel = $("#notes-panel"),
        $resultsPanel = $("#results-panel"),
        practiceMode = false,
        noteCount = 0,
        notesCorrect = 0,
        score = 0,
        keyCodesToNotes = {},
        sustain = true,
        volume = 1.0;

在这里,我们定义了一些变量来跟踪游戏状态。practiceMode变量确定我们是在玩游戏还是练习。noteCountnotesCorrectscore用于跟踪玩家的表现。

我们将所有支持键盘的代码从PianoHeroApp对象移动到GamePanel对象。这包括keyCodesToNotessustainvolume变量。我们还移动了initKeyboard()keyDown()keyUp()pressPianoKey()releasePianoKey()getPianoKeyElement()isInputTypeSupported()方法。最后,我们移动了onKeyDown()onKeyUp()事件处理程序。

现在让我们为应用程序与游戏面板交互添加一些公共方法。与启动面板一样,我们需要方法来显示和隐藏它:

this.show = function()
{
    $panel.fadeIn(startGame);
    return this;
};
this.hide = function()
{
    $panel.hide();
    return this;
};

show()公共方法将游戏面板淡入。我们传入一个对startGame()方法的引用,我们将在下一节中编写该方法,以在淡入完成时调用。

刚刚发生了什么?

我们通过添加标记来创建游戏面板,用于容纳动画note元素的区域,以及显示得分的区域。这些是我们在上一章中创建的键盘之外的内容。然后,我们创建了一个 JavaScript 对象来保存游戏面板的所有代码,包括我们之前为键盘编写的所有代码。

行动时间-创建控制器

此时在我们的主应用程序对象PianoHeroApp中剩下的不多了。我们将所有加载音频的代码移到了SplashPanel对象中,将使键盘工作的所有代码移到了GamePanel对象中。

PianoHeroApp对象现在只作为状态控制器来隐藏和显示正确的面板。首先,我们需要添加一些变量来保存对面板的引用:

function PianoHeroApp()
{
    var version = "7.1",
        audioManager = new AudioManager("audio"),
        splashPanel = new SplashPanel(audioManager),
        gamePanel = new GamePanel(audioManager),
        curPanel = undefined;

我们定义变量来保存音频管理器、启动面板和游戏面板对象。我们还有一个curPanel变量,它将被设置为当前显示的面板。一开始我们将把它设置为undefined

接下来,我们将创建一个私有的showPanel()方法,它将隐藏当前显示的面板(如果有的话),并显示另一个面板:

    function showPanel(panel)
    {
        if (curPanel) curPanel.hide();
        curPanel = panel;
        curPanel.show();
    }

这个方法以要显示的面板作为参数。这将是对SplashPanelGamePanel的引用。首先,我们检查是否正在显示面板,如果是,我们调用它的hide()方法。然后我们将curPanel设置为新面板,并调用它的show()方法。

接下来,我们定义公共的startGame()方法。如果你还记得我们为SplashPanel对象编写的代码,这个方法将在用户点击开始游戏播放歌曲按钮时从事件处理程序中调用。它会传入玩家选择的游戏选项:

    this.startGame = function(songName, rate, playGame)
    {
        gamePanel.setOptions(songName, rate, playGame);
        showPanel(gamePanel);
    };

startGame()方法接受三个参数;要播放的歌曲的名称,播放速率(控制游戏进度的快慢),以及一个布尔值(确定用户是否点击了开始游戏按钮)。

首先,我们调用GamePanel对象的setOptions()方法,稍后我们将编写。我们通过与启动面板获得的相同参数进行传递。然后我们调用showPanel()方法,传入GamePanel对象。这将开始游戏。

接下来,我们将定义公共的quitGame()方法。当用户点击退出按钮时,这将从游戏面板中调用:

    this.quitGame = function()
    {
        showPanel(splashPanel);
    };

在这个方法中,我们所做的就是调用showPanel(),将SplashPanel对象传递给它。

我们需要定义的最后一件事是应用程序的start()方法:

    this.start = function()
    {
        $(document).keydown(function(e) { curPanel.onKeyDown(e); })
                   .keyup(function(e) { curPanel.onKeyUp(e); });

        showPanel(splashPanel);
        splashPanel.loadAudio();
    };

首先,在文档上设置键盘事件处理程序,就像我们在创建钢琴应用程序时所做的那样。但是,在这个应用程序中,我们将键盘事件转发到当前面板。通过在应用程序对象中集中处理键盘事件处理程序,我们不必在每个面板中编写大量代码来订阅和取消订阅来自文档的键盘事件处理程序,当面板显示或隐藏时。

我们做的最后一件事是显示启动面板,然后调用它的loadAudio()方法来启动应用程序。

音符

我们的启动和游戏面板实现了show()hide()keydown()keyup()方法。由于 JavaScript 是无类型的,我们无法通过接口来强制执行这一点。因此,我们改为按照约定进行编程,假设所有面板都将实现这些方法。

刚刚发生了什么?

我们在主应用程序对象中添加了代码来控制游戏的状态。当玩家点击启动面板上的按钮之一时,游戏就会开始,当他们从游戏中点击退出时,它会显示启动面板。

创建音频序列

在我们玩游戏之前,我们需要一种方法来通过按照特定顺序、在正确的时间和以正确的速度播放音符来在钢琴上演奏歌曲。我们将创建一个名为AudioSequencer的对象,它接受一个音乐事件对象数组并将它们转换为音乐。

为了实现我们的音频序列,我们需要定义音乐事件的格式。我们将大致遵循 MIDI 格式,但简化得多。MIDI 是记录和回放音乐事件的标准。每个事件包含有关何时以及如何演奏音符或关闭音符的信息。

我们的事件对象将包含三个字段:

  • deltaTime:执行事件之前等待的时间量。

  • 事件:这是一个整数事件代码,确定事件的操作。它可以是以下之一:

  • 打开音符

  • 关闭音符

  • 提示点将在歌曲的开头

  • 曲目结束将表示歌曲结束。

  • 注意:这是要演奏的音符。它包含了八度和音符,并且与我们的音频文件名称匹配,例如,3C。

音频序列将通过查看每个事件中的deltaTime字段来确定在触发事件之前等待多长时间。客户端将传递一个事件处理程序函数,当事件触发时将调用该函数。然后客户端将查看事件数据并确定要演奏哪个音符。这个循环会一直持续,直到没有更多的事件为止。

创建音频序列

行动时间 - 创建 AudioSequencer

让我们在一个名为audioSequencer.js的文件中创建我们的AudioSequencer对象。我们将首先定义一些变量:

function AudioSequencer()
{
    var _events = [],
        _playbackRate = 1,
        _playing = false,
        eventHandler = undefined,
        timeoutID = 0;

首先,我们定义了一个_events数组来保存所有要播放的音乐事件。_playbackRate变量控制歌曲播放的速度。值为1时是正常速度,小于1时是较慢,大于1时是较快。_playing变量在播放歌曲时设置为trueeventHandler将设置为一个在事件触发时调用的函数,timeoutID将包含从setTimeout()返回的句柄,以防用户停止游戏,我们需要取消超时。

现在让我们定义一些公共属性方法。第一个是events()。它用于获取或设置_events数组:

    this.events = function(newEvents)
    {
        if (newEvents) {
            _events = newEvents;
            return this;
        }
        return _events;
    };

接下来是playbackRate()。它用于获取或设置_playbackRate

    this.playbackRate = function(newRate)
    {
        if (newRate) {
            _playbackRate = newRate;
            return this;
        }
        return _playbackRate;
    };

最后,我们有isPlaying(),用于确定歌曲当前是否正在播放:

    this.isPlaying = function()
    {
        return _playing;
    };

现在我们将编写公共的startPlayback()方法。该方法接受两个参数;事件处理程序函数和可选的起始位置,即_events数组的索引:

    this.startPlayback = function(callback, startPos)
    {
        startPos = startPos || 0;

        if (!_playing && _events.length > 0)
        {
            _playing = true;
            eventHandler = callback;
            playEvent(startPos);
            return true;
        }
        return false;
    };

首先,我们将startPos参数默认设置为0,如果没有提供的话。接下来,我们检查歌曲是否已经在播放,并确保我们实际上有一些事件要播放。如果是这样,我们将_playing标志设置为true,存储事件处理程序的引用,然后为第一个事件调用playEvent()。如果成功开始播放,则返回true

现在让我们编写playEvent()方法。它接受一个参数,即要触发的下一个事件的索引:

    function playEvent(index)
    {
        var event = _events[index];
        eventHandler(event.event, event.note, index);

        index++;
        if (index < _events.length)
        {
            timeoutID = setTimeout(function()
            {
                playEvent(index);
            },
            _events[index].deltaTime * (1 / _playbackRate));
        }
        else _playing = false; // all done
    }

我们首先要做的是在_events数组中获取指定索引处的事件。然后立即调用startPlayback()方法中提供的事件处理程序的回调函数,传递事件代码、要播放的音符和事件索引。

接下来,我们增加索引以获取下一个事件。如果还有其他事件,我们将调用setTimeout()来等待事件的deltaTime字段中指定的时间量,然后再次调用playEvent(),传递下一个事件的索引。我们通过将deltaTime乘以播放速率的倒数来计算等待的时间量。例如,如果播放速率为 0.5,则等待时间将是 1,0.5 或 2 倍于正常速率。这个循环将继续进行,直到没有更多的事件要播放。

我们最后需要一个公共的stopPlayback()方法。调用此方法将停止事件循环,从而停止音频事件的播放:

    this.stopPlayback = function()
    {
        if (_playing)
        {
            _playing = false;
            if (timeoutID) clearTimeout(timeoutID);
            eventHandler = undefined;
        }
    };

首先,我们检查_playing标志,以确保歌曲实际上正在播放。如果是这样,我们将标志设置为false,然后调用clearTimeout()来停止超时。这将阻止再次调用playEvent(),从而停止播放循环。

我们最后需要做的是定义播放事件代码,这样我们就不必记住事件代码编号。我们将使用AudioSequencer上的对象定义一个伪枚举,称为eventCodes

AudioSequencer.eventCodes =
{
    noteOn: 1,
    noteOff: 2,
    cuePoint: 3,
    endOfTrack: 4
};

刚刚发生了什么?

我们创建了一个音频序列对象,它接受一个音乐事件数组,类似于 MIDI 事件,并使用setTimeout()函数在正确的时间调用它们。当事件被触发时,它会调用游戏面板传入的事件处理程序函数。

注意

虽然我们编写了这段代码来播放音乐,但你可以在任何需要在预定时间发生事情的地方使用相同的技术。

播放歌曲

现在我们有了一个音频序列,我们可以进入游戏面板并添加一些代码以在练习模式下播放歌曲。当歌曲播放时,它将在屏幕上按下正确的键,就像玩家钢琴一样。稍后我们将添加代码来检查玩家的互动,看他们跟着歌曲的节奏有多好。

行动时间-添加音频序列

让我们将音频序列添加到游戏面板中。我们将进入GamePanel对象,并在其中添加一个AudioSequencer的实例:

function GamePanel(audioManager)
{
    var sequencer = new AudioSequencer();

接下来让我们编写公共的setOptions()方法,该方法从PianoHeroAppstartGame()方法中调用。它接受三个参数;歌曲名称,播放速率,以及是否在练习模式下播放游戏或歌曲:

    this.setOptions = function(songName, rate, playGame)
    {
        sequencer.events(musicData[songName])
                 .playbackRate(rate);
        practiceMode = !playGame;
        return this;
    };

我们首先将音频序列的events()属性设置为要播放的歌曲的数据。我们从musicData.js中定义的musicData对象中获取歌曲数据。然后,我们设置音频序列的playbackRate()属性。最后,我们设置practiceMode变量。

musicData对象包含了音序器可以为用户在闪屏页面上选择的所有歌曲播放的事件数据。每首歌曲都被定义为一个音乐事件对象的数组。以下是韵律“Twinkle, Twinkle Little Star”数据的示例:

var musicData =
{
    littleStar: [
        { deltaTime: 0, event: 3, note: null },
        { deltaTime: 0, event: 1, note: "3C" },
        { deltaTime: 500, event: 2, note: "3C" },
        { deltaTime: 0, event: 1, note: "3C" },
        { deltaTime: 500, event: 2, note: "3C" },
        { deltaTime: 0, event: 1, note: "3G" },
        { deltaTime: 500, event: 2, note: "3G" },
        // ...
        { deltaTime: 0, event: 4, note: null }
    ]
};

它以一个提示点事件(event: 3)开始,然后打开 3C 音符(event: 1)。500 毫秒后,关闭 3C 音符(event: 2)。它一直持续到最后一个事件,即曲目结束(event: 4)。

接下来让我们编写startGame()方法,该方法从show()方法中调用:

function startGame()
{
    $resultsPanel.hide();
    $notesPanel.show();
    // Reset score
    noteCount = 0;
    notesCorrect = 0;
    score = 0;
    // Start interval for notes animation
    intervalId = setInterval(function() { updateNotes(); },
        1000 / framesPerSecond);
    // Start playback of the song
    sequencer.startPlayback(onAudioEvent, 0);
}

我们首先隐藏结果面板并显示音符面板。然后重置分数和统计信息。

接下来,我们通过调用 JavaScript 的setInterval()函数并将intervalId变量设置为返回的句柄来启动一个间隔计时器。我们稍后将使用它来在游戏结束或玩家停止游戏时停止间隔。此间隔用于动画播放从页面顶部下落的音符面板中的元素。我们通过将 1000 毫秒除以每秒帧数来设置间隔以以恒定速率触发。我们将使用每秒 30 帧的帧速率,这足以产生相对平滑的动画,而不会拖慢游戏。在计时器的每个间隔处,我们调用updateNotes()方法,我们将在下一节中编写。

在此方法中的最后一件事是调用音频顺序器的startPlayback()方法,将音频事件处理程序方法onAudioEvent()的引用和起始位置零传递给它:

function onAudioEvent(eventCode, note)
{
    switch (eventCode)
    {
        case AudioSequencer.eventCodes.noteOn:
            addNote(note);
            break;
        case AudioSequencer.eventCodes.endOfTrack:
            sequencer.stopPlayback();
            break;
    }
}

此方法接受两个参数:音频事件代码和要播放的音符。我们使用switch语句以及我们的eventCodes枚举来确定如何处理事件。如果事件代码是noteOn,我们调用addNote()方法向音符面板添加一个note元素。如果是endOfTrack事件,我们在音频顺序器上调用stopPlayback()。我们现在可以忽略所有其他事件。

刚刚发生了什么?

我们将音频顺序器添加到游戏面板中,并连接一个处理音符事件触发的函数。我们添加了一个startGame()方法,用于启动动画间隔以动画播放note元素。

创建动画音符

现在我们将实现音符面板的代码。这是音符从页面顶部下落的动画发生的地方。它的工作方式如下:

  • 音频顺序器发送一个事件,指示应该播放一个音符(请参阅上一节中的onAudioEvent())。

  • 此时实际上并没有播放音符。相反,表示音符的矩形元素被添加到音符面板的顶部。

  • 每当我们的动画间隔计时器触发时,note元素的 y 位置会递增,使其向下移动。

  • 当元素触及音符面板的底边(以及键盘的顶边)时,它会播放与音符相关的音频剪辑。

  • 当元素完全离开音符面板时,它将从 DOM 中移除。

创建动画音符

行动时间-添加音符

让我们编写addNote()方法,该方法在上一节中由onAudioEvent()引用。此方法接受一个参数,要添加的音符的名称:

function addNote(note)
{
    noteCount++;
    // Add a new note element
    var $note = $("<div class='note'></div>");
    $note.data("note", note);
    $notesPanel.append($note);

    var $key = getPianoKeyElement(note);
    // Position the note element over the piano key
    $note.css("top", "0")
         .css("left", $key.position().left)
         .css("width", $key.css("width"));

    if ($key.hasClass("black"))
    {
        $note.addClass("black");
    }
}

首先,我们更新noteCount变量以跟踪统计信息。然后,我们使用 jQuery 创建一个新的音符<div>元素,并给它一个"note"类。我们将data-note自定义属性设置为音符的名称。当它到达面板底部时,我们将需要它来知道要播放哪个音符。最后,我们使用 jQuery 的append()方法将其添加到音符面板中。

接下来我们要做的是将note元素定位在它所代表的钢琴键上。我们通过调用现有的getPianoKeyElement()方法来获取与音符关联的钢琴键元素。我们提取钢琴键的左侧位置和宽度,并将note元素设置为相同的值,使其对齐。

我们最后要做的是检查钢琴键是黑键还是白键,方法是检查它是否定义了"black"类。如果是,则我们也给note元素添加"black"类。这将使元素以不同的颜色显示。

让我们为note元素添加样式:

#notes-panel .note
{
    position: absolute;
    display: block;
    width: 50px;
    height: 20px;
    background-color: cyan;
    /* browser specific gradients not shown */
    background: linear-gradient(left, white, cyan);
    box-shadow: 0 0 4px 4px rgba(255, 255, 255, 0.7);
}

我们将position设置为absolute,因为我们需要移动它们并将它们放在我们想要的任何位置。我们给它们一个从左到右的线性渐变,从白色渐变到青色。我们还给它一个没有偏移的白色阴影。这将使它看起来像是在黑色背景上发光:

#notes-panel .note.black
{
    background-color: magenta;
    /* browser specific gradients not shown */
    background: linear-gradient(left, white, magenta);
}

具有"black"类的音符将覆盖背景颜色,从白色渐变为品红色。

刚刚发生了什么?

我们创建了一个方法,向音符面板添加代表音符的元素。我们将这些音符定位在它们所属的钢琴键的正上方。

到了行动的时候-为音符添加动画

之前,我们在startGame()方法中使用setInterval()开始了一个间隔。updateNotes()方法在间隔到期时被调用。该方法负责更新所有note元素的位置,使它们看起来向下移动屏幕:

function updateNotes()
{
    $(".note", $notesPanel).each(function()
    {
        var $note = $(this);
        var top = $note.position().top;
        if (top <= 200)
        {
            // Move the note down
            top += pixelsPerFrame;
            $note.css("top", top);
            if (top + 20 > 200)
            {
                // The note hit the bottom of the panel
                currentNote.note = $note.data("note");
                currentNote.time = getCurrentTime();
                currentNote.$note = $note;
                if (practiceMode) pressPianoKey($note.data("note"));
            }
        }
        else
        {
            // Note is below the panel, remove it
            if (practiceMode) releasePianoKey($note.data("note"));
            $note.remove();
        }
    });

    // Check if there are any notes left
    if ($(".note", $notesPanel).length == 0)
    {
        // No more notes, game over man
        if (!practiceMode) showScore();
        endGame();
    }
}

首先,我们选择音符面板中的所有note元素并对它们进行迭代。对于每一个,我们执行以下操作:

  • 获取顶部位置并检查是否小于 200,这是音符面板的高度。

  • 如果元素仍然在音符面板内,我们将元素向下移动pixelsPerFrame变量定义的像素数。每秒 30 帧,即 2 像素。

  • 接下来,我们检查note元素的底部是否击中了音符面板的底部,方法是检查底部是否大于 200。

  • 如果是,我们将currentNote对象的note变量设置为音符,这样我们可以稍后检查用户是否演奏了正确的音符。我们还获取音符击中底部的确切时间,以确定玩家离按时演奏有多近。

  • 如果我们处于练习模式,还可以通过调用pressPianoKey()并将note元素传递给它来演奏音符。

  • 如果note元素在音符面板之外,那么我们调用releasePianoKey()并将其从 DOM 中移除。

我们要做的最后一件事是检查音符面板中是否还有任何音符元素。如果没有,游戏结束,我们调用showScore()来显示结果面板。然后我们调用endGame(),停止动画间隔。

刚刚发生了什么?

我们对note元素进行了动画处理,使它们看起来在键盘上的键上下落。当音符击中音符面板底部时,如果处于练习模式,我们会演奏音符。当note元素移出面板时,我们将其从 DOM 中移除。

试一试英雄

尝试调整帧速率,看看它如何影响动画的质量。什么是可以接受的最低帧速率?什么是可以察觉到的最高帧速率?

处理用户输入

用户已经开始了游戏,音符正在屏幕上下落。现在我们需要检查玩家是否在正确的时间按下了正确的钢琴键。当他们这样做时,我们将根据他们的准确性给他们一些分数。

行动时间-检查音符

我们将在keyDown()方法中添加对checkNote()方法的调用。checkNote()方法以音符的名称作为参数,并检查音符面板底部是否有与之匹配的note元素:

function checkNote(note)
{
    if (currentNote.note == note)
    {
        var dif = getCurrentTime() - currentNote.time;
        if (dif < gracePeriod)
        {
            notesCorrect++;
            score += Math.round(10 * (gracePeriod - dif) / gracePeriod);
            currentNote.$note.css("background", "green");
            addHitEffect();
        }
    }
}

首先检查之前在updateNotes()中设置的currentNote对象。如果它的音符与用户演奏的音符相同,那么他们可能会因在正确时间演奏而得到一些分数。要找出他们是否得分,我们首先找出音符击中面板底部的时间与当前时间之间的毫秒时间差。如果在允许的宽限期内,我们将其设置为 200 毫秒,那么我们计算得分。

我们首先增加了正确音符的数量。然后,我们通过计算他们的偏差百分比并乘以 10 来确定分数。这样,每个音符的分数在 1 到 10 之间。最后,为了给用户一些指示他们做对了,我们将元素的背景颜色改为绿色,并调用addHitEffect()

function addHitEffect()
{
    var $title = $(".title", $notesPanel);
    $title.css("color", "#012");
    setTimeout(function() { $title.css("color", "black"); }, 100);
}

addHitEffect()方法通过改变颜色在音符面板的背景中闪烁PIANO HERO标题,使用setTimeout()调用等待 100 毫秒,然后将其改回黑色。

行动时间-检查音符

刚刚发生了什么?

我们添加了一个方法来检查是否在“音符”元素的正确时间按下了正确的钢琴键。如果是这样,我们根据音符的演奏时间来添加分数,并改变音符的颜色以指示成功。

结束游戏

现在玩家可以玩游戏,我们可以跟踪分数和他们正确演奏的音符数量。游戏结束时,我们需要显示结果面板,显示分数和一些统计信息。

行动时间-创建结果面板

在歌曲的所有音符都被演奏后,updateNotes()方法调用showScore(),在那里我们将显示玩家的分数和一些统计信息:

function showScore()
{
    $notesPanel.hide();
    $resultsPanel.fadeIn();
    $(".score", $resultsPanel).text(score);
    $(".correct", $resultsPanel).text(notesCorrect);
    $(".count", $resultsPanel).text(noteCount);
    $(".note-accuracy", $resultsPanel).text(
        Math.round(100 * notesCorrect / noteCount));
    $(".timing-accuracy", $resultsPanel).text(
        Math.round(10 * score / notesCorrect));
}

首先,我们隐藏音符面板,并在其位置淡入分数面板。然后,我们在 DOM 中的占位符中填入分数和统计信息。我们显示分数、正确音符的数量和总音符数量。此外,我们使用notesCorrectnoteCount变量计算他们正确演奏的音符的百分比。

我们通过从分数和正确音符的数量中计算来获得时间准确度百分比。请记住,每个音符可能获得的总分数是 10 分,所以如果他们正确演奏了 17 个音符,那么可能获得的总分数是 170。如果分数是 154,那么 154/170≈91%。

行动时间-创建结果面板

刚刚发生了什么?

当游戏结束时,我们显示了结果面板,并填充了玩家的分数和统计信息。我们的游戏现在已经完成。试一试,成为钢琴英雄!

尝试一试

尝试编写一个音频记录器类,记录用户在键盘上演奏音符的时间,并将其保存到可以由音频序列器播放的数据对象数组中。

小测验

Q1. 哪个 JavaScript 函数可以用来创建一个定时器,直到清除为止?

  1. setTimeout()

  2. setRate()

  3. setInterval()

  4. wait()

Q2. <progress>元素的哪些属性控制标记为完成的进度条的百分比?

  1. valuemax

  2. currentValuemaxValue

  3. startend

  4. minmax

摘要

我们创建了一个基于我们在上一章中编写的钢琴应用程序的游戏。我们使用 JavaScript 计时器来实现音频序列器以播放歌曲并创建动画循环。我们创建了闪屏和游戏面板,并学会了在它们之间过渡游戏状态。

本章中我们涵盖了以下概念:

  • 如何创建一个闪屏面板并使用文本阴影产生有趣的文本效果

  • 如何使用 HTML5 进度条元素显示动态资源的加载进度

  • 使用 JavaScript 计时器函数创建音频序列器,控制音频播放以播放歌曲

  • 如何使用 JavaScript 计时器来动画 DOM 元素

  • 如何在游戏状态和面板之间过渡

  • 如何收集用户输入,验证它,并在游戏结束时显示结果

在下一章中,我们将学习如何使用 Ajax 来动态加载资源并通过构建天气小部件调用 Web 服务。

第八章:天气的变化

"气候是我们所期望的,天气是我们得到的。"

-马克·吐温

在本章中,我们将构建一个天气小部件,以了解如何使用 Ajax 异步加载内容并与 Web 服务通信。我们将学习 Ajax 以及如何使用 jQuery 的 Ajax 方法加载包含 XML 或 JSON 格式数据的文件。然后我们将从 Web 服务获取天气状况以在小部件中显示。我们还将使用 HTML 地理位置 API 来查找用户的位置,以便显示他们当地的天气。

在本章中,我们将学到以下内容:

  • 如何使用 jQuery 的 Ajax 方法获取 XML 和 JSON 数据

  • 解析从服务返回的 JSON 与 XML

  • 什么是 Web 服务以及如何使用 Ajax 异步与它们通信

  • 跨站脚本的问题,以及解决方案 JSONP

  • 如何使用 HTML5 地理位置 API 获取用户的位置

  • 如何连接到 Web 服务以获取当前天气报告

Ajax 简介

Ajax 是 JavaScript 用于向服务器发送数据和接收数据的技术。最初Ajax代表异步 JavaScript 和 XML,但现在这个含义已经丢失,因为 JSON(我们在第一章中学到的,手头的任务)已经开始取代 XML 作为打包数据的首选格式,而 Ajax 请求不需要是异步的。

使用 Ajax 将使您的应用程序更加动态和响应。与其在每次需要更新网页的部分时都进行回发,您可以仅加载必要的数据并动态更新页面。通过 Ajax,我们可以从服务器检索几乎任何东西,包括要插入到网页中的 HTML 片段和应用程序使用的静态数据。我们还可以调用提供对服务器端唯一可用的数据和服务的 Web 服务。

发出 Ajax 请求

jQuery 提供了一些方法,可以轻松访问 Web 资源并使用 Ajax 调用 Web 服务。ajax()方法是其中最原始的方法。如果你想对服务调用有最大的控制,可以使用这个方法。大多数情况下,最好使用get()post()等更高级的方法。

get()方法使使用 Ajax 进行 HTTP GET 请求变得更加容易。最简单的情况下,您传入要获取的资源或服务的 URL,它会异步发送请求并获取响应。完成后,它会执行您提供的回调函数。

例如,以下代码片段对服务器上的 XML 文件进行 GET 请求,并在对话框中显示其内容:

$.get("data/myData.xml", function(data) {
    alert("data: " + data);
});

所有的 jQuery Ajax 方法都返回一个对象,您可以附加done()fail()always()回调方法。done()方法在请求成功后调用,fail()在出现错误时调用,always()在请求成功或失败后都会调用:

$.get("data/myData.xml")
    .done(function(data) { alert("data: " + data); })
    .fail(function() { alert("error"); })
    .always(function() { alert("done"); });

传递给done()方法的数据将根据响应中指定的 MIME 类型,要么是 XML 根元素,要么是 JSON 对象,要么是字符串。如果是 JSON 对象,您可以像引用任何 JavaScript 对象一样引用数据。如果是 XML 元素,您可以使用 jQuery 来遍历数据。

您可以通过传入一个名称/值对的对象文字来为请求提供查询参数:

$.get("services/getInfo.php", {
    firstName: "John",
    lastName: "Doe"
})
.done(function(data) { /* do something */ });

这将发出以下请求:

services/getInfo.php?firstName=John&lastName=Doe

如果您更喜欢进行 POST 请求而不是 GET 请求,则可以使用post()方法,如果您使用安全协议(如 HTTPS)并且不希望在请求中看到查询参数,则可能更可取:

$.post("services/getInfo.php", {
    firstName: "John",
    lastName: "Doe"
});

注意

在一些浏览器中,包括 Chrome,您无法使用file://协议通过 Ajax 请求访问文件。在这种情况下,您需要通过 IIS 或 Apache 运行您的应用程序,或者使用其他浏览器。

行动时间-创建一个天气小部件

在本章中,我们将演示如何通过实现一个显示天气报告的小部件来进行各种 Ajax 调用。让我们从定义小部件的 HTML 标记开始:

<div id="weather-widget">
  <div class="loading">
    <p>Checking the weather...</p>
    <img src="img/loading.gif" alt="Loading..."/>
  </div>
  <div class="results">
    <header>
      <img src="img/" alt="Condition"/>Current weather for
      <div class="location"><span></span></div>
    </header>
    <section class="conditions">
      Conditions: <span data-field="weather"></span><br/>
      Temperature: <span data-field="temperature_string"></span><br/>
      Feels Like: <span data-field="feelslike_string"></span><br/>
      Humidity: <span data-field="relative_humidity"></span><br/>
      Wind: <span data-field="wind_string"></span><br/>
    </section>
  </div>
  <div class="error">
    Error: <span></span>
  </div>
</div>

小部件由三个不同的面板组成,任何时候只有一个面板会显示。<div class="loading">面板在从服务器检索天气数据时可见。它里面有一个动画图像,向用户指示正在加载某些内容。

<div class="results">面板将显示从服务器返回的天气数据。它包含占位符字段,用于放置天气数据。请注意,我们在占位符<span>元素上使用了自定义数据属性。稍后将使用这些属性从服务器返回的 XML 文档或 JSON 对象中提取正确的数据。

<div class="error">面板将在 Ajax 请求失败时显示错误消息。

现在让我们创建 JavaScript 代码来控制小部件,命名为weatherWidget.js。我们将创建一个WeatherWidget对象,其构造函数接受一个包装在 jQuery 对象中的小部件根元素的引用:

function WeatherWidget($widget)
{
    this.update = function()
    {
        $(".results", $widget).hide();
        $(".loading", $widget).show();
        getWeatherReport();
    };

    function getWeatherReport() {
        // not implemented
    }
}

在我们的对象中,我们创建了一个名为update()的公共方法。这将从页面调用,告诉小部件更新天气报告。在update()方法中,我们首先隐藏结果面板,显示加载面板。然后我们调用getWeatherReport()方法,它将进行 Ajax 调用并在完成时更新小部件。在接下来的几节中,我们将编写此方法的不同版本。

刚刚发生了什么?

我们创建了一个可以放置在网站任何页面上的天气小部件。它有一个公共的update()方法,用于告诉小部件更新其信息。

行动时间-获取 XML 数据

首先让我们创建一个从 XML 文件中获取数据并从其数据更新天气小部件的示例。我们将创建一个名为weather.html的新网页,并将天气小部件的标记放入其中。该页面将有一个检查天气按钮。单击时,它将调用天气小部件的update()方法。您可以在第八章/示例 8.1中找到此示例的代码。

接下来,我们需要创建一个包含一些天气信息的 XML 文件。我们将文件命名为weather.xml,并将其放在data文件夹中:

<weather>
    <location>Your City</location>
    <current_observation>
        <weather>Snow</weather>
        <temperature_string>38.3 F (3.5 C)</temperature_string>
        <feelslike_string>38 F (3 C)</feelslike_string>
        <relative_humidity>76%</relative_humidity>
        <wind_string>From the WSW at 1.0 MPH</wind_string>
        <icon_url>images/snow.gif</icon_url>
    </current_observation>
</weather>

现在让我们在WeatherWidget对象中编写getWeatherReport()方法:

function getWeatherReport()
{
    $.get("data/weather.xml")
        .done(function(data) {
            populateWeather(data);
       })
        .fail(function(jqXHR, textStatus, errorThrown) { 
            showError(errorThrown);
        });
}

在这个方法中,我们使用 jQuery 的get()方法执行 Ajax 请求,并将 XML 文件的路径传递给它。如果服务器调用成功,我们调用populateWeather()方法,将请求返回的数据传递给它。这将是表示我们的 XML 文件的 DOM 的根元素。如果请求失败,我们调用showError()方法,将错误消息传递给它。

接下来让我们编写populateWeather()方法。这是我们将从 XML 文档中提取数据并插入到页面中的地方:

function populateWeather(data)
{
    var $observation = $("current_observation", data);

    $(".results header img", $widget)
        .attr("src", $("icon_url", $observation).text());
    $(".location>span", $widget)
        .text($("location", data).text());

    $(".conditions>span").each(function(i, e)
    {
        var $span = $(this);
        var field = $span.data("field");
        $(this).text($(field, $observation).text());
    });

    $(".loading", $widget).fadeOut(function ()
    {
        $(".results", $widget).fadeIn();
    });
}

我们需要一种方法来从服务器检索到的 XML 文档中提取数据。幸运的是,jQuery 可以用来选择任何 XML 文档中的元素,而不仅仅是网页的 DOM。我们所要做的就是将我们的 XML 的根元素作为第二个参数传递给 jQuery 选择器。这正是我们在方法的第一行中所做的,以获取current_observation元素并将其存储在$observation变量中。

接下来,我们使用 jQuery 从icon_url元素中获取文本,并将图像的src属性设置为它。这是表示当前天气的图像。我们还从location元素中获取文本,并将其插入到小部件的标题中。

然后,我们遍历小部件条件部分中的所有<span>元素。对于每个元素,我们获取其data-field自定义数据属性的值。我们使用它来查找current_observation元素中具有相同名称的元素,获取其文本,并将其放入<span>元素中。

我们做的最后一件事是淡出加载面板并淡入结果面板,以在页面上显示当前天气。加载的数据如下所示:

执行操作-获取 XML 数据

发生了什么?

我们使用 jQuery 的get() Ajax 方法从服务器加载了一个包含天气数据的 XML 文件。然后,我们使用 jQuery 选择从 XML 文档中提取信息,并将其放入小部件的占位符元素中以在页面上显示它。

执行操作-获取 JSON 数据

现在让我们做与上一节相同的事情,只是这次我们将从包含 JSON 格式数据的文件中获取数据,而不是 XML。概念是相同的,只是从 Ajax 调用中返回的是 JavaScript 对象,而不是 XML 文档。您可以在第八章/示例 8.2中找到此示例的代码。

首先让我们定义我们的 JSON 文件,我们将其命名为weather.json,并将其放在data文件夹中:

{
    "location": {
        "city":"Your City"
    }
    ,"current_observation": {
        "weather":"Clear",
        "temperature_string":"38.3 F (3.5 C)",
        "wind_string":"From the WSW at 1.0 MPH Gusting to 5.0 MPH",
        "feelslike_string":"38 F (3 C)",
        "relative_humidity":"71%",
        "icon_url":"images/nt_clear.gif"
    }
}

这个 JSON 定义了一个匿名包装对象,其中包含一个location对象和一个current_observation对象。current_observation对象包含 XML 文档中current_observation元素的所有数据。

现在让我们重写getWeatherReport()以获取 JSON 数据:

function getWeatherReport()
{
    $.get("data/weather.json", {
        t: new Date().getTime()
    })
    .done(function(data) { populateWeather(data); })
    .fail(function(jqXHR, textStatus, errorThrown) {
        showError(errorThrown);
    });
}

我们仍然使用get()方法,但现在我们正在获取 JSON 文件。请注意,这次我们正在向 URL 添加查询参数,设置为当前时间的毫秒数。这是绕过浏览器缓存的一种方法。大多数浏览器似乎无法识别使用 Ajax 请求更改文件时。通过添加每次发出请求时都会更改的参数,它会欺骗浏览器,使其认为这是一个新请求,绕过缓存。请求将类似于data/weather.json?t=1365127077960

注意

当通过诸如 IIS 之类的 Web 服务器运行此应用程序时,您可能需要将.json文件类型添加到站点的 MIME 类型列表中(.jsonapplication/json)。否则,您将收到文件未找到的错误。

现在让我们重写populateWeather()方法:

function populateWeather(data)
{
    var observation = data.current_observation;

    $(".results header img", $widget).attr("src", observation.icon_url);
    $(".location>span", $widget).text(data.location.city);

    $(".conditions>span").each(function(i, e)
    {
        var $span = $(this);
        var field = $span.data("field");
        $(this).text(observation[field]);
    });

    $(".loading", $widget).fadeOut(function ()
    {
        $(".results", $widget).fadeIn();
    });
}

这次 jQuery 认识到我们已经以 JSON 格式加载了数据,并自动将其转换为 JavaScript 对象。因此,这就是传递给方法的data参数。要获取观察数据,我们现在可以简单地访问data对象的current_observation字段。

与以前一样,我们遍历所有的<span>占位符元素,但这次我们使用方括号来使用field自定义数据属性作为字段名从observation对象中访问数据。

发生了什么?

我们重写了天气小部件,以从 JSON 格式文件获取天气数据。由于 jQuery 会自动将 JSON 数据转换为 JavaScript 对象,因此我们可以直接访问数据,而不必使用 jQuery 搜索 XML 文档。

HTML5 地理位置 API

稍后,我们将再次重写天气小部件,以从 Web 服务获取天气,而不是从服务器上的静态文件。我们希望向用户显示其当前位置的天气,因此我们需要某种方式来确定用户的位置。HTML5 刚好有这样的东西:地理位置 API。

地理位置由几乎每个现代浏览器广泛支持。位置的准确性取决于用户设备的功能。具有 GPS 的设备将提供非常准确的位置,而没有 GPS 的设备将尝试通过其他方式(例如通过 IP 地址)尽可能接近地确定用户的位置。

通过使用navigator.geolocation对象访问地理位置 API。要获取用户的位置,您调用getCurrentPosition()方法。它需要两个参数-如果成功则是回调函数,如果失败则是回调函数:

navigator.geolocation.getCurrentPosition(
    function(position) { alert("call was successful"); },
    function(error) { alert("call failed"); }
);

成功调用的函数会传递一个包含另一个名为coords的对象的对象。以下是coords对象包含的一些更有用的字段的列表:

  • latitude:这是用户的纬度,以十进制度表示(例如,44.6770429)。

  • longitude:这是用户的经度,以十进制度表示(例如,-85.60261659)。

  • accuracy:这是位置的精度,以米为单位。

  • speed:这是用户以米每秒为单位的移动速度。这适用于带有 GPS 的设备。

  • heading:这是用户移动的方向度数。与速度一样,这适用于带有 GPS 的设备。

例如,如果您想获取用户的位置,您可以执行以下操作:

var loc = position.coords.latitude + ", " + position.coords.longitude);

用户必须允许您的页面使用 Geolocation API。如果他们拒绝您的请求,调用getCurrentPosition()将失败,并且根据浏览器,可能会调用错误处理程序或静默失败。在 Chrome 中,请求如下所示:

HTML5 Geolocation API

错误处理程序会传递一个包含两个字段codemessage的错误对象。code字段是整数错误代码,message是错误消息字符串。有三种可能的错误代码:permission deniedposition unavailabletimeout

Geolocation API 还有一个watchPosition()方法。它的工作方式与getCurrentPosition()相同,只是当用户移动时会调用您的回调函数。这样,您可以实时跟踪用户并在应用程序中更新他们的位置。

注意

在某些浏览器中,您必须通过 IIS 或 Apache 等 Web 服务器运行网页才能使地理位置功能正常工作。

行动时间-获取地理位置数据

在本节中,我们将向我们的天气小部件示例中添加一些代码,以访问 Geolocation API。您可以在chapter8/example8.3中找到本节的代码。

首先让我们进入weather.html,并在检查天气按钮旁边添加一个显示用户位置的部分:

<div id="controls">
    <div>
        Latitude: <input id="latitude" type="text"/><br/>
        Longitude: <input id="longitude" type="text"/>
    </div>
    <button id="getWeather">Check Weather</button>
    <div class="error">
        Error: <span></span>
    </div>
</div>

我们添加了一个带有文本字段的<div>元素,以显示我们从 Geolocation API 获取的用户纬度和经度。我们还添加了一个<div class="error">元素,以显示地理位置失败时的错误消息。

现在让我们进入weather.js,并向WeatherApp对象添加一些代码。我们将添加一个getLocation()方法:

function getLocation()
{
    if (navigator.geolocation)
    {
        navigator.geolocation.getCurrentPosition(
        function(position)
        {
            $("#latitude").val(position.coords.latitude);
            $("#longitude").val(position.coords.longitude);
        },
        function(error)
        {
            $("#controls .error")
                .text("ERROR: " + error.message)
                .slideDown();
        });
    }
}

首先,我们通过检查navigation对象中是否存在geolocation对象来检查 Geolocation API 是否可用。然后我们调用geolocation.getCurrentPosition()。回调函数获取position对象,并从其coords对象中获取纬度和经度。然后将纬度和经度设置到文本字段中:

行动时间-获取地理位置数据

如果由于某种原因地理位置请求失败,我们从错误对象中获取错误消息,并在页面上显示它:

行动时间-获取地理位置数据

刚刚发生了什么?

我们使用 Geolocation API 获取了用户的位置。我们提取了纬度和经度,并在页面上的文本字段中显示了它们。我们将把这些传递给天气服务,以获取他们所在位置的天气。

尝试一下

创建一个 Web 应用程序,使用 Geolocation API 跟踪用户的位置。当用户位置发生变化时,使用 Ajax 调用 Google Static Maps API 获取用户当前位置的地图,并更新页面上的图像。在您的智能手机上打开应用程序并四处走动,看看它是否有效。您可以在developers.google.com/maps/documentation/staticmaps/找到 Google Static Maps API 的文档。

使用网络服务

Web 服务是创建大多数企业级 Web 应用程序的重要组成部分。它们提供了无法直接在客户端访问的服务,因为存在安全限制。例如,您可以有一个访问数据库以检索或存储客户信息的 web 服务。Web 服务还可以提供可以从许多不同应用程序访问的集中操作。例如,提供天气数据的服务。

Web 服务可以使用任何可以接收 Web 请求并返回响应的服务器端技术创建。它可以是简单的 PHP,也可以是像.NET 的 WCF API 这样复杂的面向服务的架构。如果您是唯一使用您的 Web 服务的人,那么 PHP 可能足够了;如果 Web 服务是为公众使用而设计的,那么可能不够。

大多数 Web 服务以 XML 或 JSON 格式提供数据。过去,XML 是 Web 服务的首选格式。然而,近年来 JSON 变得非常流行。不仅因为越来越多的 JavaScript 应用程序直接与 Web 服务交互,而且因为它是一种简洁、易于阅读和易于解析的格式。许多服务提供商现在正在转向 JSON。

这本书的范围不在于教你如何编写 web 服务,但我们将学习如何通过使用提供本地天气报告的 web 服务与它们进行交互。

Weather Underground

在这个例子中,我们将从一个真实的 web 服务中获取天气。我们将使用 Weather Underground 提供的服务,网址为www.wunderground.com。要运行示例代码,您需要一个开发者 API 密钥,可以在www.wunderground.com/weather/api/免费获取。免费的开发者计划允许您调用他们的服务,但限制了您每天可以进行的服务调用次数。

跨站脚本和 JSONP

我们可以使用前面讨论过的任何 jQuery Ajax 方法来调用 Web 服务。调用与您的网页位于同一域中的 Web 服务没有问题。但是,调用存在于另一个域中的 Web 服务会带来安全问题。这就是所谓的跨站脚本,或 XSS。例如,位于http://mysite.com/myPage.html的页面无法访问http://yoursite.com的任何内容。

跨站脚本的问题在于黑客可以将客户端脚本注入到请求中,从而允许他们在用户的浏览器中运行恶意代码。那么我们如何绕过这个限制呢?我们可以使用一种称为JSONP的通信技术,它代表带填充的 JSON

JSONP 的工作原理是由于从其他域加载 JavaScript 文件存在安全异常。因此,为了绕过获取纯 JSON 格式数据的限制,JSONP 模拟了一个<script>请求。服务器返回用 JavaScript 函数调用包装的 JSON 数据。如果我们将前面示例中的 JSON 放入 JSONP 响应中,它将看起来像以下代码片段:

jQuery18107425144074950367_1365363393321(
{
    "location": {
        "city":"Your City"
    }
    ,"current_observation": {
        "weather":"Clear",
        "temperature_string":"38.3 F (3.5 C)",
        "wind_string":"From the WSW at 1.0 MPH Gusting to 5.0 MPH",
        "feelslike_string":"38 F (3 C)",
        "relative_humidity":"71%",
        "icon_url":"images/nt_clear.gif"
    }
}
);

使用 jQuery 进行 Ajax 请求的好处是,我们甚至不需要考虑 JSONP 的工作原理。我们只需要知道在调用其他域中的服务时需要使用它。要告诉 jQuery 使用 JSONP,我们将dataType参数设置为"jsonp"传递给ajax()方法。

ajax()方法可以接受一个包含所有请求参数的名称/值对对象,包括 URL。我们将dataType参数放在该对象中:

$.ajax({
    url: "http://otherSite/serviceCall", 
    dataType : "jsonp"
});

行动时间-调用天气服务

现在我们已经获得了用户的位置,我们可以将其传递给 Underground Weather 服务,以获取用户当前的天气。由于服务存在于外部域中,我们将使用 JSONP 来调用该服务。让我们进入WeatherWidget对象并进行一些更改。

首先,我们需要更改构造函数以获取 Weather Underground API 密钥。由于我们正在编写一个通用小部件,可以放置在任何站点的任何页面上,页面的开发人员需要提供他们的密钥:

function WeatherWidget($widget, wuKey)

接下来我们将更改getWeatherReport()方法。现在它获取我们想要获取天气报告的地点的坐标。在这种情况下,我们从地理位置 API 中获取的是用户的位置:

function getWeatherReport(lat, lon)
{
    var coords = lat + "," + lon;
    $.ajax({
        url: "http://api.wunderground.com/api/" + wuKey +
             "/conditions/q/" + coords + ".json", 
        dataType : "jsonp"
    })
    .done(function(data) { populateWeather(data); })
    .fail(function(jqXHR, textStatus, errorThrown) { 
        showError(errorThrown);
    });
}

我们使用ajax()方法和 JSONP 调用 Weather Underground 服务。服务的基本请求是api.wunderground.com/api/后跟 API 密钥。要获取当前天气状况,我们在 URL 中添加/conditions/q/,后跟以逗号分隔的纬度和经度。最后,我们添加".json"告诉服务以 JSON 格式返回数据。URL 最终看起来像api.wunderground.com/api/xxxxxxxx/conditions/q/44.99,-85.48.json

done()fail()处理程序与前面的示例中的处理程序相同。

现在让我们更改populateWeather()方法,以提取从服务返回的数据:

function populateWeather(data)
{
    var observation = data.current_observation;

    $(".results header img", $widget).attr("src", observation.icon_url);
    $(".location>span", $widget).text(observation.display_location.full);

    $(".conditions>span").each(function(i, e)
    {
        var $span = $(this);
        var field = $span.data("field");
        $(this).text(observation[field]);
    });

    // Comply with terms of service
    $(".results footer img", $widget)
        .attr("src", observation.image.url);

    $(".loading", $widget).fadeOut(function ()
    {
        $(".results", $widget).fadeIn();
    });
}

这个版本的populateWeather()方法几乎与我们在 JSON 文件示例中使用的方法相同。唯一的区别是我们在小部件的页脚中添加了一个显示 Weather Underground 标志的图像,这是使用他们的服务的服务条款的一部分。

唯一剩下的事情就是回到网页的主WeatherApp对象,并更改对WeatherWidget的调用,以提供 API 密钥和位置:

function WeatherApp()
{
    var weatherWidget =
            new WeatherWidget($("#weather-widget"), "YourApiKey"),
        version = "8.3";

接下来,我们更改getCurrentWeather(),当单击检查天气按钮时调用该方法,将用户的坐标传递给小部件的update()方法:

function getCurrentWeather()
{
    var lat = $("#latitude").val();
    var lon = $("#longitude").val();
    if (lat && lon)
    {
        $("#weather-widget").fadeIn();
        weatherWidget.update(lat, lon);
    }
}

在小部件淡入后,我们从文本输入字段中获取坐标。然后我们调用小部件的update()方法,将坐标传递给它。这样,用户位置的天气就显示出来了:

行动时间-调用天气服务

刚刚发生了什么?

我们更改了天气小部件,使用 Weather Underground 服务获取了从地理位置 API 获取的用户位置的当前天气。我们使用 JSONP 调用服务,因为它不在与我们网页相同的域中。

快速测验

Q1. 你使用哪个 jQuery 方法来发出 Ajax 请求?

  1. ajax()

  2. get()

  3. post()

  4. 以上所有

Q2. 何时需要使用 JSONP 进行 Ajax 请求?

  1. 调用 web 服务时

  2. 在向另一个域发出请求时

  3. 在向同一域发出请求时

  4. 进行 POST 请求时

Q3. 地理位置 API 提供什么信息?

  1. 用户的纬度和经度

  2. 用户的国家

  3. 用户的地址

  4. 以上所有

总结

在本章中,我们创建了一个可以放置在任何页面上的天气小部件。我们使用 Ajax 请求从服务器获取静态 XML 和 JSON 数据。我们学会了如何使用地理位置 API 找到用户的位置,并使用它来调用 web 服务以获取本地化的天气数据。

本章中涵盖了以下概念:

  • 如何使用 Ajax 从服务器读取 XML 和 JSON 文件

  • 如何使用 jQuery 从服务器调用返回的 XML 中提取数据

  • 如何使用 HTML5 地理位置 API 在世界任何地方获取用户的当前位置

  • 如何使用 Ajax 异步与 web 服务交互

  • 使用 JSONP 绕过跨站点脚本的安全限制

  • 如何使用地理位置和 web 服务获取用户当前位置的天气报告

在下一章中,我们将学习如何使用 Web Workers API 创建多线程 JavaScript 应用程序。我们将创建一个应用程序,绘制 Mandelbrot 分形图,而不会锁定浏览器。

第九章:Web Workers Unite

"如果你想要有创造力的工作者,就给他们足够的玩耍时间。"

—约翰·克里斯

在本章中,我们将学习如何使用 HTML5 web worker 在另一个线程中运行后台进程。我们可以使用这个功能使具有长时间运行进程的应用程序更具响应性。我们将使用 web worker 在画布上绘制 Mandelbrot 分形,以异步方式生成它,而不会锁定浏览器窗口。

在本章中,我们将学习以下主题:

  • 通过使用 web workers 使 web 应用程序更具响应性的方法

  • 如何启动和管理 web worker

  • 如何与 web worker 通信并来回发送数据

  • 如何使用 web worker 在画布上绘制 Mandelbrot 分形

  • 调试 web workers 的技巧

Web workers

Web workers 提供了一种在 Web 应用程序的主线程之外的后台线程中运行 JavaScript 代码的方式。尽管由于其异步性质,JavaScript 可能看起来是多线程的,但事实上只有一个线程。如果你用一个长时间运行的进程来占用这个线程,网页将变得无响应,直到进程完成。

过去,您可以通过将长时间运行的进程分成块来缓解这个问题,以便一次处理一点工作。在每个块之后,您将调用setTimeout(),将超时值设为零。当您调用setTimeout()时,实际上会在指定的时间后将事件放入事件队列。这允许队列中已经存在的其他事件有机会被处理,直到您的计时器事件到达队列的最前面。

如果您以前使用过线程,您可能会意识到很容易遇到并发问题。一个线程可能正在处理与另一个线程相同的数据,这可能导致数据损坏,甚至更糟糕的是死锁。幸运的是,web worker 不会给我们太多机会遇到并发问题。web worker 不允许访问非线程安全的组件,如 DOM。它们也无法访问windowdocumentparent对象。

然而,这种线程安全是有代价的。由于 web worker 无法访问 DOM,它无法执行任何操作来操作页面元素。它也无法直接操作主线程的任何数据结构。此时你可能会想,如果 web worker 无法访问任何东西,那它有什么用呢?

好吧,web worker 无法访问主线程中的数据,但它们可以通过消息来回传递数据。然而,需要记住的关键一点是,传递给 web worker 的任何数据在发送之前都会被序列化,然后在另一端进行反序列化,以便它在副本上工作,而不是原始数据。然后,web worker 可以对数据进行一些处理,并再次使用序列化将其发送回主线程。只需记住,传递大型数据结构会有一些开销,因此您可能仍然希望将数据分块并以较小的批次进行处理。

注意

一些浏览器确实支持在不复制的情况下传输对象,这对于大型数据结构非常有用。目前只有少数浏览器支持这一功能,所以我们在这里不会涉及。

生成 web worker

web worker 的代码在其自己的 JavaScript 文件中定义,与主应用程序分开。主线程通过创建一个新的Worker对象并给它文件路径来生成一个 web worker:

var myWorker = new Worker("myWorker.js");

应用程序和 worker 通过发送消息进行通信。要接收消息,我们使用addEventListener()为 worker 添加消息事件处理程序:

myWorker.addEventListener("message", function (event) {
  alert("Message from worker: " + event.data);
}, false);

一个event对象作为参数传递给事件处理程序。它有一个data字段,其中包含从 worker 传回的任何数据。data字段可以是任何可以用 JSON 表示的东西,包括字符串、数字、数据对象和数组。

创建 Worker 后,可以使用postMessage()方法向其发送消息。它接受一个可选参数,即要发送给 Worker 的数据。在这个例子中,它只是一个字符串:

myWorker.postMessage("start");

实现 Web Worker

如前所述,Web Worker 的代码在单独的文件中指定。在 Worker 内部,您还可以添加一个事件监听器,以接收来自应用程序的消息:

self.addEventListener("message", function (event) {
  // Handle message
}, false);

在 Worker 内部,有一个self关键字,它引用 Worker 的全局范围。使用self关键字是可选的,就像使用window对象一样(所有全局变量和函数都附加到window对象)。我们在这里使用它只是为了显示上下文。

Worker 可以使用postMessage()向主线程发送消息。它的工作方式与主线程完全相同:

self.postMessage("started");

当 Worker 完成后,可以调用close()方法来终止 Worker。关闭后,Worker 将不再可用:

self.close();

您还可以使用importScripts()方法将其他外部 JavaScript 文件导入 Worker。它接受一个或多个脚本文件的路径:

importScripts("script1.js", "script2.js");

这对于在主线程和 Web Worker 中使用相同的代码库非常有效。

行动时间 - 使用 Web Worker

让我们创建一个非常简单的应用程序,获取用户的名称并将其传递给 Web Worker。Web Worker 将向应用程序返回一个“hello”消息。此部分的代码可以在Chapter 9/example9.1中找到。

注意

在某些浏览器中,Web Worker 不起作用,除非您通过 IIS 或 Apache 等 Web 服务器运行它们。

首先,我们创建一个包含webWorkerApp.htmlwebWorkerApp.csswebWorkerApp.js文件的应用程序。我们在 HTML 中添加一个文本输入字段,询问用户的名称,并添加一个响应部分,用于显示来自 Worker 的消息:

<div id="main">
    <div>
        <label for="your-name">Please enter your name: </label>
        <input type="text" id="your-name"/>
        <button id="submit">Submit</button>
    </div>
    <div id="response" class="hidden">
        The web worker says: <span></span>
    </div>
</div>

webWorkerApp.js中,当用户点击提交按钮时,我们调用executeWorker()方法:

function executeWorker()
{
    var name = $("#your-name").val();
    var worker = new Worker("helloWorker.js");
    worker.addEventListener("message", function(event) {
        $("#response").fadeIn()
            .children("span").text(event.data);
    });
    worker.postMessage(name);
}

首先我们获取用户在文本字段中输入的名称。然后我们创建一个在helloWorker.js中定义了其代码的新的Worker。我们添加一个消息事件监听器,从 Worker 那里获取消息并将其放入页面的响应部分。最后,我们使用postMessage()将用户的名称发送给 Worker 以启动它。

现在让我们在helloWorker.js中创建我们的 Web Worker 的代码。在那里,我们添加了从主线程获取消息并发送消息的代码:

self.addEventListener("message", function(event) {
    sayHello(event.data);
});
function sayHello(name)
{
    self.postMessage("Hello, " + name);
}

首先,我们添加一个事件监听器来获取应用程序的消息。我们从event.data字段中提取名称,并将其传递给sayHello()函数。sayHello()函数只是在用户的名称前面加上“Hello”,然后使用postMessage()将消息发送回应用程序。在主应用程序中,它获取消息并在页面上显示它。

刚刚发生了什么?

我们创建了一个简单的应用程序,获取用户的名称并将其传递给 Web Worker。Web Worker 将消息发送回应用程序,在页面上显示 - 这就是使用 Web Worker 的简单方法。

Mandelbrot 集

演示如何使用 Web Worker 来进行一些真正的处理,我们将创建一个绘制 Mandelbrot 分形的应用程序。绘制 Mandelbrot 需要相当多的处理能力。如果不在单独的线程中运行,应用程序在绘制时会变得无响应。

绘制 Mandelbrot 是一个相对简单的过程。我们将使用逃逸时间算法。对于图像中的每个像素,我们将确定达到临界逃逸条件需要多少次迭代。迭代次数决定像素的颜色。如果我们在最大迭代次数内未达到逃逸条件,则被视为在 Mandelbrot 集内,并将其涂黑。

有关此算法和 Mandelbrot 集的更多信息,请参阅维基百科页面:

en.wikipedia.org/wiki/Mandelbrot_set

行动时间-实施算法

让我们在一个名为mandelbrotGenerator.js的新文件中创建一个MandelbrotGenerator对象。这个对象将实现生成 Mandelbrot 的算法。构造函数接受画布的宽度和高度,以及 Mandelbrot 的边界:

function MandelbrotGenerator(canvasWidth, canvasHeight, left, top,right, bottom)
    {

接下来我们定义算法使用的变量:

    var scalarX = (right - left) / canvasWidth,
        scalarY = (bottom - top) / canvasHeight,
        maxIterations = 1000,
        abort = false,
        inSetColor = { r: 0x00, g: 0x00, b: 0x00 },
        colors = [ /* array of color objects */ ];

scalarXscalarY变量用于将 Mandelbrot 坐标转换为画布坐标。它们是通过将 Mandelbrot 的宽度或高度除以画布的宽度或高度来计算的。例如,虽然画布可能设置为 640x480 像素,但 Mandelbrot 的边界可能是左上角(-2,-2)和右下角(2,2)。在这种情况下,Mandelbrot 的高度和宽度都是 4:

行动时间-实施算法

接下来,我们将算法的最大迭代次数设置为 1000。如果您将其设置得更高,您将获得更好的结果,但计算时间将更长。使用 1000 提供了处理时间和可接受结果之间的良好折衷。abort变量用于停止算法。inSetColor变量控制 Mandelbrot 集中的像素的颜色。我们将其设置为黑色。最后,有一个颜色数组,用于给不在集合中的像素上色。

让我们首先编写这些方法,将画布坐标转换为 Mandelbrot 坐标。它们只是将位置乘以标量,然后加上顶部或左侧的偏移量:

function getMandelbrotX(x)
{
    return scalarX * x + left;
}
function getMandelbrotY(y)
{
    return scalarY * y + top;
}

现在让我们在一个名为draw()的公共方法中定义算法的主循环。它以要绘制的画布上的图像数据作为参数:

this.draw = function(imageData)
{
    abort = false;

    for (var y = 0; y < canvasHeight; y++)
    {
        var my = getMandelbrotY(y);
        for (var x = 0; x < canvasWidth; x++)
        {
            if (abort) return;
            var mx = getMandelbrotX(x);
            var iteration = getIteration(mx, my);
            var color = getColor(iteration);
            setPixel(imageData, x, y, color);
        }
    }
};

在外部循环中,我们遍历画布中所有行的像素。在这个循环内,我们调用getMandelbrotY(),传入画布的 y 位置,并返回 Mandelbrot 中相应的 y 位置。

接下来,我们遍历行中的所有像素。对于每个像素,我们:

  1. 调用getMandelbrotX(),传入画布的 x 位置,并返回 Mandelbrot 中相应的 x 位置。

  2. 调用getIterations(),传入 Mandelbrot 的 x 和 y 位置。这个方法将找到达到逃逸条件所需的迭代次数。

  3. 调用getColor(),传入迭代次数。这个方法获取迭代次数的颜色。

  4. 最后,我们调用setPixel(),传入图像数据、x 和 y 位置以及颜色。

接下来让我们实现getIterations()方法。这是我们确定像素是否在 Mandelbrot 集合内的地方。它以 Mandelbrot 的 x 和 y 位置作为参数:

function getIterations(x0, y0)
{
    var x = 0,
        y = 0,
        iteration = 0;
    do
    {
        iteration++;
        if (iteration >= maxIterations) return -1;
        var xtemp = x * x - y * y + x0;
        y = 2 * x * y + y0;
        x = xtemp;
    }
    while (x * x + y * y < 4);

    return iteration;
}

首先,我们将工作的xy位置初始化为零,iteration计数器初始化为零。接下来,我们开始一个do-while循环。在循环内,我们递增iteration计数器,如果它大于maxIterations,我们返回-1。这表示逃逸条件未满足,该点在 Mandelbrot 集合内。

然后我们计算用于检查逃逸条件的 x 和 y 变量。然后我们检查条件,以确定是否继续循环。一旦满足逃逸条件,我们返回找到它所需的迭代次数。

现在我们将编写getColor()方法。它以迭代次数作为参数:

function getColor(iteration)
{
    if (iteration < 0) return inSetColor;
    return colors[iteration % colors.length];
}

如果iteration参数小于零,这意味着它在 Mandelbrot 集合中,我们返回inSetColor对象。否则,我们使用模运算符在颜色数组中查找颜色对象,以限制迭代次数的长度。

最后,我们将编写setPixel()方法。它接受图像数据、画布 x 和 y 位置以及颜色:

function setPixel(imageData, x, y, color)
{
    var d = imageData.data;
    var index = 4 * (canvasWidth * y + x);
    d[index] = color.r;
    d[index + 1] = color.g;
    d[index + 2] = color.b;
    d[index + 3] = 255; // opacity
}

这应该看起来非常熟悉,就像第五章中的内容,我们学习了如何操作图像数据。首先,我们找到图像数据数组中的像素的索引。然后,我们从color对象中设置每个颜色通道,并将不透明度设置为255的最大值。

刚刚发生了什么?

我们实现了绘制 Mandelbrot 到画布图像数据的算法。每个像素要么设置为黑色,要么根据找到逃逸条件所需的迭代次数设置为某种颜色。

创建 Mandelbrot 应用程序

现在我们已经实现了算法,让我们创建一个应用程序来使用它在页面上绘制 Mandelbrot。我们将首先在没有 Web Worker 的情况下进行绘制,以展示这个过程如何使网页无响应。然后我们将使用 Web Worker 在后台绘制 Mandelbrot,以查看差异。

行动时间-创建 Mandelbrot 应用程序

让我们从创建一个新的应用程序开始,其中包括mandelbrot.htmlmandelbrot.cssmandelbrot.js文件。我们还包括了之前为应用程序创建的mandelbrotGenerator.js。您可以在第九章/example9.2中找到本节的代码。

在 HTML 文件中,我们向 HTML 添加了一个<canvas>元素来绘制 Mandelbrot,并将大小设置为 640x480:

<canvas width="640" height="480"></canvas>

我们还添加了三个按钮,其中预设的 Mandelbrot 边界以 JSON 格式定义为data-settings自定义数据属性中的数组:

<button class="draw"
    data-settings="[-2, -2, 2, 2]">Draw Full</button>
<button class="draw"
    data-settings="[-0.225, -0.816, -0.197, -0.788]">Preset 1
</button>
<button class="draw"
    data-settings="[-1.18788, -0.304, -1.18728, -0.302]">Preset 2
</button>

现在让我们进入 JavaScript 文件,并添加调用 Mandelbrot 生成器的代码。在这里,我们定义变量来保存对画布及其上下文的引用:

function MandelbrotApp()
{
    var version = "9.2",
        canvas = $("canvas")[0],
        context = canvas.getContext("2d");

接下来,我们添加一个drawMandelbrot()方法,当其中一个按钮被点击时将被调用。它以 Mandelbrot 的边界作为参数进行绘制:

function drawMandelbrot(left, top, right, bottom)
{
    setStatus("Drawing...");
    var imageData =
        context.getImageData(0, 0, canvas.width, canvas.height);
    var generator = new MandelbrotGenerator(canvas.width, canvas.height, 
        left, top, right, bottom);
    generator.draw(imageData);
    context.putImageData(imageData, 0, 0)
    setStatus("Finished.");
}

首先,我们在状态栏中显示绘制中...的状态。然后,我们获取整个画布的图像数据。接下来,我们创建MandelbrotGenerator对象的一个新实例,传入画布和边界设置。然后我们调用它的draw()方法,传入图像数据。当它完成时,我们将图像数据绘制回画布,并将状态设置为完成

我们需要做的最后一件事是更新应用程序的start()方法:

this.start = function()
{
    $("#app header").append(version);

    $("button.draw").click(function() {
        var data = $(this).data("settings");
        drawMandelbrot(data[0], data[1], data[2], data[3]);
    });

    setStatus("ready");
};

在这里,我们为所有按钮添加了一个点击事件处理程序。当点击按钮时,我们获取settings自定义数据属性(一个数组),并将值传递给drawMandelbrot()进行绘制。

就是这样-让我们在浏览器中打开并查看一下。根据您使用的浏览器(有些比其他浏览器快得多)和您系统的速度,Mandelbrot 应该需要足够长的时间来绘制,以至于您会注意到页面已经变得无响应。如果您尝试点击其他按钮,将不会发生任何事情。还要注意,尽管我们调用了setStatus("Drawing..."),但您从未看到状态实际上发生变化。这是因为绘图算法在运行时有机会更新页面上的文本之前就接管了控制权:

开始行动-创建 Mandelbrot 应用程序

刚刚发生了什么?

我们创建了一个应用程序来绘制 Mandelbrot 集,使用了我们在上一节中创建的绘图算法。它还没有使用 Web Worker,因此在绘制时页面会变得无响应。

行动时间-使用 Web Worker 的 Mandelbrot

现在我们将实现相同的功能,只是这次我们将使用 Web Worker 来将处理转移到另一个线程。这将释放主线程来处理页面更新和用户交互。您可以在第九章/example9.3中找到本节的源代码。

让我们进入 HTML 并添加一个复选框,我们可以选择是否使用 Web Worker。这将使在浏览器中比较结果更容易:

<input type="checkbox" id="use-worker" checked />
<label for="use-worker">Use web worker</label>

我们还将添加一个停止按钮。以前没有 Web Worker 的情况下无法停止,因为 UI 被锁定,但现在我们将能够实现它:

<button id="stop">Stop Drawing</button>

现在让我们继续在一个名为mandelbrotWorker.js的新文件中创建我们的 Web Worker。我们的 worker 需要使用MandelbrotGenerator对象,因此我们将该脚本导入 worker:

importScripts("mandelbrotGenerator.js");

现在让我们为 worker 定义消息事件处理程序。在接收到包含绘制 Mandelbrot 所需数据的消息时,worker 将开始生成它:

self.addEventListener("message", function(e)
{
    var data = e.data;
    var generator = new MandelbrotGenerator(data.width, data.height,
        data.left, data.top, data.right, data.bottom);
    generator.draw(data.imageData);
    self.postMessage(data.imageData);
    self.close();
});

首先,我们创建MandelbrotGenerator的一个新实例,传入我们从主应用程序线程获取的值,包括画布的宽度和高度以及 Mandelbrot 边界。然后,我们调用生成器的draw()方法,传入消息中也包含的图像数据。生成器完成后,我们通过调用postMessage()将包含绘制 Mandelbrot 的图像数据传递回主线程。最后,我们调用close()来终止 worker。

至此,worker 就完成了。让我们回到我们的主应用程序对象MandelbrotApp,并添加代码,以便在单击按钮时启动 Web Worker。

mandelbrot.js中,我们需要向应用程序对象添加一个名为 worker 的全局变量,该变量将保存对 Web Worker 的引用。然后,我们重写drawMandelbrot()以添加一些新代码来启动 worker:

function drawMandelbrot(left, top, right, bottom)
{
    if (worker) return;

    context.clearRect(0, 0, canvas.width, canvas.height);
    setStatus("Drawing...");

    var useWorker = $("#use-worker").is(":checked");
    if (useWorker)
    {
        startWorker(left, top, right, bottom);
    }
    else
    {
        /* Draw without worker */
    }
}

首先,我们检查worker变量是否已设置。如果是,则 worker 已经在运行,无需继续。然后我们清除画布并设置状态。接下来,我们检查使用 worker复选框是否被选中。如果是,我们调用startWorker(),传入 Mandelbrot 边界参数。startWorker()方法是我们创建 Web Worker 并启动它的地方:

function startWorker(left, top, right, bottom)
{
    worker = new Worker("mandelbrotWorker.js");
    worker.addEventListener("message", function(e)
    {
        context.putImageData(e.data, 0, 0)
        worker = null;
        setStatus("Finished.");
    );

    var imageData =
        context.getImageData(0, 0, canvas.width, canvas.height);
    worker.postMessage({
        imageData: imageData,
        width: canvas.width,
        height: canvas.height,
        left: left,
        top: top,
        right: right,
        bottom: bottom
    });
}

首先,我们创建一个新的Worker,将mandelbrotWorker.js的路径传递给它。然后,我们向 worker 添加一个消息事件处理程序,当 worker 完成时将调用该处理程序。它获取从 worker 返回的图像数据并将其绘制到画布上。

接下来我们启动 worker。首先,我们从画布的上下文中获取图像数据。然后,我们将图像数据、画布的宽度和高度以及 Mandelbrot 边界放入一个对象中,通过调用postMessage()将其传递给 worker。

还有一件事要做。我们需要实现停止按钮。让我们编写一个stopWorker()方法,当单击停止按钮时将调用该方法:

function stopWorker()
{
    if (worker)
    {
        worker.terminate();
        worker = null;
        setStatus("Stopped.");
    }
}

首先,我们通过检查worker变量是否已设置来检查 worker 是否正在运行。如果是,我们调用 worker 的terminate()方法来停止 worker。调用terminate()相当于在 worker 内部调用self.close()

刚刚发生了什么?

我们实现了一个可以从后台线程绘制 Mandelbrot 的 Web Worker。这使用户可以在 Mandelbrot 绘制时继续与页面交互。我们通过添加一个停止按钮来演示这一点,该按钮可以停止绘制过程。您还会注意到,在绘制分形时,正在绘制...状态消息现在会显示出来。

试试看

我们 Mandelbrot 应用程序的一个问题是,我们正在序列化和传输整个画布的图像数据到 Web Worker,然后再传回。在我们的示例中,这是 640 * 480 * 4 字节,或 1,228,800 字节。那是 1.2 GB!看看您是否能想出一种将 Mandelbrot 的绘制分成更小块的方法。如果您想看看我是如何做到的,请查看第九章/示例 9.4

调试 Web Worker

调试 Web Worker 可能很困难。您无法访问window对象,因此无法调用alert()来显示消息,也无法使用console.log()来写入浏览器的 JavaScript 控制台。您也无法向 DOM 写入消息。甚至无法附加调试器并逐步执行代码。那么,一个可怜的开发人员该怎么办呢?

您可以为 worker 添加错误监听器,以便在 worker 线程内发生任何错误时收到通知:

worker.addEventListener("error", function(e)
{
    alert("Error in worker: " + e.filename + ", line:" + e.lineno + ", " + e.message);
});

错误处理程序传入的事件对象包含filenamelinenomessage字段。通过这些字段,您可以准确地知道错误发生的位置。

但是,如果你没有收到错误,事情只是不正常工作呢?首先,我建议你将所有处理工作的代码放在一个单独的文件中,就像我们在mandelbrotGenerator.js中所做的那样。这样可以让你从主线程以及工作者中运行代码。如果需要调试,你可以直接从应用程序运行它,并像平常一样进行调试。

您可以使用的一个调试技巧是在 Web 工作者中定义一个console对象,将消息发送回主线程,然后可以使用窗口的控制台记录它们:

var console = {
    log: function(msg)
    {
        self.postMessage({
            type: "log",
            message: msg
        });
    }
};

然后在你的应用程序中,监听消息并记录它:

worker.addEventListener("message", function(e)
{
    if (e.data.type == "log")
    {
        console.log(e.data.message);
    }
});

小测验

Q1. 如何向 Web 工作者发送数据?

  1. 你不能向工作线程发送数据。

  2. 使用postMessage()方法。

  3. 使用sendData()方法。

  4. 使用sendMessage()方法。

Q2. Web 工作者在主线程中可以访问哪些资源?

  1. DOM。

  2. window对象。

  3. document对象。

  4. 以上都不是。

摘要

在本章中,我们创建了一个应用程序来绘制 Mandelbrot 分形图,以了解如何使用 HTML Web 工作者在后台线程中执行长时间运行的进程。这使得浏览器能够保持响应并接受用户输入,同时生成图像。

我们在本章中涵盖了以下概念:

  • 如何使用 Web 工作者使 Web 应用程序更具响应性

  • 如何创建 Web 工作者并启动它

  • 如何在主线程和 Web 工作者之间发送消息和数据

  • 如何使用 Web 工作者绘制 Mandelbrot

  • 如何捕获从 Web 工作者抛出的错误

  • 如何调试 Web 工作者

在下一章和最后一章中,我们将学习如何通过组合和压缩其 JavaScript 文件来准备 Web 应用程序以发布。这将使应用程序在网络上的印记更轻。此外,我们将看到如何使用 HTML5 应用程序缓存来缓存应用程序,以便在用户离线时运行。

第十章:将应用程序发布到野外

“互联网是一个充满了自己的游戏、语言和手势的荒野,通过它们我们开始分享共同的感受。”

  • 艾未未

在本章中,我们将学习如何为发布准备 Web 应用程序。首先,我们将讨论如何压缩和合并 JavaScript 文件以加快下载速度。然后,我们将看看如何使用 HTML5 应用程序缓存接口使您的应用程序离线可用。

在本章中,我们将学习:

  • 如何合并和压缩 JavaScript 文件

  • 如何创建一个命令行脚本来准备一个应用程序发布

  • 如何使用 HTML5 应用程序缓存 API 使页面及其资源离线可用

  • 如何创建一个缓存清单文件来确定哪些资源被缓存

  • 如何确定应用程序的缓存何时已更新

合并和压缩 JavaScript

过去,JavaScript 开发人员的共识是你应该将所有的代码写在一个文件中,因为下载多个脚本文件会导致大量不必要的网络流量,并减慢加载时间。虽然减少下载文件的数量确实更好,但在一个文件中编写所有的代码很难阅读和维护。我们在其他语言中不会这样写代码,那么为什么我们在 JavaScript 中要这样做呢?

幸运的是,这个问题有一个解决方案:JavaScript 压缩器。压缩器将应用程序的所有 JavaScript 源文件合并成一个文件,并通过将本地变量重命名为最小可能的名称,删除空格和注释来压缩它们。我们既可以利用多个源代码文件进行开发的好处,又可以在发布应用程序时获得单个 JavaScript 文件的所有好处。你可以把它看作是将你的源代码编译成一个紧凑的可执行包。

有许多 JavaScript 压缩器可供选择。你可以在网上找到许多。这些压缩器的问题在于你必须复制你的源代码并将其粘贴到一个网页表单中,然后再将其复制回到一个文件中。这对于大型应用程序来说效果不太好。我建议你使用可以从命令提示符运行的压缩应用程序之一,比如雅虎的 YUI 压缩器或谷歌的 Closure 编译器:

YUI 和 Closure 都很容易使用,并且工作得非常好。它们都提供有关糟糕代码的警告(但不是相同的警告)。它们都是用 Java 编写的,因此需要安装 Java 运行时。我不能说哪一个比另一个更好。我选择 YUI 的唯一原因是如果我还想要压缩 CSS,因为 Closure 不支持它。

行动时间-创建一个发布脚本

为了为 JavaScript 准备发布,最简单的方法是创建一个可以从命令行运行的脚本。在这个例子中,我们将使用 YUI 压缩器,但它几乎与 Closure 相同。唯一的区别是命令行参数。在这个例子中,我们创建一个可以从 Windows 命令行运行的命令行脚本,它将获取我们在第七章中编写的钢琴英雄应用程序,钢琴英雄,并将其打包发布。您可以在第十章/example10.1中找到本节的代码。

在我们开始之前,我们需要为应用程序定义一个文件夹结构。我喜欢为应用程序创建一个基本文件夹,其中包含一个src文件夹和一个release文件夹。基本文件夹包含命令行批处理脚本。src文件夹包含所有的源代码和资源。release文件夹将包含压缩的 JavaScript 文件和运行应用程序所需的所有其他资源:

行动时间-创建一个发布脚本

现在让我们创建我们的批处理脚本文件,并将其命名为release.bat。我们需要告诉 YUI 要压缩哪些文件。有几种方法可以做到这一点。我们可以将所有 JavaScript 文件连接成一个文件,然后引用该文件,或者传入所有单独的文件列表。您使用的方法取决于您的需求。

如果您需要按特定顺序处理文件,或者文件不多,那么您可以将它们作为参数单独指定。如果您的应用程序中有很多文件,并且您不担心顺序,那么最简单的方法可能就是将它们连接成一个文件。在这个例子中,我们将使用type命令将所有 JavaScript 文件连接成一个名为pianoHero.collated.js的文件。

type src\*.js > pianoHero.collated.js

我们使用type命令在src文件夹中找到所有.js文件,并将它们写入一个名为pianoHero.collated.js的文件中。请注意,这不包括lib文件夹中的文件。我喜欢将它们分开,但如果你愿意的话,你当然可以包括任何外部库(如果它们的许可证允许)。现在我们将执行压缩器,传入合并的 JavaScript 文件:

java -jar ..\yui\yuicompressor-2.4.6.jar --type js -o release\pianoHero.min.js pianoHero.collated.js

我们启动 Java 运行时,告诉它在哪里找到 YUI 压缩器的 JAR 文件。我们传入一个文件类型参数js,因为我们正在压缩 JavaScript(YUI 也可以压缩 CSS)。-o参数告诉它输出的位置。最后是 JavaScript 文件(如果有多个文件)。

现在我们在release文件夹中有一个pianoHero.min.js文件。我们仍然需要将所有其他资源复制到release文件夹,包括 HTML 和 CSS 文件,jQuery 库和音频文件:

xcopy /Y src\*.html release
xcopy /Y src\*.css release
xcopy /Y /S /I src\lib release\lib
xcopy /Y /S /I src\audio release\audio

我们使用xcopy命令将pianoHero.htmlpianoHero.csslib文件夹中的所有内容以及audio文件夹中的所有内容复制到release文件夹中。此时,我们在release文件夹中有运行应用程序所需的一切。

还有最后一件事要做。我们需要删除 HTML 文件中过时的<script>元素,并用指向我们压缩后的 JavaScript 文件的元素替换它们。这部分不容易自动化,所以我们需要打开文件并手动操作:

<head>
    <title>Piano Hero</title>
    <link href="pianoHero.css" rel="StyleSheet" />
    <script src="img/jquery-1.8.1.min.js"></script>
    <script src="img/strong>"></script>
</head>

就是这样。现在在浏览器中打开应用程序,进行一次烟雾测试,确保一切仍然按照您的期望工作,然后发布它!

刚刚发生了什么?

我们创建了一个 Windows 命令行脚本,将所有 JavaScript 源文件合并为一个文件,并使用 YUI 压缩器进行压缩。我们还将运行应用程序所需的所有资源复制到release文件夹中。最后,我们将脚本引用更改为压缩后的 JavaScript 文件。

尝试一下

YUI 压缩器还可以压缩 CSS。在发布脚本中添加代码来压缩 CSS 文件。

HTML5 应用程序缓存

HTML5 应用程序缓存 API 提供了一种缓存网页使用的文件和资源的机制。一旦缓存,就好像用户在他们的设备上下载并安装了您的应用程序。这允许应用程序在用户未连接到互联网时离线使用。

注意

浏览器可能会限制可以缓存的数据量。一些浏览器将其限制为 5MB。

使应用程序被缓存的关键是缓存清单文件。这个文件是一个简单的文本文件,包含了应该被缓存的资源的信息。它被<html>元素的manifest属性引用:

<html manifest="myapp.appcache">

在清单文件中,您可以指定要缓存或不缓存的资源。该文件可以有三个部分:

  • CACHE:这是默认部分,列出要缓存的文件。声明此部分标题是可选的。在 URI 中不允许使用通配符。

  • 网络:此部分列出需要网络连接的文件。对这些文件的请求将绕过缓存。允许使用通配符。

  • FALLBACK:这个部分列出了如果资源在离线状态下不可用的备用文件。每个条目包含原始文件的 URI 和备用文件的 URI。通配符是允许的。两个 URI 必须是相对的,并且来自应用程序的同一个域。

注意

缓存清单文件可以有任何文件扩展名,但必须以 text/cache-manifest 的 MIME 类型传递。你可能需要在你的 Web 服务器中将你使用的扩展名与这个 MIME 类型关联起来。

需要注意的一件重要的事情是,一旦应用程序的文件被缓存,只有这些文件的版本会被使用,即使它们在服务器上发生了变化。应用程序缓存中的资源可以更新的方式只有两种:

  • 当清单文件发生变化时

  • 当用户清除浏览器对你的应用程序的数据存储时

我建议在开发应用程序时,将缓存清单文件放在与 HTML 文件不同的文件夹中。你不希望在编写代码时缓存文件。将它放在应用程序的基本文件夹中,以及你的发布脚本,并将它复制到你的脚本中的release文件夹中。

是否缓存你的应用程序取决于你的应用程序的性质。如果它严重依赖于对服务器的 Ajax 调用来工作,那么使它离线可用就没有意义。然而,如果你可以编写你的应用程序,使其在离线状态下本地存储数据,那么这可能是值得的。你应该确定维护缓存清单的开销是否对你的应用程序有益。

行动时间 - 创建缓存清单

让我们从我们的模板中创建一个简单的应用程序,以演示如何使用缓存清单。它包含 HTML、CSS 和 JavaScript 文件,以及一个image文件夹中的一些图片。你可以在Chapter 10/example10.2中找到这个示例的源代码。

现在让我们创建一个名为app.appcache的缓存清单文件:

CACHE MANIFEST
# v10.2.01

清单文件必须始终以CACHE MANIFEST开头。在第二行我们有一个注释。以井号(#)开头的行是注释。建议在清单文件的注释中有某种版本标识或发布日期。正如之前所述,导致应用程序重新加载到缓存中的唯一方法是更改清单文件。每次发布新版本时,你都需要更新这个版本标识。

接下来,我们添加我们想要缓存的文件。如果你愿意,你可以添加CACHE部分的标题,但这不是必需的:

CACHE:
app.html
app.css
app.js
lib/jquery-1.8.1.min.js

不幸的是,在这个部分中不允许使用通配符,所以你需要明确列出每个文件。对于一些应用程序,比如带有所有音频文件的钢琴英雄,可能需要大量输入!

接下来让我们定义NETWORK部分。现在你可能会想,这部分有什么意义?我们已经列出了所有我们想要被缓存的文件。那么为什么需要列出你不想被缓存的文件呢?原因是一旦被缓存,你的应用程序将只从缓存中获取文件,即使在线。如果你想在应用程序中使用非缓存资源,你需要在这个部分中包含它们。

例如,假设我们在页面上有一个用于跟踪页面点击的站点跟踪图像。如果我们不将它添加到NETWORK部分,即使用户在线,对它的请求也永远不会到达服务器。出于这个例子的目的,我们将使用一个静态图像文件。实际上,这可能是 PHP 或其他服务器端请求处理程序,返回一个图像:

NETWORK:
images/tracker.png

现在让我们定义FALLBACK部分。假设我们想在我们的应用程序中显示一张图片,让用户知道他们是在线还是离线。这就是我们指定从在线到离线图片的备用的地方:

FALLBACK:
online.png offline.png

这就是我们的清单文件。现在在浏览器中打开应用程序以便它被缓存。然后进入 JavaScript 文件并更改应用程序对象中version变量的值。现在刷新页面;什么都不应该改变。接下来进入清单文件并更改版本,再次刷新。仍然没有改变。发生了什么?

还记得我之前说过的吗?清单文件发生更改会导致应用程序重新加载?虽然这是真的,但在页面从缓存加载后,清单文件不会被检查是否有更改。因此用户需要两次重新加载页面才能获得更新的版本。幸运的是,我们可以在 JavaScript 中检测清单文件何时发生更改,并向用户提供消息,表明有新版本可用的方法。

让我们添加一个名为checkIfUpdateAvailable()的 JavaScript 方法来检查缓存何时已更新:

function checkIfUpdateAvailable()
{
    window.applicationCache.addEventListener('updateready',
    function(e)
    {
        setStatus("A newer version is available. Reload the page to update.");
    });
}

首先,我们向applicationCache对象添加一个updateready事件监听器。这在浏览器发现清单文件已更改并下载了更新资源后触发。当我们收到缓存已更新的通知时,我们显示一条消息告诉用户重新加载页面。现在我们只需要在应用程序的start()方法中调用这个方法,我们就准备好了。

现在去更新应用程序和清单文件中的版本号并刷新页面。你应该看到更新消息显示。再次刷新页面,你会看到版本已经改变:

操作时间-创建缓存清单

最后,让我们检查我们的回退。断开互联网连接并重新加载页面。你应该看到离线图像显示而不是在线图像。还要注意,它无法加载跟踪图像,因为我们将其标记为非缓存资源:

操作时间-创建缓存清单

刚才发生了什么?

我们学习了如何使用 HTML 应用程序缓存来缓存 Web 应用程序。我们使用清单文件定义了应该被缓存的资源,一个不被缓存的资源,以及应用程序离线时的回退资源。我们还学习了如何以编程方式检查缓存何时已更新。

弹出测验

Q1. JavaScript 压缩器做什么?

  1. 将你的代码压缩成一个压缩文件

  2. 将你的 JavaScript 文件合并成一个文件

  3. 从 JavaScript 文件中删除所有空格和注释

  4. 将本地变量重命名为尽可能小的名称

Q2. 资源何时在应用程序缓存中更新?

  1. 当服务器上的文件发生变化时

  2. 当清单文件发生更改时

  3. 资源永远不会更新

  4. 每次用户启动应用程序时

总结

在本章中,我们学习了如何将我们完成的应用程序准备好发布到世界上。我们使用 JavaScript 压缩器将所有 JavaScript 文件合并压缩成一个紧凑的文件。然后我们使用应用程序缓存 API 使应用程序离线可用。

在本章中,我们涵盖了以下概念:

  • 如何使用 YUI 压缩器合并和压缩 JavaScript 文件

  • 如何创建一个命令行脚本,打包我们的应用程序并准备发布

  • 如何使用应用程序缓存 API 缓存应用程序并使其离线可用

  • 如何创建缓存清单文件并定义缓存、非缓存和回退文件

  • 如何以编程方式检查清单文件何时发生更改并提醒用户更新可用

就是这样。我们已经从创建起始模板到准备应用程序发布,覆盖了 HTML5 Web 应用程序开发。现在去开始编写你自己的 HTML5 Web 应用程序吧。我期待看到你如何使用 HTML5 来创造下一个大事件。

附录 A. 突发测验答案

第一章,手头的任务

突发测验

问题 1 4
问题 2 4

第二章,让我们时尚起来

突发测验

问题 1 4
问题 2 1

第三章,魔鬼在细节中

突发测验

问题 1 2
问题 2 4

第四章,一块空白画布

突发测验

问题 1 3
问题 2 2

第五章,不那么空白的画布

突发测验

问题 1 1 触摸事件可以有任意数量的点与之关联,存储在touches数组中
问题 2 3 每像素四个字节,代表红色、绿色、蓝色和 alpha 值

第六章,钢琴人

突发测验

问题 1 4
问题 2 2

第七章,钢琴英雄

突发测验

问题 1 3
问题 2 1

第八章,天气变化

突发测验

问题 1 4
问题 2 2
问题 3 1

第九章,网络工作者团结起来

突发测验

问题 1 2
问题 2 4

第十章,将应用程序发布到野外

突发测验

问题 1 1
问题 2 2
posted @ 2024-05-24 11:06  绝不原创的飞龙  阅读(7)  评论(0编辑  收藏  举报