設計無限滾動下拉載入,實踐高效能頁面真諦

LucasHC發表於2017-02-28

UX Planet論壇上有過這麼一篇熱門文章: Infinite Scrolling Best Practices,它從UX角度分析了無限滾動載入的設計實踐。

無限滾動載入在網際網路上到處都有應用:
豆瓣首頁是一個,Facebook的Timeline是一個,Tweeter的話題列表也是一個。當你向下滾動,新的內容就神奇的“無中生有”了。這是一個得到廣泛讚揚的使用者體驗。

無限滾動載入背後的技術挑戰其實比想象中要多不少。尤其是要考慮頁面效能,需要做到極致。
本文通過程式碼例項,來實現一個無限滾動載入效果。更重要的是,在實現過程中,對於頁面效能的分析和處理力圖做到最大化,希望對讀者有所啟發,同時也歡迎與我討論。

另外,這篇文章程式碼外的“繼續思考”部分引用借鑑了王芃前輩對於《Complexities of an Infinite Scroller》一文的翻譯,點選戳我檢視連結
在此,向原創作者深表敬意和謝意。

效能測量

在開啟我們的程式碼之前,有必要先了解一下常用的效能測量手段:

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

這次實現中,我們使用第二種方法,因為它已經完全可以滿足我們的需求,且相容性更加全面。

整體思路和方案設計

我們要實現的頁面樣例如圖,

設計無限滾動下拉載入,實踐高效能頁面真諦
ye mian

它能夠做到無限下拉載入內容。我把紅線標出的部分叫做一個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動畫。
具體功能封裝上和一些實現層面的東西,請您繼續閱讀。

程式碼實現

DOM結構

整體結構如下:

    <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();
    }());複製程式碼

我認為好的程式設計習慣是在程式開頭部分便宣告所有的變數,防止“變數提升”帶來的潛在困擾,並且也有利於程式的整體把控。
我們來看一下變數設定:

    // 載入中狀態鎖
    1var fetching = false;
    // 用於載入時傳送請求引數,表示第幾屏內容,初始為1,以後每請求一次,遞增1
    2var page = 1; 
    // 只快取最新一次下拉資料生成的DOM節點,即需要插入的dom快取陣列
    3var slideCache = []; 
    // 用於已經生成的DOM節點儲存,存有item的offsetTop,offsetHeight
    4) var slideMap = {}; 
    // pageYOffset設定或返回當前頁面相對於視窗顯示區左上角的Y位置。
    5var lastScrollY = window.pageYOffset; var scrollY = window.pageYOffset;
    // 瀏覽器視窗的視口(viewport)高度
    6var 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,類似<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。

更新拉取資料生成的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回收

它的原理是,對於需要產生的大量DOM節點(比如我們下拉載入的資訊內容)不是主動用createElement的方式建立,而是回收利用那些已經移出視窗,暫時不會被需要的DOM節點。如圖:

設計無限滾動下拉載入,實踐高效能頁面真諦
動圖(盜圖)

雖然DOM節點本身並非耗能大戶,但是也不是一點都不消耗效能,每一個節點都會增加一些額外的記憶體、佈局、樣式和繪製。同樣需要注意的一點是,在一個較大的DOM中每一次重新佈局或重新應用樣式(在節點上增加或刪除樣式所觸發的過程)的系統開銷都會比較昂貴。所以進行DOM回收意味著我們會保持DOM節點在一個比較低的數量上,進而加快上面提到的這些處理過程。

據我觀察,在真正產品線上使用這項技術的還比較少。可能是因為實現複雜度和收益比並不很高。但是,淘寶移動端檢索頁面實現了類似的思想。如下圖,

設計無限滾動下拉載入,實踐高效能頁面真諦
淘寶做法

每載入一次資料,就生成“.page-container .J-PageContainer_頁數”的div,在滾動多屏之後,早已移除視窗的div的子節點進行了remove(),並且為了保證滾動條的正確比例和防止高度塌陷,顯示宣告瞭2956px的高度。

墓碑(Tombstones)

如之前所說,如果網路延遲較大,使用者又飛快地滾動,很容易就把我們渲染的DOM節點都甩在千里之外。這樣就會出現極差的使用者體驗。針對這種情況,我們就需要一個墓碑條目佔位在對應位置。等到資料取到之後,再代替墓碑。墓碑也可以有一個獨立的DOM元素池。並且也可以設計出一些漂亮的過渡。這種技術在國外的一些“引領技術潮流”的網站上,早已經有了應有。比如下圖取自Facebook:

設計無限滾動下拉載入,實踐高效能頁面真諦
Facebook墓碑

我在“簡書”APP客戶端上,也見過類似的方案。當然,人家是native...

設計無限滾動下拉載入,實踐高效能頁面真諦
簡書客戶端

滾動錨定

滾動錨定的觸發時機有兩個:一個是墓碑被替換時,另一個是視窗大小發生改變時(在裝置發生翻轉時也會發生)。這兩種情況,都需要調整對應的滾動位置。

總結

當你想提供一個高效能的有良好使用者體驗的功能時,可能技術上一個簡單的問題,就會演變成複雜問題的。這篇文章便是一個例證。
隨著 “Progressive Web Apps” 逐漸成為移動裝置的一等公民(會嗎?),高效能的良好體驗會變得越來越重要。
開發者也必須持續的研究使用一些模式來應對效能約束。這些設計的基礎當然都是成熟的技術為根本。

這篇文章參考了Flicker工程師,前YAHOO工程師Stephen Woods的《Building Touch Interfaces with HTML5》一書。以及王芃前輩對於《Complexities of an Infinite Scroller》一文的部分翻譯。

相關文章