我经常抱怨jQuery的DOM操作性能并不优秀,并且经常尝试用一些方法去进行优化,但是越是优化,越是沮丧地发现jQuery其实已经做得很好,从使用者的角度能够进行的优化实在有限(这并不意味着jQuery的性能是优秀的, 反之只能说它是一个相对封闭的库,无法从外部介入进行优化)。这篇文章就记录一次失败的优化经历。
优化思想
这一次优化的思想来自于数据库。在数据库优化的时候,我们常会说将大量的操作放在一个事务中一起提交,能有效提高效率。虽然对数据库不了解的我并不知道其原因,但是事务的思想却为我指明了方向(虽然是错的)。
因此我尝试将事务这一概念引入到jQuery中,通过打开和提交事务,从外部对jQuery进行一些优化,其最重要的在于减少each函数的循环次数。
众所周知,jQuery的DOM操作,以get all, set first为标准,其中用于设置DOM属性/样式的操作,几乎都是对选择出来的元素的一次遍历,jQuery.access函数就是其中最核心的部分,其中用于循环的代码如下:
- // Setting one attribute
- if ( value !== undefined ) {
- // Optionally, function values get executed if exec is true
- exec = !pass exec jQuery.isFunction(value);
- for ( var i = 0; i length; i++ ) {
- fn(
- elems[i],
- key,
- exec ? value.call(elems[i], i, fn(elems[i], key)) : value,
- pass
- );
- }
- return elems;
比如jQuery.fn.css函数就是这样的:
- jQuery.fn.css = function( name, value ) {
- // Setting 'undefined' is a no-op
- if ( arguments.length === 2 value === undefined ) {
- return this;
- }
- return jQuery.access( this, name, value, true, function( elem, name, value ) {
- return value !== undefined ?
- jQuery.style( elem, name, value ) :
- jQuery.css( elem, name );
- });
- };
因此,下面这样的代码,假设被选择的div元素有5000个,则要循环访问10000个节点:
- jQuery('div').css('height', 300).css('width', 200);
而在我的想法中,在一个事务中,可以如数据库的操作一般,通过保存所有的操作,在提交事务的时候统一进行,将10000次节点访问,减少至5000次,相当于提升了1倍的性能。
简单实现
事务式的jQuery操作中,提供2个函数:
◆ begin:打开一个事务,返回一个事务的对象。该对象拥有jQuery的所有函数,但是调用函数并不会立刻生效,只有在提交事务后才会生效。
◆ commit:提交一个事务,保证所有事先调用过的函数都生效,交返回原本的jQuery对象。
实现起来也很方便:
◆ 创建一个事务对象,复制jQuery.fn上所有函数到该对象中。
◆ 当调用某个函数时,在预先准备好的队列中添加调用的函数名和相关参数。
◆ 当提交事务时,对被选择的元素进行一次遍历,对遍历中的每个节点应用队列中的所有函数。
简单地代码如下:
- var slice = Array.prototype.slice;
- jQuery.fn.begin = function() {
- var proxy = {
- _core: c,
- _queue: []
- },
- key,
- func;
- //复制jQuery.fn上的函数
- for (key in jQuery.fn) {
- func = jQuery.fn[key];
- if (typeof func == 'function') {
- //这里会因为for循环产生key始终是***一个循环值的问题
- //因此必须使用一个闭包保证key的有效性(LIFT效应)
- (function(key) {
- proxy[key] = function() {
- //将函数调用放到队列中
- this._queue.push([key, slice.call(arguments, 0)]);
- return this;
- };
- })(key);
- }
- }
- //避免commit函数也被拦截
- proxy.commit = jQuery.fn.commit;
- return proxy;
- };
- jQuery.fn.commit = function() {
- var core = this._core,
- queue = this._queue;
- //仅一个each循环
- core.each(function() {
- var i = 0,
- item,
- jq = jQuery(this);
- //调用所有函数
- for (; item = queue[i]; i++) {
- jq[item[0]].apply(jq, item[1]);
- }
- });
- return this.c;
- };
测试环境
测试使用以下条件:
◆ 5000个div放在一个容器(div id="container"/div)中。
◆使用$(#containerdiv)选择这5000个div。
◆每个div要求设置一个随机背景色(randomColor函数),和800px以下的随机宽度(randomWidth函数)。
参加测试的调用方法有3个:
◆正常使用法:
- $('#containerdiv')
- .css('background-color', randomColor)
- .css('width', randomWidth);
◆单次循环法:
- $('#containerdiv').each(function() {
- $(this).css('background-color', randomColor).css('width', randomWidth);
- });
◆事务法:
- $('#containerdiv')
- .begin()
- .css('background-color', randomColor)
- .css('width', randomWidth)
- .commit();
◆对象赋值法:
- $('#containerdiv').css({
- 'background-color': randomColor,
- 'width': randomWidth
- });
测试浏览器选择Chrome 8系列(用IE测就直接挂了)。
悲伤的结果
原本的预测结果是,单次循环法的效率远高于正常使用法,同时事务法虽然比单次循环法慢一些,但应该比正常使用法更快,而对象赋值法其实是jQuery内部支持的单次循环法,效率应该是***的。
然而遗憾的是,结果如下:
正常使用法 | 单次循环法 | 事务法 | 对象赋值法 |
18435ms | 18233ms | 18918ms | 17748ms |
从结果上看,事务法成了最慢的一种方法。同时单次循环与正常使用并没有明显的优势,甚至依赖jQuery内部实现的对象赋值法也没有拉开很大的差距。
由于5000个元素的操作已经是非常庞大的循环,如此庞大的循环也没能拉开性能的差距,平时最常用的10个左右的元素操作更不可能有明显的优势,甚至还可能将劣势扩大化。
究其原因,由于本身单次循环法就没有明显的性能提升,因此依赖于单次循环,并是在单次循环之上进行外部构建的事务法,自然是在单次循环的基础上还需要额外增加创建事务对象、保存函数队列、遍历函数队列等开销,结果败给正常使用法也在情理之中。
至此,也算是可以宣布模仿事务的优化之道的失败。但是对这个结果却还能进一步地分析。
性能在哪里
首先,从代码的使用上来分析,将正常使用法和测试中最快的对象赋值法进行比较,可以说两者的差距仅在于循环的元素个数的不同(这里抛开了jQuery的内部问题,事实上jQuery.access的糟糕实现也确实有拖对象赋值法后腿之嫌,好在并不严重),正常使用法是10000个元素,对象赋值法是5000个元素。因此可以简单地认为,18435 17748 = 687ms是循环5000个元素的耗时,这占到整个执行过程的3.5%左右,并不是整个执行过程的主干,其实真的没有优化的必要。
那么另外96.5%的开销去了哪里呢?谨记Doglas的一句话,事实上Javascript并不慢,慢的是DOM的操作。其实剩下96.5%的开销中,除去函数调用等基本的消耗,至少有95%的时间是用在了DOM元素的样式被改变后的重新渲染之上。
发现了这个事实之后,其实也就有了更正确的优化方向,也是前端性能中的基本原则之一:在修改大量子元素时,先将根父DOM节点移出DOM树。因此如果使用以下的代码再进行测试:
- //没有重用$('#container')已经很糟糕了
- $('#container').detach().find('div')
- .css('background-color', randomColor)
- .css('width', randomWidth);
- $('#container').appendTo(document.body);
测试结果始终停留在900ms左右,与前面的数据完全不在一个数量级之上,真正的优化成功。
教训和总结
◆千万要找到正确的性能瓶颈再进行优化,盲目的猜测只会导致走上错误而偏激的道路。
◆ 数据说话,数据面前谁也别说话!
◆不认为事务这个方向是错误的,如果jQuery原生就能支持事务这样的概念,会不会有其他的点可以优化?比如一个事务自动会将父元素脱离出DOM树之类的
原文链接:http://www.otakustay.com/a-failure-in-jquery-optimization/
【编辑推荐】