问题描述
Tree / Table 组件的渲染,通常会出现需渲染超过千条(虚数,和实际渲染实例DOM结构负相关)的数据的情景, 在当前大众硬件条件和浏览器渲染机制的限制下,这必然会造成不太愉快的用户体验。
问题分析
下文皆基于 Vue 举例
我们知道当下主流浏览器使用的 JS 解释引擎,无论是 V8 还是 JavaScriptCore ,处理数百万条数据都不在话下,但为什么这一千条数据就成了浏览器过不去的坎呢?要解释这个问题,首先需要知道我们面前的网页经过了哪些重要阶段,我这里简单做了一个图
这里特意把 Vue 的 Compile 阶段设置为阶段 0 ,因为通常我们都是借助vue-loader提前完成编译,故其虽然作为主要耗时流程的一部分,但我们先忽略它。在这个渲染流程中,第 1,2 两步是由 JS 引擎处理的,他们通常不会是渲染瓶颈,正真的瓶颈在于 DOM 树操作和 Layout 过程。
方案分析
渲染优化最有效的就是减少数据的渲染,通常我们有如下三种方法:
- 懒加载:根据用户操作,只渲染用户需要的少量数据
- 切片渲染(对于响应式框架就是对数据切片):所有数据都在 JS 引擎内存中,但首次只渲染一个切片数据,其余数据在浏览器空闲时再渲染
- 虚拟滚动:所有数据都在 JS 引擎内存中,但只渲染用户看得到的数据
根据当前需求和时间限制,我本次选择的是切片渲染方式。
“数据切片”方案实现
上面提到过,对于 Vue 这样的响应是框架,切片渲染就等同于“数据切片”,故我们只需要实现一个切片函数。 该函数有如下两个任务:
- 把数据分层 N 份,
- 在浏览器空闲的时候把数据赋值给响应式对象
然后响应式对象会通知框架生成 VNode > Patch > ...
/**
* @description 数组数据切片渲染函数
* 可用于 tree/table/list 组件多数据分段渲染
* @param {Array} watcher 待赋值对象( Vue Watcher 实例)
* @param {Array} renderData 待渲染的数据
* @param {Int} sectionLength 切片长度
*/
export const stupidFiber = (
watcher,
renderData,
sectionLength = renderData.length,
) => {
if (!sectionLength) renderData
let len = renderData.length
let genSectionIndex = function (length, fragments) {
let baseLen = Math.floor(length / fragments)
let ret = []
for (let i = 1; i <= fragments; i++) {
let start = (i - 1) * baseLen
let end = i === fragments ? length : i * baseLen
ret.push([start, end])
}
return ret
}
let fragmentsIndex = genSectionIndex(len, sectionLength)
let index = 0
let create = function (timestamp) {
let fragment = fragmentsIndex[index++]
watcher.push(...renderData.slice(fragment[0], fragment[1]))
if (index < sectionLength) {
// chrome 47+, Edge 79+
window.requestIdleCallback(create)
}
}
create()
}
“数据切片”性能参考
记录的性能数据皆为测试5次的平均值,性能数据获取方式如下:
- 记录开始渲染时间(用户感知时间)
- 数据赋值给响应式对象
- 在下一个 Tick 记录渲染结束时间
let renderStart = window.performance.now()
stupidFiber(this.treeData, treeData)
this.$nextTick(() => {
console.log(window.performance.now() - renderStart)
})
数据条数 | 优化前 | 优化后 | 优化率 |
---|---|---|---|
1000 | 1201.8ms | 161ms | 87% |
2000 | 2250.6ms | 315.6ms | 86% |
“虚拟滚动”方案实现(补充)
以 ElementUI 的 el-select 举例
virtual select 和普通 select 不同的点,只就在于选项的渲染条数,于是我们在完善的 select 组件基础上进行封装时,只需关注引起选项变化的点。稍作思索便能列举如下:
- 过滤字符串发生改变时,显示过滤后的 options
- 滚动条位置变化时,显示正确位置的 options
- 下拉框显示时,滚动条位置归零,然后同上
- 携带初始值初始化时,确保正确的回显值(可以归为选项变化,也可做独立的特殊处理)
基于我们的分析和 el-select api ,可以得到如下一个序列图,绘制它的过程通常能帮助我们查漏补缺(代码开发完毕后再做,这里提前是为方便大家理解代码)
<template>
<el-select
ref="select"
:value="value"
:size="size"
:disabled="disabled"
:clearable="clearable"
:filterable="filterable"
:allowCreate="allowCreate"
:loading="loading"
:popperClass="popperClass"
:remote="remote"
:loadingText="loadingText"
:noMatchText="noMatchText"
:noDataText="noDataText"
:filterMethod="filterMethod"
:remoteMethod="remoteMethod"
:multiple="multiple"
:multipleLimit="multipleLimit"
:placeholder="placeholder"
:defaultFirstOption="defaultFirstOption"
:reserveKeyword="reserveKeyword"
:valueKey="valueKey"
:collapseTags="collapseTags"
:popperAppendToBody="popperAppendToBody"
@input="input"
@change="change"
@clear="clear"
@blur="blurHandle"
@focus="focusHandle"
@visible-change="visibleChange"
@remove-tag="removeTag"
>
<el-option
v-for="item in renderOptions"
:key="item.value"
:label="item.label"
:value="item.value"
>
</el-option>
</el-select>
</template>
<script>
export default {
name: 'XVirtualSelect',
props: {
// peculiar
options: {
required: true,
type: Array,
},
viewportHeight: {
type: Number,
default: 274,
},
padding: {
type: Number,
default: 6,
},
itemHeight: {
type: Number,
default: 34,
},
reserveCount: {
type: Number,
default: 4,
},
// channel
value: {
required: true,
},
size: String,
disabled: Boolean,
clearable: Boolean,
filterable: Boolean,
allowCreate: Boolean,
loading: Boolean,
popperClass: String,
remote: Boolean,
loadingText: String,
noMatchText: String,
noDataText: String,
remoteMethod: Function,
multiple: Boolean,
multipleLimit: {
type: Number,
default: 0,
},
placeholder: {
type: String,
default() {
return '请选择'
},
},
defaultFirstOption: Boolean,
reserveKeyword: Boolean,
valueKey: {
type: String,
default: 'value',
},
collapseTags: Boolean,
popperAppendToBody: {
type: Boolean,
default: true,
},
},
data() {
return {
optionWrapper: null,
optionInnerContainer: null,
section: [],
optionsFiltered: null,
visible: false,
}
},
computed: {
visibleHeight() {
return this.viewportHeight - this.padding * 2
},
visibleCount() {
return Math.ceil(this.visibleHeight / this.itemHeight)
},
renderCount() {
return this.visibleCount + this.reserveCount
},
validOptions() {
return this.optionsFiltered || this.options
},
renderOptions() {
let [start, end] = this.section
return this.validOptions.slice(start, end)
},
virtualHeight() {
return this.itemHeight * this.validOptions.length
},
},
watch: {
virtualHeight() {
this.setVirtualHeight()
},
visible(nv) {
if (nv) {
this.init()
} else {
this.optionsFiltered = null
}
},
},
methods: {
// peculiar
init() {
let start = 0
let end = 0
if (this.value) {
let options = this.options
let value = this.value
for (let i = 0; i < options.length; i++) {
let option = options[i]
if (option.value === value) {
start = i
break
}
}
}
let halfVisibleCount = Math.ceil(this.visibleCount / 2)
start < halfVisibleCount ? (start = 0) : (start -= halfVisibleCount)
end = start + this.renderCount
if (this.optionWrapper && this.optionInnerContainer) {
let scrollTop = start * this.itemHeight
window.requestAnimationFrame(() => {
this.optionWrapper.scrollTop = scrollTop
this.optionInnerContainer.style.paddingTop = scrollTop + 'px'
})
}
this.section = [start, end]
},
reset() {
this.section = [0, this.renderCount]
this.optionWrapper.scrollTop = 0
this.optionInnerContainer.style.paddingTop = ''
},
filterMethod(value) {
value ? this.reset() : this.init()
this.optionsFiltered = this.options.filter((option) =>
option.label.includes(value),
)
},
setVirtualHeight() {
this.optionInnerContainer.style.height = `${this.virtualHeight}px`
},
// channel methods
focus() {
this.$refs.select.focus()
},
blur() {
this.$refs.select.blur()
},
// channel events
input(...args) {
this.$emit('input', ...args)
},
change(...args) {
this.$emit('change', ...args)
},
clear(...args) {
this.$emit('clear', ...args)
},
blurHandle(...args) {
this.$emit('blur', ...args)
},
focusHandle(...args) {
this.$emit('focus', ...args)
},
visibleChange(...args) {
this.visible = args[0]
this.$emit('visible-change', ...args)
},
removeTag(...args) {
this.$emit('remove-tag', ...args)
},
},
created() {
this.init()
},
mounted() {
this.$nextTick(() => {
let wrapper = this.$refs.select.$el.querySelector(
'.el-select-dropdown__wrap',
)
let inner = this.$refs.select.$el.querySelector('.el-scrollbar__view')
// caching for read performance
let itemHeight = this.itemHeight
let renderCount = this.renderCount
this.optionWrapper = wrapper
this.optionInnerContainer = inner
this.setVirtualHeight()
wrapper.addEventListener('scroll', (e) => {
let startIndex = Math.floor(wrapper.scrollTop / itemHeight)
let endIndex = startIndex + renderCount
inner.style.paddingTop = wrapper.scrollTop + 'px'
this.section = [startIndex, endIndex]
})
})
},
}
</script>
后台管理系统其实常与 virtual xx 打交道,但组件库大多都不支持就令人费解,无奈才得有这一次梳理思路、记录实现,往后理解取用也方便一些。
重要声明
以上皆为短期出差期间、业务开发之余针对甲方现有系统的小优化,委实用时仓促、细节不足,请君取其精华去之糟粕。