列表虚拟化
性能优化 | Vue.js https://cn.vuejs.org/guide/best-practices/performance.html#virtualize-large-lists
更新优化#
Props 稳定性#
在 Vue 之中,一个子组件只会在其至少一个 props 改变时才会更新。思考以下示例:
<ListItem
v-for="item in list"
:id="item.id"
:active-id="activeId" />
在 <ListItem>
组件中,它使用了 id
和 activeId
两个 props 来确定它是否是当前活跃的那一项。虽然这是可行的,但问题是每当 activeId
更新时,列表中的每一个 <ListItem>
都会跟着更新!
理想情况下,只有活跃状态发生改变的项才应该更新。我们可以将活跃状态比对的逻辑移入父组件来实现这一点,然后让 <ListItem>
改为接收一个 active
prop:
<ListItem
v-for="item in list"
:id="item.id"
:active="item.id === activeId" />
现在,对于大多数的组件来说,activeId
改变时,它们的 active
prop 都会保持不变,因此它们无需再更新。总结一下,这个技巧的核心思想就是让传给子组件的 props 尽量保持稳定。
v-once
#
v-once
是一个内置的指令,可以用来渲染依赖运行时数据但无需再更新的内容。它的整个子树都会在未来的更新中被跳过。查看它的 API 参考手册可以了解更多细节。
v-memo
#
v-memo
是一个内置指令,可以用来有条件地跳过某些大型子树或者 v-for
列表的更新。查看它的 API 参考手册可以了解更多细节。
通用优化#
以下技巧能同时改善页面加载和更新性能。
大型虚拟列表#
所有的前端应用中最常见的性能问题就是渲染大型列表。无论一个框架性能有多好,渲染成千上万个列表项都会变得很慢,因为浏览器需要处理大量的 DOM 节点。
但是,我们并不需要立刻渲染出全部的列表。在大多数场景中,用户的屏幕尺寸只会展示这个巨大列表中的一小部分。我们可以通过列表虚拟化来提升性能,这项技术使我们只需要渲染用户视口中能看到的部分。
要实现列表虚拟化并不简单,幸运的是,你可以直接使用现有的社区库:
减少大型不可变数据的响应性开销#
Vue 的响应性系统默认是深度的。虽然这让状态管理变得更直观,但在数据量巨大时,深度响应性也会导致不小的性能负担,因为每个属性访问都将触发代理的依赖追踪。好在这种性能负担通常这只有在处理超大型数组或层级很深的对象时,例如一次渲染需要访问 100,000+ 个属性时,才会变得比较明显。因此,它只会影响少数特定的场景。
Vue 确实也为此提供了一种解决方案,通过使用 shallowRef()
和 shallowReactive()
来绕开深度响应。浅层式 API 创建的状态只在其顶层是响应式的,对所有深层的对象不会做任何处理。这使得对深层级属性的访问变得更快,但代价是,我们现在必须将所有深层级对象视为不可变的,并且只能通过替换整个根状态来触发更新:
const shallowArray = shallowRef([
/* 巨大的列表,里面包含深层的对象 */
])
// 这不会触发更新...
shallowArray.value.push(newObject)
// 这才会触发更新
shallowArray.value = [...shallowArray.value, newObject]
// 这不会触发更新...
shallowArray.value[0].foo = 1
// 这才会触发更新
shallowArray.value = [
{
...shallowArray.value[0],
foo: 1
},
...shallowArray.value.slice(1)
]
避免不必要的组件抽象#
有些时候我们会去创建无渲染组件或高阶组件 (用来渲染具有额外 props 的其他组件) 来实现更好的抽象或代码组织。虽然这并没有什么问题,但请记住,组件实例比普通 DOM 节点要昂贵得多,而且为了逻辑抽象创建太多组件实例将会导致性能损失。
需要提醒的是,只减少几个组件实例对于性能不会有明显的改善,所以如果一个用于抽象的组件在应用中只会渲染几次,就不用操心去优化它了。考虑这种优化的最佳场景还是在大型列表中。想象一下一个有 100 项的列表,每项的组件都包含许多子组件。在这里去掉一个不必要的组件抽象,可能会减少数百个组件实例的无谓性能消耗。
客服IM消息列表虚拟滚动技术实践 | 得物技术 https://mp.weixin.qq.com/s/Ovf0LZe74mD3LH4tWnhcTg
1
场景分析
在IM系统中,核心事件都是围绕着“聊天”这个主题展开的,在聊天的过程中,获悉用户的需求,再通过系统集成的各种工具,帮助用户完成诉求;“聊天”在IM业务中就是“会话消息”,当客服与用户之间存在大量聊天消息的时候,如何更好的去加载用户历史消息,提升客服查看消息体验,是一个值得研究的方向。
由于聊天室的特殊布局,历史消息加载需要用到虚拟滚动的方式去实现,如果想要更好的性能,还需要使用虚拟列表技术,而虚拟滚动技术又分为“上拉加载”和“下拉加载”,在移动端领域,还需要“下拉刷新”,如何选择合适的技术方案是我们接下来需要讨论的问题。
2
虚拟滚动技术调研
虚拟滚动技术的使用场景主要是在布局空间较小,不方便添加分页器的页面,例如移动端列表页,IM系统左侧进线会话列表,会话消息列表,右侧功能区域订单/商品查询列表等。
例如:会话进线列表,商品查询列表可以用到上拉加载,会话消息列表需要用到下拉加载,在移动端,页面刷新还需要用到下拉刷新。
下拉加载、上拉加载、下拉刷新方案对比:
技术方案 |
触发方式 |
应用场景 |
技术特点/难点 |
下拉加载 |
滚动到页面顶部触发 |
会话消息列表数据加载 |
需要解决回滚定位不准的问题,还需要关注页面图片/视频资源的对滚动定位的影响 |
上拉加载 |
滚动到页面底部触发 |
订单/商品列表数据加载,select下拉框,移动端列表页面 |
需要计算滚动到页面底部,加载滚动体验较好,更符合用户的视觉感受 |
下拉刷新 |
拖动页面顶部向下移动一定距离触发 |
H5页面刷新 |
需要处理好下拉橡皮筋效果,成功后刷新页面 |
上面对我们系统中需要用到的加载/刷新技术做了简单的实现和应用场景对比,其中上拉加载,下拉刷新不作为此次讨论的重点,且社区中实现的方案和博客也较多,我们此次重点讨论的是下拉加载在IM会话消息中的应用和体验优化。
3
下拉加载在会话消息的应用
3.1 会话消息历史数据下拉加载流程
历史数据拉取会经历三个过程:
- 用户滚动消息到页顶,触发加载机制,在拉取数据的过程中,顶部展示一个“数据正在加载中”的loading文案,告知用户需要等待加载结果的完成;
- 数据返回之后,会被置于原数据的顶部(array.unshift(newArray)),渲染后原来的内容就会被新的内容压到页面底部;
- 为了提高用户的体验,还需要将页面滚动到滚动条最后停留的位置(加载前最后一条消息位置)
3.2 如何实现下拉加载
- 监听页面scroll事件
// 监听会话消息区域添加滚动监听事件
const listenScrollEvent = () => {
chatMsgContainer.value.addEventListener('scroll', scrolHandle)
}
// 滚动逻辑处理回调函数
const scrolHandle = throttle(event => {
const { scrollHeight, scrollTop } = chatMsgContainer.value || {}
const { target } = event || {}
// 记录下当前会话滚动位置,切换会话的时候需要回滚到最后停留的位置
userInfo.value.scrollPosition = scrollHeight - scrollTop || 0
// 超出一屏,滚动到顶部,且没有拉取完所有的数据
if (
target.scrollTop === 0 &&
target.scrollHeight > target.clientHeight &&
!userInfo.value?.isComplete
) {
handleScrollEvent(event) // 拉取历史消息
}
}, 300)
-
监听数据变化执行回滚动作
// 消息滚动
const handleMessageScroll = (len: number, oldLen: number) => {
if (!len) return
let msgScrollTimer = null
let targetDom = null
nextTick(() => {
// 获取到加载后最后一条数据位置
const recentlyMsg = messagePools[len - 1]
// 计算新加载数据条数
const calcMsgLenDiff = len - oldLen
// 首次加载数据的时候让滚动条滚动到最底部
if (len <= LIMIT_MESSAGE) {
// msgid是会话中的唯一标识,可以用此作为唯一ID
targetDom = document.querySelector(recentlyMsg.msgid)
// true 元素的顶部将对齐到可滚动祖先的可见区域的顶部。对应于scrollIntoViewOptions: {block: "start", inline: "nearest"}
firstDom?.scrollIntoView?.(true)
} else if (calcMsgLenDiff <= 1 && !recentlyMsg?.isHistory) {
// 这里用来处理用户/客服发送消息滚动逻辑
handleUserOrCustomerMsg()
} else if (calcMsgLenDiff >= 1) {
// 拉取历史消息逻辑
// 获取到加载前最后一条数据位置
const prevLastMsg = messagePools[calcMsgLenDiff - 1]
targetDom = document.querySelector(prevLastMsg.msgid)
targetDom?.scrollIntoView?.()
}
userInfo.value.isShowLoading = false
})
}
// 监听会话消息数据变化
watch(
() => messagePools.length,
(len, oldLen) => {
handleMessageScroll(len, oldLen)
},
{
immediate: true
}
)
-
下拉加载体验优化方案及效果
如果只是按照上面的方式去处理,当页面中存在图片/视频的情况下,由于图片/视频渲染慢于普通文本,在加载图片/视频类型的消息的时候,回滚的位置就会有偏差,不能准确的回滚到预期的位置,我们对以下三种方案进行了对比实现,最终选择了反向渲染加载的方案,如下:
3.2.1 setTimeout延时回滚方案
- 优点:简单易实现,只需要设置一个合适的定时器时间,对于大部分场景都能回滚正确;
-
缺点:可靠性较低,资源加载慢的情况下,也会出现回滚不准确的情况,且setTimeout会带来页面闪烁的问题;
// 消息滚动
const handleMessageScroll = (len: number, oldLen: number) => {
if (!len) return
let msgScrollTimer = null
let targetDom = null
nextTick(() => {
// 获取到加载后最后一条数据位置
const recentlyMsg = messagePools[len - 1]
// 计算新加载数据条数
const calcMsgLenDiff = len - oldLen
// 首次加载数据的时候让滚动条滚动到最底部
if (len <= LIMIT_MESSAGE) {
...
// 针对图片/视频渲染慢的场景做个补偿
msgScrollTimer = setTimeout(() => {
clearTimeout(msgScrollTimer)
firstDom?.scrollIntoView?.(true)
}, SCROLL_THRESHOLD)
} else if (calcMsgLenDiff <= 1 && !recentlyMsg?.isHistory) {
// 这里用来处理用户/客服发送消息滚动逻辑
handleUserOrCustomerMsg()
} else if (calcMsgLenDiff >= 1) {
// 拉取历史消息逻辑
// ...
// 针对图片/视频渲染慢的场景做个补偿
msgScrollTimer = setTimeout(() => {
clearTimeout(msgScrollTimer)
targetDom?.scrollIntoView?.()
}, SCROLL_THRESHOLD)
}
userInfo.value.isShowLoading = false
})
}
3.2.2 监听img/vedio的onload事件方案
- 优点:可以回滚的精准度较高,没有页面闪烁的问题;
-
缺点:如果不是虚拟列表,每次滚动的时候可能会有大量的DOM节点查询操作,造成页面滚动卡顿;
const allImgOrVedioLoaded = async() => {
const imgNodes = document.querySelectorAll('.messageWrapper img') || []
const vedioNodes = document.querySelectorAll('.messageWrapper vedio') || []
const promises = [...imgNodes, ...vedioNodes]
// 等待所有的资源加载完成,无论成功还是失败
return await Promise.allSettled(
promises.map(source => {
new Promise(resolve => {
source.addEventListener('load', () => resolve(source))
})
})
)
}
// 消息滚动
const handleMessageScroll = (len: number, oldLen: number) => {
if (!len) return
let msgScrollTimer = null
let targetDom = null
nextTick(() => {
...
// 等待img/vedio所有资源加载完成,执行回滚操作
allImgOrVedioLoaded().then(() => {
firstDom.scrollIntoView(true)
})
} else if (calcMsgLenDiff <= 1 && !recentlyMsg?.isHistory) {
// 这里用来处理用户/客服发送消息滚动逻辑
handleUserOrCustomerMsg()
} else if (calcMsgLenDiff >= 1) {
// 拉取历史消息逻辑
// ...
// 等待img/vedio所有资源加载完成,执行回滚操作
allImgOrVedioLoaded().then(() => {
targetDom.scrollIntoView()
})
}
userInfo.value.isShowLoading = false
})
}
定时器/onload方案下拉加载回滚流程图:
最终效果:
3.2.3 反向渲染加载方案
前面我们有提到过“上拉加载”,当滚动到底部加载新的一页的数据,数据从底部添加,无需执行回滚动作,整体的体验更加流畅自然。既然“上拉加载”有这么多好处,那我们可不可以使用这样的方式来模仿我们的“下拉加载”呢?显然是可以的,我们页面布局在使用flex布局的情况下,可以反转主轴,这样我们就可以像“上拉加载”一样,触发到页面底部的时候,就去拉取新的历史数据,且反向渲染只是数据的反转,并不会带来视觉上的反转;
display: flex;
flex-direction: column-reverse;
最终效果:
3.3 带来的效果
4
总结
在IM应用中,会话消息列表扮演着很重要的角色,是用户与客服沟通结果最终呈现的地方,所以想要提升页面的加载性能和用户体验,下拉加载性能和体验一直是一个重要的指标,当然对于大列表组件最好结合使用虚拟列表技术,尽量少的DOM渲染和尽量精准的滚动效果才能给客服带来最极致的体验。最后做个总结:在IM会话消息列表体验优化事项中我们对“上拉加载”、“下拉加载”、“下拉刷新”的技术特点和使用场景做了分析,然后对于下拉加载精确回滚这个场景,提出了三种解决方案:“定时器方案”、“等待图片/视频资源onload完成方案”、“反向渲染方案”;这三种方案各有利弊,希望能对读者带来一些启发和帮助。