Skip to content

Commit

Permalink
feat(VirtualList): 虚拟列表
Browse files Browse the repository at this point in the history
  • Loading branch information
Tenny committed Sep 11, 2024
1 parent fc0da47 commit df5e027
Show file tree
Hide file tree
Showing 3 changed files with 84 additions and 65 deletions.
47 changes: 30 additions & 17 deletions docs/components/virtuallist.md
Original file line number Diff line number Diff line change
Expand Up @@ -4,48 +4,49 @@

对于这种列表数据,我们可以采用虚拟滚动来进行性能优化。

> 1. **现在暂时只支持固定高度的列表**
> 2. 以后可以考虑使用 `css3` 属性 [content-visibility](https://developer.mozilla.org/zh-CN/docs/Web/CSS/content-visibility)
## 演示

<script setup>
import { VirtualList } from "../../src"

const avatars = [
'https://07akioni.oss-cn-beijing.aliyuncs.com/07akioni.jpeg',
'https://avatars.githubusercontent.com/u/20943608?s=60&v=4',
'https://avatars.githubusercontent.com/u/46394163?s=60&v=4',
'https://avatars.githubusercontent.com/u/39197136?s=60&v=4',
'https://avatars.githubusercontent.com/u/19239641?s=60&v=4'
]

const items = Array.from({ length: 10000 }, (_, i) => ({
key: `${i}`,
id: `${i}`,
value: i,
avatar: avatars[i % avatars.length]
}))
</script>

### 基础用法

对于固定高度, 一次性渲染 `10w` 条数据。
对于固定高度, 一次性渲染 `1w` 条数据。`item-size` 表示每一行的高度

<ClientOnly>
<CodePreview>
<textarea lang="vue">
<script setup>
<textarea lang="vue" v-pre>
<script setup lang="ts">
const items = Array.from({ length: 10000 }, (_, i) => ({
id: `${i}`,
value: i,
}));
</script>
<template>
<nt-virtual-list :items="items" :item-size="42" key-field="id">
<template #default="{ item }">
<span>{{ item.value }}</span>
</template>
</nt-virtual-list>
</template>
</textarea>
<template #preview>
<div class="virtual-list-demo-container">
<VirtualList :items="items" :item-size="42">
<VirtualList :items="items" :item-size="42" key-field="id">
<template #default="{ item }">
<img class="avatar" :src="item.avatar" alt="" style="display:inline-block;width:30px;border-radius:50%" />
<span>{{ item.value }}</span>
</template>
</VirtualList>
</div>

</template>
</CodePreview>
</ClientOnly>
Expand All @@ -57,4 +58,16 @@
<!-- prettier-ignore -->
| 参数 | 说明 | 类型 | 默认值 |
| --- | --- | --- | --- |
| x | x | x | x |
| `items` | *必传*, 需要展示的数据 | `any[]` | - |
| `item-size` | *必传*, 表项的高度,用于计算滚动大小和位置 | `number` | - |
| `item-class` | 列表项的 `class` | `string` | - |
| `key-field` | 选项 `key` 的字段名, 用于 `v-for``key`, 不传则用 `index` | `string` | - |
| `buffer` | 冲区数量,列表会在上下多渲染额外的项 | `number` | `2` |

### VirtualList Slots

<!-- prettier-ignore -->
| 名称 | 说明 | 字段 |
| --- | --- | --- |
| `default` | 渲染列表项内容 | `item`: 列表项数据 |
| `render` | 渲染整个列表项, 需要手动遍历数据列表进行渲染 | `items`: 可现实列表数据 |
1 change: 0 additions & 1 deletion src/app_components/SourceCode.vue
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ onMounted(async () => {
const parser = new DOMParser();
const doc = parser.parseFromString(preCode, 'text/html');
const children = doc.body.children;
for (let i = 0, len = children.length; i < len; i++) {
if (children[i] != null) {
fragment.appendChild(children[i]);
Expand Down
101 changes: 54 additions & 47 deletions src/components/VirtualList.vue
Original file line number Diff line number Diff line change
@@ -1,24 +1,24 @@
<template>
<div class="nt-virtual-list" ref="$list" @scroll="handleListScroll">
<!-- 占位元素, 用于撑开滚动条,达到滚动效果 -->
<div class="nt-virtual-placeholder" ref="$placeholder"></div>
<!-- 内容元素, 用于显示列表项 -->
<div class="nt-virtual-content" ref="$itemContent">
<!-- 列表项 -->
<div
v-for="(item, index) in visibleData"
:style="{ height: props.itemSize + 'px' }"
:key="keyField != null ? (item as any)[keyField] : index"
:class="['nt-virtual-item', itemClass]"
>
<slot :item="item"></slot>
<div class="nt-virtual-list" ref="$list" @scroll.passive="handleScroll">
<div :style="{ height: `${totalSize}px` }">
<div :style="{ transform: `translate3d(0px, ${startOffset}px, 0px)` }">
<slot name="render" :data="visibleData">
<!-- 列表项 -->
<div
v-for="(item, index) in visibleData"
:style="{ height: props.itemSize + 'px' }"
:key="keyField != null ? (item as any)[keyField] : index"
:class="['nt-virtual-item', itemClass]"
>
<slot :item="item"></slot>
</div>
</slot>
</div>
</div>
</div>
</template>
<script setup lang="ts" generic="T">
import { onMounted, ref } from 'vue';
import { debounce } from 'ph-utils/web';
const props = withDefaults(
defineProps<{
Expand All @@ -29,58 +29,65 @@ const props = withDefaults(
itemClass?: string;
/** 选项 key 的字段名, 用于 v-for 的 key */
keyField?: string;
/** 缓冲区数量,列表会在上下多渲染额外的项 */
buffer?: number;
}>(),
{
itemClass: '',
buffer: 2,
},
);
/** 可视区域内能显示的数据总数 */
let visibleCount = 0;
/** 是否正在处理数据 */
let loading = false;
const $list = ref<HTMLDivElement>();
const $itemContent = ref<HTMLDivElement>();
const $placeholder = ref<HTMLDivElement>();
/** 实际显示的数据列表 */
const visibleData = ref<T[]>([]);
/** 容器总高度 */
const totalSize = ref(0);
/** 滑动便宜 */
const startOffset = ref(0);
/** 可视区域能够展示的最大元素个数 */
let numVisible = 0;
/** 滑动延迟处理,滑动完成后,延迟处理更新数据,避免频繁触发数据更新 */
let _t = -1;
function renderData() {
function updateVisibleItems() {
if ($list.value != null) {
// 计算可视区域数据的开始索引
const startIndex = Math.floor($list.value.scrollTop / props.itemSize);
if ($itemContent.value != null) {
// 开始项距离容器顶部的距离, 保证在滚动时数据一直在可视区域内
const top = `${startIndex * props.itemSize}px`;
$itemContent.value.style.top = top;
}
// 当前滚动位置
const scrollTop = $list.value.scrollTop;
/** 可视区域开始索引 */
let startIndex = Math.floor(scrollTop / props.itemSize);
/** 上缓冲区起始索引 */
let topStartIndex = Math.max(0, startIndex - props.buffer);
/** 下缓冲区结束索引 */
const endIndex = Math.min(
props.items.length,
startIndex + numVisible + props.buffer,
);
// 偏移量, 当滑动位置是某一项的一部分的时候,计算已经滚动的那一部分距离
let offset = scrollTop - (scrollTop % props.itemSize);
// 生成可视区域数据
visibleData.value = props.items.slice(
startIndex,
startIndex + visibleCount,
) as any[];
visibleData.value = props.items.slice(topStartIndex, endIndex) as any[];
/** 当前显示index-缓冲区的index就是缓冲区数量 */
startOffset.value = offset - (startIndex - topStartIndex) * props.itemSize;
}
}
function handleListScroll() {
debounce(() => {
if (loading) return;
loading = true;
renderData(); // 重新渲染数据
loading = false;
}, 150)();
function handleScroll() {
cancelAnimationFrame(_t);
_t = requestAnimationFrame(() => {
updateVisibleItems(); // 重新渲染数据
});
}
onMounted(() => {
totalSize.value = props.itemSize * props.items.length;
if ($list.value != null) {
visibleCount = Math.ceil($list.value.clientHeight / props.itemSize);
if ($placeholder.value != null) {
const height = props.itemSize * props.items.length;
$placeholder.value.style.height = `${height}px`;
}
renderData(); // 初始化渲染数据
let rect = $list.value.getBoundingClientRect();
numVisible = Math.ceil(rect.height / props.itemSize);
updateVisibleItems(); // 初始化渲染数据
}
});
</script>

0 comments on commit df5e027

Please sign in to comment.