Lazyload是通过延迟加载来实现按需加载,达到节省资源,加快浏览速度的目的。
网上也有不少类似的效果,这个Lazyload主要特点是:
支持使用window(窗口)或元素作为容器对象;
对静态(位置大小不变)元素做了大量的优化;
支持垂直、水平或同时两个方向的延迟。
由于内容比较多,下一篇再介绍图片延迟加载效果。
兼容:ie6/7/8, firefox 3.5.5, opera 10.10, safari 4.0.4, chrome 3.0
程序说明
【基本原理】
首先要有一个容器对象,容器里面是_elems加载元素集合。
用隐藏或替换等方法,停止元素加载内容。
然后历遍集合元素,当元素在加载范围内,再进行加载。
加载范围一般是容器的视框范围,即浏览者的视觉范围内。
当容器滚动或大小改变时,再重新历遍元素判断。
如此重复,直到所有元素都加载后就完成。
【容器对象】
程序一开始先用_initContainer程序初始化容器对象。
先判断是用window(窗口)还是一般元素作为容器对象:
var doc = document, isWindow = container == window || container == doc || !container.tagName || (/^(?:body|html)$/i).test( container.tagName );
如果是window,再根据文档渲染模式选择对应的文档对象:
if ( isWindow ) { container = doc.compatMode == 'CSS1Compat' ? doc.documentElement : doc.body; }
定义好执行方法后,再绑定scroll和resize事件:
this._binder = isWindow ? window : container; $$E.addEvent( this._binder, "scroll", this.delayLoad ); isWindow && $$E.addEvent( this._binder, "resize", this.delayResize );
如果是window作为容器,需要绑定到window对象上,为了方便移除用了_binder属性来保存绑定对象。
【加载数据】
当容器滚动或大小改变时,就会通过事件绑定(例如scroll/resize)自动执行_load加载程序。
ps:如果不能绑定事件(如resize),应手动执行load或resize方法。
当容器大小改变(resize)时,还需要先执行_getContainerRect程序获取视框范围。
要获取视框范围,一般元素可以通过_getRect方位参数获取程序来获取。
但如果容器是window就麻烦一点,测试以下代码:
<!doctype html> <style>html,body{border:5px solid #06F;}</style> <body> <div style="border:1px solid #000;height:2000px;"></div> </body> </html> <script> alert(document.documentElement.offsetHeight) </script>
在ie会得到想要的结果,但其他浏览器得到的是文档本身的高度。
所以在_getContainerRect程序中,其他浏览器要用innerWidth/innerHeight来获取:
this._getContainerRect = isWindow && ( "innerHeight" in window ) ? function(){ return { "left": 0, "right": window.innerWidth, "top": 0, "bottom":window.innerHeight }} : function(){ return oThis._getRect(container); };
ps:更多相关信息可以看“Finding the size of the browser window”。
在_load程序中,先根据位置参数、滚动值和阈值计算_range加载范围参数:
var rect = this._rect, scroll = this._getScroll(), left = scroll.left, top = scroll.top, threshold = Math.max( 0, this.threshold | 0 ); this._range = { top: rect.top + top - threshold, bottom: rect.bottom + top + threshold, left: rect.left + left - threshold, right: rect.right + left + threshold }
在_getScroll获取scroll值程序中,如果是document时会通过$$D来获取,详细看这里dom部分。
threshold阈值的作用是在视框范围的基础上增大加载范围,实现类似预加载的功能。
最后执行_loadData数据加载程序。
【加载模式】
程序初始化时会执行_initMode初始化模式设置程序。
根据mode的设置,选择加载模式:
switch ( this.options.mode.toLowerCase() ) { case "vertical" : this._initStatic( "vertical", "vertical" ); break; case "horizontal" : this._initStatic( "horizontal", "horizontal" ); break; case "cross" : case "cross-vertical" : this._initStatic( "cross", "vertical" ); break; case "cross-horizontal" : this._initStatic( "cross", "horizontal" ); break; case "dynamic" ://动态加载 default : this._loadData = this._loadDynamic; }
包括以下几种模式:
vertical:垂直方向加载模式
horizontal:水平方向加载模式
cross/cross-vertical:垂直正交方向加载模式
cross-horizontal:水平正交方向加载模式
dynamic:动态加载模式
其中"dynamic"模式是一般的加载方式,没有约束条件,但也没有任何优化。
其余都属于静态加载模式,适用于加载对象集合元素的位置(相对容器)或大小不会改变(包括加载后)的情况。
其中两个正交方向加载模式("cross"模式)适用于两个方向都需要判断的情况。
程序会对静态加载的情况尽可能做优化,所以应该优先选择静态加载模式。
【动态加载】
动态加载是使用_loadDynamic程序作为加载程序的:
this._elems = $$A.filter( this._elems, function( elem ) { return !this._insideRange( elem ); }, this );
程序会用_insideRange程序来判断元素是否在加载范围内,并用filter筛选出加载范围外的元素,重新设置加载集合。
在_insideRange程序中,先用元素位置和加载范围参数作比较,判断出元素是否在加载范围内:
var range = this._range, rect = elem._rect || this._getRect(elem), insideH = rect.right >= range.left && rect.left <= range.right, insideV = rect.bottom >= range.top && rect.top <= range.bottom, inside = { "horizontal": insideH, "vertical": insideV, "cross": insideH && insideV }[ mode || "cross" ];
在动态加载中,不会为元素记录位置参数,所以每次都会用_getRect程序获取加载元素的位置信息。
动态加载会默认使用"cross"模式来判断,即水平和垂直方向都判断。
如果元素在加载范围内,会执行_onLoadData自定义加载程序,进行元素的加载。
【静态加载】
静态加载是程序的重点,也是程序的主要特色。
主要是利用集合元素位置大小固定的性质进行优化,利用这个方式会大大提高程序执行效率,越多加载项会越明显。
原理是对加载集合进行排序,转换成有序集合,这样加载范围内的元素总是加载集合中连续的一段。
即可以把加载集合分成3部分,在加载范围前面的,在加载范围内的和加载范围后面的。
以horizontal模式左右滚动为例,加载过程大致如下:
1,记录每个元素的位置参数,按left坐标的大小对加载集合进行排序(从小到大),设置强制加载,跳到1.1;
1.1,记录加载范围,如果是强制加载,跳到1.2,否则跳到2;
1.2,设置索引为0,跳到3;
2,判断滚动的方向,如果向右滚动跳到3,否则跳到4,没有滚动的话取消执行;
3,向后历遍元素,判断元素是否在加载范围内,是的话跳到3.1,否则跳到3.2,如果没有元素,跳到6;
3.1,加载当前元素,并把它从集合中移除,跳回3;
3.2,判断元素的left是否大于容器的right,是的话跳到5,否则跳回3;
4,向前历遍元素,判断元素是否在加载范围内,是的话跳到4.1,否则跳到4.2,如果没有元素,跳到6;
4.1,加载当前元素,并把它从集合中移除,跳回4;
4.2,判断元素的right是否大于容器的left,是的话跳到5,否则跳回4;
5,当前元素已经超过了加载范围,不用继续历遍,跳到6;
6,合并未加载的元素,并记录当前索引,等待滚动,如果全部元素都加载了,就完成退出。
7,当容器滚动时,跳到1.1;当容器大小改变时,设置强制加载,跳到1.1;当容器位置发生变化时,需要重新修正元素坐标,跳到1;
首先加载元素会在_rect属性中记录位置参数,不用重复获取,是一个优化。
更关键的地方是每次滚动只需对上一次索引到加载范围内的元素进行判断,大大减少了判断次数。
大致理解了原理后,后面再详细分析。
在_initMode模式设置中,对静态加载的情况会调用_initStatic初始化静态加载程序。
并传递两个参数mode(模式)和direction(方向)。
根据方向判断方式分三种模式:"vertical"(垂直)、"horizontal"(水平)和"cross"(正交)。
这里先分析一下前两种模式。
在_initStatic程序中,先根据direction设置排序函数,再设置_setElems重置元素集合程序:
var pos = isVertical ? "top" : "left", sortFunction = function( x, y ) { return x._rect[ pos ] - y._rect[ pos ]; }, getRect = function( elem ) { elem._rect = this._getRect(elem); return elem; }; this._setElems = function() { this._elems = $$A.map( this._elems, getRect, this ).sort( sortFunction ); };
其中_setElems有两个意义,一个是记录元素的坐标参数,还有是把加载集合用map转换成数组并排序。
因为自定义的加载集合有可以是NodeList,而用sort就必须先把它转换成数组。
最后设置_loadData加载函数:
this._loadData = $$F.bind( this._loadStatic, this, "_" + mode + "Direction", $$F.bind( this._outofRange, this, mode, "_" + direction + "BeforeRange" ), $$F.bind( this._outofRange, this, mode, "_" + direction + "AfterRange" ) );
其中_loadStatic静态加载程序是程序的核心部分,优化的核心就所在。
这里给它包装了三个参数:
direction:方向获取程序的程序名;
beforeRange:判断是否超过加载范围前面的程序;
afterRange:判断是否超过加载范围后面的程序。
通过包装,除了方便参数的使用,还能使程序结构更加清晰。
direction可能是"_verticalDirection"(垂直滚动方向获取程序)或"_horizontalDirection"(水平滚动方向获取程序)。
在里面在调用_getDirection程序获取滚动方向:
var now = this._getScroll()[ scroll ], _scroll = this._lastScroll; if ( force ) { _scroll[ scroll ] = now; this._index = 0; return 1; } var old = _scroll[ scroll ]; _scroll[ scroll ] = now; return now - old;
原理是通过_getScroll获取当前的滚动值跟上一次的滚动值_lastScroll相差的结果来判断。
如果结果是0,说明没有滚动,如果大于0,说明是向后滚动,否则就是向前滚动。
然后记录当前滚动值作为下一次的参考值。
如果是强制执行(force为true),就重置_index属性为0,并返回1,模拟初始向后滚动的情况。
强制执行适合在不能根据方向做优化的情况下使用,例如第一次加载、resize、刷新等。
这时虽然不能做优化,但保证了加载的准确性。
在_loadStatic中,先用direction获取方向值:
direction = this[ direction ]( force ); if ( !direction ) return;
没有滚动的话就直接返回。
然后根据方向和上一次的索引来历遍加载集合,其中关键的一点是判断元素是否超过加载范围。
这个主要是通过beforeRange和afterRange程序来判断的。
从_loadData的设置可以看出,它们是包装了对应compare判断程序参数的_outofRange程序。
在"_vertical"方向,compare可能是:
_verticalBeforeRange:垂直平方向上判断元素是否超过加载范围的上边;
_verticalAfterRange:垂直方向上判断元素是否超过加载范围的下边。
在"horizontal"方向,compare可能是:
_horizontalBeforeRange:水平方向上判断元素是否超过加载范围的左边;
_horizontalAfterRange:水平方向上判断元素是否超过加载范围的右边。
在_outofRange中,通过compare来判断是否超过范围:
if ( !this._insideRange( elem, mode ) ) { middle.push(elem); return this[ compare ]( elem._rect ); }
先用_insideRange判断元素是否在加载范围内,不是的话把元素保存到middle,再用compare判断是否超过加载范围。
回到_loadStatic程序,根据方向判断,如果是向后滚动,先根据索引,取出加载范围前面的元素,保存到begin:
begin = elems.slice( 0, i );
这一部分肯定在加载范围外,不需要再历遍,再向后历遍集合:
for ( var len = elems.length ; i < len; i++ ) { if ( afterRange( middle, elems[i] ) ) { end = elems.slice( i + 1 ); break; } } i = begin.length + middle.length - 1;
当afterRange判断超过加载范围后面,根据当前索引取出后面的元素,保存到end。
然后修正索引,给下一次使用。
如果是向前滚动,跟前面相反,根据索引取出加载范围后面的元素,保存到end:
end = elems.slice( i + 1 );
再向前历遍集合:
for ( ; i >= 0; i-- ) { if ( beforeRange( middle, elems[i] ) ) { begin = elems.slice( 0, i ); break; } } middle.reverse();
当beforeRange判断超过加载范围前面,根据当前索引取出前面的元素,保存到begin。
由于middle在beforeRange里面是用push添加的,但实际上是倒序历遍,所以要reverse一下。
ps:虽然push/reverse可以直接用unshift代替,但元素越多前者的效率会越高。
最后修正一下索引,合并begin、middle和end成为新的加载集合:
this._index = Math.max( 0, i ); this._elems = begin.concat( middle, end );
这样就完成了一次加载,等待下一次了。
这部分有点抽象,不太好表达,有什么疑问的地方欢迎提出。
【cross模式】
cross模式即正交方向加载模式,是指垂直和水平都需要判断的模式。
也就是说,元素需要同时在两个方向的加载范围内才会加载。
按主次方向又分两种模式:"cross-vertical"(垂直正交)和"cross-horizontal"(水平正交)。
前者以垂直方向为主,水平方向为次,后者相反。
在_initStatic程序中,如果使用cross模式,会设置_crossDirection滚动方向获取程序:
this._crossDirection = $$F.bind( this._getCrossDirection, this, isVertical ? "_verticalDirection" : "_horizontalDirection", isVertical ? "_horizontalDirection" : "_verticalDirection" );
可以看出,这是包装了primary和secondary参数的_getCrossDirection程序。
其中primary是主滚动方向获取程序,secondary是次滚动方向获取程序。
在_getCrossDirection中会根据主辅方向的滚动情况设置正交滚动值:
direction = this[ primary ](); secondary = this[ secondary ](); if ( !direction && !secondary ) { return 0; } else if ( !direction ) { if ( this._direction ) { direction = -this._direction; } else { force = true; } } else if ( secondary && direction * this._direction >= 0 ) { force = true; }
包括以下几个情况:
1,主次方向都没有滚动的话,直接返回0;
2,主方向没有滚动而次方向有的话,就用上次滚动的反方向,如果没有上一次滚动就执行强制加载;
3,主次方向都有滚动,同时主滚动方向跟上次不是相反的话,就执行强制加载;
4,主次方向都有滚动,同时主滚动方向跟上次相反的话,按一般情况处理;
5,主方向有滚动而次方向没有的话,就是一般的情况,不用特别处理。
利用两个方向要同时判断的性质,在情况2只要从主方向加载范围内的元素找出在次方向也在加载范围内的就行了。
这个可以通过不断取反方向来实现,即先从3到7判断,再反方向7到3判断如此类推。
情况3和情况4一般发生在刷新或设置了延迟时间比较长的情况。
如果主方向跟上次相同的话,可能会出现索引两边都有需要加载的元素的情况,不能确定方向,所以只能执行强制加载。
ps:如果在主方向的滚动量超过加载范围的话也能做优化,不过判断比较麻烦就不做了。
而如果方向相反的话,需要加载的元素只会出现在索引到加载范围的方向上,按一般情况历遍就行了。
cross模式跟其余两个静态加载模式的最主要区别就在于方向的判断上。
其他部分都差不多的,就不再详细说明了。
【resize】
先了解一下浏览器拖拉触发resize的方式。
例如在xp的系统性能选项中,设置是否“拖拉时显示窗口内容”会有不同的拖拉效果:
选择是的话,由于内容会跟着浏览器的拖拉同时渲染页面,导致resize事件的持续触发;
选择否的话,内容在拖拉完成才会渲染,并触发resize事件,即在拖拉过程中resize事件只会在确定后才触发一次;
不过ff有点特殊,即使选择否,它右下角的触发点还是会按照拖拉同时渲染页面的方式触发的。
后面测试时建议选择否,会比较准确看到结果。
再看看resize事件的支持情况。
在ie,haslayout的块级和内联元素都支持onresize事件,其他浏览器只有window对象支持。
而ie6/7跟ie8的支持程度也有不同,测试以下代码:
<!doctype html> <body> <div id="show">0</div> <div id="div" style="border:1px solid #000"></div> <script> var i = 0; div.onresize = function(){ show.innerHTML = ++i; } setTimeout('div.innerHTML="test"', 1000) setTimeout('div.style.height="50px"', 2000) </script> </body> </html>
在ie8两种情况都会触发onresize,但ie6/7只有第二种情况触发。
鉴于情况比较复杂,程序在使用document时才绑定事件,其他情况由使用者自己设置。
resize事件有不少的问题,处理时要小心。
chrome的resize有一个问题(bug?),每次触发resize都会执行两次事件,或者说会触发两次。
而ie就复杂了,window, body和documentElement的resize会相互影响。
在ie8测试以下代码:
<!doctype html> <style>html,body{border:5px solid #06F;}</style> <body><div id="div" style="height:100px;"></div></body> </html> <script> window.onresize = function(){ div.innerHTML += "window, "; } //document.documentElement.onresize = function(){ div.innerHTML += "documentElement, "; } //document.body.onresize = function(){ div.innerHTML += "body, "; } </script>
当上下拖放时,onresize只会触发一次,但左右拖放时会触发两次。
换成documentElement会有差不多的结果,两个一起用的话左右拖放时documentElement会触发两次,window一次。
只设置body的话感觉就正常了,上下左右都只会触发一次。
而documentElement和body同时设置的效果跟documentElement和window的效果差不多。
如果window和body同时设置的话,后一个会覆盖前一个。
看来window和body的onresize对应的是同一个对象事件,可能为了在body设置也能做到window一样的效果。
个人推测,window和documentElement多出的一次,可能是由于同时触发了body的resize造成的。
ps:onresize时,用srcElement获取不到触发元素,所以确定不了是那个元素触发的。
ie7的结果更ie8差不多,ie6就有些不同,不过估计也是盒模式的不同造成的。
具体产生原因还不清楚,这里我也很糊涂。 虽然问题弄不清楚,解决方法还是有的。
要绑定resize就是因为视框范围发生了变化,要重新设置视框范围,那么可以通过看两次resize之间视框范围有没有变化来确实是否执行程序。
在resizeDelay方法中,就是通过clientWidth和clientHeight来判断的:
this.resizeDelay = function(){ var clientWidth = container.clientWidth, clientHeight = container.clientHeight; if( clientWidth != width || clientHeight != height ) { width = clientWidth; height = clientHeight; oThis._delay( oThis.resize ); } };
ps:如果只是针对document直接用window的innerHeight/innerWidth就不用理会文档模式。
【延时加载】
一般情况下,触发程序会绑定到容器的scroll和resize事件中。
但很多时候scroll和resize会被连续触发执行,大量连续的执行会占用很多资源。
为了防止无意义的连续执行,程序设置了一个_delay方法来做延时:
var oThis = this, delay = this.delay; if ( this._lock ) { this._timer = setTimeout( function(){ oThis._delay(run); }, delay ); } else { this._lock = true; run(); setTimeout( function(){ oThis._lock = false; }, delay ); }
原理是用一个_lock属性,程序运行一次后_lock设为true,并用一个setTimeout延时设置它为false。
在锁定(_lock为true)期间,程序不会立即执行,达到延时的效果。
为了保证最后一次触发程序即使在锁定期间也能完成,还用了一个_timer来延时这次执行。
这种延时有什么好处,直接用setTimeout延时不是更简单方便吗?
首先直接用setTimeout只能保证最后一次程序能执行,而这种方式能保证第一次和最后一次都能执行。
直接用setTimeout更大的问题是,如果持续触发,会导致程序一直不能执行(前提是执行时有正确clear掉定时器),而这种方式能保证程序在时间段内执行一次。
还有一个方法是不绑定事件,只用setTimeout或setInterval来监听。
好处是没有连续触发的问题,也不会有resize的bug,但需要一直监听视框范围是否改变。
一般情况下,绑定事件效率应该比较好。
使用技巧
【选择模式】
如果加载元素位置固定大小不固定的情况下只能选择"dynamic"动态加载,否则应该优先选择静态加载。
在静态加载中,如果基本上是用于垂直或水平滚动,应该用"vertical"或"horizontal"模式。
两个方向都需要的话,如果主要是垂直滚动的话就用"cross-vertical"模式,否则用"cross-horizontal"模式。
【延迟html渲染】
Lazyload的一个作用就是延迟html渲染。
原理是先保存元素里面的html,当判断元素在加载范围里面时,再加载里面的html。
程序主要是做判断的部分,而如何保存和加载就看各位的想象力了。
以下几种方法个人认为还不错的:
1,ajax法:保存地址,加载时利用ajax读取实际内容并插入到元素中;
使用恰当的话能有效节省服务器资源,特别是要读数据库的地方,但响应速度受网络影响,而且不利seo,类似的还可以用iframe。
2,textarea法:把html保存到一个textarea中,加载时把value插入元素中;
利用了textarea的特性,第二个实例就使用了这个方法,淘宝用的也是这个方式,使用简单,响应速度快。
不过仅仅是html的话,貌似也没必要延迟,可以考虑关联一些dom操作之类的。
2,注释法:把html保存到一个注释中,加载时把内容插入元素中;
跟textarea法类似,但效率应该更好,加载时找出nodeType为8的节点,再把nodeValue插入元素中;
但在ie如果用innerHTML添加注释会被自动忽略掉,使用时注意。
以上方法都有一个问题,在不支持js的时候不能平稳退化,谁有更好的方法的话欢迎赐教。
除此之外,还可以用来延迟js执行,css渲染等,下一篇还会有图片的延迟加载。
【position的bug】
在写第一个实例的窗口模式时,遇到了两个bug:
在ie6/7,overflow为scroll或hidden的元素,其中position为absolute或relative的子孙元素会出现异常。
解决方法:
1.为包含块元素添加属性position:relative。
2.把该元素的position:relative属性去掉,使用默认的static定位,并通过margin-top等属性实现类似的效果。
参考自“IE6 CSS bug”。
还有一个问题是,在ie6,overflow为visible的元素,会被其内容撑开。
解决方法:
在ie6下,本来overflow为visible的元素设为hidden,并把内容position设为relative。
原理请看“IE6 overflow:visible bug”。
还要注意的是,加载元素只能是容器的子元素。
使用说明
实例化时,必须有一个元素集合作为参数,可以是元素数组或NodeList集合。
可选参数用来设置系统的默认属性,包括:
属性: 默认值//说明
container: window,//容器
mode: "dynamic",//模式
threshold: 0,//加载范围阈值
delay: 100,//延时时间
beforeLoad: function(){},//加载前执行
onLoadData: function(){}//显示加载数据
还提供了以下方法:
load:加载程序;
resize:容器大小改变加载程序,其参数说明是否重置元素集合;
delayLoad:延迟的load程序;
delayResize:延迟的resize程序;
isFinish:指明程序是否执行完成;
dispose:销毁程序,其参数说明是否加载所有元素。