vue3 虚拟渲染组件

481 阅读19分钟

背景

我之前参考Complexities of an infinite scroller实现过一个 vue 的虚拟滚动插件,但不符合目前项目的需求(不支持异步模块渲染,滚动加载,定位等),同时为了提高性能/体验,所以进行了重构。主要重构点如下(其实基本重写了):
1、用 ResizeObserver 监听滚动元素高度变化,实时计算滚动元素位置;
2、用 IntersectionObserver 替代 scroll 事件监听,优化滚动性能;
3、支持异步渲染(e.g. image);
4、支持定位(定位到具体元素上);
5、支持在指定位置更新/删除/添加元素;
6、支持滚动加载;
7、组件自动化测试覆盖(cypress);
用 vue3+typescript 实现的虚拟滚动列表,主要用于一些长列表的展示/chat等场景,组件已经在多种场景下内测了一段时间并后投入线上,目前性能和用户反馈使用体验都不错,项目已基本稳定,欢迎体验/start/pr。

本文主要是整理记录下开发中遇到过的问题和一些性能优化的思路。
在线展示
文档

整体流程

1、接受传入数据,根据当前scrollTop位置计算只渲染大概两屏以上数据(根据传入props参数 perPageItemNum 确定,2屏到5屏左右在渲染性能和滚动体验上比较好,具体可根据自己情况设置);
2、通过绝对定位对元素进行定位,在滚动过程中通过 IntersectionOberser 观察列表,当滚动时,有新的元素进入可视区域后计算新的渲染数据; e.g.

// 当前渲染的是[20,...40]这些元素(一页10个滚动元素),在scrollTop位置渲染的是30
// 当向上滚动,index为 29 的滚动元素进入可视区域后,重新获取渲染数据为[19,...39]

3、给所有当前渲染元素加入 ResizeObserver 观察,当元素尺寸(高度)发生变化时,修改其他元素的位置及容器高度;

但在开发过程中遇到过很多问题,主要是滚动/性能/体验等问题。

滚动

在组件开发前期,滚动问题其实是没有考虑周全的,在开发/测试过程中遇到过很多滚动问题,跑偏过,也重构过。主要是因为这几个问题:
1、滚动锚定;
2、滚动补偿;
3、数据修正;
4、滚动条保持在底部;

滚动锚定(scroll anchoring)

因为组件是需要支持异步渲染的,即每个子元素都会是动态变化高度的。如果滚动条位置会不断变化,体验会非常差,特别是定位功能根本无法支持。
但我们的需求是需要滚动条锚定在某个元素上,再具体一点的要求如下:
1、当用户滚动停止,其他元素resize时,都需要锚定在当前scrollTop位置上;
2、当使用了定位方法后,其他元素resize时,都需要锚定在定位元素上;
搜索了一番发现浏览器其实是有滚动锚定的特性的overflow-anchor,默认是开启的,可以通过该css属性进行修改/关闭。

但这点很奇怪,既然是开启了,那浏览器是用哪个元素作为锚定的呢?
关于这方面的文档和说明很少,我进行了一些测试,主要是测试开启/关闭滚动锚定功能,修改数据时浏览器的表现。测试代码大概如下:

<ul>
    <template v-for="item in list">
        <li>{{item.content}}</li>
    </template>
</ul>

当默认开启滚动锚定时:

// 删除/增加/修改(删除增加元素,修改元素高度)
1、当删除/修改scrollTop上方元素时,滚动条同时向上补偿删除滚动元素的位移(e.g. 当前scrollTop为100px,删除一个30px的元素后,滚动条也会向上滚动30px,scrollTop为70px,用户无感知);  
2、当删除scrollTop下方元素时,滚动条位置不变;  

当关闭滚动锚定时(父容器设置overflow-anchor: none;):

// 删除/增加/修改(删除增加元素,修改元素高度)
1、当删除/修改scrollTop上方元素时,滚动条位置不变(e.g. 当前scrollTop为100px,当删除一个30px的元素后,滚动条位置不变,仍是100px,用户可以看到内容往下滚动了一下);  
2、当删除scrollTop下方元素时,滚动条位置不变;  

那如果我修改了只让list里面某个元素有锚定功能呢?e.g.

ul>li {
    overflow-anchor: none;
}
li[data-key="30"] {
    overflow-anchor: auto;
}

测试了一下发现是没有意义的,只要里面某一个元素有锚定功能,所有元素相对于滚动条的位置都是不变的。但有一种特殊的应用,在底部添加一个锚定可以实现固定在底部的功能。具体代码可以参考你所不知道的滚动锚定
mdn上有一句话描述的比较好,滚动锚定会调整滚动位置以补偿可视区域外的变化

但同时滚动锚点也有使用场景的限制,CSS Scroll Anchoring Module Level 1

A DOM node N is an excluded subtree if it is an element and any of the following conditions holds:
N’s computed value of the display property is none.
N’s computed value of the position property is fixed.
N’s computed value of the position property is absolute and N’s containing block is an ancestor of the scrolling box.
N’s computed value of the overflow-anchor property is none.

对于我目前的方案来说,需要使用绝对定位来实现,所以无法使用浏览器滚动锚点的特性(即当上方元素发生变化, e.g. 当上方数据resize,高度从100->200时,当前viewport内容会被下推100px);
但给了我一个思路,如果我自己通过"滚动补偿"来实现锚定呢?

滚动补偿

开始我不太确定滚动补偿的用户体验,但测试之后感觉体验很好。滚动补偿是针对两种场景:
1、使用组件 locate 方法进行定位;
2、用户已经停止滚动;
这两种场景下,当发生元素resize时,立刻进行滚动条位置补偿。补偿的原理主要是当发生resize的元素在当前top元素上方时,会导致滚动条位置变化,需要补偿这部分位置的变化。 其实这里还有个问题很有趣,因为当用户正在滚动如何也进行滚动补偿,会导致计算量增加很多并且出现滚动迟滞的体验(滚动条来回滚动补偿),所以需要区分用户滚动跟程序滚动(locate/滚动补偿)。

区分 用户滚动/程序滚动:

看了mdn文档,尝试用 Event.currentTarget 来进行区分,但发现有很多兼容性问题。 因为目的是让用户在滚动时不进行滚动补偿,对精确度要求不高,所以我尝试添加了滚轮事件监听,当用户操作滚轮时,标识用户正在滚动中,在防抖后再标识为停止滚动.

注: 目前chrome是不建议进行滚轮监听,后面需要考虑下怎么处理比较好。Feature: Passive event listeners

数据修正

当滚动过快/直接locate时,列表元素尚未完成渲染(intersectionOberser事件尚未订阅),已滚动到下一批元素位置中,会导致页面显示空白(没有正确的intersection计算获取当前渲染元素)。
所以需要设置一个滚动结束的数据修正,主要是滚动结束后通过 scrollTop 进行计算当前渲染的元素是否正确,如果不正确则重新渲染正确的元素。
实际滚动过程中,除非直接拖动滚动条,通过滚轮滚动很少出现需要修正的时候。

滚动条保持在底部

这个其实是类似聊天的交互,看起来很简单的问题,其实处理起来很麻烦。
滚动条保持在底部,需要处理两种情况:
1、初始化时,滚动到数据底部;
2、当滚动条在底部时,底部新增数据应该也保持在底部;
通过记录每次滚动停止的位置,在resize前跟容器高度进行对比,判断是否在底部,如果在的话需要在容器渲染完成后补偿滚动到底部.

注:其实有考虑另外一种做法,使用flex布局,修改主轴方向(flex-direction: column-reverse)会让底部变为顶部,导致计算位置的方式需要调整,后面有时间再尝试一下。

性能问题

其实涉及到很多性能细节问题,我主要是根据自己记录的几个主要问题来说一下:
1、DOM回收;
2、尽量依赖框架进行优化;
3、debounce/throttle的正确使用;
4、避免 resize 导致的全量数据更新;
5、GPU加速;

DOM节点回收

因为项目会涉及到大量的DOM销毁,所以比较关注DOM回收情况,在测试时遇到了奇怪的情况,DOM没有主动回收,在滚动过程中不断递增:
image.png

一、vue 框架问题;
这个是在特定情况下才会出现(使用vue.js devtools谷歌插件), vue@3.2.40及以上版本已修复issue
更新版本后显示正常的不断销毁/创建过程:
image.png

二、需要手动清理的DOM引用;
插件中主要涉及到两个需要手动清理的DOM引用:
1、容器监听滚动事件在销毁后的处理;
2、Oberser API涉及到的DOM引用(should-i-call-resizeobserver-unobserve-for-removed-elements);

尽量依赖框架优化

在组件开发过程中主要遇到几个跟vue渲染性能相关的问题:
一、设置唯一key渲染列表。
其实我之前针对不同场景(数据的crud)做了一些测试和源码的分析,更加详细的分析我后面再写文章分析。现在默认使用nonoid来生成唯一key。初始化生成十万条数据耗费100ms内,性能还可以接受。

二、用 expose 暴露组件方法;
1、如果通过props传入数据的方式,使用watch api深度监听数据的变化,数据量太大,会导致大量的性能伤害。
2、通过 expose 方法导出更利于测试;
3、通过 expose 方法导出更利于开发者理解,不需要太多关于内存共用的心理负担;

三、尽量使用 nextTick 而非setTimeout等定时器;
其实nextTick的原理很简单,就是按兼容情况顺序用promise->setImmediate->setTimeout(()=>{}, 0),但有两个原因是建议尽量使用 nextTick:
1、嵌套的 setTimeout 会有延迟;
2、当使用 setTimeout 时很容易出现异常,大部分开发者对渲染的过程不太熟悉,当出现一些渲染问题时就改一下setTimeout的时间,一层一层积累下来容易出现很多问题;

debounce/throttle

我一直以为我对节流/防抖很熟悉,但在需要准确在周期前后执行的时候还是遇到一些问题,具体就是 leading/trailing 参数的使用。具体可以查看 浅出篇 7 个角度吃透 Lodash 防抖节流原理 这篇文章,写的很详细了。

尽量避免 resize 导致的全量数据更新

在虚拟渲染中,当其中一个或多个数据发生 resize 时,需要更新其他数据的位置,否则会出现重叠情况,但如果每次出现resize都全量更新会导致大量的性能消耗,所以尝试尽量避免全量数据更新的性能问题:
1、当正常滚动情况下发生resize时,只更新当前渲染的数据,但同时更新列表的高度(实时更新列表高度),滚到到什么位置就只更新当前渲染数据的位置。

2、只有发生数据修正(滚动过快/拖动滚动条/定位等)才会全量更新(全量遍历会阻塞渲染大概270ms),并延迟执行当前位置后面的数据计算;
实际上一开始我尝试了只更新到数据修正位置,但后来发现如果发生多次来回的数据修正时,底部位置是错误的,因为整体的高度是正确的,但底部位置没有更新。
所以我将当前渲染内容后面的计算延迟执行(后面需要正确的位置,但暂不需要渲染出来);

3、对于一些特殊的节点(已渲染过,但每次加载都仍会有两次甚至多次size变化,e.g. 加载图片控制台设置禁止缓存)。
给组件提供一个emits('itemLoaded'),如果已渲染完成,则给元素设置固定高度,避免多次resize导致的重新定位/重新计算, e.g.

const emits = defineEmits(['itemLoaded'])

function imageLoaded() {
    emits('itemLoaded')
}

<template>
    <img @load="imageLoaded" :src="itemData.imgUrl" />
</template>

4、以上三点做到即使是做到仅对当前数据位置重新计算,但 resize 事件触发过多(demo中即使只有40个item也会触发上千次),仍会存在大量的计算;
测试通过节流控制周期在1ms内可以大量减少resize事件触发,并不影响渲染效果(用户无感知)。

GPU加速

GPU加速即由GPU来进行计算/渲染,以提高计算/渲染速度,因为GPU有千万个内核,在并发简单计算优势比GPU高很多。

先说下结论: 经过大量的测试,GPU加速在当前项目中并没有提高太多的性能,反而会造成额外的资源消耗,所以暂时不在项目中使用。 一般来说开启GPU加速有两种方式,但一般是指css3 GPU加速:
1、通过特定的css3属性将图层提升到合成层,浏览器会自动为你通过GPU进行加速渲染;

1、Use CSS transform functions or transition the opacity or filter values
2、Add the will-change property to your element.
3、Create an animated canvas drawing via OffscreenCanvas
4、Create a WebGL 3D drawing

2、主动调用 GPU 内核进行计算,e.g. gpu.js;
但我仔细看了下,gpu.js主要是进行矩阵运算.暂时在我遇到的项目中没有使用场景,这里不深入。

我为了验证GPU给页面带来的效果,不断调整渲染数量经过很多次测试,发现并没有带来性能提升(以FPS为指标).
为了进一步了解GPU加速,我做了一些更详细的实践(下面详细讲)。那在我们没有开启GPU加速时,下面由于 resize 导致的位置变化的 paint 会交给GPU进行:
image.png
从图可以看到,其实 paint 在这里所占的比例是很小的,所以我上面进行的 FPS 的测试看不到效果。

有人可能会说苍蝇肉也是肉啊,但当图层交给GPU渲染时,还会将图层纹理存放到GPU中,会占用较多显卡内存,以我项目中10万数据渲染40条的列表来说:
1、在未开启GPU加速时:
image.png 2、开启GPU之后:
image.png

可以看到通过GPU来进行渲染的话,列表会增加40M左右的GPU内存占用(由于我使用的AMD Radeon显卡,暂时不好进行进程的GPU占用测试)。对当前项目来说是得不偿失的,所以取消了GPU加速。

GPU加速性能分析

为了减少其他事件造成的噪音,我在无痕模式下通过animation做了一个简单的动画:

img {
  will-change: transform; // 开启GPU加速
  display: block;
  position: absolute;
  border: 1px solid #333;
  animation: move 4s infinite linear;
}
@keyframes move {
  0% {
    top: 0; 
    left: 0;
  }
  25% {
    top: 0;
    left: 1000px;
  }
  50% {
    top: 1000px;
    left: 1000px;
  }
  75% {
    top: 1000px;
    left: 0;
  }
  100% {
    top: 0px; 
    left: 0px;
  }
}

发现在CSS触发的渲染事件中,少了一个 paint 的步骤:
(非GPU加速):
image.png (GPU加速):
image.png

也就是说: 合成图层(Compositing Layers)的 paint 交给GPU来进行。

web worker

这里先说下结果: 由于考虑兼容/资源消耗等原因,删除了 web worker 进行复杂计算代码(下面详细讲)。
但对于单线程的js来说,通过web worker是解决复杂计算导致阻塞是很好的思路,所以详细记录了过程。

其实在vite中使用web worker并不复杂,在主线程中创建 worker 对象,发送(postMessage)/接受(onmessage) worker 进程消息, e.g.

// 主进程
const worker = new Worker(new URL('./worker.ts', import.meta.url))

worker.onmessage = (e) => {
  console.log('get data from worker', e.data)
}

// worker 线程
window.addEventListener('message', (e) => {
    console.log(`接收到数据, ${e.data}`)
})

由于我需要处理的数据是一个非常大的数据列表,如果直接使用原有的对象进行传输(序列化/反序列化),会影响传输速度和翻倍的内存消耗:
1、测试结果是10万条数据大概30m,这部分内存是无法共享的(后面我也尝试用 SharedArrayBuffer 进行内存共享的尝试,但暂时不可行);
2、传输和序列化/反序列化大概需要300多毫秒,再加上数据遍历计算的300毫秒,空白会比较长;
如果是需要多并发场景下是可以考虑的,但在我的使用场景下不仅增加了资源消耗,还增加了执行时间. 所以不考虑用到项目中。

SharedArrayBuffer

SharedArrayBuffer 主要是创建一个共享的存储空间在不同线程中共享。并可以通过 DataView/TypedArray/Atomics 等方式修改存储内容。
但看了下文档 DataView/TypedArray/Atomics 等方式只支持各种数值类型,e.g.

const buffer = new SharedArrayBuffer(1024) // 创建 1024 byte的存储空间
const array = new Int8Array(buffer) // 通过视图将存储空间设置为 8位有符号 的数组

array[0] = 18 // 通过视图设置数组第一个值为 18

但在当前项目下(或者绝大部分使用场景),我是需要处理复杂数组的(e.g. [{name: 'vb', age: 18}, ...]), 思考了下,有两种思路可以考虑:
1、在主进程写入数据的时候将对象序列化为字符串再转化为Unicode写入,在再读取的时候再将Unicode转为字符串再序列化为对象;

// 字符串/Unicode相互转换方法
function strToBinary (str) {
    let list = str.split('')
    return list.map(item => {
        return item.charCodeAt().toString(10)
    })
}
function binaryToStr (arr) {
    const _arr = []

    for(let i = 0, len = arr.length; i < len; i += 1) {
        _arr.push(String.fromCharCode(arr[i]))
    }
    return _arr.join('')
}

// 主进程写入数据
const worker = new Worker(new URL('./../worker/worker.js', import.meta.url)) // worker
const list = [{name: 'vb', age: 18, transformY: 0, height: 175}, {name: 'fish', age: 18, transformY: 175, height: 159}] // 原始数据
const str = JSON.stringify(list)
const binaryArray = strToBinary(str) // 转为 Unicode 数组
const buffer = new SharedArrayBuffer(binaryArray.length) // 创建共享空间
const array = new Uint8Array(buffer, 0, binaryArray.length) // 创建视图

for(let i = 0, len = binaryArray.length; i < len; i += 1) {
    array[i] = binaryArray[i] // 通过视图写入数据
}
worker.postMessage(buffer) // 发送数据

// 读取数据
addEventListener('message', (ev) => {
    const array = new Uint8Array(ev.data) // ev.data 是buffer数据,创建视图进行读取
    const list = JSON.parse(binaryToStr(array))

    console.log(`数据内容: ${JSON.stringify(list[0])}`)
})

2、将复杂的数组改为多个数组使用,例如在我项目中,需要重新计算的是transformY的值:

// 原数据
const list = [{name: 'vb', age: 18, transformY: 0, height: 175}, {name: 'fish', age: 18, transformY: 175, height: 159}]
// 改为
const heightBuffer = new SharedArrayBuffer(1024) // 存储高度 list
const transformYBuffer = new SharedArrayBuffer(1024) // 存储transformY list
const list1 = [{name: 'vb', age: 18}, {name: 'fish', age: 18}]
const list2 = new Uint8Array(heightBuffer)
const list3 = new Uint8Array(transformYBuffer)

list2[0] = 175
list2[1] = 159
list3[0] = 0
list3[1] = 175

可以看到,目前想到的两种方案虽然可行,但除了大大提高项目复杂度外,并没有提高项目执行效率,各种序列化甚至大大增加了执行时间和存储空间。
目前来看,仅在number[]下使用内存共享是可以减少通信时间和节省内存空间的,在其他情况下不太适用。

问题记录

1、是否要将数据结构从数组改为hash, 若需要,key value的规则是什么?
需要对比下几个涉及到数据结构的流程里面两种数据结构的区别:
一、初始渲染,我们只需要渲染一小部分数据(e.g. 我 chatMode demo中只渲染40条数据);

// 数组
我可以快速提取需要渲染的数据出来(currentData),当resize/reposition时,通过引用修改,只修改当前数据即可同步到所有的数据中;  
// hash
需要定义一个定义一个数据模型,所有对于源数据的操作通过整个数据模型进行操作(包括提供给外部的接口)。

二、滚动加载,需要在源数据中提取部分数据填充到当前渲染数据中;

// 数组
直接在源数据中通过例如slice的方式提取准确索引位置数据即可;  
// hash
除了确定提取方式,还需要考虑提取后的数据如何按顺序(其实Object也是有顺序的,但需要对key进行规范化,例如使用索引值作为key。也可以考虑使用Map是按照插入时间排序的)合并到当前数据中;  

三、修改源数据(添加/删除/修改内容/调整顺序);

// 前提
需要覆盖哪些修改的场景?(修改顺序, 删除元素, 添加元素,修改元素内容)   
需要保证是引用修改,而不是重新赋值(但重新赋值的场景很常见,所以需要提供api,在组件内控制重新赋值的情况下进行引用修改,其实就是通过遍历的方式进行重新赋值)。  
所以需要提供四个api: 删除/添加/修改/全部更新,并在进行已上操作后除 修改 外,需要重新更新当前渲染的列表(为了避免误操作,除了初始化需要对通过 props 修改源数据进行警告并不进行操作)。  
// 数组
对源数据进行 删除/添加/修改/全部更新 后,除了 修改 外,基本上 currentData 会重新渲染;    
// hash
以索引为key构建的对象,对源数据进行 删除/添加/修改/全部更新 后,除了 修改 外,基本上currentData会重新渲染;

对比下来后,其实数组和hash区别不大。

2、关于容器和滚动加载(支持向上滚动和向下滚动)的提示,我目前是在容器头部和底部各写了一个loading,比较冗余。
我尝试只写一个loading通过flex来调换位置,但遇到了一个"坑"(With flexbox and "column-reverse", the page jumps when scrolling on Chrome),flex的column-reverse会修改滚动条的逻辑,定位到底部并将scrollTop=0改为底部,往上滚动会是负数的scrollTop值(demo)。其实细想好像也符合我的需求,但目前版本需要稳定,暂时不做这个大的改动(浏览器表现有些差异,scrollTop的计算和定位相关都需要改),后面再看看。
reverse 渲染问题

3、nextTick是否可以保证DOM已渲染完成?
其实nextTick逻辑很简单,就是把任务放到微任务/宏任务队列中,等待同步任务(在vue中即patch进行的DOM操作)进行后,才进行。
只能保证DOM已操作(e.g. 已插入DOM),不能保证DOM已完成渲染.如果有遇到需要需要保证渲染完成的逻辑,需要自己考虑例如递归校验执行,或者由onload逻辑触发等。

4、什么是CPU占用率,GPU占用率?
CPU 使用率是单位时间内 CPU 使用情况的统计,以百分比的方式展示。GPU占用 是指 GPU 内核在过去的采样周期中一个或多个内核在 GPU 上执行的时间百分比。
统计方式:
1、CPU占用 可以通过 top 命令进行统计数据导出;
2、GPU占用 可用 nvidia-smi 工具进行统计;

5、SharedArrayBuffer需要启用跨域隔离,具体是需要在服务响应头增加两个参数:

Cross-Origin-Opener-Policy-Report-Only: same-origin
Cross-Origin-Embedder-Policy-Report-Only: require-corp

具体到例如我使用的vite本地服务,可以通过插件 vite-plugin-cross-origin-isolation 来处理。

参考文档

1、Updates in hardware-accelerated animation capabilities: developer.chrome.com/blog/hardwa…
2、搞不懂CPU使用率?何谈性能优化: zhuanlan.zhihu.com/p/475472392
3、finish removing 'instant' from scroll-behavior? github.com/w3c/csswg-d…
4、你所不知道的滚动锚定: obeta.me/posts/2019-…
5、Determine user scroll vs JavaScript scrollTop change: stackoverflow.com/questions/4…
6、GPU Accelerated Compositing in Chrome: www.chromium.org/developers/…
7、理解矩阵: www.zhoulujun.cn/index.php?m…
8、Blink浏览器渲染原理及调试: docs.google.com/presentatio…