Taro使用VirtualList来加载大数量的DOM节点

2,783 阅读7分钟

起因

因为公司要开发多端小程序,经过研究就选中了凹凸实验室的Taro框架。之前我们就用原生的语言写了微信小程序,现在就将微信小程序的代码都用Taro翻一遍。

遇到一个页面是这样的,页面上有很多品牌名,可以通过拖拽页面浏览品牌名,可以通过点击右边侧边栏字母可以快速定位到点击字母开头的品牌名列表,也可以通过输入字母去匹配复合的品牌名。通过点击选择或者反选品牌名,选中的品牌名会列在上方。点击最下方的清除按钮可以清除所有选中状态,点击确定就表示选择完成,返回上一页。过程如图:

brands

看起来就很简单的代码,就把原来代码的逻辑搬过来用了用:把品牌名含有的特殊字符处理了一下,根据品牌名的首字母划分一个二维数组,同时维护一个Map来方便快速找到需要处理的数据。将数据放到ScrollView组件中,然后通过品牌名数量计算了一下高度,控制当前页面最顶上品牌的首字母,用ScrollView组件的scrollIntoView来处理侧边栏字母的定位。大概看了两天源代码,然后又花了2天把代码搬过来就愉快的提交了。处理好就开开心心去过五一了,假后上班,跑了一下build,用真机测试了一下。然后发现页面卡的不忍直视,没有性能可言,于是乎开始了各种技术方案的踩坑之路。

填坑之路

性能差的原因

在Taro的官方文档中有这么一说

Taro Next 在一个页面加载时需要经历以下步骤:

  1. 框架(React/Nerv/Vue)把页面渲染到虚拟 DOM 中
  2. Taro 运行时把页面的虚拟 DOM 序列化为可渲染数据,并使用 setData() 驱动页面渲染
  3. 小程序本身渲染序列化数据 和原生小程序或编译型小程序框架相比,步骤 1 和 步骤 2 是多余的。如果页面的业务逻辑代码没有性能问题的话,大多数性能瓶颈出在步骤 2 的 setData() 上:由于初始化渲染是页面的整棵虚拟 DOM 树,数据量比较大,因此 setData() 需要传递一个比较大的数据,导致初始化页面时会一段白屏的时间。这样的情况通常发生在页面初始化渲染的 wxml 节点数比较大或用户机器性能较低时发生。 看了眼品牌数量,原来有两千多个。两千多个节点就已经让Taro白屏好几秒。

问题列表

  1. 刚进入brands,页面会长时间出现白屏,渲染缓慢。
  2. 在点击选择品牌名的时候,页面重新渲染非常缓慢。
  3. 在品牌列表最上方会显示A,B,C,D等来显示当前页面品牌的首字母,在滑动的时候切换很慢
  4. 点击右侧的字母栏,定位到选中品牌非常慢

问题一和问题二

这两个问题都是处理渲染问题的,也是Virtual List的实践,所以放在一起处理。

预渲染

官方文档用预渲染处理,在 /config/index.js/config/dev.js/config/prod.js 中加入

const config = {
  ...
  mini: {
    prerender: {
      match: 'pages/**/brands
    }
  }
};

module.exports = config

但是在build以后报错了,taro引用包报错。没找到对应的解决方案,就抛弃了这个操作。

先渲染一部分数据

这个方案就是一种预渲染,首先渲染大概70个品牌名(大概就是白屏的时间可以向下滑动的页面数量,大约是6个页面的长度)。让页面快速加载数据,完成渲染。在页面加载完毕以后,重新渲染全部品牌。

onReady() {
  Taro.nextTick(() => {
    //加载全部的品牌名
  })
}

这样处理以后,页面加载可以做到秒开,但是点击选中品牌依旧是非常卡顿。所以还有待优化。

品牌名组件化

在上面先渲染一部分数据的情况下,将一行行的品牌名做成一个个组件。渲染的时候使用外部传入的props中的isSelected,之后点击交互就用组件内部的state来维护。通过点击事件,将选择状态传到品牌页面,更新数据。

function Index(props) {
  const { name, isSelected, onTap } = props
  const [ selected, setSelected ] = useState(isSelected)
  
  const handleClick = (status) => {
    setState(!status)
    onTap & onTap(!status) // 把选中状态传到页面
  }
  
  return (
    <View className='' onClick={handleClick.bind(this, selected} >
        { name }
    </View>
  )
}

这样处理以后,发现渲染的速度还是很慢,并且外面没法改变组件内部的状态。页面点击清除按钮,无法将选中的状态清除。只能放弃。

Virtual List

使用Virtual List以后,可以完美解决以上两个问题,缺点就是在快速滑动页面的情况下,页面来不及渲染,就会一直白屏,等滑动停止以后,页面会渲染出品牌名。

问题三:在品牌列表最上方会显示A,B,C,D等来显示当前页面品牌的首字母,在滑动的时候切换很慢

css解决

position: sticky 可以便捷吸顶问题。 设置元素CSS:

view {
  position: sticky;
  top: 20px;
}

元素根据正常文档流进行定位,会依据最近的拥有滚动机制的祖先元素(不能有overflow:visible以外的 overflow 属性)的top、left、right和bottom发生偏移。更多了解 position: sticky 虽然通过css代码解决了品牌首字母变化问题,但是当元素变成position: sticky,该元素展示的首字母对应品牌列表都会滑出屏幕。在选择右边快捷定位的时候,点击以后,只有代表的A、B、C...首字母置顶,对应的品牌名列表都没有出现在屏幕内。就放弃这个简单取巧的方法了。

IntersectionObserver

设置一个IntersectionObserver,通过IntersectionObserver.relativeToViewport的固定位置设置参照,如果相交,就将置顶的首字母变成相交的A,B,C,D等首字母。但是在首字母发生变化的时候,页面的UI也会相应发生变化,再引起observer的变化,在边界的情况下,会出现两个字母不断闪烁的情况。又放弃了。

计算高度

计算每个首字母所有的品牌列表的数量,通过数量与单个品牌名的UI高度相乘,计算出每个首字母开头的品牌的高度,得到一个高度数组。通过scroll来得到scrollOffset,落在哪个高度区间就显示哪个首字母。方案可行。

问题四:点击右侧的字母栏,定位到选中品牌非常慢

ScrollView的scrollIntoView

每个首字母位置都可以设置一个元素,设置好id。就可以使用scrollIntoView来快速定位。但是ScrollView的渲染非常慢。放弃

VirtualList的scrollTop

同问题三的解决方法,点击那个首字母,就快速拿到他的高度起始位置,设置scrollTop的值就可以快速定位。有一个缺点是在定位以后移动一小段距离,点击同一个位置,不会发生跳转。稍微使用一些代码解决。代码如下:

  const handleScrollToTitle = (title: string) => {
    const offset = brandLetters.get(title) || 0 // 得到该首字母在整个队列中的起始高度

    if (offset === scrollTop) {
      // 相等就是同一个字母连续点了两次,将offset - 1来绕过去
      setScrollTop(offset - 1)
      setTimeout(() => setScrollTop(offset), 0)
    } else {
      setScrollTop(offset)
    }
  }

使用Virtual List以后,就有个如下效果

修改以后brand页面

第一次使用Virtual List,整理了一下整个页面的实现遇到问题。特别要感谢一下Alex在边上给出的各种解决方案,帮助我把这个页面写完了!可以体验一下,从筛选进去,有个品牌,点进去就是了。

例子