让javascript跑得更快

作者Cal Henderson是PHP,MySQL和Perl专家,现任flickr架构师,同时也是vitamin的特聘顾问。在这篇文章里,着重探讨怎样使用户体验最快:包括初始页面的下载,随后页面的下载,以及随着应用渐进、内容变化而进行的资源下载。适合网络工程师阅读。感谢htmlor的优秀翻译。

-------------------------------------------------------------------------------------------------------

flickr对javascript干的好事

在一个讨论web技术的网站vitamin上发现这篇《Serving JavaScript Fast》,读过之后大有收获,茅塞顿开。于是就有了翻译过来的念头——我这人有个毛病,看到有意思的英文文章,就想自己翻过来(虽然英文水平很烂)。先在网上查了查,已经有blog谈到这篇文章(我算是后知后觉了),有总结要点的《Flickr 的开发者的 Web 应用优化技巧》,也有延伸开来的《接着讲Flickr的八卦》,但似乎没有全文翻译的(这下就好,不会忙了半天发现是无用功)。之后,就写信问作者可不可以,作者一口答应:“sure - i’d love you to translate it”,只是要求我翻好之后给他一个链接地址。得到准许,心里就有底了。
先介绍一下作者。Cal Henderson,伦敦人,现居加利福尼亚的旧金山。PHP,MySQL和Perl专家,现任flickr架构师(flickr被收购后就在yahoo了),同时也是vitamin的特聘顾问(写些技术性文章)。
既然他是架构师,flickr用的应该就是文中谈到的这些技术,于是参照文章,再对比网站,种种迹象表明确实如此。虽然在中国访问flickr速度不敢恭维,加速效果不得而知,但其用了n多css和javascript资源却似乎从没出过什么问题,也从侧面印证了这些技术的有效性。
仔细的看完文章,还有个强烈的感觉:这老兄也太能卖关子了,一句话非分成三句说,摆事实讲道理是够透彻,就是有点太@#$%了…… 算了,他怎么说我怎么翻吧,忠实于原著嘛,要不就成篡改了。经过几天努力,加上同事thincat兄倾力援手(小弟不胜感激啊),终于完工(@_@ 真是苦力活啊,我再也不想干了~)。
全文翻译如下:
让javascript跑得更快

作者:Cal Henderson
下一代web应用让javascript和css得堪大用。我们会告诉你怎样使这些应用又快又灵。
建立了号称“Web 2.0”的应用,也实现了富内容(rich content)和交互,我们期待着css和javascript扮演更加重要的角色。为使应用干净利落,我们需要完善那些渲染页面的文件,优化其大小和形态,以确保提供最好的用户体验——在实践中,这就意味着一种结合:使内容尽可能小、下载尽可能快,同时避免对未改动资源不必要的重新获取。
由于 css和js文件的形态,情况有点复杂。跟图片相比,其源代码很有可能频繁改动。而一旦改动,就需要客户端重新下载,使本地缓存无效(保存在其他缓存里的版本也是如此)。在这篇文章里,我们将着重探讨怎样使用户体验最快:包括初始页面的下载,随后页面的下载,以及随着应用渐进、内容变化而进行的资源下载。
我始终坚信这一点:对开发者来说,应该尽可能让事情变得简单。所以我们青睐于那些能让系统自动处理优化难题的方法。只需少许工作量,我们就能建立一举多得的环境:它使开发变得简单,有极佳的终端性能,也不会改变现有的工作方式。
好大一沱

老的思路是,为优化性能,可以把多个css和js文件合并成极少数大文件。跟十个5k的js文件相比,合并成一个50k的文件更好。虽然代码总字节数没变,却避免了多个HTTP请求造成的开销。每个请求都会在客户端和服务器两边有个建立和消除的过程,导致请求和响应header带来开销,还有服务器端更多的进程和线程资源消耗(可能还有为压缩内容耗费的cpu时间)。
(除了HTTP请求,)并发问题也很重要。默认情况下,在使用持久连接(persistent connections)时,ie和firefox在同一域名内只会同时下载两个资源(在HTTP 1.1规格书中第8.1.4节的建议)(htmlor注:可以通过修改注册表等方法改变这一默认配置)。这就意味着,在我们等待下载2个js文件的同时,将无法下载图片资源。也就是说,这段时间内用户在页面上看不到图片。
(虽然合并文件能解决以上两个问题,)可是,这个方法有两个缺点。第一,把所有资源一起打包,将强制用户一次下载完所有资源。如果(不这么做,而是)把大块内容变成多个文件,下载开销就分散到了多个页面,同时缓解了会话中的速度压力(或完全避免了某些开销,这取决于用户选择的路径)。如果为了随后页面下载得更快而让初始页面下载得很慢,我们将发现更多用户根本不会傻等着再去打开下一个页面。
第二(这个影响更大,一直以来却没怎么被考虑过),在一个文件改动很频繁的环境里,如果采用单文件系统,那么每次改动文件都需要客户端把所有css和js重新下载一遍。假如我们的应用有个100k的合成的js大文件,任何微小的改动都将强制客户端把这100k再消化一遍。
分解之道

(看来合并成大文件不太合适。)替代方案是个折中的办法:把css和js资源分散成多个子文件,按功能划分、保持文件个数尽可能少。这个方案也是有代价的,虽说开发时代码分散成逻辑块(logical chunks)能提高效率,可在下载时为提高性能还得合并文件。不过,只要给build系统(把开发代码变成产品代码的工具集,是为部署准备的)加点东西,就没什么问题了。
对于有着不同开发和产品环境的应用来说,用些简单的技术可以让代码更好管理。在开发环境下,为使条理清晰,代码可以分散为多个逻辑部分(logical components)。可以在Smarty(一种php模板语言)里建立一个简单的函数来管理javascript的下载:
SMARTY:
{insert_js files="foo.js,bar.js,baz.js"}

PHP:
function smarty_insert_js($args){
foreach (explode(',', $args['files']) as $file){
echo "<script type=\"text/javascript\" SOURCE=\"/javascript/$file\"></script>\n";
}
}

OUTPUT:
<script type="text/javascript" SOURCE="/javascript/foo.js"></script><script type="text/javascript" SOURCE="/javascript/bar.js"></script><script type="text/javascript" SOURCE="/javascript/baz.js"></script>

(htmlor注:wordpress中会把“src”替换成不知所谓的字符,因此这里只有写成“SOURCE”,使用代码时请注意替换,下同)
就这么简单。然后我们就命令build过程(build process)去把确定的文件合并起来。这个例子里,合并的是foo.js和bar.js,因为它们几乎总是一起下载。我们能让应用配置记住这一点,并修改模板函数去使用它。(代码如下:)
SMARTY:
{insert_js files="foo.js,bar.js,baz.js"}

PHP:# 源文件映射图。在build过程合并文件之后用这个图找到js的源文件。

$GLOBALS['config']['js_source_map'] = array( 'foo.js' => 'foobar.js', 'bar.js' => 'foobar.js', 'baz.js' => 'baz.js',);
function smarty_insert_js($args){
if ($GLOBALS['config']['is_dev_site']){
$files = explode(',', $args['files']);
}else{
$files = array();
foreach (explode(',', $args['files']) as $file){
$files[$GLOBALS['config']['js_source_map'][$file]]++;
}
$files = array_keys($files);
}
foreach ($files as $file){
echo "<script type=\"text/javascript\" SOURCE=\"/javascript/$file\"></script>\n";
}
}

OUTPUT:
<script type="text/javascript" SOURCE="/javascript/foobar.js"></script><script type="text/javascript" SOURCE="/javascript/baz.js"></script>

模板里的源代码没必要为了分别适应开发和产品阶段而改动,它帮助我们在开发时保持文件分散,发布成产品时把文件合并。想更进一步的话,可以把合并过程(merge process)写在php里,然后使用同一个(合并文件的)配置去执行。这样就只有一个配置文件,避免了同步问题。为了做的更加完美,我们还可以分析 css和js文件在页面中同时出现的几率,以此决定合并哪些文件最合理(几乎总是同时出现的文件是合并的首选)。
对css来说,可以先建立一个主从关系的模型,它很有用。一个主样式表控制应用的所有样式表,多个子样式表控制不同的应用区域。采用这个方法,大多数页面只需下载两个css文件,而其中一个(指主样式表)在页面第一次请求时就会缓存。
对没有太多css和js资源的应用来说,这个方法在第一次请求时可能比单个大文件慢,但如果保持文件数量很少的话,你会发现其实它更快,因为每个页面的数据量更小。让人头疼的下载花销被分散到不同的应用区域,因此并发下载数保持在一个最小值,同时也使得页面的平均下载数据量很小。
压缩

谈到资源压缩,大多数人马上会想到mod_gzip(但要当心,mod_gzip实际上是个魔鬼,至少能让人做恶梦)。它的原理很简单:浏览器请求资源时,会发送一个header表明自己能接受的内容编码。就像这样:
Accept-Encoding: gzip,deflate服务器遇到这样的header请求时,就用gzip或deflate压缩内容发往客户端,然后客户端解压缩。这过程减少了数据传输量,同时消耗了客户端和服务器的cpu时间。也算差强人意。但是,mod_gzip的工作方式是这样的:先在磁盘上创建一个临时文件,然后发送(给客户端),最后删除这个文件。在高容量的系统中,由于磁盘io问题,很快就会达到极限。要避免这种情况,可以改用mod_deflate(apache 2才支持)。它采用更合理的方式:在内存里做压缩。对于apache 1的用户来说,可以建立一块ram磁盘,让mod_gzip在它上面写临时文件。虽然没有纯内存方式快,但也不会比往磁盘上写文件慢。
话虽如此,其实还是有办法完全避免压缩开销的,那就是预压缩相关静态资源,下载时由mod_gzip提供合适的压缩版本。如果把压缩添加在build过程,它就很透明了。需要压缩的文件通常很少(用不着压缩图片,因为并不能减小更多体积),只有css和js文件(和其他未压缩的静态内容)。
配置选项会告诉mod_gzip去哪里找到预压缩过的文件。
mod_gzip_can_negotiate Yesmod_gzip_static_suffix .gzAddEncoding gzip .gz新一点的mod_gzip版本(从1.3.26.1a开始)添加一个额外的配置选项后,就能自动预压缩文件。不过在此之前,必须确认apache有正确的权限去创建和覆盖压缩文件。
mod_gzip_update_static Yes可惜,事情没那么简单。某些Netscape 4的版本(尤其是4.06-4.08)认为自己能够解释压缩内容(它们发送一个header这么说来着),但其实它们不能正确的解压缩。大多数其他版本的 Netscape 4在下载压缩内容时也有各种各样的问题。所以要在服务器端探测代理类型,(如果是Netscape 4,就要)让它们得到未压缩的版本。这还算简单的。ie(版本4-6)有些更有意思的问题:当下载压缩的javascript时,有时候ie会不正确的解压缩文件,或者解压缩到一半中断,然后把这半个文件显示在客户端。如果你的应用对javascript的依赖比较大(htmlor注:比如ajax应用),那么就得避免发送压缩文件给ie。在某些情况下,一些更老的5.x版本的ie倒是能正确的收到压缩的javascript,可它们会忽略这个文件的etag header,不缓存它。(thincat 友情提示:尽管压缩存在一些浏览器不兼容的现象,由于这些不能很好的支持压缩的浏览器数量现在已经非常少了,我认为这种由于浏览器导致的压缩不正常的情况可以忽略不计。这些过时的浏览器还能不能在现在流行的windows或unix环境下面安装都存在不小的问题)
既然gzip压缩有这么多问题,我们不妨把注意力转到另一边:不改变文件格式的压缩。现在有很多这样的javascript压缩脚本可用,大多数都用一个正则表达式驱动的语句集来减小源代码的体积。它们做的不外乎几件事:去掉注释,压缩空格,缩短私有变量名和去掉可省略的语法。
不幸的是,大多数脚本效果并不理想,要么压缩率相当低,要么某种情形下会把代码搞得一团糟(或者两者兼而有之)。由于对解析树的理解不完整,压缩器很难区分一句注释和一句看似注释的引用字符串。因为闭合结构的混合使用,要用正则表达式发现哪些变量是私有的并不容易,因此一些缩短变量名的技术会打乱某些闭合代码。
还好有个压缩器能避免这些问题:dojo压缩器(现成的版本在这里)。它使用rhino(mozilla的javascript引擎,是用java实现的)建立一个解析树,然后将其提交给文件。它能很好的减小代码体积,仅用很小的成本:因为只在build时压缩一次。由于压缩是在build过程中实现的,所以一清二楚。(既然压缩没有问题了,)我们可以在源代码里随心所欲的添加空格和注释,而不必担心影响到产品代码。
与javascript相比,css文件的压缩相对简单一些。由于css语法里不会有太多引用字符串(通常是url路径跟字体名),我们可以用正则表达式大刀阔斧的干掉空格(htmlor注:这句翻的最爽,哈哈)。如果确实有引用字符串的话,我们总可以把一串空格合成一个(因为不需要在url路径和字体名里查找多个空格和tab)。这样的话,一个简单的perl脚本就够了:

#!/usr/bin/perl
my $data = '';
open F, $ARGV[0] or die "Can't open source file: $!";
$data .= $_ while <F>;
close F;
$data =~ s!/*(.*?)*/!!g; # 去掉注释
$data =~ s!s+! !g; # 压缩空格
$data =~ s!} !}\n!g; # 在结束大括号后添加换行
$data =~ s!\n$!!; # 删除最后一个换行
$data =~ s! { ! {!g; # 去除开始大括号后的空格
$data =~ s!; }!}!g; # 去除结束大括号前的空格
print $data;

然后,就可以把单个的css文件传给脚本去压缩了。命令如下:
perl compress.pl site.source.css > site.compress.css
做完这些简单的纯文本优化工作后,我们就能减少数据传输量多达50%了(这个量取决于你的代码格式,可能更多)。这带来了更快的用户体验。不过我们真正想做的是,尽可能避免用户请求的发生——除非确实有必要。这下HTTP缓存知识派上用场了。