一步步創(chuàng)建一個(gè)Vue超長列表組件

文章以及代碼存放于Github涎跨。

今天所實(shí)現(xiàn)的組件我稱為“超長列表”洼冻,列表是當(dāng)前互聯(lián)網(wǎng)產(chǎn)品中常見的組織/展現(xiàn)數(shù)據(jù)的一種形式,隨著數(shù)據(jù)量不斷變得龐大隅很,我們會對數(shù)據(jù)進(jìn)行分頁撞牢,但是目前龐大的數(shù)據(jù)以及愈加豐富的內(nèi)容,讓我們的設(shè)備在維持大量數(shù)據(jù)時(shí),性能上的瓶頸漸漸顯示出來普泡,我們的網(wǎng)頁在滑動時(shí)可能會出現(xiàn)卡頓播掷,這是這個(gè)組件所需要處理的問題。

在iOS開發(fā)中有名為UITableView的組件撼班,在android開發(fā)中有被稱為ListView的組件歧匈,它們通過銷毀不可見區(qū)域的元素,來達(dá)到性能優(yōu)化的目的砰嘁,我們在組件當(dāng)中也采用這樣的邏輯件炉,即便列表中有10000個(gè)元素,當(dāng)屏幕可視區(qū)域中可能只有5個(gè)元素矮湘,那么我們只顯示5個(gè)元素斟冕,這將大大減少我們的網(wǎng)頁對于硬件資源的消耗。

下面是常見的列表渲染形式缅阳,<PostCard>就是我們所需要的展現(xiàn)的列表元素磕蛇。

<div class="large-list">
  <PostCard v-for="(post, index) in list" :key="post.id" :post="post"></PostCard>
</div>
export default {
  name: 'LargeList',
  props: {
    list: {
      type: Array,
      default() {
        return [];
      },
    },
  },
};

先來考慮最簡單的情況,假設(shè)所有的<PostCard>的高度都是100十办,來實(shí)現(xiàn)上面所說的效果秀撇。list數(shù)組依然包含所有的數(shù)據(jù),但我們需要另外一個(gè)數(shù)組向族,決定需要展示哪些數(shù)據(jù)呵燕。

{
  // ...
  data() {
    return {
      startIndex: 0,
      endIndex: 0,
      // 容器高度信息
      containerHeight: 0,
    };
  },
  computed: {
    /**
     * 展示列表
     */
    displayList() {
      return this.list.slice(this.startIndex, this.endIndex);
    },
  }
  // ...
}
<PostCard v-for="(post, index) in displayList" :key="post.id" :post="post"></PostCard>

現(xiàn)在我們要想辦法確定startIndexendIndexstartIndex是可視列表(displayList)中的第一個(gè)元素的下標(biāo)件相,endIndex是最后一個(gè)元素的下標(biāo)+1再扭,在固定高度的情況下,startIndexendIndex的計(jì)算十分簡單

// 首先我們?yōu)長argeList添加scroll事件的監(jiān)聽
{
  // ...
  created() {
    window.addEventListener('scroll', this.scrollCallback);
  },
  beforeDestroy() {
    window.removeEventListener('scroll', this.scrollCallback);
  },
  // ...
}
// 添加scroll處理函數(shù) scrollCallback
{
  // ...
  methods: {
    scrollCallback() {
      this.startIndex = Math.floor(window.scrollY / 100);
      this.endIndex = Math.floor((window.scrollY + window.innerHeight) / 100) + 1;
    },
  },
  // ...
}

到此為止夜矗,已經(jīng)完成了一個(gè)最簡單的邏輯泛范,但是還沒有完,還需要對元素的樣式進(jìn)行一些適當(dāng)?shù)难a(bǔ)充

<div class="large-list" :style="{height: containerHeight + 'px'}">
  <PostCard v-for="(post, index) in displayList" :key="post.id" :style="{top: metaMap[post.id].top + 'px'}"></PostCard>
</div>
// 存儲每個(gè)PostCard的一些樣式數(shù)據(jù)
{
  // ...
  data() {
    return {
      metaMap: {},
    };
  },
  // ...
  crerated() {
    for (let i = 0, len = this.list.length; i < len; i++) {
      Vue.set(this.metaMap, post.id, {
        top: i * 100,
        height: 100,
      });
    }
  },
}

通用化:允許子元素高度變化

<PostCard>的高度可能在不同情況下顯示高度不同紊撕,甚至在瀏覽過程中罢荡,可能實(shí)時(shí)地發(fā)生變化,組件應(yīng)該做好子元素的高度會發(fā)生變化的準(zhǔn)備逛揩。

當(dāng)子元素的高度發(fā)生變化時(shí)柠傍,應(yīng)該做什么?元素高度發(fā)生變化辩稽,其他元素的位置可能需要發(fā)生相應(yīng)的修改惧笛,但是只需要更新其他可視元素的數(shù)據(jù)即可。

{
  // ...
  methods: {
    /**
     * 子元素高度發(fā)生變化時(shí)的處理函數(shù)
     */
    onHeightChange(height, id) {
      // 更新容器的高度數(shù)據(jù)
      this.containerHeight += height - this.metaMap[id].height;
      // 更新元素的高度數(shù)據(jù)
      this.metaMap[id].height = height;
      // 更新 __高度發(fā)生變化的元素__ 之后的 __其他可視元素__ 的top數(shù)據(jù)
      const pos = this.displayList.map(post => post.id).indexOf(id) + 1;
      this.displayList.slice(pos).forEach((post, index) => {
        const prevPost = this.displayList[index - 1];
        this.metaMap[id].top = this.metaMap[prevPost.id].top + height;
      })
    },
  },
  // ...
}

當(dāng)元素的高度不再固定時(shí)逞泄,startIndexendIndex就不能那么輕松地計(jì)算出來患整,需要在整個(gè)list中尋找需要顯示的列表內(nèi)容拜效,二分查找是個(gè)不錯(cuò)的選擇。(二分查找并非本文重點(diǎn)各谚,這里不再列出)

{
  // ...
  methods: {
    scrollCallback() {
      this.startIndex = this.binarySearch(window.scrollY);
      this.endIndex = this.binarySearch(window.scrollY + window.innerHeight) + 1;
    },
  },
  // ...
}

通用化:允許子元素是任意組件

之前將<PostCard>組件直接在<LargeList>組件中注冊紧憾,為了讓<LargeList>適用于各種場景,子元素是什么樣的昌渤,應(yīng)該有<LargeList>的父元素決定

<LargeList :list="list" @display-change="onDisplayChange">
  <PostCard v-for="(post, index) in displayList" :key="post.id" :post="post"></PostCard>
</LargeList>
export default {
  data() {
    return {
      startIndex: 0,
      endIndex: 0,
    };
  },
  computed: {
    displayList() {
      return this.list.slice(this.startIndex, this.endIndex);
    },
  },
  methods: {
    onDisplayChange(startIndex, endIndex) {
      this.startIndex = startIndex;
      this.endIndex = endIndex;
    },
  },
}

自然我們更新LargeList中關(guān)于scroll的處理

{
  methods: {
    scrollCallback() {
      startIndex = this.binarySearch(window.scrollY);
      endIndex = this.binarySearch(window.scrollY + window.innerHeight) + 1;
      // 使用display-change的形式來通知外部組件更新數(shù)據(jù)
      this.$emit('display-change', startIndex, endIndex);
    },
  },
}

使用slot的方式赴穗,需要解決一些問題:

  1. 子元素樣式改變,例如top位置的改變
  2. 子元素的height-change事件監(jiān)聽

這是使用模板(template字段)所無法做到的事情膀息,需要使用更加靈活的render函數(shù)來實(shí)現(xiàn)般眉。

ps: 這里實(shí)現(xiàn)的render函數(shù)使用了官方文檔中沒有的內(nèi)容,僅供參考潜支。

{
  // ...
  render(h) {
    const displayList = this.$slots.default || [];
    displayList.forEach((vnode) => {
      /** 組件實(shí)例 */
      const instance = vnode.componentInstance;
      /** 組件配置 */
      const options = vnode.componentOptions;
      // tip: 依賴于未公開的instance._events甸赃,并不是一件好事
      // 如果組件已經(jīng)實(shí)例化,并且沒有監(jiān)聽heightChange事件
      if (instance && !instance._events.heightChange) {
        instance.$on('heightChange', this.onHeightChange);
      } else if (options) {
        // 
        if (options.listeners) {
          options.listeners.heightChange = this.onHeightChange;
        } else {
          options.listeners = {
            heightChange: this.onHeightChange,
          };
        }
      } else if (!instance) {
        // 組件尚未實(shí)例化冗酿,還可以通過修改虛擬節(jié)點(diǎn)的數(shù)據(jù)的形式埠对,來實(shí)施監(jiān)聽
        if (vnode.data) {
          if (vnode.data.on) {
            vnode.data.on.heightChange = this.onHeightChange;
          } else {
            vnode.data.on = {
              heightChange: this.onHeightChange,
            };
          }
        }
      }
      // 沒有data的話丑罪,可能哪里存在問題
      if (vnode.data) {
        const style = vnode.data.style;
        // @ts-ignore
        const id = vnode.componentOptions!.propsData!.id;
        const top = this.topMap[id] + 'px';
        if (!style) {
          vnode.data.style = {
            top,
          };
        } else if (typeof style === 'string') {
          vnode.data.style = style + `; top: ${top}`;
        } else if (isPlainObject(style)) {
          // @ts-ignore
          vnode.data.style.top = top;
        }
      }
    });

    return h('div', {
      class: 'large-list',
      style: {
          height: this.containerHeight + 'px',
      },
    }, displayList);
  },
  // ...
}

優(yōu)化: 完善細(xì)節(jié)表現(xiàn)

預(yù)先加載部分子元素

目前的邏輯是:當(dāng)子元素進(jìn)入可視區(qū)域內(nèi)蚁趁,再開始渲染元素软啼。這種邏輯下匾鸥,假如設(shè)備性能不佳,用戶可能會有子元素“突然出現(xiàn)”的感覺精拟。為了減輕這個(gè)問題的影響,在滑動過程中,不論向上還是向下滑動裕循,都需要多渲染幾個(gè)元素,通過修改scrollCallback函數(shù)的邏輯能夠很方便地實(shí)現(xiàn)這個(gè)功能净刮。

{
  // ...
  props: {
    // 需要預(yù)先加載的高度
    preloadHeight: {
      type: Number,
      default: 100,
    },
  },
  methods: {
    scrollCallback() {
      const top = window.scrollY - this.preloadHeight;
      const bottom = window.scrollY + window.innerHeight + this.preloadHeight;
      this.startIndex = top < 0 ? 0 : this.binarySearch(top);
      this.endIndex = bottom < 0 ? 0 : this.binarySearch(bottom) + 1;
    },
  },
  // ...
}

解決metaMap中數(shù)據(jù)丟失的問題

組件中的子元素顯示位置全依賴于metaMap中的數(shù)據(jù)剥哑,當(dāng)用戶離開有<LargeList>的頁面,<LargeList>被銷毀時(shí)metaMap中也就丟失了淹父,當(dāng)用戶再次回來時(shí)株婴,<LargeList>遇到的第一個(gè)問題:需要額外消耗性能來重新處理子元素的高度變化。更嚴(yán)重的問題是:一般返回上一頁時(shí)暑认,會將頁面滾動區(qū)域固定在離開時(shí)的位置困介,此時(shí)因?yàn)闆]有原本的metaMap數(shù)據(jù),所以渲染的結(jié)果與用戶離開時(shí)所看到的內(nèi)容可能不符蘸际。所能想到解決問題最簡單的做法就是:當(dāng)用戶離開頁面將metaMap保存下來座哩。

<LargeList>對外提供兩個(gè)prop: persistenceload,分別接收一個(gè)函數(shù)粮彤,用于存儲數(shù)據(jù)和加載數(shù)據(jù)根穷,具體數(shù)據(jù)存儲和加載的方式姜骡,將由外層組件決定,這提供更好的靈活性屿良。

{
  // ...
  props: {
    /** 持久化 */
    persistence: {},
    /** 加載數(shù)據(jù) */
    load: {},
  },
  created() {
    let data = null;
    if (this.load && (data = this.load())) {
      // 如果存在持久化數(shù)據(jù)情況下
      this.metaMap = data.metaMap;
      this.containerHeight = data.containerHeight;
      this.startIndex = data.startIndex;
      this.endIndex = data.endIndex;
    } else {
      // 如果不存在持久化數(shù)據(jù)
      // 向metaMap中加入數(shù)據(jù)
      let containerHeight = 0;
      for (let i = 0, len = this.idList.length; i < len; i++) {
        const id = '' + this.idList[i];
        const height = this.defaultItemHeight + this.defaultItemGap;
        Vue.set(this.metaMap, id, {
          top: i * height,
          height,
        });
        containerHeight += height;
      }
      this.containerHeight = containerHeight;
    }
  },
  beforeDestroy() {
    // 完成持久化過程
    if (this.persistence) {
      this.persistence({
        metaMap: this.metaMap,
        startIndex: this.startIndex,
        endIndex: this.endIndex,
        containerHeight: this.containerHeight,
      });
    }
  },
  // ...
}
?著作權(quán)歸作者所有,轉(zhuǎn)載或內(nèi)容合作請聯(lián)系作者
  • 序言:七十年代末圈澈,一起剝皮案震驚了整個(gè)濱河市,隨后出現(xiàn)的幾起案子尘惧,更是在濱河造成了極大的恐慌康栈,老刑警劉巖,帶你破解...
    沈念sama閱讀 219,366評論 6 508
  • 序言:濱河連續(xù)發(fā)生了三起死亡事件喷橙,死亡現(xiàn)場離奇詭異谅将,居然都是意外死亡,警方通過查閱死者的電腦和手機(jī)重慢,發(fā)現(xiàn)死者居然都...
    沈念sama閱讀 93,521評論 3 395
  • 文/潘曉璐 我一進(jìn)店門饥臂,熙熙樓的掌柜王于貴愁眉苦臉地迎上來,“玉大人似踱,你說我怎么就攤上這事隅熙。” “怎么了核芽?”我有些...
    開封第一講書人閱讀 165,689評論 0 356
  • 文/不壞的土叔 我叫張陵囚戚,是天一觀的道長。 經(jīng)常有香客問我轧简,道長驰坊,這世上最難降的妖魔是什么? 我笑而不...
    開封第一講書人閱讀 58,925評論 1 295
  • 正文 為了忘掉前任哮独,我火速辦了婚禮拳芙,結(jié)果婚禮上,老公的妹妹穿的比我還像新娘皮璧。我一直安慰自己舟扎,他們只是感情好,可當(dāng)我...
    茶點(diǎn)故事閱讀 67,942評論 6 392
  • 文/花漫 我一把揭開白布悴务。 她就那樣靜靜地躺著睹限,像睡著了一般。 火紅的嫁衣襯著肌膚如雪讯檐。 梳的紋絲不亂的頭發(fā)上羡疗,一...
    開封第一講書人閱讀 51,727評論 1 305
  • 那天,我揣著相機(jī)與錄音别洪,去河邊找鬼叨恨。 笑死,一個(gè)胖子當(dāng)著我的面吹牛蕉拢,可吹牛的內(nèi)容都是我干的特碳。 我是一名探鬼主播诚亚,決...
    沈念sama閱讀 40,447評論 3 420
  • 文/蒼蘭香墨 我猛地睜開眼,長吁一口氣:“原來是場噩夢啊……” “哼午乓!你這毒婦竟也來了站宗?” 一聲冷哼從身側(cè)響起,我...
    開封第一講書人閱讀 39,349評論 0 276
  • 序言:老撾萬榮一對情侶失蹤益愈,失蹤者是張志新(化名)和其女友劉穎梢灭,沒想到半個(gè)月后,有當(dāng)?shù)厝嗽跇淞掷锇l(fā)現(xiàn)了一具尸體蒸其,經(jīng)...
    沈念sama閱讀 45,820評論 1 317
  • 正文 獨(dú)居荒郊野嶺守林人離奇死亡敏释,尸身上長有42處帶血的膿包…… 初始之章·張勛 以下內(nèi)容為張勛視角 年9月15日...
    茶點(diǎn)故事閱讀 37,990評論 3 337
  • 正文 我和宋清朗相戀三年,在試婚紗的時(shí)候發(fā)現(xiàn)自己被綠了摸袁。 大學(xué)時(shí)的朋友給我發(fā)了我未婚夫和他白月光在一起吃飯的照片钥顽。...
    茶點(diǎn)故事閱讀 40,127評論 1 351
  • 序言:一個(gè)原本活蹦亂跳的男人離奇死亡,死狀恐怖靠汁,靈堂內(nèi)的尸體忽然破棺而出蜂大,到底是詐尸還是另有隱情,我是刑警寧澤蝶怔,帶...
    沈念sama閱讀 35,812評論 5 346
  • 正文 年R本政府宣布奶浦,位于F島的核電站,受9級特大地震影響踢星,放射性物質(zhì)發(fā)生泄漏澳叉。R本人自食惡果不足惜,卻給世界環(huán)境...
    茶點(diǎn)故事閱讀 41,471評論 3 331
  • 文/蒙蒙 一沐悦、第九天 我趴在偏房一處隱蔽的房頂上張望成洗。 院中可真熱鬧,春花似錦所踊、人聲如沸泌枪。這莊子的主人今日做“春日...
    開封第一講書人閱讀 32,017評論 0 22
  • 文/蒼蘭香墨 我抬頭看了看天上的太陽。三九已至误证,卻和暖如春继薛,著一層夾襖步出監(jiān)牢的瞬間,已是汗流浹背愈捅。 一陣腳步聲響...
    開封第一講書人閱讀 33,142評論 1 272
  • 我被黑心中介騙來泰國打工遏考, 沒想到剛下飛機(jī)就差點(diǎn)兒被人妖公主榨干…… 1. 我叫王不留,地道東北人蓝谨。 一個(gè)月前我還...
    沈念sama閱讀 48,388評論 3 373
  • 正文 我出身青樓灌具,卻偏偏與公主長得像青团,于是被迫代替她去往敵國和親。 傳聞我的和親對象是個(gè)殘疾皇子咖楣,可洞房花燭夜當(dāng)晚...
    茶點(diǎn)故事閱讀 45,066評論 2 355

推薦閱讀更多精彩內(nèi)容