摘要:論壇上有過這么一篇熱門文章,它從角度分析了無限滾動加載的設計實踐。無限滾動加載背后的技術挑戰其實比想象中要多不少。整體思路和方案設計我們要實現的頁面樣例如圖,它能夠做到無限下拉加載內容。
UX Planet論壇上有過這么一篇熱門文章: Infinite Scrolling Best Practices,它從UX角度分析了無限滾動加載的設計實踐。
無限滾動加載在互聯網上到處都有應用:
豆瓣首頁是一個,Facebook的Timeline是一個,Tweeter的話題列表也是一個。當你向下滾動,新的內容就神奇的“無中生有”了。這是一個得到廣泛贊揚的用戶體驗。
無限滾動加載背后的技術挑戰其實比想象中要多不少。尤其是要考慮頁面性能,需要做到極致。
本文通過代碼實例,來實現一個無限滾動加載效果。更重要的是,在實現過程中,對于頁面性能的分析和處理力圖做到最大化,希望對讀者有所啟發,同時也歡迎與我討論。
在開啟我們的代碼之前,有必要先了解一下常用的性能測量手段:
1)使用window.performance
HTML5帶來的performance API功能強大。我們可以使用其performance.now()精確計算程序執行時間。performance.now()與Date.now()不同的是,返回了以微秒(百萬分之一秒)為單位的時間,更加精準。并且與 Date.now() 會受系統程序執行阻塞的影響不同,performance.now() 的時間是以恒定速率遞增的,不受系統時間的影響(系統時間可被人為或軟件調整)。
同時,也可以使用performance.mark()標記各種時間戳(就像在地圖上打點),保存為各種測量值(測量地圖上的點之間的距離),便可以批量地分析這些數據了。
2)使用console.time方法與console.timeEnd方法
其中console.time方法用于標記開始時間,console.timeEnd方法用于標記結束時間,并且將結束時間與開始時間之間經過的毫秒數在控制臺中輸出。
3)使用專業的測量工具/平臺:jsPerf
這次實現中,我們使用第二種方法,因為它已經完全可以滿足我們的需求,且兼容性更加全面。
整體思路和方案設計我們要實現的頁面樣例如圖,
它能夠做到無限下拉加載內容。我把紅線標出的部分叫做一個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動畫。
具體功能封裝上和一些實現層面的東西,請您繼續閱讀。
整體結構如下:
主體內容放在id為“expListBox”的container里面,id為“expList”的ul是頁面加載內容的容器。
因為每次加載并append進入HTML的內容相對較多。我使用了模版來取代傳統的字符串拼接。前端模版這次選用了我的同事顏海鏡大神的開源作品,模版結構為:
<#dataList.forEach(function (v) {#> <#})#>
以上模版內容由每次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個函數。在上面的代碼中,注釋已經寫明白了每個方法的具體作用。接下來,我們逐個分析。
滾動處理程序handleScroll它接受兩個變量,第二個是一個布爾值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,類似, src: 圖片資源地址 }, ... ]
slideCache由updateItemCache函數更新,主要用于懶加載時的賦值src。這樣我們做到“只寫入DOM”原則,不需要再從DOM讀取。
2)slideMap:緩存DOM節點的高度和offsetTop,以DOM節點的id為索引。存儲方式:
slideMap = { s-97r45: { node: DOM node,類似, 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。
更新拉取數據生成的DOM緩存針對每一個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); }; }是否在isVisible區域判斷
該函數接受相應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的高度。
墓碑(Tombstones)如之前所說,如果網絡延遲較大,用戶又飛快地滾動,很容易就把我們渲染的DOM節點都甩在千里之外。這樣就會出現極差的用戶體驗。針對這種情況,我們就需要一個墓碑條目占位在對應位置。等到數據取到之后,再代替墓碑。墓碑也可以有一個獨立的DOM元素池。并且也可以設計出一些漂亮的過渡。這種技術在國外的一些“引領技術潮流”的網站上,早已經有了應有。比如下圖取自Facebook:
我在“簡書”APP客戶端上,也見過類似的方案。當然,人家是native...
滾動錨定滾動錨定的觸發時機有兩個:一個是墓碑被替換時,另一個是窗口大小發生改變時(在設備發生翻轉時也會發生)。這兩種情況,都需要調整對應的滾動位置。
總結當你想提供一個高性能的有良好用戶體驗的功能時,可能技術上一個簡單的問題,就會演變成復雜問題的。這篇文章便是一個例證。
隨著 “Progressive Web Apps” 逐漸成為移動設備的一等公民(會嗎?),高性能的良好體驗會變得越來越重要。
開發者也必須持續的研究使用一些模式來應對性能約束。這些設計的基礎當然都是成熟的技術為根本。
這篇文章參考了Flicker工程師,前YAHOO工程師Stephen Woods的《Building Touch Interfaces with HTML5》一書。以及王芃前輩對于《Complexities of an Infinite Scroller》一文的部分翻譯。
文章版權歸作者所有,未經允許請勿轉載,若此文章存在違規行為,您可以聯系管理員刪除。
轉載請注明本文地址:http://specialneedsforspecialkids.com/yun/81817.html
摘要:論壇上有過這么一篇熱門文章,它從角度分析了無限滾動加載的設計實踐。無限滾動加載背后的技術挑戰其實比想象中要多不少。整體思路和方案設計我們要實現的頁面樣例如圖,它能夠做到無限下拉加載內容。 UX Planet論壇上有過這么一篇熱門文章: Infinite Scrolling Best Practices,它從UX角度分析了無限滾動加載的設計實踐。 無限滾動加載在互聯網上到處都有應用:豆瓣...
摘要:大潮來襲前端開發能做些什么去年谷歌和火狐針對提出了的標準,顧名思義,即的體驗方式,我們可以戴著頭顯享受沉浸式的網頁,新的標準讓我們可以使用語言來開發。 VR 大潮來襲 --- 前端開發能做些什么 去年谷歌和火狐針對 WebVR 提出了 WebVR API 的標準,顧名思義,WebVR 即 web + VR 的體驗方式,我們可以戴著頭顯享受沉浸式的網頁,新的 API 標準讓我們可以使用 ...
摘要:使用移動設備查看頁面時會發現,在微信瀏覽器中有頂部導航欄有效解決圖片使用單位邊角缺失的問題前端掘金起因在移動端使用布局時圖片也需要用單位。移動端實踐前端掘金說起,相信大家并不陌生。 Sticky Footer,完美的絕對底部 - 前端 - 掘金寫在前面 做過網頁開發的同學想必都遇到過這樣尷尬的排版問題:在主體內容不足夠多或者未完全加載出來之前,就會導致出現(圖一)的這種情況,原因是因為...
閱讀 891·2021-10-13 09:39
閱讀 1480·2021-10-11 10:57
閱讀 2589·2019-08-26 13:53
閱讀 2537·2019-08-26 12:23
閱讀 3679·2019-08-23 18:30
閱讀 3744·2019-08-23 18:08
閱讀 2523·2019-08-23 18:04
閱讀 2958·2019-08-23 16:28