虚拟滚动之原理及其封装

这仍然是笔者正在进行中某个前端基础项目的一项基础研究。

目前GitHub上只放出demo的版本,将在 https://github.com/dangjingtao/vList.git 持续更新。

现作文以记之。

前端的业务开发中会遇到一些不分页且数据条数超过1000加载的列表(长列表),不分页的需求在一般前端程序员看来是不可思议的。正常的思考逻辑是,当数据量20w+时,后端报文可去到30+M,查询时间可能去到几十秒。但是前端如果尝试渲染这些数据,花费的时间必定是以分钟计算。通常是3分钟以上。相比之下,由前端优化这个问题更为迫切,责任更为突出(锅更大)。

对于作为业务程序员的笔者来说,长列表性能优化是工作中反复需要面临的问题之一。

1. 否定

上来先说结论,完整渲染的长列表是不可能满足业务上的需求的。

先做一个小测试:

const createElements = count => {    const start = new Date();    for (let i = 0; i < count; i++) {        const element = document.createElement('div');        element.appendChild(document.createTextNode('' + i));        document.body.appendChild(element);    }    setTimeout(function () {        console.log('渲染耗时:',new Date() - start);    }, 0);};document.querySelector('#btn').addEventListener('click', e => createElements(10000))

这段代码意思是:点击按钮,依次生成10000个带文字的节点

为什么要setTimeout?

你可能注意到了上面的测试代码中的时间计算过程中并没有直接在调用完 API 之后直接计算时间,而是使用了一个 setTimeout,按照正常逻辑似乎、完全、可以这么写:

const start = Date.now();// bala bala...console.log(Date.now() - start);

你若直接看console的测试结果,只有40多毫秒,看来挺快嘛。

实际上对于 DOM 的性能测试这么做是不科学的,因为 DOM 的操作会引起浏览器的回流(reflow)。如果浏览器的 reflow 执行的时间远大于代码执行时间,会造成你时间计算完成之后,浏览器仍然在卡顿。统计的时间应该是从 开始创建元素 到 可以进行响应 的时间,所以一个合理的做法是把计算放 setTimeout(function() {}, 0) 中。setTimeout() 中的 callback 会被推迟到浏览器主线程 reflow 结束后才执行,这个时间和 Chrome Devtools 下的 Profile 的时间基本吻合,可以信任这个时间作为渲染时间。

如图,在一个空白的html上生成10000个dom,需要耗费约870ms。

959011df02ed9245e48e9ff1453d367a.png

打印结果为856ms,基本与测试相符。

好了。根据测试结果计算。在笔者的电脑上,创建 10000 个带文本节点就需要 800ms+,笔者实际业务中的列表每个条数据都需要 20个左右的节点。那么,实际单纯渲染10000条数据,理论上最快得17s。

2. 斟酌

非完整渲染的长列表一般有两种方式:

•懒渲染:这个就是常见的无限滚动的,每次只渲染一部分(比如 10 条),等剩余部分滚动到可见区域,就再渲染另一部分。•可视区域渲染:只渲染可见部分,不可见部分不渲染。

先说懒渲染,经常跟移动端打交道的程序员对于懒加载应该并不陌生。二者其实可以认为是一个东西。但这里懒渲染更加侧重于从列表优化的角度说明问题。这是一种前后端共同优化的方式,后端一次加载比较少的数据,就不用查询等几十秒,前端首次渲染更少的数据速度当然会更快。看起来很好。

遗憾的是有三点重大缺陷:

•边滚边加载的模式,会导致页面越发卡顿。(实际上是把锅丢到了后面)•无法实现动态反映选中状态•滚动条无法正确反映操作者当前浏览的信息在全部列表中的位置。而且我百万级数据加载,你一次给我加载十几条,滚到底太慢了,是想愚弄用户吗!

三条理由都很有道理。所以懒渲染被摈弃了。

于是方案来到了可视区域渲染。

可视区渲染有个更出名的名字,叫做虚拟滚动——指的是只渲染可视区域的列表项,非可见区域的完全不渲染,在滚动条滚动时动态更新列表项。

[注]:实际上考虑页面流畅性,不可能完全不渲染视区之外的内容,建议是预留2-3屏。

有两个重要的基本概念:

•可滚动区域:假设有 1000 条数据,每个列表项的高度是 30,那么可滚动的区域的高度就是 1000 * 30。当用户改变列表的滚动条的当前滚动值的时候,会造成可见区域的内容的变更。•可见区域:比如列表的高度是 300,右侧有纵向滚动条可以滚动,那么视觉可见的区域就是可见区域。

相比较于懒渲染,虚拟滚动要求一次性全部拿到数据,但是滚动条能够完全正确地反映当前页面在全部数据的位置。滚动无非是对几十个dom进行操作,可以达到极高的后续渲染性能。而且一旦实现,可以把页面慢的锅完全丢给后端了。

3. 理想

最低目标当然是满足需求。react方面的封装有

https://bvaughn.github.io/react-virtualized/#/components/List

参考它的实现,是时候考虑封装了。就定名VList吧。封装之前,应根据易用性设计options api。

初始化字段数据类型说明
itemHeightNumber每项高度
containerDOM滚动容器
containerContentDOM滚动内容
maxHeightNumber不撑起滚动条的最大高度
initDataArray加载的数据
renderFUNCTION渲染item的具体函数。参数为itemData和索引值,希望在此处能够挂载某个flag标识,即可如实反映勾选/非勾选状态。
itemEventHandlersArrayitem内的时间处理函数,支持class绑定。

预期的使用方法是:

const vlist = new Vlist({    itemHeight: 65,    container: document.querySelector("#list"),    containerContent:document.querySelector('.ul'),    maxHeight: document.documentElement.clientHeight,    isDebounce:true,    initData: data,    render: function (itemData, index) {        return `
data-index="${index}" class="left">
class="right">
class="title">${itemData.id}
class="price">${itemData.name}
class="price">${itemData.address}
` }, itemEventHandlers:[ { eventTargetClass:'left', eventType:'click', handler:function(e){ console.log(e.target) } } ]});

4. 实现

实现虚拟滚动就是处理滚动条滚动后的可见区域的变更,其中具体步骤如下:

1.计算当前可见区域起始数据的 startIndex2.计算当前可见区域结束数据的 endIndex3.计算当前可见区域的数据,并渲染到页面中4.计算 startIndex 对应的数据在整个列表中的偏移位置 startOffset,并设置到列表上

vList对象基本过程:

初始化(mixin) -> 添加数据(addData) -> 绑定滚动事件(scrollEvent)

该逻辑可直接写在构造函数里:

constructor(opts) {    // mixin    this.mixin(opts);    const {        initData,        isDebounce    } = opts;    //初始化数据    if (initData) {        this.addData(initData);    }    this.scrollEventBind = this.scrollEvent.bind(this);    // 绑定滚动事件    if (isDebounce) {        this.container.addEventListener("scroll", (e) => {            this.debounce(this.scrollEventBind, 40);        });    } else {        this.container.addEventListener("scroll", this.scrollEventBind, false);    }}

4.1 初始化

那么在构造函数获取得配置后,马上通过mixin初始化自身的配置,方便其它函数共享:

/** * 配置mixin * @param {*} opts  */mixin(opts) {    let {        itemHeight,        container,        containerContent,        maxHeight,        render,        initData,        itemEventHandlers,        isDebounce    } = opts;    // 计算最大高度,否则取设备高度     maxHeight = maxHeight ? maxHeight : document.documentElement.clientHeight;    const _this = {        itemHeight, // 每项高度        container,  // 滚动容器        containerContent, // 滚动内容        maxHeight, // 出现滚动条的高度        showItemCount: Math.ceil(maxHeight / itemHeight) + 1, // 视图区域显示item的个数        items: [], // 可见列表项        startIndex: 0, // 第一个item索引        render, // 渲染每一项的函数        data: [], // 列表数据        itemEventHandlers, //事件处理        isDebounce // 性能优化点:防抖    }    Object.keys(_this).forEach(key => {        this[key] = _this[key];    })}

4.2 添加数据

添加数据的主要内容,做两件事:

1.是判断数据能否被撑起来,并做一些样式设置。2.计算需要展示的内容,展示。

/** * 添加数据 * @param {*} data 所需添加的数据,在原基础上加 */addData(data) {    let isInit = this.data.length == 0;    this.data = this.data.concat(data);    const realHeight = parseInt(this.data.length * this.itemHeight);    if (realHeight > this.maxHeight) {        // 出现滚动条        this.showItemCount = Math.ceil(this.maxHeight / this.itemHeight) * 3;//视图区域显示item的个数        this.container.style.height = this.maxHeight + 'px';    } else {        // 支撑起内容高度,不触发滚动条        this.container.style.height = realHeight + 'px';        this.showItemCount = this.data.length + 1;//视图区域显示item的个数    }    this.containerContent.style.height = realHeight + 'px';    if (isInit) {        this.initList();    }}

对应initList 方法异常简单,根据展示区数据量做循环,逐个生成数据。存在this.items中其中,生成数据时,可在此绑定eventHandlers的事件。

/*** 初始化列表* 只根据startIndex渲染可视区范围的数据*/initList() {    const count = this.data.length < this.showItemCount ? this.data.length : this.showItemCount;    for (let i = 0; i < count; i++) {        const item = this.renderItem({            index: i        });        this.containerContent.appendChild(item.dom);        this.items.push(item);    }}/** * 渲染单行容器样式 * @param {Object} dom内容   * 最好是以template模板的形式,并加上事件 */renderItem(item) {    const index = item.index;    // 此处应该配置    const itemDom = item.dom ? item.dom : document.createElement("DIV");    const itemData = this.data[index];    // 填充    itemDom.innerHTML = this.render(itemData, index);    // 绑定事件,目前只支持item内class选择器    this.itemEventHandlers.forEach((x, i) => {        const targets = itemDom.querySelectorAll(`.${x.eventTargetClass}`);        for (let j = 0; j < targets.length; j++) {            targets[j].addEventListener(x.eventType, e => x.handler(e));        }    });    // 设置高度    itemDom.style.position = "absolute";    itemDom.style.top = (index * this.itemHeight) + "px";    itemDom.style.height = this.itemHeight + "px";    itemDom.style.width = "100%";    itemDom.style.overflow = "hidden";    item.dom = itemDom;    item.dom.setAttribute("index", index);    item.top = index * this.itemHeight;    return item;}

更新视区的方式很多,比如,通过transfer2D,或是改变上面的padding,而vList采用定位的方法来做。

4.3 绑定滚动

计算渲染边界,确定是否渲染。

/*** 滚动事件*/scrollEvent() {    const containerScrollTop = this.container.scrollTop;    const { itemHeight, startIndex, maxHeight } = this;    // 滚动触发计算    const fakeStartIndex = Math.floor(containerScrollTop / itemHeight) - Math.ceil(maxHeight / itemHeight) - 1;    let startIndexNew = fakeStartIndex >= 0 ? fakeStartIndex : 0;    const maxStartIndex = this.data.length - this.showItemCount + 1;    startIndexNew = startIndexNew > maxStartIndex ? maxStartIndex : startIndexNew;    if (containerScrollTop < 0) return; // ios兼容    if (startIndexNew === startIndex) return; // android兼容    const scrollOver = startIndexNew + this.showItemCount - 1 >= this.data.length;    const renderOver = startIndexNew - startIndex === 1;    // 如果到底没有渲染完就再渲染一次    if (scrollOver && renderOver === false) {        startIndexNew--;    }    this.diffRender(startIndex, startIndexNew);    this.startIndex = startIndexNew;}

而以下代码侧重渲染

/** * startIndex比较渲染 * @param {*} startIndex  * @param {*} startIndexNew  */diffRender(startIndex, startIndexNew) {    const showItemCount = this.showItemCount;    const items = this.items;    const moveCount = Math.abs(startIndex - startIndexNew);    if (moveCount >= showItemCount) {        // 全部渲染        items.forEach((item, idx) => {            item.index = startIndexNew + idx;            this.renderItem(item);        })    } else {        // 部分渲染        if (startIndex - startIndexNew > 0) {            // 往上滚            for (let i = 1; i <= moveCount; i++) {                let item = items[showItemCount - i];                item.index = item.index - showItemCount;                this.renderItem(item);            }            this.items = items.splice(showItemCount - moveCount, moveCount).concat(items);        } else {            for (let i = 0; i < moveCount; i++) {                const item = items[i];                item.index = item.index + showItemCount;                this.renderItem(item);            }            this.items = items.concat(items.splice(0, moveCount));        }    }}

那么主要功能就实现了。

5. 小结

在虚拟dom成为主流的今日,如果不亲自去调查了解,你发现不了这么一个事实:习惯于从视图层取数据的前端原来还大有人在。

视图层依赖dom,而dom成为一种负担不得控制的时候,你会发现很多人技穷了。这时应该大胆地把数据处理的某些逻辑放到js内存来做。

认识到这点,不是一种优越感。这本来就是各有优劣的两个方面,相反当你要亲自去纠正,会深感自身才学不足,考虑的东西需要更多。

当前代码优化点仍然存在。往后将以续作形式更新。

f7bb33fe6ed9bb07682c920d6fc23ea3.png

Logo

开放原子开发者工作坊旨在鼓励更多人参与开源活动,与志同道合的开发者们相互交流开发经验、分享开发心得、获取前沿技术趋势。工作坊有多种形式的开发者活动,如meetup、训练营等,主打技术交流,干货满满,真诚地邀请各位开发者共同参与!

更多推荐