Vue结合webworker实现虚拟滚动

205 阅读2分钟

在大数据加载时,如果数据量过大,就会导致界面卡顿,导致用户流失。那就需要使用相关的处理方法,在开发中常用的方法就是利用虚拟滚动来处理界面的相关渲染。

在虚拟滚动时,只需要实现视图显示范围内的数据及时切换变化。

1.设计出相关的界面框架结构

<template>
    <div ref="container" class="container" @scroll="handleScroll">
        <div class="placeholder" :style="{ height: listHeight }"></div>
        <div class="list-wrapper" :style="{ transform: getTransform }">
            <div class="card-item" v-for="(item, index) in virtualData" ref="itemRefs" :key="index"
                :data-index="item.index">
                <span>{{ `${item.index}.${item.value}` }}</span>
            </div>
        </div>
    </div>
</template>
<style>
.container {
    height: 100%;
    overflow: auto;
    position: relative;
}

.placeholder {
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    z-index: -1;
}

.card-item {
    padding: 10px;
    color: #777;
    box-sizing: border-box;
    border-bottom: 1px solid #e1e1e1;
}
</style>
  • container:视图显示区域
  • placeholder:设为position:absolute,目的是设置滑动区域的高
  • list-wrapper:内容填充区域

2. 设置完相关的的区域后,计算出需要滑动区域的高

根据传过来的数组的长度,算出最后一个数组的bottom位置,立即滑动区域的高

const listHeight = computed(() => {
    const data = positions.value[positions.value.length - 1]?.bottom || 0
    return `${data}px`
})

3. 通过滑动计算出显示区域的开始位置和结束位置

3.1 采用二分法快速算出滑动的开始位置

const handleScroll = (e) => {
    const scrollTop = e.target.scrollTop
    start.value = getStart(scrollTop)
    offset.value = positions.value[start.value].top
}

const getStart = (scrollTop) => {
    let left = 0;
    let right = positions.value.length - 1;
    while (left <= right) {
        const mid = Math.floor((left + right) / 2);
        if (positions.value[mid].bottom === scrollTop) {
            return mid + 1;
        } else if (positions.value[mid].bottom > scrollTop) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return left;
}

3.2 在界面渲染后,获取滑动区域的高度

onMounted(() => {
    containerHeight.value = container.value.clientHeight
})

3.3 将滑动区域的高/item的高,加上开始位置,就是结束的位置

const renderCount = computed(() => {
    return Math.ceil(containerHeight.value / props.itemHeight)
})

const end = computed(() => {
    return start.value + renderCount.value
})

4. 最关键的一步了,就是在每一行的高不确定时,应该如何计算呢?

4.1 在计算量较大的时候,引入worker,多线程进行,计算相关的数据

mainWorker.js

// 初始化计算position的值
function initPosition(data,height){
    const positions = [];
    for (let i = 0; i < data.length; i++) {
        positions.push({
            index: i,
            height: height,
            top: height * i,
            bottom: height * (i + 1)
        })
    }
    return positions;
}


addEventListener('message', e => {
    const { data } = e
    const obj = JSON.parse(data)
    let postData = "";
    if (obj?.type === 'initPosition') {
        const positions = initPosition(obj.data, obj.height);
        postData = JSON.stringify({
            type: obj.type,
            data: positions
        })
    }
    return postMessage(postData);
})
const worker = ref(null)
worker.value = new Worker(new URL('../workers/mainWorker.js', import.meta.url))

const initPosition = () => {
    positions.value = []
    if (props.dataList.length > 0) {
        sendMessageToWorker({
            type: "initPosition",
            data: props.dataList,
            height: props.itemHeight
        }, (data) => {
            positions.value.push(...data.data)
        })
    }
}

4.2 传过来的数据偶尔还会有变化,利用watch监听数据变化

watch(() => props.dataList, () => {
    initPosition()
}, {
    immediate: true
})

5.每一个item的高不固定,在item渲染完成后,需要更新对应地方的bottom,更新内容的Y位置

5.1 使用onUpdate方法。onUpdate方法是在视图渲染完后成,就会自动执行。 onUpdate方法

image.png

const updatePosition = () => {
    if (positions.value.length === 0) {
        return
    }
    itemRefs.value.forEach((el) => {
        const index = el.getAttribute('data-index')
        const realHeight = el.getBoundingClientRect().height
        let diffVal = positions.value[index]?.height - realHeight
        const curItem = positions.value[index]
        if (diffVal !== 0 && index < props.dataList.length) {
            curItem.height = realHeight
            curItem.bottom = curItem.bottom - diffVal
            for (let i = index + 1; i < positions.value.length - 1; i++) {
                positions.value[i].top = positions.value[i].top - diffVal;
                positions.value[i].bottom = positions.value[i].bottom - diffVal;
            }
        }
    })
}

onUpdated(() => {
    updatePosition()
})

5.2 根据滑动的高,得出的位置,进行translateY的转换

const getTransform = computed(() => {
    return `translate3d(0,${offset.value}px,0)`
})

6.VirutalItem的完整代码

<template>
    <div ref="container" class="container" @scroll="handleScroll">
        <div class="placeholder" :style="{ height: listHeight }"></div>
        <div class="list-wrapper" :style="{ transform: getTransform }">
            <div class="card-item" v-for="(item, index) in virtualData" ref="itemRefs" :key="index"
                :data-index="item.index">
                <span>{{ `${item.index}.${item.value}` }}</span>
            </div>
        </div>
    </div>
</template>

<script setup>
import { ref, computed, watch, onMounted, onUpdated, defineProps } from 'vue'

const props = defineProps({
    dataList: {
        type: Array,
        default: () => {
            return []
        }
    },
    itemHeight: {
        type: Number,
        default: 60
    }
})

const container = ref(null)

const containerHeight = ref(0)

const start = ref(0)
const offset = ref(0)
const itemRefs = ref()

const positions = ref([])

const worker = ref(null)


worker.value = new Worker(new URL('../workers/mainWorker.js', import.meta.url))


function sendMessageToWorker(data, cb) {

    const dataString = JSON.stringify(data)
    worker.value.postMessage(dataString)
    worker.value.onmessage = (e) => {
        const obj = JSON.parse(e.data)
        if(obj.type === data.type){
            cb(obj)
        }
    }
}



const listHeight = computed(() => {
    const data = positions.value[positions.value.length - 1]?.bottom || 0
    return `${data}px`
})

const getTransform = computed(() => {
    return `translate3d(0,${offset.value}px,0)`
})


const renderCount = computed(() => {
    return Math.ceil(containerHeight.value / props.itemHeight)
})

const end = computed(() => {
    return start.value + renderCount.value
})

const virtualData = computed(() => {
    return props.dataList.slice(start.value, end.value)
})


const handleScroll = (e) => {
    const scrollTop = e.target.scrollTop
    start.value = getStart(scrollTop)
    offset.value = positions.value[start.value].top
}

const getStart = (scrollTop) => {
    let left = 0;
    let right = positions.value.length - 1;
    while (left <= right) {
        const mid = Math.floor((left + right) / 2);
        if (positions.value[mid].bottom === scrollTop) {
            return mid + 1;
        } else if (positions.value[mid].bottom > scrollTop) {
            right = mid - 1;
        } else {
            left = mid + 1;
        }
    }
    return left;
}

const initPosition = () => {
    positions.value = []
    if (props.dataList.length > 0) {
        sendMessageToWorker({
            type: "initPosition",
            data: props.dataList,
            height: props.itemHeight
        }, (data) => {
            positions.value.push(...data.data)
        })
    }
}

const updatePosition = () => {
    if (positions.value.length === 0) {
        return
    }
    itemRefs.value.forEach((el) => {
        const index = el.getAttribute('data-index')
        const realHeight = el.getBoundingClientRect().height
        let diffVal = positions.value[index]?.height - realHeight
        const curItem = positions.value[index]
        if (diffVal !== 0 && index < props.dataList.length) {
            curItem.height = realHeight
            curItem.bottom = curItem.bottom - diffVal
            for (let i = index + 1; i < positions.value.length - 1; i++) {
                positions.value[i].top = positions.value[i].top - diffVal;
                positions.value[i].bottom = positions.value[i].bottom - diffVal;
            }
        }
    })
}

onMounted(() => {
    containerHeight.value = container.value.clientHeight
})

onUpdated(() => {
    updatePosition()
})


watch(() => props.dataList, () => {
    initPosition()
}, {
    immediate: true
})



</script>


<style>
.container {
    height: 100%;
    overflow: auto;
    position: relative;
}

.placeholder {
    position: absolute;
    left: 0;
    top: 0;
    right: 0;
    z-index: -1;
}

.card-item {
    padding: 10px;
    color: #777;
    box-sizing: border-box;
    border-bottom: 1px solid #e1e1e1;
}
</style>

7.在外层使用时设置对应div的宽高

<template>
    <div class="virtual-container">
        <VirutalItem :dataList="data"></VirutalItem>
    </div>
</template>

<script setup>
import { ref } from 'vue'

import { testData } from "../utils/testData.js"


import VirutalItem from './VirutalItem.vue'

const data = ref([])

const index = ref(0)

// 测试数据
for (let i = 0; i < 10000; i++) {
    data.value.push({
        index: i + 1,
        value: testData[index.value]
    })
    index.value++
    if (index.value >= testData.length) {
        index.value = 0
    }
}

</script>

<style>
.virtual-container {
    width: 400px;
    height: 800px;
}
</style>

8.相关的学习链接暂时找不到了,未加上,等以后找到了,再补。。。。