聊聊前端開發中的長列表

前端的業務開發中會遇到一些無法使用分頁方式來載入的列表,我們一般把這種列表叫做長列表。在本篇文章中,我們把長列表定義成數據長度大於 1000 條,並且不能使用分頁的形式來展示的列表。

本篇文章探討了以下幾個話題:

  1. 完整渲染的長列表是否有優化的可能?優化的極限在什麼位置?

  2. 如果使用非完整的渲染長列表,有哪些方案以及具體的實現思路。

本篇文章的內容和具體框架無關,只是在部分例子中使用了 Vue 來實現。

完整渲染長列表

如果長列表不去做任何優化,一次完整渲染出來,到底需要多長時間呢?那麼首先要先了解創建所有的 HTMLElement 並添加到 Document 中的時間消耗,因為業務中會混雜一些其他的代碼,你的業務的性能不會比這個時間快。對瀏覽器創建元素的性能有大概的了解,才能知道長列表的優化極限在哪裡。

我們可以寫一個簡單的方法來測試這個性能:

var createElements = function(count) {n var start = new Date();n n for (var i = 0; i < count; i++) {n var element = document.createElement(div);n element.appendChild(document.createTextNode( + i));n document.body.appendChild(element);n }n n setTimeout(function() {n alert(new Date() - start);n }, 0);n};n

我們給一個 Button 綁定了一個 onclick 事件,這個事件調用了 createElements(10000);。 從 Chrome 的 Profile 標籤頁看到的數據如下:

Event Click 只執行了 20.20ms,其他時間合計是 450ms,具體如下:

  • Event Click: 20.20ms

  • Recalculage Style: 16.86ms

  • Layout: 410.6ms

  • Update Layer Tree: 11.93ms

  • Paint: 9.2ms

檢測渲染時間的方法

你可能注意到了上面的測試代碼中的時間計算過程中並沒有直接在調用完 API 之後直接計算時間,而是使用了一個 setTimeout,下面會進行一些解釋。

最簡單的計算一段代碼執行的時間可以這麼寫:

var start = Date.now();nn// ...nnalert(Date.now() - start);n

但是對於 DOM 的性能測試這麼做是不科學的,因為 DOM 的操作會引起瀏覽器的 (reflow)[What is DOM reflow?],如果瀏覽器的 reflow 執行的時間遠大於代碼執行時間,會造成你時間計算完成之後,瀏覽器仍然在卡頓。統計的時間應該是從『開始創建元素』到『可以進行響應』的時間,所以一個合理的做法是把計算放在 setTimeout(function() {}, 0) 中。setTimeout() 中的 callback 會被推遲到瀏覽器主線程 reflow 結束後才執行,這個時間和 Chrome Devtools 下的 Profile 的時間基本吻合,可以信任這個時間作為渲染時間。

修改後的代碼如下:

var start = Date.now();nn// ...nsetTimeout(function() {n alert(Date.now() - start);n}, 0);n

如果需要更高的精度,可以使用 performance.now() 來替換 Date.now(),這個 API 可以精確到千分之一毫秒。

嘗試使用不同的 DOM API

在前幾年,優化元素創建性能經常提到的是使用 createDocumentFragment、innerHTML 來替代 createElement,通過 "createElement vs createDocumentFragment" 能找到相當多測測試結果。這篇文章中甚至說 『using DocumentFragments to append about 2700 times faster than appending with innerHTML』,我們可以做個簡單的實驗,看看這個結論在 Google Chrome 中是否仍然適用。

我們會分別測試以下 4 種情況:

  1. 創建一個空元素,並立即添加到 document 中。

  2. 創建一個包含文本的元素,並立即添加到 document 中。

  3. 創建一個 DocumentFragment,用來保存列表項,最後再把 DocumentFragment 添加到 document 中。

  4. 拼出所有列表項的 HTML,使用元素的 innerHTML 屬性賦值。

其中進行第一個測試的原因如下:使用空元素和帶文本節點的元素,性能相差有 5 倍左右。

創建空元素的方法如下:

var createEmptyElements = function(count) {n var start = new Date();n n for (var i = 0; i < count; i++) {n var element = document.createElement(div);n document.body.appendChild(element);n }n n setTimeout(function() {n alert(new Date() - start);n }, 0);n};n

創建帶文本元素的方法如下:

var createElements = function(count) {n var start = new Date();n n for (var i = 0; i < count; i++) {n var element = document.createElement(div);n element.appendChild(document.createTextNode( + i));n document.body.appendChild(element);n }n n setTimeout(function() {n alert(new Date() - start);n }, 0);n};n

使用 DocumentFragment 的方法如下:

var createElementsWithFragment = function(count) {n var start = new Date();n var fragment = document.createDocumentFragment();n n for (var i = 0; i < count; i++) {n var element = document.createElement(div);n element.appendChild(document.createTextNode( + i));n fragment.appendChild(element);n }n n document.body.appendChild(fragment);n n setTimeout(function() {n alert(new Date() - start);n }, 0);n};n

使用 innerHTML 的方法如下:

var createElementsWithHTML = function(count) {n var start = new Date();n var array = [];n n for (var i = 0; i < count; i++) {n array.push(<div> + i + </div>);n }n n var element = document.createElement(div);n element.innerHTML = array.join();n document.body.appendChild(element);n n setTimeout(function() {n alert(new Date() - start);n }, 0);n};n

數據統計

測試代碼的計算的時間每次執行都會有一些誤差,表格中的數據使用的是進行 10 次測試的平均值:

從結果上來看,只有 innerHTML 會有 10% 的性能優勢,createElement 和 createDocumentFragment 性能基本持平。對於現代瀏覽器來講,性能瓶頸根本不在調用 DOM API 的階段,無論使用哪種方式來使用 DOM API 添加元素,對性能的影響都微乎其微。

非完整渲染長列表

從上面的測試結果中可以看到,創建 10000 個節點就需要 500ms+,實際業務中的列表每個節點都需要 20 個左右的節點。那麼,500ms 也僅能渲染 500 個左右的列表項。

所以完整渲染的長列表基本上很難達到業務上的要求的,非完整渲染的長列表一般有兩種方式:

  • 懶渲染:這個就是常見的無限滾動的,每次只渲染一部分(比如 10 條),等剩餘部分滾動到可見區域,就再渲染另一部分。

  • 可視區域渲染:只渲染可見部分,不可見部分不渲染。

懶渲染

懶渲染就是大家平常說的無限滾動,指的就是在滾動到頁面底部的時候,再去載入剩餘的數據。這是一種前後端共同優化的方式,後端一次載入比較少的數據可以節省流量,前端首次渲染更少的數據速度會更快。這種優化要求產品方必須接受這種形式的列表,否則就無法使用這種方式優化。

實現的思路非常簡單:監聽父元素的 scroll 事件(一般是 window),通過父元素的 scrollTop 判斷是否到了頁面是否到了頁面底部,如果到了頁面底部,就載入更多的數據。

本文使用 Vue 實現了一個簡單的例子,這個例子中的可滾動區域是在 window 上的,其中的核心代碼只有三行:

const maxScrollTop = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - window.innerHeight;nconst currentScrollTop = Math.max(document.documentElement.scrollTop, document.body.scrollTop);nnif (maxScrollTop - currentScrollTop < 20) {n //...n}n

你可以點擊 此處 查看在線 Demo,也可以通過完整的代碼本地運行:

<template>n <div class="lazy-list">n <div class="lazy-render-list-item" v-for="item in data">{{ item }}</div>n </div>n</template>nn<style>n .lazy-render-list {n border: 1px solid #666;n }nn .lazy-render-list-item {n padding: 5px;n color: #666;n height: 30px;n line-height: 30px;n box-sizing: border-box;n }n</style>nn<script>n export default {n name: lazy-render-list,nn data() {n const count = 40;n const data = [];nn for (let i = 0; i < count; i++) {n data.push(i);n }nn return {n count,n datan };n },nn mounted() {n window.onscroll = () => {n const maxScrollTop = Math.max(document.body.scrollHeight, document.documentElement.scrollHeight) - window.innerHeight;n const currentScrollTop = Math.max(document.documentElement.scrollTop, document.body.scrollTop);nn if (maxScrollTop - currentScrollTop < 20) {n const count = this.count;n for (let i = count; i < count + 40; i++) {n this.data.push(i);n }n this.count = count + 40;n }n };n }n };n</script>n

如果要應用在生產上,建議使用成熟的類庫,可以通過 "框架名 + infinite scroll"來進行搜索。

可視區域渲染

可視區域渲染指的是只渲染可視區域的列表項,非可見區域的完全不渲染,在滾動條滾動時動態更新列表項。可視區域渲染適合下面這種場景:

  • 每個數據的展現形式的高度需要一致(非必須,但是最小高度需要確定)。

  • 產品設計上,一次需要載入的數據量比較大「1000條以上」。

  • 產品設計上,滾動條需要掛載在一個固定高度的區域(在 window 上也可以,但是需要整個區域都只顯示這個列表)。

本文使用 Vue 實現了一個例子來說明這種類型的列表該如何實現,這個例子做了以下三個設定:

  • 列表的高度為 400px。

  • 列表中的每個元素的高度是 30px。

  • 一次載入 10000 條數據。

你可以點擊 此處 查看在線 Demo,也可以通過完整的代碼本地運行:

<template>n <div class="list-view" @scroll="handleScroll($event)">n <div class="list-view-phantom" :style="{ height: data.length * 30 + px }"></div>n <div v-el:content class="list-view-content">n <div class="list-view-item" v-for="item in visibleData">{{ item.value }}</div>n </div>n </div>n</template>nn<style>n .list-view {n height: 400px;n overflow: auto;n position: relative;n border: 1px solid #666;n }nn .list-view-phantom {n position: absolute;n left: 0;n top: 0;n right: 0;n z-index: -1;n }nn .list-view-content {n left: 0;n right: 0;n top: 0;n position: absolute;n }nn .list-view-item {n padding: 5px;n color: #666;n height: 30px;n line-height: 30px;n box-sizing: border-box;n }n</style>nn<script>n export default {n props: {n data: {n type: Arrayn },nn itemHeight: {n type: Number,n default: 30n }n },nn ready() {n this.visibleCount = Math.ceil(this.$el.clientHeight / this.itemHeight);n this.start = 0;n this.end = this.start + this.visibleCount;n this.visibleData = this.data.slice(this.start, this.end);n },nn data() {n return {n start: 0,n end: null,n visibleCount: null,n visibleData: [],n scrollTop: 0n };n },nn methods: {n handleScroll(event) {n const scrollTop = this.$el.scrollTop;n const fixedScrollTop = scrollTop - scrollTop % 30;n this.$els.content.style.webkitTransform = `translate3d(0, ${fixedScrollTop}px, 0)`;nn this.start = Math.floor(scrollTop / 30);n this.end = this.start + this.visibleCount;n this.visibleData = this.data.slice(this.start, this.end);n }n }n };n</script>n

例子代碼中的實現細節如下,可以參考這個說明來輔助理解這個例子:

  • 使用一個 phantom 元素來撐起整個這個列表,讓列表的滾動條出現。

  • 列表裡面使用變數 visibleData(Array 類型) 記錄目前需要顯示的所有數據。

  • 列表裡面使用變數 visibleCount 記錄可見區域最多顯示多少條數據。

  • 列表裡面使用變數 start、end 記錄可見區域數據的開始和結束索引。

  • 在滾動的時候,修改真實顯示區域的 transform: translate2d(0, y, 0)。

上面只是一個簡單的例子,如果要用在生產上,你可以建議使用 Clusterize 或者 React Virtualized。

你可能會發現無限滾動在移動端很常見,但是可見區域渲染並不常見,這個主要是因為 iOS 上 UIWebView 的 onscroll 事件並不能實時觸發。筆者曾嘗試過使用 iScroll 來實現類似可視區域渲染,雖然初次渲染慢的問題可以解決,但是會出現滾動時體驗不佳的問題(會有白屏時間)。

總結

本文通過一些測試數據來驗證了長列表的性能瓶頸,並通過例子講解了兩種非完整渲染的實現思路,希望能對你有所啟發。

推薦閱讀:

Vuejs 中使用 markdown
推薦閱讀-第9期
ThreeJS快速入門
HTML5網頁端如何調用手機瀏覽器分享功能?

TAG:前端开发 | 前端性能优化 |