文章以及代碼存放于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)在我們要想辦法確定startIndex
和endIndex
,startIndex
是可視列表(displayList)中的第一個(gè)元素的下標(biāo)件相,endIndex是最后一個(gè)元素的下標(biāo)+1再扭,在固定高度的情況下,startIndex
和endIndex
的計(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í)逞泄,startIndex
和endIndex
就不能那么輕松地計(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的方式赴穗,需要解決一些問題:
- 子元素樣式改變,例如top位置的改變
- 子元素的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: persistence
和load
,分別接收一個(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,
});
}
},
// ...
}