Array 数据通用渲染优化

192 阅读3分钟

问题描述

Tree / Table 组件的渲染,通常会出现需渲染超过千条(虚数,和实际渲染实例DOM结构负相关)的数据的情景, 在当前大众硬件条件和浏览器渲染机制的限制下,这必然会造成不太愉快的用户体验。

问题分析

下文皆基于 Vue 举例

我们知道当下主流浏览器使用的 JS 解释引擎,无论是 V8 还是 JavaScriptCore ,处理数百万条数据都不在话下,但为什么这一千条数据就成了浏览器过不去的坎呢?要解释这个问题,首先需要知道我们面前的网页经过了哪些重要阶段,我这里简单做了一个图

这里特意把 Vue 的 Compile 阶段设置为阶段 0 ,因为通常我们都是借助vue-loader提前完成编译,故其虽然作为主要耗时流程的一部分,但我们先忽略它。在这个渲染流程中,第 1,2 两步是由 JS 引擎处理的,他们通常不会是渲染瓶颈,正真的瓶颈在于 DOM 树操作和 Layout 过程。

方案分析

渲染优化最有效的就是减少数据的渲染,通常我们有如下三种方法:

  • 懒加载:根据用户操作,只渲染用户需要的少量数据
  • 切片渲染(对于响应式框架就是对数据切片):所有数据都在 JS 引擎内存中,但首次只渲染一个切片数据,其余数据在浏览器空闲时再渲染
  • 虚拟滚动:所有数据都在 JS 引擎内存中,但只渲染用户看得到的数据

根据当前需求和时间限制,我本次选择的是切片渲染方式。

“数据切片”方案实现

上面提到过,对于 Vue 这样的响应是框架,切片渲染就等同于“数据切片”,故我们只需要实现一个切片函数。 该函数有如下两个任务:

  1. 把数据分层 N 份,
  2. 在浏览器空闲的时候把数据赋值给响应式对象

然后响应式对象会通知框架生成 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次的平均值,性能数据获取方式如下:

  1. 记录开始渲染时间(用户感知时间)
  2. 数据赋值给响应式对象
  3. 在下一个 Tick 记录渲染结束时间
let renderStart = window.performance.now()
stupidFiber(this.treeData, treeData)
this.$nextTick(() => {
  console.log(window.performance.now() - renderStart)
})
数据条数优化前优化后优化率
10001201.8ms161ms87%
20002250.6ms315.6ms86%

“虚拟滚动”方案实现(补充)

以 ElementUI 的 el-select 举例

virtual select 和普通 select 不同的点,只就在于选项的渲染条数,于是我们在完善的 select 组件基础上进行封装时,只需关注引起选项变化的点。稍作思索便能列举如下:

  1. 过滤字符串发生改变时,显示过滤后的 options
  2. 滚动条位置变化时,显示正确位置的 options
  3. 下拉框显示时,滚动条位置归零,然后同上
  4. 携带初始值初始化时,确保正确的回显值(可以归为选项变化,也可做独立的特殊处理)

基于我们的分析和 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 打交道,但组件库大多都不支持就令人费解,无奈才得有这一次梳理思路、记录实现,往后理解取用也方便一些。

重要声明

以上皆为短期出差期间、业务开发之余针对甲方现有系统的小优化,委实用时仓促、细节不足,请君取其精华去之糟粕