UX Planet論壇上有過這麼一篇熱門文章: Infinite Scrolling Best Practices,它從UX角度分析了無限滾動加載的設計實踐。javascript
無限滾動加載在互聯網上處處都有應用:
豆瓣首頁是一個,Facebook的Timeline是一個,Tweeter的話題列表也是一個。當你向下滾動,新的內容就神奇的「無中生有」了。這是一個獲得普遍讚賞的用戶體驗。css
無限滾動加載背後的技術挑戰其實比想象中要多很多。尤爲是要考慮頁面性能,須要作到極致。
本文經過代碼實例,來實現一個無限滾動加載效果。更重要的是,在實現過程當中,對於頁面性能的分析和處理力圖作到最大化,但願對讀者有所啓發,同時也歡迎與我討論。html
另外,這篇文章代碼外的「繼續思考」部分引用借鑑了王芃前輩對於《Complexities of an Infinite Scroller》一文的翻譯,點擊戳我查看連接。
在此,向原創做者深表敬意和謝意。前端
在開啓咱們的代碼以前,有必要先了解一下經常使用的性能測量手段:java
1)使用window.performance node
HTML5帶來的performance API功能強大。咱們可使用其performance.now()精確計算程序執行時間。performance.now()與Date.now()不一樣的是,返回了以微秒(百萬分之一秒)爲單位的時間,更加精準。而且與 Date.now() 會受系統程序執行阻塞的影響不一樣,performance.now() 的時間是以恆定速率遞增的,不受系統時間的影響(系統時間可被人爲或軟件調整)。
同時,也可使用performance.mark()標記各類時間戳(就像在地圖上打點),保存爲各類測量值(測量地圖上的點之間的距離),即可以批量地分析這些數據了。jquery
2)使用console.time方法與console.timeEnd方法git
其中console.time方法用於標記開始時間,console.timeEnd方法用於標記結束時間,而且將結束時間與開始時間之間通過的毫秒數在控制檯中輸出。github
3)使用專業的測量工具/平臺:jsPerfweb
此次實現中,咱們使用第二種方法,由於它已經徹底能夠知足咱們的需求,且兼容性更加全面。
咱們要實現的頁面樣例如圖,
它可以作到無限下拉加載內容。我把紅線標出的部分叫作一個block-item,後續也都用這種命名。
1)關於設計方案,確定第一個最基本、最樸素的思想是下拉到底部以後發送ajax異步請求,成功以後的回調裏進行頁面拼接。
2)可是觀察頁面佈局,很明顯圖片較多,每個block-item區塊都有一張配圖。當加載後的內容插入到頁面中時,瀏覽器就開始獲取圖片。這意味着全部的圖像同時下載,瀏覽器中的下載通道將被佔滿。同時,因爲內容優先於用戶瀏覽而加載,因此可能被迫下載底部那些永遠也不會被用戶瀏覽到的圖像。
因此,咱們須要設計一個懶加載效果,使得頁面速度更快,而且節省用戶的流量費用和延長電池壽命。
3)上一條提到的懶加載實現上,爲了不到真正的頁面底部時才進行加載和渲染,而形成用戶較長時間等待。咱們能夠設置一個合理閾值,在用戶滾動到頁面底部以前,先進行提早加載。
4)另外,頁面滾動的事件確定是須要監聽的。同時,頁面滾動問題也比較棘手,後面將專爲滾動進行分析。
5)DOM操做咱們知道是及其緩慢而低效的,有興趣的同窗能夠研究一下jsPerf上一些經典的benchmark,好比這篇。關於形成這種緩慢的緣由,社區上一樣有不少文章有過度析,這裏就再也不深刻。但我想總結並補充的是:DOM操做,光是爲了找一個節點,就從本質上比簡單的檢索內存中的值要慢。一些DOM操做還須要從新計算樣式來讀取或檢索一個值。更突出的問題在於:DOM操做是阻塞的,因此當有一個DOM操做在進行時,其餘的什麼都不能作,包括用戶與頁面的交互(除了滾動)。這是一個極度傷害用戶體驗的事實。
因此,在下面的效果實現中,我採用了大量「難以想象」的DOM緩存,甚至極端的緩存everything。固然,這樣作的收益也在最後部分有所展示。
滾動問題不難想象在於高頻率的觸發滾動事件處理上。具我親測,在極端case下,滾動及其卡頓。即便滾動不卡頓,你能夠打開Chrome控制檯發現,幀速率也很是慢。關於幀速率的問題,咱們有著名的16.7毫秒理論。關於這個時間分析,社區上也有很多文章闡述,這裏再也不展開。
針對於此,有不少讀者會馬上想到「截流和防抖動函數」(Throttle和Debounce)。
簡單總結一下:
1)Throttle容許咱們限制激活響應的數量。咱們能夠限制每秒回調的數量。反過來,也就是說在激活下一個回調以前要等待多少時間;
2)Debounce意味着當事件發生時,咱們不會當即激活回調。相反,咱們等待必定的時間並檢查相同的事件是否再次觸發。若是是,咱們重置定時器,並再次等待。若是在等待期間沒有發生相同的事件,咱們就當即激活回調。
具體這裏就不代碼實現了。原理明白以後,應該不難寫出。
可是我這裏想從移動端主要瀏覽器處理滾動的方式入手,來思考這個問題:
1)在Android機器上,用戶滾動屏幕時,滾動事件高頻率發生——在Galaxy-SIII手機上,大約頻率是一秒一百次。這意味着,滾動處理函數也被調用了數百次,而這些又都是成本較大的函數。
2)在Safari瀏覽器上,咱們遇到的問題偏偏是相反的:用戶每次滾動屏幕時,滾動事件只在滾動動畫中止時才觸發。當用戶在iPhone上滾動屏幕時,不會運行更新界面的代碼(滾動中止時纔會運行一次)。
另外,我想也許會有讀者想到rAf(requestAnimationFrame),可是據我觀察,不少前端其實並不明白requestAnimationFrame技術的原理和解決的問題。只是機械地把動畫性能、掉幀問題甩到這麼一個名詞上。在真實項目中,也沒有親自實現過,更不要說考慮requestAnimationFrame的兼容性狀況了。這裏場景我並不會使用rAf,由於。setTimeout的定時器值推薦最小使用16.7ms(緣由請去社區上找答案,再也不細講),咱們這裏並不會超過這個限制,而且考慮兼容性。關於這項技術的使用,若是有問題,歡迎留言討論。
基於以上,個人解決方案是既不一樣於Throttle,也不一樣於Debounce,可是和這兩個思想,尤爲是Throttle又比較相似:把滾動事件替換爲一個帶有計時器的滾動處理程序,每100毫秒進行簡單檢查,看這段時間內用戶是否滾動過。若是沒有,則什麼都不作;若是有,就進行處理。
在圖像加載完成時,使用淡入(fade in)效果出現。這在實際狀況上會稍微慢一下,應該慢一個過渡執行時間。但用戶體驗上感受會更快。這是已經被證明且廣泛應用的小「trick」。可是據我感受,它確實有效。咱們的代碼實現也採用了這個小竅門。不過相似這種「社會心理學」範疇的東西,顯然不是本文研究的重點。
代碼上將會採用:超前閾值的懶加載+DOM Cache和圖片Cache+滾動throttle模擬+CSS fadeIn動畫。
具體功能封裝上和一些實現層面的東西,請您繼續閱讀。
總體結構以下:
<div class="exp-list-box" id="expListBox">
<ul class="exp-list" id="expList">
</ul>
<div class="ui-refresh-down"></div>
</div>複製代碼
主體內容放在id爲「expListBox」的container裏面,id爲「expList」的ul是頁面加載內容的容器。
由於每次加載並append進入HTML的內容相對較多。我使用了模版來取代傳統的字符串拼接。前端模版此次選用了個人同事顏海鏡大神的開源做品,模版結構爲:
<#dataList.forEach(function (v) {#>
<div id="s-<#=v.eid#>" class="slide">
<li>
<a href="<#=v.href#>">
<img class="img" src="data:image/gif;base64,R0lGODdhAQABAPAAAP%2F%2F%2FwAAACwAAAAAAQABAEACAkQBADs%3D" data-src="<#=v.src#>">
</img>
<strong><#=v.title#></strong>
<span class="writer"><#=v.writer#></span>
<span class="good-num"><#=v.succNum#></span>
</a>
</li>
</div>
<#})#>複製代碼
以上模版內容由每次ajax請求到的數據填充,並添加進入頁面,構成每一個block-item。
這裏須要注意觀察,有助於對後面邏輯的理解。頁面中一個block-item下div屬性存有該block-item的eid值,對應class叫作"slide",子孫節點包含有一個image標籤,src初始賦值爲1px的空白圖進行佔位。真實圖片資源位置存儲在"data-src"中。
另外,請求返回的數據dataList能夠理解爲由9個對象構成的數組,也就是說,每次請求加載9個block-item。
樣式方面不是這篇文章的重點,挑選最核心的一行來講明一下:
.slide .img{
display: inline-block;
width: 90px;
height: 90px;
margin: 0 auto;
opacity: 0;
-webkit-transition: opacity 0.25s ease-in-out;
-moz-transition: opacity 0.25s ease-in-out;
-o-transition: opacity 0.25s ease-in-out;
transition: opacity 0.25s ease-in-out;
}複製代碼
惟一須要注意的是image的opacity設置爲0,圖片將會在成功請求並渲染後調整爲1,輔助transition屬性實現一個fade in效果。
對應咱們上面所提到的那個「trick」
我是徹底按照業務需求來設計,並無作抽象。其實這樣的一個下拉加載功能徹底能夠抽象出來。有興趣的讀者能夠下去本身進行封裝和抽象。
咱們先把精力集中在邏輯處理上。
下面進入咱們最核心的邏輯部分,爲了防止全局污染,我把它放入了一個當即執行函數中:
(function() {
var fetching = false;
var page = 1;
var slideCache = [];
var itemMap = {};
var lastScrollY = window.pageYOffset;
var scrollY = window.pageYOffset;
var innerHeight;
var topViewPort;
var bottomViewPort;
function isVisible (id) {
// ...判斷元素是否在可見區域
}
function updateItemCache (node) {
// ....更新DOM緩存
}
function fetchContent () {
// ...ajax請求數據
}
function handleDefer () {
// ...懶加載實現
}
function handleScroll (e, force) {
// ...滾動處理程序
}
window.setTimeout(handleScroll, 100);
fetchContent();
}());複製代碼
我認爲好的編程習慣是在程序開頭部分便聲明全部的變量,防止「變量提高」帶來的潛在困擾,而且也有利於程序的總體把控。
咱們來看一下變量設置:
// 加載中狀態鎖
1)var fetching = false;
// 用於加載時發送請求參數,表示第幾屏內容,初始爲1,之後每請求一次,遞增1
2)var page = 1;
// 只緩存最新一次下拉數據生成的DOM節點,即須要插入的dom緩存數組
3)var slideCache = [];
// 用於已經生成的DOM節點儲存,存有item的offsetTop,offsetHeight
4) var slideMap = {};
// pageYOffset設置或返回當前頁面相對於窗口顯示區左上角的Y位置。
5)var lastScrollY = window.pageYOffset; var scrollY = window.pageYOffset;
// 瀏覽器窗口的視口(viewport)高度
6)var innerHeight;
// isVisible的上下閾值邊界
7) var topViewPort;
8) var bottomViewPort;複製代碼
關於DOM cache的變量詳細說明,在後文有提供。
一樣,咱們有5個函數。在上面的代碼中,註釋已經寫明白了每一個方法的具體做用。接下來,咱們逐個分析。
它接受兩個變量,第二個是一個布爾值force,表示是否強制觸發滾動程序執行。
核心思路是:若是時間間隔100毫秒內,沒有發生滾動,且並未強制觸發,則do nothing,間隔100毫秒以後再次查詢,而後直接return。
其中,是否發生滾動由lastScrollY === window.scrollY來判斷。
在100毫秒以內發生滾動或者強制觸發時,須要判斷是否滾動已接近頁面底部。若是是,則拉取數據,調用fetchContent方法,並調用懶加載方法handleDefer。
而且在這個處理程序中,咱們計算出來了isVisible區域的上下閾值。咱們使用600做爲浮動區間,這麼作的目的是在必定範圍內提早加載圖片,節省用戶等待時間。固然,若是咱們進行抽象時,能夠把這個值進行參數化。
function handleScroll (e, force) {
// 若是時間間隔內,沒有發生滾動,且並未強制觸發加載,則do nothing,再次間隔100毫秒以後查詢
if (!force && lastScrollY === window.scrollY) {
window.setTimeout(handleScroll, 100);
return;
}
else {
// 更新文檔滾動位置
lastScrollY = window.scrollY;
}
scrollY = window.scrollY;
// 瀏覽器窗口的視口(viewport)高度賦值
innerHeight = window.innerHeight;
// 計算isVisible上下閾值
topViewPort = scrollY - 1000;
bottomViewPort = scrollY + innerHeight + 600;
// 判斷是否須要加載
// document.body.offsetHeight;返回當前網頁高度
if (window.scrollY + innerHeight + 200 > document.body.offsetHeight) {
fetchContent();
}
// 實現懶加載
handleDefer();
window.setTimeout(handleScroll, 100);
}複製代碼
這裏我用到了本身封裝的ajax接口方法,它基於zepto的ajax方法,只不過又手動採用了promise包裝一層。實現比較簡單,固然有興趣能夠找我要一下代碼,這裏再也不詳細說了。
咱們使用前端模版進行HTML渲染,同時調用updateItemCache,將這次數據拉取生成的DOM節點緩存。以後手動觸發handleScroll,更新文檔滾動位置和懶加載處理。
function fetchContent () {
// 設置加載狀態鎖
if (fetching) {
return;
}
else {
fetching = true;
}
ajax({
url: (!location.pathname.indexOf('/m/') ? '/m' : '')
+ '/list/asyn?page=' + page + (+new Date),
timeout: 300000,
dataType: 'json'
}).then(function (data) {
if (data.errno) {
return;
}
console.time('render');
var dataList = data.data.list;
var len = dataList.length;
var ulContainer = document.getElementById('expList');
var str = '';
var frag = document.createElement('div');
var tpl = __inline('content.tmpl');
for (var i = 0; i < len; i++) {
str = tpl({dataList: dataList});
}
frag.innerHTML = str;
ulContainer.appendChild(frag);
// 更新緩存
updateItemCache(frag);
// 已經拉去完畢,設置標識爲true
fetching = false;
// 強制觸發
handleScroll(null, true);
page++;
console.timeEnd('render');
}, function (xhr, type) {
console.log('Refresh:Ajax Error!');
});
}複製代碼
以前參數裏提到過,一共有兩個用於緩存的對象/數組:
1)slideCache:緩存最近一次加載過的數據生成的DOM內容,緩存方式爲數組儲存:
slideCache = [
{
id: "s-97r45",
img: img DOM節點,
node: 父容器DOM node,相似<div id="s-<#=v.eid#>" class="slide"></div>,
src: 圖片資源地址
},
...
]複製代碼
slideCache由updateItemCache函數更新,主要用於懶加載時的賦值src。這樣咱們作到「只寫入DOM」原則,不須要再從DOM讀取。
2)slideMap:緩存DOM節點的高度和offsetTop,以DOM節點的id爲索引。存儲方式:
slideMap = {
s-97r45: {
node: DOM node,相似<div id="s-<#=v.eid#>" class="slide"></div>,
offTop: 300,
offsetHeight: 90
}
}複製代碼
slideMap根據isVisible方法的參數進行更新和讀取。使得咱們在判斷是否isVisible時,大量減小讀取DOM的操做。
在上面的滾動處理程序中,咱們調用了handleDefer函數。咱們看一下這個函數的實現:
function handleDefer () {
// 時間記錄
console.time('defer');
// 獲取dom緩存
var list = slideCache;
// 對於遍歷list裏的每一項,都使用一個變量,而不是在循環內部聲明。節省內存,把性能高效,作到極致。
var thisImg;
for (var i = 0, len = list.length; i < len; i++) {
thisImg = list[i].img; // 這裏咱們都是從內存中讀取,而不用讀取DOM節點
var deferSrc = list[i].src; // 這裏咱們都是從內存中讀取,而不用讀取DOM節點
// 判斷元素是否可見
if (isVisible(list[i].id)) {
// 這個函數是圖片onload邏輯
var handler = function () {
var node = thisImg;
var src = deferSrc;
// 建立一個閉包
return function () {
node.src = src;
node.style.opacity = 1;
}
}
var img = new Image();
img.onload = handler();
img.src = list[i].src;
}
}
console.timeEnd('defer');
}複製代碼
主要思路就是對DOM緩存中的每一項進行循環遍歷。在循環中,判斷每一項是否已經進入isVisible區域。若是進入isVisible區域,則對當前項進行真實src賦值,並設置opacity爲1。
針對每個slide類,咱們緩存對應DOM節、id、子元素img DOM節點:
function updateItemCache (node) {
var list = node.querySelectorAll('.slide');
var len = list.length;
slideCache = [];
var obj;
for (var i=0; i < len; i++) {
obj = {
node: list[i],
id: list[i].getAttribute('id'),
img: list[i].querySelector('.img')
}
obj.src = obj.img.getAttribute('data-src');
slideCache.push(obj);
};
}複製代碼
該函數接受相應DOM id,並進行判斷。
若是判斷條件晦澀難懂的話,你必定要手動畫畫圖理解一下。若是你就是懶得畫圖,那麼也不要緊,我幫你畫好了,只是醜一些。。。
function isVisible (id) {
var offTop;
var offsetHeight;
var data;
var node;
// 判斷此元素是否已經懶加載正確渲染,分爲在屏幕之上(已經懶加載完畢)和屏幕外,已經添加到dom中,可是還未請求圖片(懶加載以前)
if (itemMap[id]) {
// 直接獲取offTop,offsetHeight值
offTop = itemMap[id].offTop;
offsetHeight = itemMap[id].offsetHeight;
}
else {
// 設置該節點,而且設置節點屬性:node,offTop,offsetHeight
node = document.getElementById(id);
// offsetHeight是自身元素的高度
offsetHeight = parseInt(node.offsetHeight);
// 元素的上外緣距離最近採用定位父元素內壁的距離
offTop = parseInt(node.offsetTop);
}
if (offTop + offsetHeight > topViewPort && offTop < bottomViewPort) {
return true;
}
else {
return false;
}
}複製代碼
如上代碼,咱們主要進行了兩方面的性能考量:
1)延遲加載時間
2)渲染DOM時間
總體收益以下:
優化前延遲平均值:49.2ms 中間值:43ms;
優化後延遲平均值:17.1ms 中間值:11ms;
優化前渲染平均值:2129.6ms 中間值:2153.5ms;
優化後渲染平均值:120.5ms 中間值:86ms;
作完這些,其實也遠遠沒有達到所謂的「極致化」性能體驗。咱們無非就作了各類DOM緩存、映射、懶加載。若是繼續分析edge case,咱們還能作的更多,好比:DOM回收、墓碑和滾動錨定。這些其實不少都是借鑑客戶端開發理念,可是超前的谷歌開發者團隊也都有了本身的實現。好比在去年7月份的
一篇文章:Complexities of an Infinite Scroller就都有所說起。這裏從原理(非代碼)層面,也給你們作個介紹。
它的原理是,對於須要產生的大量DOM節點(好比咱們下拉加載的信息內容)不是主動用createElement的方式建立,而是回收利用那些已經移出視窗,暫時不會被須要的DOM節點。如圖:
雖然DOM節點自己並不是耗能大戶,可是也不是一點都不消耗性能,每個節點都會增長一些額外的內存、佈局、樣式和繪製。一樣須要注意的一點是,在一個較大的DOM中每一次從新佈局或從新應用樣式(在節點上增長或刪除樣式所觸發的過程)的系統開銷都會比較昂貴。因此進行DOM回收意味着咱們會保持DOM節點在一個比較低的數量上,進而加快上面提到的這些處理過程。
據我觀察,在真正產品線上使用這項技術的還比較少。多是由於實現複雜度和收益比並不很高。可是,淘寶移動端檢索頁面實現了相似的思想。以下圖,
每加載一次數據,就生成「.page-container .J-PageContainer_頁數」的div,在滾動多屏以後,早已移除視窗的div的子節點進行了remove(),而且爲了保證滾動條的正確比例和防止高度塌陷,顯示聲明瞭2956px的高度。
如以前所說,若是網絡延遲較大,用戶又飛快地滾動,很容易就把咱們渲染的DOM節點都甩在千里以外。這樣就會出現極差的用戶體驗。針對這種狀況,咱們就須要一個墓碑條目佔位在對應位置。等到數據取到以後,再代替墓碑。墓碑也能夠有一個獨立的DOM元素池。而且也能夠設計出一些漂亮的過渡。這種技術在國外的一些「引領技術潮流」的網站上,早已經有了應有。好比下圖取自Facebook:
我在「簡書」APP客戶端上,也見過相似的方案。固然,人家是native...
滾動錨定的觸發時機有兩個:一個是墓碑被替換時,另外一個是窗口大小發生改變時(在設備發生翻轉時也會發生)。這兩種狀況,都須要調整對應的滾動位置。
當你想提供一個高性能的有良好用戶體驗的功能時,可能技術上一個簡單的問題,就會演變成複雜問題的。這篇文章即是一個例證。
隨着 「Progressive Web Apps」 逐漸成爲移動設備的一等公民(會嗎?),高性能的良好體驗會變得愈來愈重要。
開發者也必須持續的研究使用一些模式來應對性能約束。這些設計的基礎固然都是成熟的技術爲根本。
這篇文章參考了Flicker工程師,前YAHOO工程師Stephen Woods的《Building Touch Interfaces with HTML5》一書。以及王芃前輩對於《Complexities of an Infinite Scroller》一文的部分翻譯。