远程脚本编程
现代web应用经常会使用远程脚本编程和服务器通讯,而不刷新当前页面。这使得web应用更灵活,更像桌面程序。我们来看一下几种用JavaScript和服务器通讯的方法。
XMLHttpRequest
现在,XMLHttpRequest
是一个特别的对象(构造函数),绝大多数浏览器都可以用,它使得我们可以从JavaScript来发送HTTP请求。发送一个请求有以下三步:
- 初始化一个
XMLHttpRequest
对象(简称XHR) - 提供一个回调函数,供请求对象状态改变时调用
- 发送请求
第一步很简单:
var xhr = new XMLHttpRequest();
但是在IE7之前的版本中,XHR的功能是使用ActiveX对象实现的,所以需要做一下兼容处理。
第二步是给readystatechange
事件提供一个回调函数:
xhr.onreadystatechange = handleResponse;
最后一步是使用open()
和send()
两个方法触发请求。open()
方法用于初始化HTTP请求的方法(如GET,POST)和URL。send()
方法用于传递POST的数据,如果是GET方法,则是一个空字符串。open()
方法的最后一个参数用于指定这个请求是不是异步的。异步是指浏览器在等待响应的时候不会阻塞,这明显是更好的用户体验,因此除非必须要同步,否则异步参数应该使用true:
xhr.open("GET", "page.html", true);
xhr.send();
下面是一个完整的示例,它获取新页面的内容,然后将当前页面的内容替换掉(可以在http://jspatterns.com/book/8/xhr.html看到示例):
var i, xhr, activeXids = [
'MSXML2.XMLHTTP.3.0',
'MSXML2.XMLHTTP',
'Microsoft.XMLHTTP'
];
if (typeof XMLHttpRequest === "function") { // native XHR
xhr = new XMLHttpRequest();
} else { // IE7以下
for (i = 0; i < activeXids.length; i += 1) {
try {
xhr = new ActiveXObject(activeXids[i]);
break;
} catch (e) {}
}
}
xhr.onreadystatechange = function () {
if (xhr.readyState !== 4) {
return false;
}
if (xhr.status !== 200) {
alert("Error, status code: " + xhr.status);
return false;
}
document.body.innerHTML += "<pre>" + xhr.responseText + "<\/pre>"; };
xhr.open("GET", "page.html", true);
xhr.send("");
代码中的一些说明:
- 因为IE6及以下版本中,创建XHR对象有一点复杂,所以我们通过一个数组列出ActiveX的名字,然后遍历这个数组,使用
try-catch
块来尝试创建对象。 - 回调函数会检查
xhr
对象的readyState
属性。这个属性有0到4一共5个值,4代表“complete”(完成)。如果状态还没有完成,我们就继续等待下一次readystatechange
事件。 - 回调函数也会检查xhr对象的
status
属性。这个属性和HTTP状态码对应,比如200(OK)或者是404(Not found)。我们只对状态码200感兴趣,而将其它所有的都报为错误(为了简化示例,否则需要检查其它不代表出错的状态码)。 - 上面的代码会在每次创建XHR对象时检查一遍支持情况。你可以使用前面提到过的模式(如条件初始化)来重写上面的代码,使得只需要做一次检查。
JSONP
JSONP(JSON with padding)是另一种发起远程请求的方式。与XHR不同,它不受浏览器同源策略的限制,所以考虑到加载第三方站点内容的安全问题,使用它时应该很谨慎。
一个XHR请求的返回可以是任何类型的文档:
- XML文档(过去很常用)
- HTML片段(很常用)
- JSON数据(轻量、方便)
- 简单的文本文件及其它
而使用JSONP的话,返回的数据格式经常是被一个函数包裹的JSON,具体的函数名称在请求的时候提供。
JSONP的请求URL通常是像这样:
http://example.org/getdata.php?callback=myHandler
getdata.php
可以是任何类型的页面或者脚本。callback
参数指定用来处理响应的JavaScript函数(译注:也就是前面提到的包裹JSON的函数)。
这个URL会被放到一个动态生成的<script>
元素中,像这样:
var script = document.createElement("script");
script.src = url;
document.body.appendChild(script);
服务器返回的JSON数据作为参数被传递给回调函数(函数名在请求时指定,也出现在返回的结果中)。最终的结果实际上是页面中多了一个新的脚本,这个脚本的内容就是一个函数调用,如:
myHandler({"hello": "world"});
JSONP示例:井字棋
我们来看一个使用JSONP的井字棋游戏示例,玩家就是客户端(浏览器)和服务器。它们两者都会产生1到9之间的随机数,我们使用JSONP去取服务器产生的数字(图8-2)。
你可以在http://jspatterns.com/book/8/ttt.html玩这个游戏。
图8-2 使用JSONP的井字棋游戏
界面上有两个按钮:一个用于开始新游戏,一个用于取服务器下的棋(客户端下的棋会在一定数量的延时之后自动进行):
<button id="new">New game</button>
<button id="server">Server play</button>
界面上包含9个单元格,每个都有对应的id,比如:
<td id="cell-1"> </td>
<td id="cell-2"> </td>
<td id="cell-3"> </td>
……
整个游戏是在一个全局对象ttt
中实现:
var ttt = {
// 已经下过的棋盘
played: [],
// 快捷函数
get: function (id) {
return document.getElementById(id);
},
// 处理点击
setup: function () {
this.get('new').onclick = this.newGame;
this.get('server').onclick = this.remoteRequest;
},
// 清除棋盘
newGame: function () {
var tds = document.getElementsByTagName("td"),
max = tds.length,
i;
for (i = 0; i < max; i += 1) {
tds[i].innerHTML = " ";
}
ttt.played = [];
},
// 发送请求
remoteRequest: function () {
var script = document.createElement("script");
script.src = "server.php?callback=ttt.serverPlay&played=" + ttt.played.join(',');
document.body.appendChild(script);
},
// 回调,服务器下棋
serverPlay: function (data) {
if (data.error) {
alert(data.error);
return;
}
data = parseInt(data, 10);
this.played.push(data);
this.get('cell-' + data).innerHTML = '<span class="server">X<\/span>';
setTimeout(function () {
ttt.clientPlay();
}, 300); // 假装想破脑袋
},
// 客户端下棋
clientPlay: function () {
var data = 5;
if (this.played.length === 9) {
alert("Game over");
return;
}
// 随机产生数字1 - 9,直到找到空格格
while (this.get('cell-' + data).innerHTML !== " ") {
data = Math.ceil(Math.random() * 9);
}
this.get('cell-' + data).innerHTML = 'O';
this.played.push(data);
}
};
ttt
对象维护着一个已经填过的单元格的列表ttt.played
,并且将它发送给服务器,这样服务器就可以返回一个没有玩过的数字。如果有错误发生,服务器会像这样响应:
ttt.serverPlay({"error": "Error description here"});
如你所见,JSONP中的回调函数必须是公开的并且全局可访问的函数,它并不一定要是全局函数,也可以是一个全局对象的方法。如果没有错误发生,服务器将会返回一个函数调用,像这样:
ttt.serverPlay(3);
这里的3
是指3号单元格是服务器要下棋的位置。在这种情况下,数据非常简单,甚至都不需要使用JSON格式,只需要一个简单的值就可以了。
框架(frame)和图片信标(image beacon)
另外一种做远程脚本编程的方式是使用框架。你可以使用JavaScript来创建框架并改变它的src
属性(URL),新URL的页面中可以包含数据和函数调用来更新调用者,也就是框架之外的父页面。
远程脚本编程中最最简单的情况是你只需要传递一点数据给服务器,而并不需要服务器的响应内容。在这种情况下,你可以创建一个新的图片,然后将它的src
指向服务器的脚本:
new Image().src = "http://example.org/some/page.php";
这种模式叫作图片信标,当你想发送一些数据给服务器记录时很有用,比如做访问统计。因为信标的响应对你来说完全是没有用的,所以通常的做法(不推荐)是让服务器返回一个1x1的GIF图片。更好的做法是让服务器返回一个"204 No Content"
HTTP响应。这意味着返回给客户端的响应只有响应头(header)而没有响应体(body)。
部署JavaScript
在生产环境中使用JavaScript时,有不少性能方面的考虑,我们来讨论一下最重要的一些。如果需要了解所有的细节,可以参见O’Reilly出社的《高性能网站建设指南》和《高性能网站建设进阶指南》。
合并脚本
创建高性能网站的第一个原则就是尽量减少外部引用的组件(译注:这里指文件),因为HTTP请求的代价是比较大的。具体就JavaScript而言,可以通过合并外部脚本来显著提高页面加载速度。
我们假设你的页面正在使用jQuery库,这是一个.js
文件。然后你使用了一些jQuery插件,这些插件也是单独的文件。这样的话在你还一行代码都没有写的时候就已经有了四五个文件了。把这些文件合并起来是很有意义的,尤其是其中的一些体积很小(2-3kb)时,这种情况下,HTTP协议的开销会比下载本身还大。合并脚本的意思就是创建一个新的js文件,然后把每个文件的内容粘贴进去。
当然,合并的操作应该放在代码部署到生产环境之前,而不是在开发环境中,否则会使调试变得困难。
合并脚本的不便之处是:
在部署前多了一步操作,但这很容易使用命令行自动化工具来做,比如使用Linux/Unix的
cat
:$ cat jquery.js jquery.quickselect.js jquery.limit.js > all.js
失去一些缓存上的便利——当你对某个文件做了一点小修改之后,会使得整个合并后的代码缓存失效。所以比较好的方法是为大的项目设定一个发布计划,或者是将代码合并为两个文件:一个包含可能会经常变更的代码,另一个包含那些不会轻易变更的“核心”。
你需要处理合并后文件的命名或者是版本问题,比如使用一个时间戳
all_20100426.js
或者是使用文件内容的hash值。
这就是主要的不便之处,但它带来的好处却是远远大于这些麻烦的。
压缩代码
第二章中,我们讨论过代码压缩。部署之前进行代码压缩也是一个很重要的步骤。
从用户的角度来想,完全没有必要下载代码中的注释,因为这些注释根本不影响代码运行。
压缩代码能带来多少好处取决于代码中注释和空白的数量,也取决于你使用的压缩工具。平均来说,压缩可以减少50%左右的体积。
服务端脚本压缩也是应该要做的事情。配置启用gzip压缩是一个一次性的工作,能带来立杆见影的速度提升。即使你正在使用共享的空间,供应商并没有提供那么多服务器配置的空间,大部分的供应商也会允许使用.htaccess
配置文件。所以可以将这些加入到站点根目录的.htaccess
文件中:
AddOutputFilterByType DEFLATE text/html text/css text/plain text/xml application/javascript application/json
平均下来压缩会节省70%的文件体积。将代码压缩和服务端压缩合计起来,你的用户只需要下载你写出来的未压缩文件体积的15%左右。
缓存头
与比较流行的观点相反,其实文件在浏览器缓存中的时间并没有那么久。你可以通过使用Expires
头来增加非首次访问时命中缓存的概率:
这也是一个在.htaccess
中做的一次性配置工作:
ExpiresActive On
ExpiresByType application/x-javascript "access plus 10 years"
它的弊端是当你想更改这个文件时,你需要给它重命名,如果你已经处理好了合并的文件命名规则,那你就已经处理好这里的命名问题了。
使用CDN
CDN是指“文件分发网络”(Content Delivery Network)。这是一项收费(有时候还相当昂贵)的托管服务,它将你的文件分发到世界上各个不同的数据中心,但代码中的URL却都是一样的,这样可以使用户更快地访问。
即使你没有CDN的预算,你仍然有一些可以免费使用的东西:
- Google托管了很多流行的开源库,你可以免费使用,并从它的CDN中得到速度提升(译注:鉴于Google在国内的尴尬处境,不建议使用)
- 微软托管了jQuery和自家的Ajax库
- 雅虎在自己的CDN上托管了YUI库
加载策略
怎样在页面上引入脚本,这是一个看起来很简单的问题——使用<script>
元素,然后要么写内联的JavaScript代码要么在src
属性中指定一个独立的文件:
// 第一种选择
<script>
console.log("hello world");
</script>
// 第二种选择
<script src="external.js"></script>
但是,当你的目标是要构建一个高性能的web应用的时候,有些模式和考虑点还是应该知道的。
作为题外话,来看一些比较常见的开发者会用在<script>
元素上的属性:
language="JavaScript"
还有一些不同大小写形式的
"JavaScript"
,有的时候还会带上一个版本号。language
属性不应该被使用,因为默认的语言就是JavaScript。版本号也不像想象中工作得那么好,这应该是一个设计上的错误。type="text/javascript"
这个属性是HTML4和XHTML1标准所要求的,但它不应该存在,因为浏览器会假设它就是JavaScript。HTML5不再要求这个属性。除非是要强制通过验证,否则没有任何使用
type
的理由。defer
(或者是HTML5中更好的
async
)是一种指定浏览器在下载外部脚本时不阻塞页面其它部分的方法,但还没有被广泛支持。关于阻塞的更多内容会在后面提及。
script元素的位置
script
元素会阻塞页面的下载。浏览器会同时下载好几个组件(文件),但遇到一个外部脚本的时候,会停止其它的下载,直到脚本文件被下载、解析、执行完毕。这会严重影响页面的加载时间,尤其是当页面加载时发生多次阻塞的时候。
为了尽量减小阻塞带来的影响,你可以将script元素放到页面的尾部,在</body>
之前,这样就没有可以被脚本阻塞的元素了。此时,页面中的其它组件(文件)已经被下载完毕并呈现给用户了。
最坏的“反模式”是在文档的头部使用独立的文件:
<!doctype html>
<html>
<head>
<title>My App</title>
<!-- 反模式 -->
<script src="jquery.js"></script>
<script src="jquery.quickselect.js"></script>
<script src="jquery.lightbox.js"></script>
<script src="myapp.js"></script>
</head>
<body>
……
</body>
</html>
一个更好的选择是将所有的文件合并起来:
<!doctype html>
<html>
<head>
<title>My App</title>
<script src="all_20100426.js"></script>
</head>
<body>
……
</body>
</html>
最好的选择是将合并后的脚本放到页面的尾部:
<!doctype html>
<html>
<head>
<title>My App</title>
</head>
<body>
……
<script src="all_20100426.js"></script>
</body>
</html>
HTTP分块
HTTP协议支持“分块编码”,它允许将页面分成一块一块发送。所以如果你有一个很复杂的页面,你不需要将那些(静态)头部信息也等到所有的服务端工作都完成后再开始发送。
一个简单的策略是在组装页面其余部分的时候将页面<head>
的内容作为第一块发送。也就是像这样子:
<!doctype html>
<html>
<head>
<title>My App</title>
</head>
<!-- 第一块结束 -->
<body>
……
<script src="all_20100426.js"></script> </body>
</html>
<!-- 第二块结束 -->
这种情况下可以做一个简单的改动,将JavaScript移回<head>
,随着第一块一起发送。
这样的话可以让浏览器在拿到head
区内容后就开始下载脚本文件,而此时页面的其它部分在服务端还尚未就绪:
<!doctype html>
<html>
<head>
<title>My App</title>
<script src="all_20100426.js"></script> </body>
</head>
<!-- 第一块结束 -->
<body>
……
</html>
<!-- 第二块结束 -->
一个更好的办法是使用第三块内容,让它在页面尾部,只包含脚本。如果有一些每个页面都用到的静态的头部,也可以将这部分随第一块一起发送:
<!doctype html> <html>
<head>
<title>My App</title> </head>
<body>
<div id="header">
<img src="logo.png" />
...
</div>
<!-- 第一块结束 -->
... The full body of the page ...
<!-- 第二块结束 -->
<script src="all_20100426.js"></script>
</body>
</html>
<!-- 第三块结束 -->
这种方法很适合使用渐进增强思想的网站(关键业务不依赖JavaScript)。当HTML的第二块发送完毕的时候,浏览器已经有了一个加载、显示完毕并且可用的页面,就像禁用JavaScript时的情况。当JavaScript随着第三块到达时,它会进一步增强页面,为页面锦上添花。
动态script元素实现非阻塞下载
前面已经说到过,JavaScript会阻塞后面文件的下载,但有一些模式可以防止阻塞:
- 使用XHR加载脚本,然后作为一个字符串使用
eval()
来执行。这种方法受同源策略的限制,而且引入了eval()
这种“反模式”` - 使用
defer
和async
属性,但有浏览器兼容性问题 - 使用动态
<script>
元素
最后一种是一个很好并且实际可行的模式。和介绍JSONP时所做的一样,创建一个新的script
元素,设置它的src
属性,然后将它放到页面上。
这是一个异步加载JavaScript、不阻塞其它文件下载的示例:
var script = document.createElement("script");
script.src = "all_20100426.js";
document.documentElement.firstChild.appendChild(script);
这种模式的缺点是,在这之后加载的脚本不能依赖当前加载的这个脚本,因为这个脚本是异步加载的,所以无法保证它什么时候会被加载进来,如果要依赖的话,很可能会访问到(因还未加载完毕导致的)未定义的对象。
如果要解决这个问题,可以让内联的脚本不立即执行,而是作为一个函数放到一个数组中。当依赖的脚本加载完毕后,再执行数组中的所有函数。所以一共有三个步骤。
首先,创建一个数组用来存储所有的内联代码,定义的位置尽量靠前:
var mynamespace = {
inline_scripts: []
};
然后你需要将这些单独的内联脚本包裹进一个函数中,然后将每个函数放到inline_scripts
数组中,也就是这样:
// 原来的:
// <script>console.log("I am inline");</script>
// 修改后的:
<script>
mynamespace.inline_scripts.push(function () {
console.log("I am inline");
});
</script>
最后一步是使用异步加载的脚本遍历这个数组,然后执行函数:
var i, scripts = mynamespace.inline_scripts, max = scripts.length;
for (i = 0; i < max; max += 1) {
scripts[i]();
}