十二、将 WebRTC 用于视频聊天
最近几年,浏览器制造商突破了以前认为的原生 JavaScript 代码的界限,增加了 API 来支持许多新功能,包括我们在前一章中看到的基于像素的绘制,现在,终于有了一种方法,使用称为 Web 实时通信(WebRTC) API 的 API 通过互联网将多媒体数据(视频和音频)从一个浏览器传输到另一个浏览器,所有这些都不需要使用插件。虽然在撰写本文时,这种支持目前只出现在 Chrome、Firefox 和 Opera 的桌面版本中,占全球 web 使用量的 50%多一点(source:bit . ly/cani use _ webrtc
),但我觉得这种技术对互联网通信的未来非常重要,作为开发人员,我们需要在这种 API 还处于采用阶段时就了解它。
在本章中,我们将介绍 WebRTC 规范的基础知识,包括如何使用对等网络从连接到设备的网络摄像头和麦克风发送和接收数据,以及如何使用 JavaScript 在浏览器中构建简单的视频聊天客户端。
WebRTC 规范
WebRTC 规范最初是由 Google 发起的,目的是包含在他们的 Chrome 浏览器中,并承诺提供一个 API,允许开发人员:
- 检测设备功能,包括基于连接到设备的摄像头和/或麦克风的视频和/或音频支持
- 从设备连接的硬件捕获媒体数据
- 编码并通过网络传输媒体
- 在浏览器之间建立直接的对等连接,自动处理防火墙或网络地址转换(NAT)带来的任何复杂问题
- 解码媒体流,将其呈现给最终用户,使音频和视频同步,并消除任何音频回声
WebRTC 项目页面可以通过 www。webrtc。org
,包括规范的当前状态,并包含跨浏览器互操作性的注释,以及演示和到其他网站的链接。
接入网络摄像头和麦克风
如果我们想使用 WebRTC 规范创建一个视频聊天应用,我们需要确定如何从连接到运行该应用的设备的网络摄像头和麦克风访问数据。JavaScript API 方法navigator.getUserMedia()
是其中的关键。我们通过传递三个参数来调用它:一个详细说明我们希望访问哪种类型的媒体的对象(video
和audio
是目前唯一可用的属性选项),一个在成功建立到网络摄像头和/或麦克风的连接时执行的回调函数,以及一个在没有成功建立到网络摄像头和/或麦克风的连接时执行的回调函数。当执行该方法时,浏览器会提示用户当前网页正试图访问他们的网络摄像头和/或麦克风,并提示他们是否允许或拒绝访问,如图 12-1 所示。如果他们拒绝访问,或者用户没有网络摄像头或麦克风连接,则执行第二个回调函数,指示多媒体数据不能被访问;否则,执行第一个回调函数。
图 12-1。
The user must allow or deny access to their webcam and microphone before we can access them
在撰写本文时,在某些浏览器中通过带前缀的方法名来访问getUserMedia()
方法,但是在所有浏览器中具有相同的输入参数序列。因此,我们可以编写一个小的 polyfill 来支持在所有支持的浏览器中访问这个 API,这样我们就可以在整个代码中通过getUserMedia()
方法名来访问它。清单 12-1 中的代码显示了一个简单的 polyfill,允许在所有支持的 web 浏览器中通过相同的方法调用来访问网络摄像头和麦克风。
清单 12-1。getUserMedia() API 的简单聚合填充
// Expose the browser-specific versions of the getUserMedia() method through the standard
// method name. If the standard name is already supported in the browser (as it is in Opera),
// use that, otherwise fall back to Mozilla's, Google's or Microsoft's implementations as
// appropriate for the current browser
navigator.getUserMedia = navigator.getUserMedia || navigator.mozGetUserMedia ||
navigator.webkitGetUserMedia || navigator.msGetUserMedia;
出于安全原因,WebRTC 仅在使用 web 服务器访问试图使用它的文件时工作,而不是直接从浏览器中加载的本地文件运行代码。有许多方法可以在本地启动 web 服务器来运行本章中的代码清单,尽管最简单的方法可能是从 http://httpd.apache.org
下载 Apache web 服务器软件并在您的机器上运行。如果你更喜欢冒险,请跳到第十四章,在那里我将解释如何使用 Node.js 应用平台创建和运行本地 web 服务器。
我们可以使用清单 12-1 中的 polyfill 来调用getUserMedia()
方法,尝试访问用户的网络摄像头和麦克风,如清单 12-2 所示。
清单 12-2。接入网络摄像头和麦克风
// Define a function to execute if we are successfully able to access the user's webcam and
// microphone
function onSuccess() {
alert("Successful connection made to access webcam and microphone");
}
// Define a function to execute if we are unable to access the user's webcam and microphone -
// either because the user denied access or because of a technical error
function onError() {
throw new Error("There has been a problem accessing the webcam and microphone");
}
// Using the polyfill from Listing 12-1, we know the getUserMedia() method is supported in the
// browser if the method exists
if (navigator.getUserMedia) {
// We can now execute the getUserMedia() method, passing in an object telling the browser
// which form of media we wish to access ("video" for the webcam, "audio" for the
// microphone). We pass in a reference to the onSuccess() and onError() functions which
// will be executed based on whether the user grants us access to the requested media types
navigator.getUserMedia({
video: true,
audio: true
}, onSuccess, onError);
} else {
// Throw an error if the getUserMedia() method is unsupported by the user's browser
throw new Error("Sorry, getUserMedia() is not supported in your browser");
}
当运行清单 12-2 中的代码时,如果您发现没有提示您允许访问网络摄像头和麦克风,请检查以确保您正在 web 服务器上的 HTML 页面的上下文中运行您的代码,无论是在本地计算机上还是通过网络连接。如果你的浏览器显示你正在使用file:///
协议浏览一个 URL,那么你将需要通过http://
使用一个网络服务器来运行它。在 Chrome 和 Opera 中,浏览器会在浏览器标签中显示的页面名称旁边使用红色圆圈图标,直观地表明它当前正在录制您的音频和/或视频,而 Firefox 会在地址栏中显示绿色摄像头图标来表明这一事实。
既然我们能够访问用户的网络摄像头和麦克风,我们需要能够对他们返回的数据做一些事情。由getUserMedia()
方法触发的onSuccess()
回调方法被传递一个参数,该参数表示由设备提供的原始数据流,以便在应用中使用。我们可以将这个流直接传递到页面上的 HTML5 <video>
元素的输入中,允许用户从他们自己的网络摄像头和麦克风接收数据。清单 12-3 中的代码展示了如何使用浏览器的window.URL.createObjectURL()
方法来实现这一点,它创建了一个特定的本地 URL,可以用来访问多媒体流以这种方式提供的数据。
清单 12-3。将网络摄像头和麦克风传回给用户
// Use the getUserMedia() polyfill from Listing 12-1 for best cross-browser support
// Define a function to execute if we are successfully able to access the user's webcam and
// microphone, taking the stream of data provided and passing it as the "src" attribute of a
// new <video> element, which is then placed onto the current HTML page, relaying back to the
// user the output from theirwebcam and microphone
function onSuccess(stream) {
// Create a new <video> element
var video = document.createElement("video"),
// Get the browser to create a unique URL to reference the binary data directly from
// the provided stream, as it is not a file with a fixed URL
videoSource = window.URL.createObjectURL(stream);
// Ensure the <video> element start playing the video immediately
video.autoplay = true;
// Point the "src" attribute of the <video> element to the generated stream URL, to relay
// the data from the webcam and microphone back to the user
video.src = videoSource;
// Add the <video> element to the end of the current page
document.body.appendChild(video);
}
function onError() {
throw new Error("There has been a problem accessing the webcam and microphone");
}
if (navigator.getUserMedia) {
navigator.getUserMedia({
video: true,
audio: true
}, onSuccess, onError);
} else {
throw new Error("Sorry, getUserMedia() is not supported in your browser");
}
既然我们有能力访问用户的网络摄像头和麦克风,并将他们的输入反馈给用户,我们就有了完全在浏览器中运行的简单双向视频聊天应用的开端。
创建一个简单的视频聊天 Web 应用
让我们通过创建一个浏览器内视频聊天 web 应用来了解 WebRTC 规范的重要部分。
视频聊天应用的要点是,我们从正在浏览同一 web 服务器的两个用户的设备中捕获视频和音频,并将捕获的视频和音频流从一个设备传输到另一个设备,反之亦然。我们已经介绍了如何使用getUserMedia()
API 方法从用户设备捕获数据流,所以让我们研究一下如何建立一个呼叫,以及如何发送和接收数据流,以便构建我们的视频聊天应用。
连接和信号
我们需要一种方法将一个设备直接连接到另一个设备,并在两者之间保持开放的数据连接。我们希望在两台设备之间建立直接或对等的连接,而不需要中间服务器来中继数据,从而将连接速度以及视频和音频质量保持在最佳水平。无论设备是使用公共 IP 地址直接连接到互联网、位于防火墙之后,还是位于采用网络地址转换(NAT)与本地网络中大量设备共享有限公共 IP 地址的路由器设备之后,这种连接都必须是可能的。
WebRTC 依赖于交互式连接建立(ICE)框架,该框架是一种规范,允许两个设备直接相互建立对等连接,而不管一个或两个设备是否直接连接到公共 IP 地址、防火墙后面或采用 NAT 的网络上。它简化了整个连接过程,所以我们不必担心这个问题,并且它可以在支持的浏览器中使用RTCPeerConnection
“class”接口。
建立对等连接的过程包括创建这个RTCPeerConnection
“类”的一个实例,传入一个包含一个或多个能够帮助建立设备间连接的服务器的详细信息的对象。这些服务器可以采用两种服务器协议,以不同的方式帮助建立这种连接:NAT 的会话穿越实用程序(STUN)和使用 NAT 周围中继的穿越(TURN)。
STUN 服务器将设备的内部 IP 地址和未使用的本地端口号映射到其外部可见的 IP 地址和未使用的面向外部的端口号(在本地网络之外),以便可以使用该端口将流量直接路由到本地设备。这是一个快速而有效的系统,因为服务器仅在初始连接时需要,并且一旦建立就可以移动到其他任务,然而仅在相当简单的 NAT 配置上工作,其中只有一个设备,即客户端,实际上位于 NAT 之后。任何支持多个 NAT 设备或其他大型企业系统的设置都不能使用这种类型的服务器建立对等连接。这就是另一个选择,转而介入的地方。
TURN 服务器使用公共互联网上的中继 IP 地址,通常是公共 TURN 服务器本身的 IP 地址,将一个设备连接到另一个设备,其中两个设备都在 NAT 之后。因为它起着中继的作用,所以它必须中继通信双方之间的所有数据,因为直接连接是不可能的。这意味着数据带宽被有效地减少,并且服务器必须在整个视频呼叫期间都是活动的以中继数据,这使得它不是一个有吸引力的解决方案,尽管即使在这样复杂的 NAT 设置中仍然允许视频呼叫发生。
ICE framework 使用适合所连接设备的 STUN 或 TURN 服务器,在视频聊天双方之间建立连接。ICE 的配置是通过传递一个被称为候选服务器的列表,然后对其进行优先级排序。它使用会话描述协议(SDP)来通知远程用户本地用户的连接细节,这被称为要约,并且远程用户在它识别要约时做同样的事情,被称为应答。一旦双方都有了对方的详细资料,他们之间的对等连接就建立了,通话就可以开始了。虽然看起来有很多步骤,但这一切都发生得非常快。
您的应用中有许多公共 STUN 服务器可供使用,包括一些由 Google 运行和操作的服务器,您可以在 http:// bit. ly/ stun_ list 上找到这些服务器的在线列表。公共 TURN 服务器不太常见,因为它们中继数据,因此需要大量的可用带宽,这可能是昂贵的;然而,一项服务可以通过number。维亚吉尼。ca
,这将允许你建立一个免费账户,通过他们的服务器运行你自己的眩晕/变身服务器。
包含 ICE 服务器详细信息的示例配置对象可能如下所示,用于建立与RTCPeerConnection
“类”的对等连接:
{
iceServers: [{
// Mozilla's public STUN server
url: "stun:23.21.150.121"
}, {
// Google's public STUN server
url: "stun:stun.l.google.com:19302"
}, {
// Create your own TURN server at
http://numb.viagenie.ca
// escape any extended characters in the username, e.g. the @ character becomes %40
url: "turn:numb.viagenie.ca",
username: "denodell%40gmail.com",
credential: "password"
}]
}
现在,如果你仔细阅读,你可能会注意到一个令人困惑的情况,关于我们的点对点连接的设置,即:如果我们还不知道我们连接的是谁,我们如何通过中介在两个对等点之间建立连接?当然,答案是我们不能,这让我们进入了理解如何建立视频通话的下一部分——信令通道。
我们需要做的是提供一种机制,在呼叫建立之前和建立期间在连接方之间发送消息。每一方都需要监听另一方发送的消息,并在需要时发送消息。这种消息传递可以使用 Ajax 来处理,尽管这样效率会很低,因为双方都必须频繁地询问中间服务器另一方是否发送了任何消息——大多数情况下答案是“没有”。更好的解决方案是更新的EventSource
API(在bit . ly/event source _ basics
了解更多信息)或 WebSockets 技术(在bit . ly/web sockets _ basics
了解更多信息),这两者都允许服务器将消息“推送”到连接的客户端后一种方法的好处是,它在支持 WebRTC 的所有浏览器中都受支持,因此使用这种方法不需要多种填充或变通方法。
将 Firebase 服务用于简单的信令
我们不用构建自己的服务器来支持 WebSocket 连接,而是可以利用现有的基于云的解决方案来代表我们在连接方之间存储和传输数据。一个这样的解决方案是 Firebase(可从 http:// firebase 获得)。com ,如图 12-2 所示,它提供了一个简单的在线数据库和一个小的 JavaScript API,用于使用 WebSockets 通过 web 访问数据(如果运行 WebSockets 的浏览器不支持 web sockets,它实际上会退回到其他解决方案)。其免费的基本解决方案足以满足我们作为信令服务连接和配置视频聊天客户端的需求。
图 12-2。
Firebase provides a data-access API perfect for realtime communication between devices
访问 Firebase 网站并注册一个免费帐户。然后,您将收到一封电子邮件,告知您新创建的在线数据库的详细访问信息。每一个都有自己独特的 URL 和在页面上使用的<script>
标签的详细信息,以加载在代码中使用的 Firebase API。不管创建的 URL 是什么,<script>
标签总是相同的:
<script src="
https://cdn.firebase.com/v0/firebase.js"></script
对于使用给定名称 https://glaring-fire-9593.firebaseio.com/
,
创建的 URL,可以使用以下 JavaScript 代码在您的代码中建立连接,并在数据库中保存数据:
var dataRef = new Firebase("
https://glaring-fire-9593.firebaseio.com
dataRef.set("I am now writing data into Firebase!");
Firebase 中的数据以类似于 JavaScript 对象的方式存储为嵌套对象。事实上,当您访问您的唯一 URL 时,您能够以您应该非常熟悉的伪 JSON 格式查看您的数据。可以使用 Firebase API 的set()
方法将数据添加到数据库中,并且我们能够检测任何连接的客户端何时使用 API 的on()
方法添加了数据。这意味着在我们的视频聊天中,一旦使用 ICE 框架建立了连接,双方就可以通知对方,当双方从对方收到所需的信息时,视频通话就可以开始了。
现在,如果我们在任何人都可以访问的公共 web 服务器上托管我们的视频聊天客户端,我们将需要一种方法来限制哪些人可以相互聊天,否则我们会冒着完全陌生的人相互建立呼叫的风险,因为他们是最先连接到服务器的两个用户——我们在这里不是在构建聊天轮盘!我们需要一种方式让特定的用户能够相互连接,一种简单的技术是允许连接的用户创建聊天室,并允许连接的客户能够创建或加入这些聊天室之一。当两个用户加入一个房间时,我们可以限制我们的信令只发生在该房间的用户之间,以便只有指定的各方可以相互通信。我们将允许视频聊天的第一方(发起者)命名他们的聊天室。然后,他们可以将这个房间名称通知给他们的呼叫伙伴,他们将在访问聊天客户端网页时指定这个房间名称。然后,我们将把各方之间发送和接收的要约和应答消息与聊天室名称相关联,并与 ICE 候选人详细信息一起直接连接双方,这样我们就可以通过这种方式将多方相互连接起来。这导致 Firebase 中的数据库结构可以在 JSON 中以类似于下面的格式表示:
{
"chatRooms": {
"room-001": {
"offer": "...",
"answer": "...",
"candidate": "..."
},
"room-002": {
...
}
}
}
Firebase 在需要客户端和服务器互相推送消息的应用中使用简单,这是它与视频聊天客户端一起使用的优势,提供了在同一个聊天中将双方连接在一起所需的信令通道。
构建视频聊天客户端
我们已经到了这样一个地步,我们可以将我们所学的一切整合在一起,构建一个视频聊天客户端,我们将构建一个如图 12-3 所示的例子。我们可以访问本地用户的网络摄像头和麦克风,我们可以建立一个信号通道,锁定到一个已知的聊天室名称,以共享有关如何将聊天双方相互连接的信息,然后我们可以使用 ICE 框架在双方之间建立一个对等连接,通过该连接流式传输视频和音频数据,以显示在远程方的浏览器窗口中。
图 12-3。
Example video call using our simple chat client
我们将代码分成三个文件,第一个是包含两个 HTML5 <video>
标签的 HTML 页面,一个向用户显示本地视频,另一个显示远程方的视频——音频也通过这个标签输出。该页面还包含 Start Call 和 Join Call 按钮,前者将随机生成一个聊天室名称,然后可以传递给远程方,后者允许用户输入一个聊天室名称来加入,通过使用相同的聊天室名称来连接双方。第二个和第三个文件是 JavaScript 文件,前者用于配置一个可重用的“类”,用于创建支持视频聊天所必需的代码,后者是一个特定的使用示例,根据 HTML 网页中显示的当前应用本身的需求进行配置。
我们首先需要建立一个 web 服务器,因为getUserMedia()
API 只能在运行在http
或http
协议上的 HTML 页面的上下文中工作。有许多产品可供使用;然而,最简单的方法可能是从 httpd 下载、安装和配置 Apache。阿帕奇。org
。
一旦您在本地或使用托管的在线解决方案启动并运行了 web 服务器,您将使用 HTML5 <video>
标签创建一个 HTML 页面,在该页面中将呈现来自远程用户的视频和音频。清单 12-4 显示了这样一个 HTML 页面的例子。
清单 12-4。一个简单的 HTML 页面,显示从远程方捕获的视频和音频
<!DOCTYPE html>
<html>
<head>
<title>In-Browser Video Chat</title>
<meta charset="utf-8">
<style>
body {
font-family: Helvetica, Arial, sans-serif;
}
.videos {
position: relative;
}
.video {
display: block;
position: absolute;
top: 0;
left: 0;
}
.local-video {
z-index: 1;
border: 1px solid black;
}
</style>
</head>
<body>
<h1>In-Browser Video Chat</h1>
<!-- Display the video chat room name at the top of the page -->
<p id="room-name"></p>
<!-- Create buttons which start a new video call or join an existing video call -->
<button id="start-call">Start Call</button>
<button id="join-call">Join Call</button>
<!-- Display the local and remote video streams, with the former displayed smaller
and layered above to the top left corner of the other -->
<div class="videos">
<video class="video local-video" id="local-video" width="200" autoplay muted></video>
<video class="video" id="remote-video" width="600" autoplay></video>
</div>
<!-- Load the script to enable Firebase support within this application -->
<script src="
https://cdn.firebase.com/v0/firebase.js"></script
<!-- Load the VideoChat "class" definition -->
<script src="Listing12-5.js"></script>
<!-- Load the code to instantiate the VideoChat "class" and connect it to this page -->
<script src="Listing12-6.js"></script>
</body>
</html>
清单 12-5 中的代码显示了如何创建 VideoChat“类”,它创建了必要的代码来处理本地浏览器和远程浏览器之间的通信,在两者之间传输视频和音频。
清单 12-5。支持浏览器内视频聊天的视频聊天“类”
// Define a "class" that can be used to create a peer-to-peer video chat in the browser. We
// have a dependency on Firebase, whose client API script should be loaded in the browser
// before this script executes
var VideoChat = (function(Firebase) {
// Polyfill the required browser features to support video chat as some are still using
// prefixed method and "class" names
// The PeerConnection "class" allows the configuration of a peer to peer connection
// between the current web page running on this device and the same running on another,
// allowing the addition of data streams to be passed from one to another, allowing for
// video chat-style appliations to be built
var PeerConnection = window.mozRTCPeerConnection || window.webkitRTCPeerConnection,
// The RTCSessionDescription "class" works together with the RTCPeerConnection to
// initialize the peer to peer data stream using the Session Description Protocol (SDP)
SessionDescription = window.mozRTCSessionDescription || window.RTCSessionDescription,
// The IceCandidate "class" allows instances of peer to peer "candidates" to be created
// - a candidate provides the details of the connection directly to our calling
// partner, allowing the two browsers to chat
IceCandidate = window.mozRTCIceCandidate || window.RTCIceCandidate,
// Define the two types of participant in a call, the person who initiated it and the
// person who responded
_participantType = {
INITIATOR: "initiator",
RESPONDER: "responder"
},
// Define an object containing the settings we will use to create our PeerConnection
// object, allowing the two participants in the chat to locate each other's IP
// addresses over the internet
_peerConnectionSettings = {
// Define the set of ICE servers to use to attempt to locate the IP addresses
// of each of the devices participating in the chat. For best chances of
// success, we include at least one server supporting the two different
// protocols, STUN and TURN, which provide this IP lookup mechanism
server: {
iceServers: [{
// Mozilla's public STUN server
url: "stun:23.21.150.121"
}, {
// Google's public STUN server
url: "stun:stun.l.google.com:19302"
}, {
// Create your own TURN server at
http://numb.viagenie.ca
url: "turn:numb.viagenie.ca",
username: "denodell%40gmail.com",
credential: "password"
}]
},
// For interoperability between different browser manufacturers' code, we set
// this DTLS/SRTP property to "true" as it is "true" by default in Firefox
options: {
optional: [{
DtlsSrtpKeyAgreement: true
}]
}
};
// Polyfill the getUserMedia() method, which allows us to access a stream of data provided
// by the user's webcam and/or microphone
navigator.getUserMedia = navigator.getUserMedia || navigator.mozGetUserMedia || navigator.webkitGetUserMedia || navigator.msGetUserMedia;
// If the current browser does not support the "classes" and methods required for video
// chat, throw an error - at the time of writing, Google Chrome, Mozilla Firefox and
// Opera are the only browsers supporting the features required to support video chat
if (!navigator.getUserMedia && !window.RTCPeerConnection) {
throw new Error("Your browser does not support video chat");
}
// Define a generic error handler function which will throw an error in the browser
function onError(error) {
throw new Error(error);
}
// Define the VideoChat "class" to use to create a new video chat on a web page
function VideoChat(options) {
options = options || {};
// Allow two callback functions, onLocalStream() and onRemoteStream() to be passed in.
// The former is executed once a connection has been made to the local webcam and
// microphone, and the latter is executed once a connection has been made to the remote
// user's webcam and microphone. Both pass along a stream URL which can be used to
// display the contents of the stream inside a <video> tag within the HTML page
if (typeof options.onLocalStream === "function") {
this.onLocalStream = options.onLocalStream;
}
if (typeof options.onRemoteStream === "function") {
this.onRemoteStream = options.onRemoteStream;
}
// Initialize Firebase data storage using the provided URL
this.initializeDatabase(options.firebaseUrl || "");
// Set up the peer-to-peer connection for streaming video and audio between two devices
this.setupPeerConnection();
}
VideoChat.prototype = {
// Define the participant type for the current user in this chat - the "initiator", the
// one starting the call
participantType: _participantType.INITIATOR,
// Define the participant type for the remote user in this chat - the "responder", the
// one responding to a request for a call
remoteParticipantType: _participantType.RESPONDER,
// Create a property to store the name for the chat room in which the video call will
// take place - defined later
chatRoomName: "",
// Define a property to allow loading and saving of data to the Firebase database
database: null,
// Define a method to be called when a local data stream has been initiated
onLocalStream: function() {},
// Define a method to be called when a remote data stream has been connected
onRemoteStream: function() {},
// Define a method to initialize the Firebase database
initializeDatabase: function(firebaseUrl) {
// Connect to our Firebase database using the provided URL
var firebase = new Firebase(firebaseUrl);
// Define and store the data object to hold all the details of our chat room
// connections
this.database = firebase.child("chatRooms");
},
// Define a method to save a given name-value pair to Firebase, stored against the
// chat room name given for this call
saveData: function(chatRoomName, name, value) {
if (this.database) {
this.database.child(chatRoomName).child(name).set(value);
}
},
// Define a method to load stored data from Firebase by its name and chat room name,
// executing a callback function when that data is found - the connection will wait
// until that data is found, even if it is generated at a later time
loadData: function(chatRoomName, name, callback) {
if (this.database) {
// Make a request for the data asynchronously and execute a callback function once
// the data has been located
this.database.child(chatRoomName).child(name).on("value", function(data) {
// Extract the value we're after from the response
var value = data.val();
// If a callback function was provided to this method, execute it, passing
// along the located value
if (value && typeof callback === "function") {
callback(value);
}
});
}
},
// Define a method to set up a peer-to-peer connection between two devices and stream
// data between the two
setupPeerConnection: function() {
var that = this;
// Create a PeerConnection instance using the STUN and TURN servers defined
// earlier to establish a peer-to-peer connection even across firewalls and NAT
this.peerConnection = new PeerConnection(_peerConnectionSettings.server, _peerConnectionSettings.options);
// When a remote stream has been added to the peer-to-peer connection, get the
// URL of the stream and pass this to the onRemoteStream() method to allow the
// remote video and audio to be presented within the page inside a <video> tag
this.peerConnection.onaddstream = function(event) {
// Get a URL that represents the stream object
var streamURL = window.URL.createObjectURL(event.stream);
// Pass this URL to the onRemoteStream() method, passed in on instantiation
// of this VideoChat instance
that.onRemoteStream(streamURL);
};
// Define a function to execute when the ICE framework finds a suitable candidate
// for allowing a peer-to-peer data connection
this.peerConnection.onicecandidate = function(event) {
if (event.candidate) {
// Google Chrome often finds multiple candidates, so let's ensure we only
// ever get the first it supplies by removing the event handler once a
// candidate has been found
that.peerConnection.onicecandidate = null;
// Read out the remote party's ICE candidate connection details
that.loadData(that.chatRoomName, "candidate:" + that.remoteParticipantType, function(candidate) {
// Connect the remote party's ICE candidate to this connection forming
// the peer-to-peer connection
that.peerConnection.addIceCandidate(new IceCandidate(JSON.parse(candidate)));
});
// Save our ICE candidate connection details for connection by the remote
// party
that.saveData(that.chatRoomName, "candidate:" + that.participantType, JSON.stringify(event.candidate));
}
};
},
// Define a method to get the local device's webcam and microphone stream and handle
// the handshake between the local device and the remote party's device to set up the
// video chat call
call: function() {
var that = this,
// Set the constraints on our peer-to-peer chat connection. We want to be
// able to support both audio and video so we set the appropriate properties
_constraints = {
mandatory: {
OfferToReceiveAudio: true,
OfferToReceiveVideo: true
}
};
// Get the local device's webcam and microphone stream - prompts the user to
// authorize the use of these
navigator.getUserMedia({
video: true,
audio: true
}, function(stream) {
// Add the local video and audio data stream to the peer connection, making
// it available to the remote party connected to that same peer-to-peer
// connection
that.peerConnection.addStream(stream);
// Execute the onLocalStream() method, passing the URL of the local stream,
// allowing the webcam and microphone data to be presented to the local
// user within a <video> tag on the current HTML page
that.onLocalStream(window.URL.createObjectURL(stream));
// If we are the initiator of the call, we create an offer to any connected
// peer to join our video chat
if (that.participantType === _participantType.INITIATOR) {
// Create an offer of a video call in this chat room and wait for an
// answer from any connected peers
that.peerConnection.createOffer(function(offer) {
// Store the generated local offer in the peer connection object
that.peerConnection.setLocalDescription(offer);
// Save the offer details for connected peers to access
that.saveData(that.chatRoomName, "offer", JSON.stringify(offer));
// If a connected peer responds with an "answer" to our offer, store
// their details in the peer connection object, opening the channels
// of communication between the two
that.loadData(that.chatRoomName, "answer", function(answer) {
that.peerConnection.setRemoteDescription(
new SessionDescription(JSON.parse(answer))
);
});
}, onError, _constraints);
// If we are the one joining an existing call, we answer an offer to set up
// a peer-to-peer connection
} else {
// Load an offer provided by the other party - waits until an offer is
// provided if one is not immediately present
that.loadData(that.chatRoomName, "offer", function(offer) {
// Store the offer details of the remote party, using the supplied
// data
that.peerConnection.setRemoteDescription(
new SessionDescription(JSON.parse(offer))
);
// Generate an "answer" in response to the offer, enabling the
// two-way peer-to-peer connection we need for the video chat call
that.peerConnection.createAnswer(function(answer) {
// Store the generated answer as the local connection details
that.peerConnection.setLocalDescription(answer);
// Save the answer details, making them available to the initiating
// party, opening the channels of communication between the two
that.saveData(that.chatRoomName, "answer", JSON.stringify(answer));
}, onError, _constraints);
});
}
}, onError);
},
// Define a method which initiates a video chat call, returning the generated chat
// room name which can then be given to the remote user to use to connect to
startCall: function() {
// Generate a random 3-digit number with padded zeros
var randomNumber = Math.round(Math.random() * 999);
if (randomNumber < 10) {
randomNumber = "00" + randomNumber;
} else if (randomNumber < 100) {
randomNumber = "0" + randomNumber;
}
// Create a simple chat room name based on the generated random number
this.chatRoomName = "room-" + randomNumber;
// Execute the call() method to start transmitting and receiving video and audio
// using this chat room name
this.call();
// Return the generated chat room name so it can be provided to the remote party
// for connection
return this.chatRoomName;
},
// Define a method to join an existing video chat call using a specific room name
joinCall: function(chatRoomName) {
// Store the provided chat room name
this.chatRoomName = chatRoomName;
// If we are joining an existing call, we must be the responder, rather than
// initiator, so update the properties accordingly to reflect this
this.participantType = _participantType.RESPONDER;
this.remoteParticipantType = _participantType.INITIATOR;
// Execute the call() method to start transmitting and receiving video and audio
// using the provided chat room name
this.call();
}
};
// Return the VideoChat "class" for use throughout the rest of the code
return VideoChat;
}(Firebase));
清单 12-5 中的代码可以如清单 12-6 所示在浏览器中创建一个简单的视频聊天应用,与清单 12-4 中创建的 HTML 页面一起工作。
清单 12-6。使用视频聊天“类”创建浏览器内视频聊天
// Get a reference to the <video id="local-video"> element on the page
var localVideoElement = document.getElementById("local-video"),
// Get a reference to the <video id="remote-video"> element on the page
remoteVideoElement = document.getElementById("remote-video"),
// Get a reference to the <button id="start-call"> element on the page
startCallButton = document.getElementById("start-call"),
// Get a reference to the <button id="join-call"> element on the page
joinCallButton = document.getElementById("join-call"),
// Get a reference to the <p id="room-name"> element on the page
roomNameElement = document.getElementById("room-name"),
// Create an instance of the Video Chat "class"
videoChat = new VideoChat({
// The Firebase database URL for use when loading and saving data to the cloud - create
// your own personal URL at
http://firebase.com
firebaseUrl: "
https://glaring-fire-9593.firebaseio.com/
// When the local webcam and microphone stream is running, set the "src" attribute
// of the <div id="local-video"> element to display the stream on the page
onLocalStream: function(streamSrc) {
localVideoElement.src = streamSrc;
},
// When the remote webcam and microphone stream is running, set the "src" attribute
// of the <div id="remote-video"> element to display the stream on the page
onRemoteStream: function(streamSrc) {
remoteVideoElement.src = streamSrc;
}
});
// When the <button id="start-call"> button is clicked, start a new video call and
// display the generated room name on the page for providing to the remote user
startCallButton.addEventListener("click", function() {
// Start the call and get the chat room name
var roomName = videoChat.startCall();
// Display the chat room name on the page
roomNameElement.innerHTML = "Created call with room name: " + roomName;
}, false);
// When the <button id="join-call"> button is clicked, join an existing call by
// entering the room name to join at the prompt
joinCallButton.addEventListener("click", function() {
// Ask the user for the chat room name to join
var roomName = prompt("What is the name of the chat room you would like to join?");
// Join the chat by the provided room name - as long as this room name matches the
// other, the two will be connected over a peer-to-peer connection and video streaming
// will take place between the two
videoChat.joinCall(roomName);
// Display the room name on the page
roomNameElement.innerHTML = "Joined call with room name: " + roomName;
}, false);
因此,我们创建了一个简单而实用的浏览器内视频聊天客户端。你可以进一步扩展这一想法,用特定的登录用户 id 取代聊天室名称的概念,通过一个列出你的“朋友”的界面(如 Skype 等提供的界面)将用户相互连接,但增加的好处是在浏览器内运行,而不需要下载任何特殊的应用或插件来支持这一点。
随着浏览器对本章所涉及的 API 的支持的提高,可以构建的应用的可能性也会提高。通过bit . ly/can use _ WebRTC
了解当前浏览器对 WebRTC 的支持水平,并在浏览器中尝试自己的点对点聊天想法。
摘要
在本教程中,我解释了什么是 WebRTC,并演示了如何通过这个 API 从用户的机器上访问网络摄像头和麦克风。然后,我解释了流、对等连接和信令信道的基础知识,当它们一起使用时,有助于支持在浏览器中构建简单的视频聊天客户端。
在下一章中,我将介绍 JavaScript 中的 HTML 模板,以及它在基于 web 的应用中简化服务器返回的数据量的潜力。
十三、使用客户端模板
基于 JavaScript 的单页面 web 应用(其中页面的某些部分会动态更新以响应服务器端数据更新或用户操作)为用户提供了一种体验,这种体验与以前只保留给本机桌面和移动应用的体验非常相似,从而避免了为更新页面的各个部分或向当前页面添加新的用户界面组件而刷新整个页面的需要。在本章中,我们将研究更新当前页面的选项,同时保持要显示的数据和呈现数据的 DOM 结构之间的明显分离。
动态更新页面内容
我们知道,为了通过 JavaScript 在页面上更新或创建 HTML 内容,我们使用文档对象模型(DOM)来改变属性和元素树,以便影响所需的内容变化。这适用于简单和小型的页面更新,但是扩展性不好,因为每个元素需要大量的 JavaScript 代码,并且可能需要复杂和耗时的查找来定位要更新的确切元素。如果在 JavaScript 代码不知道的情况下更新了 HTML 页面结构,我们也有可能找不到需要更新的元素。
我们可以使用元素的innerHTML
属性来动态访问和更新该元素中的 HTML,就像它是一个普通的文本字符串一样,而不是逐个节点地操作 DOM 树。唯一的问题是,当复杂的 HTML 结构表示为字符串时,在其中插入动态字符串和其他数据的代码可能难以理解,这使得维护和开发更加困难,这是专业 JavaScript 开发人员不惜一切代价想要避免的。清单 13-1 显示了这样一个例子,文本在添加到页面之前被插入到一长串 HTML 中。
清单 13-1。将 JavaScript 数据与 HTML 字符串组合在一起
var firstName = "Den",
lastName = "Odell",
company = "AKQA",
city = "London",
email = "denodell@me.com",
divElem = document.createElement("div");
// Applying data and strings to HTML structures results in a complicated mess of difficult to
// maintain code
divElem.innerHTML = "<p>Name: <a href=\"mailto:" + email + "\">" + firstName + " " + lastName +
"</a><br>Company: " + company + "</p><p>City: " + city + "</p>";
// Add the new <div> DOM element to the end of the current HTML page once loaded
window.addEventListener("load", function() {
document.body.appendChild(divElem);
}, false);
尽管这种方法很复杂,但是确实需要将 JavaScript 数据(可能通过 Ajax 加载,也可能不通过 Ajax 加载)与动态显示在页面上的 HTML 文本字符串结合起来。在这一章中,我们将讨论解决这个问题的可行方案,主要集中在客户端 HTML 模板解决方案,它允许页面动态更新,同时保持我们希望呈现的数据与用于适当标记它的 HTML 分开。作为专业的 JavaScript 开发人员,这种关注点的分离对我们来说非常重要,因为它可以随着我们的应用的增长而扩展,并且为我们自己和其他项目团队成员带来最小的困惑。
通过 Ajax 动态加载
动态更新页面内容的最简单的解决方案是在服务器端执行数据与 HTML 代码的组合,通过一个简单的 Ajax 调用将组合的 HTML 代码作为字符串返回,我们可以简单地将它放在页面上,也许替换现有元素的内容。当然,这本身并不能解决问题;我们只是将问题从客户端转移到服务器端,然后需要一个合适的解决方案。这样的服务器端模板解决方案有很多,比如 Smarty(对于 PHP,bit . ly/Smarty _ template
),Liquid(对于 Ruby,bit . ly/Liquid _ template
),Apache Velocity(对于 Java,bit . ly/Velocity _ template
),Spark(对于 ASP.NET,bit . ly/Spark _ template
)。我们只需要点击服务器提供的特定 web 服务 URL,然后返回一个 HTML 字符串,使用元素的innerHTML
属性直接放入页面上的元素中。
这种技术的明显优势在于,它只需要在浏览器中运行很少的 JavaScript 代码。我们只需要一个函数来从服务器加载所需的 HTML,并将其放在页面上指定的元素中。清单 13-2 显示了一个这样的函数的例子,它请求一个 HTML 字符串并把它附加到当前页面。
清单 13-2。通过动态加载 HTML 并用响应填充当前页面
// Define a method to load a string of HTML from a specific URL and place this within a given
// element on the current page
function loadHTMLAndReplace(url, element) {
// Perform an Ajax request to the given URL and populate the given element with the response
var xhr = new XMLHttpRequest(),
LOADED_STATE = 4,
OK_STATUS = 200;
xhr.onreadystatechange = function() {
if (xhr.readyState !== LOADED_STATE) {
return;
}
if (xhr.status === OK_STATUS) {
// Populate the given element with the returned HTML
element.innerHTML = xhr.responseText;
}
};
xhr.open("GET", url);
xhr.send();
}
// Load the HTML from two specific URLs and populate the given elements with the returned markup
loadHTMLAndReplace("/ajax/ticket-form.html", document.getElementById("ticket-form"));
loadHTMLAndReplace("/ajax/business-card.html", document.getElementById("business-card"));
这种技术的缺点是,应用需要频繁的可视化更新来响应变化的数据,最终会从服务器下载大量多余的信息,因为它反映了更新的数据及其周围的标记,而我们真正想要显示的是已经变化的数据。显然,如果数据周围的标记每次都保持不变,就会有冗余数据被下载,这将导致每次下载量更大,因此可能会更慢。根据您的应用,这可能是也可能不是一个大问题,但请始终考虑您的用户,特别是那些传统上速度较慢的移动连接用户,他们可能会为下载的兆字节数据付费,并可能会因为这样的决定而遭受损失。
用一个单独的 HTML 块作为模板效率会高得多,Ajax 请求服务器只提供原始数据(可能是 JSON 格式的),在相关位置填充该模板以产生结果页面结构来更新显示。这就是客户端模板思想发挥作用并找到其主要用例的地方。
客户端模板
模板只是一个文本字符串,其中包含特定的占位符文本标记,在结果输出到当前页面之前,应该用适当的数据替换这些标记。考虑下面的简单模板,它使用双括号标记模式{{
和}}
,这在任何其他类型的文本字符串中都不常见,用来表示要替换的文本的位置,以及其值应该用于替换标记的数据变量的名称:
Template:
<p>
Name: <a href="mailto:{{email}}">{{firstName}} {{lastName}}</a><br>
Company: {{company}}
</p>
<p>City: {{city}}</p>
通过将该模板与存储在 JavaScript 数据对象中的值相结合,如下所示:
Data:
{
"firstName": "Den",
"lastName": "Odell",
"email": "denodell@me.com",
"company": "AKQA",
"city": "London"
}
我们将产生一个字符串,然后我们可以输出到我们的页面,包含我们希望显示的文本。通过将模板本地存储在我们的页面中,并且只需要通过 Ajax 更新数据,我们减少了下载多余或重复数据的需要:
Output:
<p>
Name: <a href="mailto:denodell@me.com">Den Odell</a><br>
Company: AKQA
</p>
<p>City: London</p>
不同的模板解决方案使用不同的标记文本模式来表示应该提供数据的点。虽然理论上可以使用任何标记,但重要的是要确保您的标记足够明显,这样它们通常不会出现在您希望显示的模板中的任何其他文本中,否则它们会被意外替换。
在本章的剩余部分,我们将看看 web 应用中客户端模板的一些解决方案,包括其他专业 JavaScript 开发人员使用的一些流行的第三方开放模板库。
没有库的客户端模板
因为客户端模板化是通过字符串替换实现的,所以我们可以围绕用于执行替换的正则表达式,用几行 JavaScript 编写一个非常基本的实现,使用 DOM 元素的innerHTML
属性将结果 HTML 或文本字符串附加到我们的页面。清单 13-3 显示了这样一个模板解决方案的例子,它用 JavaScript 对象的属性值替换模板字符串中特殊格式的标记,产生一个 HTML 字符串,然后添加到当前页面。
清单 13-3。通过字符串替换的基本客户端模板
// Define the HTML template to apply data to, using {{ ... }} to denote the data property name
// to be replaced with real data
var template = "<p>Name: <a href=\"mailto:{{email}}\">{{firstName}} {{lastName}}</a><br>Company: {{company}}</p><p>City: {{city}}</p>",
// Define two data objects containing properties to be inserted into the HTML template using
// the property name as key
me = {
firstName: "Den",
lastName: "Odell",
email: "denodell@me.com",
company: "AKQA",
city: "London"
},
bill = {
firstName: "Bill",
lastName: "Gates",
email: "bill@microsoft.com",
company: "Microsoft",
city: "Seattle"
};
// Define a simple function to apply data from a JavaScript object into a HTML template,
// represented as a string
function applyDataToTemplate(templateString, dataObject) {
var key,
value,
regex;
// Loop through each property name in the supplied data object, replacing all instances of
// that name surrounded by {{ and }} with the value from the data object
for (key in dataObject) {
regex = new RegExp("{{" + key + "}}", "g");
value = dataObject[key];
// Perform the replace
templateString = templateString.replace(regex, value);
}
// Return the new, replaced HTML string
return templateString;
}
// Outputs:
// <p>Name: <a href="mailto:denodell@me.com">Den Odell</a><br>Company: AKQA</p>
// <p>City: London</p>
alert(applyDataToTemplate(template, me));
// Outputs:
// <p>Name: <a href="mailto:bill@microsoft.com">Bill Gates</a><br>Company: Microsoft</p>
// <p>City: Seattle</p>
alert(applyDataToTemplate(template, bill));
对于简单的模板和 JavaScript 数据,这种解决方案工作得很好;但是,如果需要迭代数组或数据对象,或者添加逻辑来根据某些数据属性的值显示或隐藏不同的部分,这种解决方案是不够的,需要进行相当大的扩展来支持这一点。在这种情况下,最好交给预先编写好的、成熟的第三方开源 JavaScript 客户端模板库来完成这项工作。
使用 Mustache.js 的客户端模板
Mustache 是一种无逻辑的模板语言,由 Chris Wanstrath 在 2009 年开发,以最流行的编程语言实现为特色;Mustache.js 是它的 JavaScript 实现。它最初源于 Google Templates(后来被称为 cTemplates),后者被用作生成 Google 搜索结果页面的模板系统。术语无逻辑是指定义的模板结构不包含if
、then
、else
或for
循环语句;但是,它包含一个称为标记的通用结构,允许根据存储在被引用的 JavaScript 数据中的值类型(称为数据散列)来执行这种行为。每个标签由双括号{{
和}}
表示,当从直角看时,看起来很像八字胡,因此得名。你可以通过bit . ly/mustache _ Github
从它的 Github 项目页面下载 Mustache.js。该库在缩小后仅重 1.8KB,并启用了 gzip 压缩。
让我们开始研究 Mustache.js,使用它来执行我们的初始示例的模板化,以呈现与清单 13-3 中相同的 HTML 输出。我们将把它分成两个代码清单:一个 HTML 页面,如清单 13-4 所示,包含在特殊配置的<script>
标签中写出的模板本身,并引用 Mustache.js 库;一个 JavaScript 文件,其内容如清单 13-5 所示,包含应用于模板的数据,并调用 Mustache.js 来执行由此模板产生的 HTML 的呈现。
清单 13-4。包含用于 Mustache.js 的客户端模板的 HTML 页面
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Mustache.js Example</title>
</head>
<body>
<h1>Mustache.js Example</h1>
<!-- Define the template we wish to apply our data to. The "type" attribute
needs to be any non-standard MIME type in order for the element's contents
to be interpreted as plain text rather than executed -->
<script id="template" type="x-tmpl-mustache">
<p>
Name: <a href="mailto:{{email}}">{{firstName}} {{lastName}}</a><br>
Company: {{company}}
</p>
<p>
City: {{city}}
</p>
</script>
<!-- Load the Mustache.js library -->
<script src="lib/mustache.min.js"></script>
<!-- Execute our script to combine the template with our data -->
<script src="Listing13-5.js"></script>
</body>
</html>
清单 13-4 中包含模板的<script>
标签被赋予了一个type
属性,浏览器不会将其识别为标准的 MIME 类型。这将导致浏览器将其内容视为纯文本,而不是要执行的内容,但不会将标签的内容明显地写到页面上。然后可以通过 JavaScript 引用标签的内容,方法是通过元素的id
属性值定位元素并获取其innerHTML
属性的内容。清单 13-4 中的 HTML 页面引用了清单 13-5 中的 JavaScript 代码,如下所示,在浏览器中运行时产生了如图 13-1 所示的结果。请注意用于基于两个输入参数产生输出字符串的Mustache.render()
方法的使用:模板字符串和 JavaScript 数据散列对象。
清单 13-5。使用 Mustache.js 将数据与 HTML 模板结合起来
// Locate and store a reference to the <script id="template"> element from our HTML page
var templateElement = document.getElementById("template"),
// Extract the template as a string from within the template element
template = templateElement.innerHTML,
// Create two elements to store our resulting HTML in once our template is
// combined with our data
meElement = document.createElement("div"),
billElement = document.createElement("div"),
// Define two objects containing data to apply to the stored template
meData = {
firstName: "Den",
lastName: "Odell",
email: "denodell@me.com",
company: "AKQA",
city: "London"
},
billData = {
firstName: "Bill",
lastName: "Gates",
email: "bill@microsoft.com",
company: "Microsoft",
city: "Seattle"
};
// Use Mustache.js to apply the data to the template and store the result within the
// newly created elements
meElement.innerHTML = Mustache.render(template, meData);
billElement.innerHTML = Mustache.render(template, billData);
// Add the new elements, populated with HTML, to the current page once loaded
window.addEventListener("load", function() {
document.body.appendChild(meElement);
document.body.appendChild(billElement);
}, false);
图 13-1。
An example HTML page with data populated into templates with Mustache.js
Mustache 模板格式的完整详细文档可以通过bit . ly/Mustache _ docs
在线查看。这种格式分为四种不同类型的数据表示:变量、节、注释和分部。
变量
Mustache 模板格式允许用一个同名的关联数据属性替换任何由双括号{{
和}}
包围的文本标记,就像我们在清单 13-4 的 HTML 页面中创建的模板一样。在 Mustache 的说法中,双括号内的名称被称为变量或键。
所有变量都被替换为 HTML 转义字符串,这意味着放入变量中的数据字符串中的任何 HTML 都将作为文本写出,而不是解释为 HTML 元素。如果您希望文本被解释为 HTML,您应该用三重括号标记{{{
和}}}
将您的键名括起来,如下所示。如果需要呈现 JavaScript 对象属性的值,可以使用标准的点符号来导航对象层次结构。
Template:
{{name}}
{{{name}}}
From {{address.country}}
Data Hash:
{
name: "Den <strong>Odell</strong>",
address: {
country: "UK"
}
}
Output:
Den <strong>Odell</strong>
Den <strong>Odell</strong>
From UK
如果模板标记中表示的键名在提供的数据哈希对象中不存在,则输出中的标记将被一个空字符串替换。这不同于清单 13-3 中我们最初的语义模板示例,如果相关的数据值不存在,那么在结果输出中标签将保持不变。
部分
Mustache 模板中的一个部分有围绕模板块的开始和结束标记。然后,这些标签之间的模板块的内容在结果输出中重复一次或多次,这取决于存储在该标签的关联数据键值中的数据类型。标签中使用的关键字名称前面有一个散列字符(#
)来表示该部分的开始标签,前面有一个斜线字符(/
)来表示该部分的结束标签,例如{{#section}}{{/section}}
。有四种类型的节——条件节、迭代器节、函数节和反转节——它们的声明类似,但根据传递给它们的数据类型执行不同的功能。
条件部分
如果被引用的标记键的数据值是布尔类型true
,则显示节块的内容;如果选择false
,则不显示该部分。这同样适用于falsy
值,例如空字符串或空数组——在这些情况下,该部分不会显示。这种行为为我们提供了执行条件if
语句的等效操作的能力,如此处所示。只有当isAvailable
数据属性的值为truthy
时,字符串YES
才会包含在输出中。
Template:
Available:
{{#isAvailable}}
YES
{{/isAvailable}}
Data Hash:
{
isAvailable: true
}
Output:
Available:
YES
迭代器部分
如果节标记引用的数据属性的格式是包含一个或多个项的数组列表,则开始和结束标记之间的节的内容将为列表中的每个项重复一次,每个单独项的数据将被传递到每次迭代的节中。这为我们提供了执行迭代数据循环的能力,相当于 JavaScript for
循环,如下所示:
Template:
<h1>People</h1>
{{#people}}
<p>Name: {{name}}</p>
{{/people}}
Data Hash:
{
people: [
{name: "Den Odell"},
{name: "Bill Gates"}
]
}
Output:
<h1>People</h1>
<p>Name: Den Odell</p>
<p>Name: Bill Gates</p>
职能部门
如果 section 标签引用的数据格式是一个function
,那么事情就变得非常有趣了。Mustache.js 将立即执行该函数,并应返回一个函数,该函数将在每次 section 标记引用该函数的名称时执行,并传入两个参数:作为字符串的模板 section 块的文字文本内容(在任何模板替换发生之前),以及一个直接引用 Mustache.js 的内部render()
方法的函数,该方法允许以某种方式操作第一个参数中的值,然后将其内容与应用的数据一起输出。这使得基于输入数据创建过滤器、应用缓存或执行其他基于字符串的模板操作成为可能。此行为的一个示例如下所示:
Template:
{{#strongLastWord}}
My name is {{name}}
{{/strongLastWord}}
Data Hash:
{
name: "Den Odell",
strongLastWord: function() {
return function(text, render) {
// Use the supplied Mustache.js render() function to apply the data to the
// supplied template text
var renderedText = render(text),
// Split the resulting text into an array of words
wordArray = renderedText.split(" "),
wordArrayLength = wordArray.length,
// Extract the final word from the array
finalWord = wordArray[wordArrayLength - 1];
// Replace the last entry in the array of words with the final word wrapped
// in a HTML <strong> tag
wordArray[wordArrayLength - 1] = "<strong>" + finalWord + "</strong>";
// Join together the word array into a single string and return this
return wordArray.join(" ");
}
}
}
Output:
My name is Den <strong>Odell</strong>
倒置截面
当您开始认真使用 Mustache 模板时,您会发现需要根据反转条件显示文本或 HTML 块。如果一个数据值代表一个truthy
值或包含要迭代的项目,您希望显示一个节块。如果值是falsy
或者不包含要迭代的项目,您希望显示另一个块。反转部分允许这种行为,并通过使用脱字符(^
)代替标签键名前的哈希(#
)字符来表示,如下所示:
Template:
Available:
{{#isAvailable}}
YES
{{/isAvailable}}
{{^isAvailable}}
NO
{{/isAvailable}}
{{#people}}
<p>Name: {{name}}</p>
{{/people}}
{{^people}}
<p>No names found</p>
{{/people}}
Data Hash:
{
isAvailable: false,
people: []
}
Output:
Available: NO
<p>No names``found
评论
如果您希望在 Mustache 模板中包含不希望输出到结果字符串的开发注释或评论,只需创建一个标签,以双括号和感叹号(!
)字符开始,以双右括号结束,如下所示:
Template:
<h1>People</h1>
{{! This section will contain a list of names}}
{{^people}}
<p>No names found</p>
{{/people}}
Data Hash:
{
people: []
}
Output:
<h1>People</h1>
<p>No names found</p>
部分模板
Mustache 支持跨多个<script>
标签分离模板的能力,甚至支持在运行时可以组合在一起产生最终结果的分离文件。这允许创建可重复使用的代码片段并分别存储,以便跨多个模板使用。这种包含用于更大模板的代码片段的文件称为部分模板,简称为部分模板。
分部由标准标记中的给定名称引用,名称前面带有大于号(>
)字符,表示它是分部模板。想象一个包含以下两个模板的 HTML 页面,这两个模板包含在分别标有template
和people
的id
属性值的<script>
标签中:
<script id="template" type="x-tmpl-mustache">
<h1>People</h1>
{{>people}}
</script>
<script id="people" type="x-tmpl-mustache">
{{#people}}
<p>Name: {{name}}</p>
{{/people}}
</script>
注意第一个模板中对名为people
的分部的引用。尽管这个名字与赋予第二个模板的id
属性相匹配,但是两者之间的引用并不是自动的,这需要在 Mustache.js 中进行配置。要做到这一点,你必须将你想在 JavaScript 对象中使用的任何部分传递给Mustache.render()
方法的第三个参数,如清单 13-6 所示。第一个参数是主模板,第二个参数是应用于模板的数据。partials JavaScript 对象(第三个参数)中的属性名与模板中用于引用任何 partials 的标记名相关联。请注意,数据可用于组合模板,就好像在将数据应用到模板之前,两个模板已组合成一个文件一样。
清单 13-6。用 Mustache.js 引用分部模板
// Locate and store a reference to the <script id="template"> element from our HTML page
var templateElement = document.getElementById("template"),
// Locate and store a reference to the <script id="people"> element
peopleTemplateElement = document.getElementById("people"),
// Extract the template as a string from within the template element
template = templateElement.innerHTML,
// Extract the "people" template as a string from within the <script> element
peopleTemplate = peopleTemplateElement.innerHTML,
// Create an element to store our resulting HTML in once our template is
// combined with the partial and our data
outputElement = document.createElement("div"),
// Define an object containing data to apply to the stored template
data = {
people: [{
name: "Den Odell"
}, {
name: "Bill Gates"
}]
};
// Use Mustache.js to apply the data to the template, and allow access to the named partial
// templates and store the result within the newly created element
outputElement.innerHTML = Mustache.render(template, data, {
people: peopleTemplate
});
// Add the new element, populated with HTML, to the current page once loaded
window.addEventListener("load", function() {
document.body.appendChild(outputElement);
}, false);
// The resulting HTML will be:
/*
People</h1>
<p>Name: Den Odell</p>
<p>Name: Bill Gates</p>
*/
以这种方式使用分部文件允许创建具有共享组件、导航、页眉和页脚以及可重用代码片段的大型应用,因为分部文件可以从其他分部文件继承代码。这减少了代码的重复,并允许生成和维护高效的模板。唯一要记住的是,当引用它们的主模板被渲染时,所有的分部模板都必须准备好使用。这意味着,如果您选择通过 Ajax 加载模板文件,而不是将它们存储在当前的 HTML 页面中,那么所有的文件都必须在呈现时加载,这样它们就可以传递给主模板的单个Mustache.render()
方法。
Mustache.js 是一个非常有用的小程序库,除了简单的变量替换之外,它还支持客户端模板,允许条件和迭代部分,并支持外部分部模板。
使用 Handlebars.js 的客户端模板
Handlebars 一种客户端模板格式,旨在扩展 Mustache 格式的功能。Handlebars 向后兼容 Mustache 模板,但支持额外的功能,包括块助手,这是对 Mustache 部分原则的扩展,用于阐明和改进每个模板块的显示逻辑的行为。
支持这些模板的 Handlebars.js 库可以直接从其主页下载,网址为 handlebarsjs。com
,如图 13-2 所示,当缩小并使用 gzip 压缩时,该库的重量为 13KB,因此与 Mustache.js 相比,要大得多。然而,正如我们将在本节稍后看到的,有一种预编译模板的技术,这样它们可以用于 Handlebars 库的缩减版本,从而产生更具可比性的文件大小。
图 13-2。
Handlebars.js home page
Handlebars 的用法与 Mustache 非常相似,从 HTML 页面中引用这个库,模板与 JavaScript 数据散列对象相结合,产生所需的输出字符串,插入到当前页面中。模板可以存储在页面本身的<script>
标签中,就像我们在 Mustache 中看到的那样,或者使用 Ajax 通过单独的文件加载。同样,数据对象可以直接存储在 JavaScript 文件中,或者通过 Ajax 动态加载。
该库的全局Handlebars
对象包含帮助呈现模板的方法;Handlebars.compile()
方法将模板字符串作为参数,并返回一个函数。然后可以执行该函数,向它传递用于呈现模板的数据哈希对象,它将返回组合两者的结果字符串,以便在您的页面中使用。清单 13-7 显示了一个简单的例子,它使用了一个基本的页面内模板和本地 JavaScript 数据的compile()
方法。
清单 13-7。包含简单把手模板的 HTML 页面
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Handlebars.js Example</title>
</head>
<body>
<h1>Handlebars.js Example</h1>
<div id="result"></div>
<!-- Define the template we wish to apply our data to. The "type" attribute
needs to be any non-standard MIME type in order for the element's contents
to be interpreted as plain text rather than executed -->
<script id="template" type="x-tmpl-handlebars">
<p>
Name: <a href="mailto:{{email}}">{{firstName}} {{lastName}}</a><br>
Company: {{company}}
</p>
<p>
City: {{city}}
</p>
</script>
<!-- Load the Handlebars.js library -->
<script src="lib/handlebars.min.js"></script>
<!-- Execute our script to combine the template with our data. Included
here for brevity, move to an external JavaScript file for production -->
<script>
// Get a reference to the template element and result element on the page
var templateElem = document.getElementById("template"),
resultElem = document.getElementById("result"),
// Get the string representation of the Handlebars template from the template
// element
template = templateElem.innerHTML,
// Define the data object to apply to the template
data = {
firstName: "Den",
lastName: "Odell",
email: "denodell@me.com",
company: "AKQA",
city: "London"
},
// The compile() method returns a function which the data object can be passed to
// in order to produce the desired output string
compiledTemplate = Handlebars.compile(template);
// Combine the data with the compiled template function and add the result to the page
resultElem.innerHTML = compiledTemplate(data);
</script>
</body>
</html>
现在让我们一起看看 Handlebars 模板和 Handlebars.js 库的一些特性,包括片段、帮助器和用于提高性能的模板预编译。
部分的
Handlebars 模板中的部分模板看起来与 Mustache 模板中的部分模板相同,但是两者在将部分模板的细节提供给库进行呈现的方式上有所不同。Handlebars.registerPartial()
方法允许使用两个参数将分部注册到手柄:分部的名称和分部模板的字符串表示。必须在从正在呈现的模板中引用分部之前调用此函数。通过多次调用registerPartial()
方法可以注册多个分部。就像 Mustache.js 一样,在呈现主模板之前,必须加载并注册所有分部。
助手
Handlebars 用自己的功能丰富的处理程序(称为助手)扩展了 Mustache 部分的概念。这些使您能够迭代数据列表,或执行条件表达式,使用具有清晰名称的帮助器,使模板更容易阅读和理解——这很重要,特别是当您的模板变得更加复杂时,这与 Mustache 有显著的不同。使用 Mustache,在不知道传递给模板的数据类型的情况下,您无法确定一个部分是用作条件块、迭代还是其他什么;在这里,帮助者的名字清楚地表明了区别。有几个内置的助手,Handlebars 让您能够轻松地创建自己的助手,这些助手可以简单地在您的所有模板中重用。助手名称的前面是标签中的散列(#
)字符,后面是应用于助手的数据键的名称。在这一节中,我们将看看一些常见的助手,我将解释如何轻松地创建自己的助手来满足特定模板的需求。
with
帮手
with
助手允许您将不同的数据上下文应用到它所围绕的模板块。这为重复使用点符号导航传递给助手的特定数据属性层次结构提供了一种替代方法。为了从一个节中导航回父上下文,可以在变量名前使用../
标记。这里展示了一个简单的with
助手的例子,它应该可以让一切变得清晰。
Template:
<h1>{{name}}</h1>
{{#with address}}
<p>{{../name}} lives at: {{street}}, {{city}}, {{region}}, {{country}}</p>
{{/with}}
Data Hash:
{
name: "Den Odell",
address: {
street: "1 Main Street",
city: "Hometown",
region: "Homeshire",
country: "United Kingdom"
}
}
Output:
<h1>Den Odell</h1>
<p>Den Odell lives at: 1 Main Street, Hometown, Homeshire, United Kingdom</p>
each
帮手
each
助手允许你迭代一个数据列表,比如一个数组或对象。数组项的值,如果不是对象或数组本身,可以通过使用保留的变量名{{this}}
来访问。在数组中循环时,原数组中当前项的索引由保留的变量名{{@index}}
提供。当迭代一个对象时,当前属性的键的名称由保留的变量名{{@key}}
提供。如果提供的数据列表为空,可以添加一个可选的{{else}}
部分,允许您提供一个要呈现的部分块,如下例所示:
Template:
<h1>People</h1>
{{#each people}}
<p>Item {{@index}}: {{this}}</p>
{{else}}
<p>No names found</p>
{{/each}}
Data Hash:
{
people: [
"Den Odell",
"Bill Gates"
]
}
Output:
<h1>People</h1>
<p>Item 0: Den Odell</p>
<p>Item 1: Bill Gates</p>
if
和unless
助手
使用if
和unless
手柄辅助工具,可以根据某些数据属性的值有条件地显示模板截面块。如果传递给它的值是一个truthy
值(除了false
、undefined
、null
,空字符串或空数组之外的任何值),那么if
助手将显示相关的节块,而unless
助手仅在数据值为falsy
时才显示相关的节块。在任一情况下,可以添加可选的{{else}}
部分来捕捉反转的情况,如下例所示:
Template:
<h1>People</h1>
{{#if people}}
<p>Item {{@index}}: {{this}}</p>
{{else}}
<p>No names found</p>
{{/if}}
{{#unless isAvailable}}
<p>Not available</p>
{{/unless}}
Data Hash:
{
isAvailable: false,
people: []
}
Output:
<h1>People</h1>
<p>No names found</p>
<p>Not available</p>
log
帮手
如果您正在为模板提供一个大型的多级数据对象,当在整个模板中使用多个助手时,要知道您在数据层次结构中的位置,或者在任何特定的点上您有哪些可用的数据,有时会变得令人困惑。幸运的是,Handlebars 提供了一个log
调试助手,允许您在模板中的任意点查看可用数据的状态。只需传递您希望查看的数据对象变量的名称,或者使用{{log this}}
来显示当前上下文中可用的数据。如果最终页面生成是通过命令行工具处理的,则数据不是被写出到生成的页面本身,而是被写出到命令行;如果生成是在浏览器中实时发生的,则数据被写出到浏览器的开发人员控制台窗口中。
自定义助手
除了内置的帮助器,Handlebars 还提供了创建您自己的自定义帮助器的能力,允许您在模板中提供您需要的确切功能。这些可以在块级工作,以对模板的一部分执行操作,或者在单个数据项级工作,例如格式化特定的数据以供显示。
可以使用Handlebars.registerHelper()
方法创建一个定制的帮助器,向其传递两个参数:帮助器的唯一名称,以及在呈现时在模板中遇到帮助器时要执行的函数,该函数对模板中传递给它的数据执行所需的行为。清单 13-8 展示了一些简单的自定义助手,你可能会发现它们对你自己的模板很有用。
清单 13-8。自定义车把助手示例
// The registerHelper() method accepts two arguments - the name of the helper, as it will
// be used within the template, and a function which will be executed whenever the
// helper is encountered within the template. The function is always passed at least one
// parameter, an object containing, amongst others, a fn() method which performs the same
// operation as Handlebars' own render ability. This method takes a data object and
// returns a string combining the template within the block helper with the data in
// this object
// Define a block helper which does nothing other than pass through the data in the
// current context and combine it with the template section within the block
Handlebars.registerHelper("doNothing", function(options) {
// To use the current data context with the template within the block, simply use
// the 'this' keyword
return options.fn(this);
});
// The helper can be passed parameters, if required, listed one by one after the helper
// name within double braces. These are then made available within the function as
// separate input parameters. The final parameter is always the options object, as before
Handlebars.registerHelper("ifTruthy", function(conditional, options) {
return conditional ? options.fn(this) : options.inverse(this);
});
// If more than one or two parameters need to be passed into the helper, named parameters
// can be used. These are listed as name/value pairs in the template when the helper is
// called, and are made available within the options.hash property as a standard
// JavaScript object ready to pass to the options.fn() method and used to render the
// data within
Handlebars.registerHelper("data", function(options) {
// The options.hash property contains a JavaScript object representing the name/value
// pairs supplied to the helper within the template. Rather than pass through the
// data context value 'this', here we pass through the supplied data object to the
// template section within the helper instead
return options.fn(options.hash);
});
// Create a simple inline helper for converting simple URLs into HTML links. Inline helpers
// can be used without being preceded by a hash (#) character in the template.
Handlebars.registerHelper("link", function(url) {
// The SafeString() method keeps HTML content intact when rendered in a template
return new Handlebars.SafeString("<a href=\"" + url + "\">" + url + "</a>");
});
清单 13-8 中给出的自定义助手可以像下面的示例模板中演示的那样使用:
Base Template:
{{#doNothing}}
<h1>Dictionary</h1>
{{/doNothing}}
{{#ifTruthy isApiAvailable}}
<p>An API is available</p>
{{/ifTruthy}}
{{#ifTruthy words}}
<p>We have preloaded words</p>
{{else}}
<p>We have no preloaded words</p>
{{/ifTruthy}}
<dl>
{{#data term="vastitude" definition="vastness; immensity" url="
http://dictionary.com/browse/vastitude
{{>definition}}
{{/data}}
{{#data term="achromic" definition="colorless; without coloring matter" url="
http://dictionary.com/browse/achromic
{{>definition}}
{{/data}}
</dl>
Partial "definition" Template
<dt>{{term}} {{link url}}</dt>
<dd>{{definition}}</dd>
Data hash:
{
isApiAvailable: true,
words: []
}
Output:
<h1>Dictionary</h1>
<p>An API is available</p>
<p>We have no preloaded words</p>
<dl>
<dt>vastitude <a href="
http://dictionary.com/browse/vastitude
">
http://dictionary.com/browse/vastitude</a></dt
<dd>vastness; immensity</dd>
<dt>achromic <a href="
http://dictionary.com/browse/achromic
">
http://dictionary.com/browse/achromic</a></dt
<dd>colorless; without coloring``matter
</dl>
预编译模板以获得最佳性能
正如我们已经看到的,Handlebars.js compile()
方法获取一个模板字符串,并将其转换为一个函数,然后可以执行该函数,传递数据以应用于模板。结果是显示在页面上的最终标记字符串。如果您知道您的模板在应用运行时不会改变,您可以利用 Handlebars.js 的预编译特性,该特性允许您提前执行这种模板到函数的转换,向您的应用提供一个较小的 JavaScript 文件,其中只包含要应用数据的模板函数。文件大小要小得多,因为它们可以利用许多优化,否则在运行时是不可能的,并且它们只需要在 HTML 页面中运行 Handlebars.js 的精简运行时版本。这个特殊版本的库删除了通过预编译过程变得多余的函数。手柄的运行时版本可以从主页 handlebarsjs 下载。com
和重量在一个更苗条的 2.3 KB 缩小后,当使用 gzip 压缩服务。这在大小上与 Mustache 库相当,但包含了 Handlebars 的额外好处和简单性,因此它是在代码中使用模板的一个很好的解决方案,而不会牺牲文件大小,因此也不会减少下载时间。如果您正在寻找一个解决方案来提供本章中提到的 gzip 压缩版本的库,请访问。com 来定位对所需文件的内容交付网络托管版本的引用。
预编译步骤需要提前进行,在页面上使用模板之前,我们可以利用 Handlebars.js 提供的命令行工具来执行这个步骤。该工具运行在 Node.js 应用框架上,我们将在下一章详细介绍。现在,你可以从 bit. ly/ node_ js
下载该框架,并按照说明进行安装。同时安装节点包管理器(NPM)工具,并允许轻松安装在框架内运行的应用,包括 Handlebars.js 预编译器应用。在命令行中输入以下命令,安装 Handlebars.js 预编译器 1.3.0 版(Mac 和 Linux 用户可能需要在命令前加上sudo
,以授予安装该工具的必要权限,从而可以访问机器上的任何文件夹):
npm install –g handlebars@1.3.0
我们明确说明了与 Handlebars.js 主页上可下载的库版本相匹配的工具的版本号。如果两个版本不匹配,我们就不能保证任何预编译模板都能正常工作。
在命令行上导航到包含模板文件的目录,并执行以下命令来预编译模板,用要预编译的模板文件的名称替换templatefile.handlebars
:
handlebars templatefile.handlebars
您会注意到,生成的预编译模板函数被直接写到命令行,而不是保存到文件中,这并不是我们所需要的。要将生成的模板保存到一个新文件中,在命令行中添加--output
选项,并指定一个扩展名为.js
的文件名(因为我们将返回一个 JavaScript 函数供页面使用)。如果我们还添加了--min
选项,生成的 JavaScript 文件将被缩小,为我们节省了以后的优化任务。因此,最终的命令如下所示,其中templatefile.handlebars
被替换为要预编译的模板的名称,而templatefile.js
被替换为预编译输出 JavaScript 模板文件的名称:
handlebars templatefile.handlebars --output templatefile.js --min
让我们创建一个真实的例子来展示如何使用预编译模板。考虑清单 13-9 所示的模板文件。
清单 13-9。要预编译的手柄模板
<dl>
{{#each words}}
<dt>{{term}} <a href="{{url}}">{{url}}</a></dt>
<dd>{{definition}}</dd>
{{else}}
<p>No words supplied</p>
{{/each}}
</dl>
让我们用清单 13-9 中的模板,用handlebars
命令行工具预编译它。我们将使用下面的命令,它生成清单 13-10 所示的简化 JavaScript 代码,代表我们的预编译模板:
handlebars Listing13-9.handlebars --output Listing13-10.js --min
清单 13-10。清单 13-9 中模板的预编译版本
!function(){var a=Handlebars.template,t=Handlebars.templates=Handlebars.templates||{};t["Listing13-9"]=a(function(a,t,e,l,n){function r(a,t){var l,n,r="";return r+="\n <dt>",(n=e.term)?l=n.call(a,{hash:{},data:t}):(n=a&&a.term,l=typeof n===i?n.call(a,{hash:{},data:t}):n),r+=o(l)+' <a href="',(n=e.url)?l=n.call(a,{hash:{},data:t}):(n=a&&a.url,l=typeof n===i?n.call(a,{hash:{},data:t}):n),r+=o(l)+'">',(n=e.url)?l=n.call(a,{hash:{},data:t}):(n=a&&a.url,l=typeof n===i?n.call(a,{hash:{},data:t}):n),r+=o(l)+"</a></dt>\n <dd>",(n=e.definition)?l=n.call(a,{hash:{},data:t}):(n=a&&a.definition,l=typeof n===i?n.call(a,{hash:{},data:t}):n),r+=o(l)+"</dd>\n "}function s(){return"\n <p>No words supplied</p>\n "}this.compilerInfo=[4,">=1.0.0"],e=this.merge(e,a.helpers),n=n||{};var d,h="",i="function",o=this.escapeExpression,c=this;return h+="<dl>\n ",d=e.each.call(t,t&&t.words,{hash:{},inverse:c.program(3,s,n),fn:c.program(1,r,n),data:n}),(d||0===d)&&(h+=d),h+="\n</dl>"})}();
一旦预编译,模板就可以在我们的 HTML 页面中引用使用。因为编译阶段不再需要直接发生在浏览器中,所以我们获得了比浏览器内编译更好的性能。将这一点与该解决方案所需的较小下载量结合起来,您可以看到这种技术对于确保大型 web 应用的良好性能是多么有用。
清单 13-11 中的代码显示了一个示例 HTML 页面,我们可以用它将 JavaScript 数据对象插入预编译的模板,并将结果 HTML 字符串写到当前页面。
清单 13-11。引用预编译模板和 Handlebars.js 库的运行时版本的 HTML 页
<!doctype html>
<html>
<head>
<meta charset="utf-8">
<title>Handlebars.js Example</title>
</head>
<body>
<h1>Handlebars.js Example</h1>
<!-- Create an element to store the result of applying the template to the data -->
<div id="result"></div>
<!-- Load the Handlebars.js runtime library, which should be used with
precompliled templates only -->
<script src="lib/handlebars.runtime.min.js"></script>
<!-- Load our precompiled template -->
<script src="Listing13-10.js"></script>
<!-- Plug the data into the template and render onto the page -->
<script>
// Precompiled templates are added as properties to the Handlebars.templates object
// using their original template name as the key (this name was set by the command line
// tool and stored in the Listing13-10.js file)
var template = Handlebars.templates["Listing13-9"],
// The template is a function, which should be passed the data to render within the
// template. The result is the combination of the two, as a String.
result = template({
words: [{
term: "vastitude",
url: "
http://dictionary.com/browse/vastitude
definition: "vastness; immensity"
}, {
term: "achromic",
url: "
http://dictionary.com/browse/achromic
definition: "colorless; without coloring matter"
}]
});
// Write the resulting string onto the current page within the <div id="result">
// element. Produces the following result:
/*
<dl>
<dt>vastitude
<a href="``http://dictionary.com/browse/vastitude">http://dictionary.com/browse/vastitude</a
</dt>
<dd>vastness; immensity</dd>
<dt>achromic
<a href="``http://dictionary.com/browse/achromic">http://dictionary.com/browse/achromic</a
</dt>
<dd>colorless; without coloring matter</dd>
</dl>
*/
document.getElementById("result").innerHTML = result;
</script>
</body>
</html>
运行清单 13-11 中的代码会产生如图 13-3 所示的结果。
图 13-3。
The resulting page from running the code in Listing 13-9 to combine data with a precompiled template
Handlebars 提供了比 Mustache 更具描述性的模板语言,并通过其自定义助手功能提供了可扩展性。为了克服处理这种模板所需的额外库大小,它提供了预编译模板的能力,以便与库的缩减版本一起使用,从而提供改进的性能和与 Mustache tiny 相似的数据占用大小。js 库。正是由于这些原因,Handlebars 在大型 web 应用中得到了广泛的应用。
备选客户端模板库
我们已经详细研究了 Mustache 和 Handlebars 模板,包括我在内的许多人都认为它是最流行、最受支持的 JavaScript 模板解决方案。然而,这些并不是您唯一可用的选项,包括嵌入式 JavaScript (EJS)和下划线. JS 在内的库也提供了类似的模板功能,我将在本节中解释。
嵌入 JavaScript 的客户端模板(EJS)
嵌入式 JavaScript (EJS)是一种开源的 JavaScript 模板语言,专为那些最熟悉 JavaScript 语言并且喜欢以熟悉的代码友好的方式编码模板逻辑的人而设计。它允许使用简单的 JavaScript 格式的if
语句、for
循环和数组索引从一组输入数据中输出所需的文本字符串。EJS 图书馆可以从主页通过bit . ly/ejs-template
下载,当缩小并使用 gzip 压缩时,重量只有 2.4 KB。该库拥有广泛的浏览器支持,可以追溯到 Firefox 1.5 和 Internet Explorer 6。
在 EJS 模板中,要执行的代码包含在以<%
开始并以%>
结束的部分中,这是 Ruby 语言开发人员用于模板化的风格,要输出的变量包装在<%=
和%>
中——注意附加的等号(=
)。
EJS 支持添加视图助手的能力,这些助手可以简化常见输出类型的创建,比如 HTML 链接,可以使用命令<%= link_to("link text", "/url") %>
创建以下输出:<a href="/url">link text</a>
。可用助手的完整列表可在 EJS 维基网站 bit. ly/ ejs_ wiki
上找到。
这里显示了一个简单的 EJS 示例模板:
Template:
<h1><%= title %></h1>
<dl>
<% for (var index = 0; index < words.length; index++) { %>
<dt><%= link_to(words[index].term, words[index].url) %></dt>
<dd><%= words[index].definition %></dd>
<% }
if (!words) { %>
<p>No words supplied</p>
<% } %>
</dl>
Data Hash:
{
title: "EJS Example",
words: [{
term: "vastitude",
url: "
http://dictionary.com/browse/vastitude
definition: "vastness; immensity"
}, {
term: "achromic",
url: "
http://dictionary.com/browse/achromic
definition: "colorless; without coloring matter"
}]
}
Output:
<h1>EJS Example</h1>
<dl>
<dt><a href="
http://dictionary.com/browse/vastitude">vastitude</a></dt
<dd>vastness; immensity</dd>
<dt><a href="
http://dictionary.com/browse/achromic">achromic</a></dt
<dd>colorless; without coloring matter</dd>
</dl>
与 Mustache.js 和 Handlebars.js 相比,EJS 的一个优势是,它不需要任何特殊代码就可以将远程 JSON 文件中的 JavaScript 数据应用到存储在另一个远程文件中的模板,这需要使用 Mustache.js 和 Handlebars.js 编写额外的 Ajax 代码
典型地,EJS
“类”的实例化是通过将 URL 传递给外部模板文件以异步加载,然后执行其render()
或update()
方法,这取决于要应用于模板的数据是否已经存在并加载到 JavaScript 中,在这种情况下,render()
方法被传递数据对象并返回输出字符串。向update()
方法传递了两个参数,一个是 HTML 页面元素的id
,用于在其中呈现结果模板,另一个是 JSON 数据文件的 URL,用于在将数据应用到模板之前异步加载。
new EJS({url: "/templatefile.ejs"}).render(dataObject);
new EJS({url: "/templatefile.ejs"}).update("pageElementId", "/datafile.json");
如果您喜欢使用逻辑与您熟悉的 JavaScript 文件非常相似的模板,那么 EJS 可能是满足您需求的最佳模板解决方案。
下划线. js
Underscore.js 是一个 JavaScript 库,包含 80 多个有用的帮助函数,用于处理代码中的数据集合、数组、对象和函数,以及一系列实用函数,其中一个是专门针对基本模板的。该库可以通过 bit. ly/ u-js
从其主页下载,大小为 5 KB,采用 gzip 编码。当包含在页面中时,它通过下划线(_
)全局变量提供对其方法的访问。如果您在代码中使用了 Backbone.js MVC 库(bit . ly/backbone _ MVP
,您可能会认出 Underscore.js,因为它依赖于这个库。
模板化是通过下划线. js _.template()
方法实现的,该方法被传递一个模板字符串并返回一个可以执行的函数,传递用于以 JavaScript 对象的形式呈现模板的数据,与模板化通过 Handlebars.js 实现的方式非常相似。与 EJS 类似,<%
和%>
分隔符表示要执行的代码,<%=
和%>
分隔符表示要写出到结果字符串的变量。除了像if
和for
这样的 JavaScript 命令,您还可以设置变量并访问整个下划线. js 库,以便在您的模板中利用它的其他实用方法。
一个简单的下划线模板可能如下所示,使用下划线. js _.each()
方法迭代数据列表,而不是使用for
循环:
Template:
<h1><%= title %></h1>
<dl>
<% _.each(words, function(word) { %>
<dt><a href="<%= word.url %>"><%= word.term %></a></dt>
<dd><%= word.definition %></dd>
<% }
if (!words) { %>
<p>No words supplied</p>
<% } %>
</dl>
Data Hash:
{
title: "Underscore.js Example",
words: [{
term: "vastitude",
url: "
http://dictionary.com/browse/vastitude
definition: "vastness; immensity"
}, {
term: "achromic",
url: "
http://dictionary.com/browse/achromic
definition: "colorless; without coloring matter"
}]
}
Output:
<h1>Underscore.js Example</h1>
<dl>
<dt><a href="
http://dictionary.com/browse/vastitude">vastitude</a></dt
<dd>vastness; immensity</dd>
<dt><a href="
http://dictionary.com/browse/achromic">achromic</a></dt
<dd>colorless; without coloring matter</dd>
</dl>
要在 HTML 页面上使用下划线模板,请引用库和模板,这需要通过 Ajax 手动加载,或者直接从 HTML 页面或 JavaScript 变量引用。然后执行_.template()
方法将模板字符串编译成函数,然后可以用数据调用该函数。
var template = _.template(templateString),
output = template(data);
如果您需要在模板中添加一些熟悉的 helper 方法,或者您已经在应用中使用了 Backbone.js MVC 库,那么您可能会发现 Underscore.js 是满足您需求的最佳模板解决方案。
考虑渐进增强
在这一章中,我们已经了解了客户端模板解决方案,它允许您动态加载特殊格式的模板,将它们与 JavaScript 数据结合,然后将结果输出附加到当前 HTML 页面。这使得 web 应用用户无需刷新页面即可获取和显示新内容,就像桌面应用一样。然而,动态内容加载带来了一个警告:构建一个完全通过 JavaScript 呈现和更新显示的 web 应用意味着意外的页面刷新可能会将整个应用重置回其初始视图状态,并且搜索引擎可能会很难抓取通过应用表示的内容,因为不是所有的应用都可以处理 JavaScript。这也违背了网络上的 URL 原则:URL 代表一个对象或一段内容,而不是一个完整的应用。
使用渐进式增强的原则构建您的 web 应用,其中 HTML 链接和表单在被跟踪时会转到单独且不同的 URL,JavaScript 用于防止这些链接和表单导致页面刷新和分层,从而改善用户体验。执行 Ajax 调用并动态加载模板和数据,使用 HTML5 历史 API(bit . ly/History API
)更新地址栏中的 URL,以匹配表示加载的新数据或显示的页面部分的 URL。如果用户在浏览器中意外刷新了页面,他们将被带到地址栏中更新后的 URL 所代表的内容,并保持在他们之前到达的应用中的相同位置。当 JavaScript 被禁用时,就像大多数搜索引擎爬虫一样,链接仍然工作,允许内容和站点数据像你希望的那样被爬行,在关键点直接创建到你的应用的链接。
摘要
在本章中,我介绍了客户端模板作为一种解决方案,用于构建具有动态更新页面内容的大型 web 应用,将专门标记的模板与 JavaScript 数据相结合,生成包含在当前页面中的 HTML。我已经介绍了流行的 Mustache 和 Handlebars 模板语言,以及它们相关的 JavaScript 库,还有一些替代语言,比如嵌入式 JavaScript (EJS)和下划线. JS
在下一章中,我将介绍 Node.js,这是一个为 JavaScript 语言构建的应用框架,它允许专业 JavaScript 开发人员编写服务器端代码和服务器软件来支持他们的 web 应用,从而将完整的开发堆栈带入 JavaScript 开发人员的领域。