原文:
zh.annas-archive.org/md5/F338796025D212EF3B95DC40480B4CAD译者:飞龙
第七章:钢琴英雄
“音乐的一大好处是,当它打动你时,你感觉不到痛苦。”
- 鲍勃·马利
在本章中,我们将把上一章的钢琴应用程序转变成一个游戏,玩家必须在音符按下屏幕时以正确的时间演奏歌曲的音符。我们将创建一个启动页面,用于跟踪图像加载并允许玩家选择游戏选项。我们将创建一个音频序列以播放音乐数据中的歌曲。在游戏过程中,我们将收集钢琴键盘输入并验证以确定玩家的得分。
在本章中我们将学到以下内容:
-
如何使用 HTML5 进度条元素跟踪资源的加载
-
如何使用 JavaScript 定时器来控制音频播放以播放歌曲
-
如何使用 DOM 元素动画来移动它们在屏幕上
-
如何在游戏状态之间过渡
-
如何获取用户输入并验证它
创建钢琴英雄
我们的钢琴英雄游戏将从我们在上一章中构建的 HTML5 钢琴应用程序开始。我们将添加一个音频序列到其中以播放预先录制的歌曲。为了得分,玩家需要跟着演奏歌曲的音符,并在正确的时间演奏。还将有一个练习模式,只播放歌曲,以便玩家能听到它。
我们的游戏将有两个不同的主面板。第一个将是启动面板,这是游戏的起点。当应用程序首次启动时,它将显示一个进度条,因为音频正在加载。加载完成后,它将显示游戏的选项。当玩家点击播放按钮时,他们将转到游戏面板。
游戏面板包含钢琴键盘和一个显示要演奏的音符从上面掉下来的区域。如果用户在正确的时间演奏了正确的音符,他们会得到积分。在歌曲结束时,玩家的得分和一些统计数据将被显示。游戏结束后,应用程序将转回到启动面板,用户可以选择选项并再次游戏。
通常有助于绘制一个流程图,显示游戏如何从一个状态过渡到另一个状态。
行动时间-创建启动面板
让我们从上一章创建的钢琴应用程序开始,并将文件重命名为pinaoHero.html,pianoHero.js和pianoHero.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变量确定我们是在玩游戏还是练习。noteCount、notesCorrect和score用于跟踪玩家的表现。
我们将所有支持键盘的代码从PianoHeroApp对象移动到GamePanel对象。这包括keyCodesToNotes、sustain和volume变量。我们还移动了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();
}
这个方法以要显示的面板作为参数。这将是对SplashPanel或GamePanel的引用。首先,我们检查是否正在显示面板,如果是,我们调用它的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变量在播放歌曲时设置为true。eventHandler将设置为一个在事件触发时调用的函数,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()方法,该方法从PianoHeroApp的startGame()方法中调用。它接受三个参数;歌曲名称,播放速率,以及是否在练习模式下播放游戏或歌曲:
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 中的占位符中填入分数和统计信息。我们显示分数、正确音符的数量和总音符数量。此外,我们使用notesCorrect和noteCount变量计算他们正确演奏的音符的百分比。
我们通过从分数和正确音符的数量中计算来获得时间准确度百分比。请记住,每个音符可能获得的总分数是 10 分,所以如果他们正确演奏了 17 个音符,那么可能获得的总分数是 170。如果分数是 154,那么 154/170≈91%。
刚刚发生了什么?
当游戏结束时,我们显示了结果面板,并填充了玩家的分数和统计信息。我们的游戏现在已经完成。试一试,成为钢琴英雄!
尝试一试
尝试编写一个音频记录器类,记录用户在键盘上演奏音符的时间,并将其保存到可以由音频序列器播放的数据对象数组中。
小测验
Q1. 哪个 JavaScript 函数可以用来创建一个定时器,直到清除为止?
-
setTimeout() -
setRate() -
setInterval() -
wait()
Q2. <progress>元素的哪些属性控制标记为完成的进度条的百分比?
-
value和max -
currentValue和maxValue -
start和end -
min和max
摘要
我们创建了一个基于我们在上一章中编写的钢琴应用程序的游戏。我们使用 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>元素中。
我们做的最后一件事是淡出加载面板并淡入结果面板,以在页面上显示当前天气。加载的数据如下所示:
发生了什么?
我们使用 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 类型列表中(.json,application/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 中,请求如下所示:
错误处理程序会传递一个包含两个字段code和message的错误对象。code字段是整数错误代码,message是错误消息字符串。有三种可能的错误代码:permission denied,position unavailable或timeout。
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 请求?
-
ajax() -
get() -
post() -
以上所有
Q2. 何时需要使用 JSONP 进行 Ajax 请求?
-
调用 web 服务时
-
在向另一个域发出请求时
-
在向同一域发出请求时
-
进行 POST 请求时
Q3. 地理位置 API 提供什么信息?
-
用户的纬度和经度
-
用户的国家
-
用户的地址
-
以上所有
总结
在本章中,我们创建了一个可以放置在任何页面上的天气小部件。我们使用 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。它们也无法访问window、document或parent对象。
然而,这种线程安全是有代价的。由于 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.html、webWorkerApp.css和webWorkerApp.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 */ ];
scalarX和scalarY变量用于将 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 位置。
接下来,我们遍历行中的所有像素。对于每个像素,我们:
-
调用
getMandelbrotX(),传入画布的 x 位置,并返回 Mandelbrot 中相应的 x 位置。 -
调用
getIterations(),传入 Mandelbrot 的 x 和 y 位置。这个方法将找到达到逃逸条件所需的迭代次数。 -
调用
getColor(),传入迭代次数。这个方法获取迭代次数的颜色。 -
最后,我们调用
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;
}
首先,我们将工作的x和y位置初始化为零,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.html、mandelbrot.css和mandelbrot.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 集,使用了我们在上一节中创建的绘图算法。它还没有使用 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);
});
错误处理程序传入的事件对象包含filename、lineno和message字段。通过这些字段,您可以准确地知道错误发生的位置。
但是,如果你没有收到错误,事情只是不正常工作呢?首先,我建议你将所有处理工作的代码放在一个单独的文件中,就像我们在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 工作者发送数据?
-
你不能向工作线程发送数据。
-
使用
postMessage()方法。 -
使用
sendData()方法。 -
使用
sendMessage()方法。
Q2. Web 工作者在主线程中可以访问哪些资源?
-
DOM。
-
window对象。 -
document对象。 -
以上都不是。
摘要
在本章中,我们创建了一个应用程序来绘制 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.html,pianoHero.css,lib文件夹中的所有内容以及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 压缩器不做什么?
-
将你的代码压缩成一个压缩文件
-
将你的 JavaScript 文件合并成一个文件
-
从 JavaScript 文件中删除所有空格和注释
-
将本地变量重命名为尽可能小的名称
Q2. 资源何时在应用程序缓存中更新?
-
当服务器上的文件发生变化时
-
当清单文件发生更改时
-
资源永远不会更新
-
每次用户启动应用程序时
总结
在本章中,我们学习了如何将我们完成的应用程序准备好发布到世界上。我们使用 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 |

被折叠的 条评论
为什么被折叠?



