Table
表格组件在 Web 开发中的应用随处可见,不过当表格数据量大后,伴随而来的是性能问题:渲染的 DOM 太多,渲染和交互都会有一定程度的卡顿。
通常,我们有两种优化表格的方式:一种是分页,另一种是虚拟滚动。这两种方式的优化思路都是减少 DOM 渲染的数量。在我们公司的项目中,会选择分页的方式,因为虚拟滚动不能正确的读出行的数量,会有 Accessibility 的问题。
记得 19 年的时候,我在 Zoom 已经推行了基于 Vue.js 的前后端分离的优化方案,并且基于 ElementUI 组件库开发了 ZoomUI。其中我们在重构用户管理页面的时候使用了 ZoomUI 的 Table
组件替换了之前老的用 jQuery 开发的 Table
组件。
因为绝大部分场景 Table
组件都是分页的,所以并不会有性能问题。但是在某个特殊场景下:基于关键词的搜索,可能会出现 200 * 20 条结果且不分页的情况,且表格是有一列是带有 checkbox
的,也就是可以选中某些行进行操作。
当我们去点选其中一行时,发现过了好久才选中,有明显的卡顿感,而之前的 jQuery 版本却没有这类问题,这一比较令人大跌眼镜。难道好好的技术重构,却要牺牲用户体验吗?
既然有性能问题,那么我们的第一时间的思路应该是要找出产生性能问题的原因。
首先,ZoomUI 渲染的 DOM 数量是要多于 jQuery 渲染的 Table
的,因此第一个思考方向是让 Table
组件尽可能地减少 DOM 的渲染数量。
20 列数据通常在屏幕下是展示不全的,老的 jQuery Table 实现很简单,底部有滚动条,而 ZoomUI 在这种列可滚动的场景下,支持了左右列的固定,这样在左右滑动过程中,可以固定某些列一直展示,用户体验更好,但这样的实现是有一定代价的。
想要实现这种固定列的布局,ElementUI 用了 6 个 table
标签来实现,那么为什么需要 6 个 table
标签呢?
首先,为了让 Table
组件支持丰富的表头功能,表头和表体都是各自用一个 table
标签来实现。因此对于一个表格来说,就会有 2 个 table
标签,那么再加上左侧 fixed
的表格,和右侧 fixed
的表格,总共有 6 个 table
标签。
在 ElementUI 实现中,左侧 fixed
表格和右侧 fixed
表格从 DOM 上都渲染了完整的列,然后从样式上控制它们的显隐:
但这么实现是有性能浪费的,因为完全不需要渲染这么多列,实际上只需要渲染固定展示的列的 DOM,然后做好高度同步即可。ZoomUI 就是这么实现的,效果如下:
当然,仅仅减少 fixed
表格渲染的列,性能的提升还不够明显,有没有办法在列的渲染这个维度继续优化呢?
这就是从业务层面的优化了,对于一个 20 列的表格,往往关键的列并没有多少,那么我们可不可以初次渲染仅仅渲染关键的列,其它列通过配置方式的渲染呢?
根据上述需求,我给 Table
组件添加了如下功能:
Table
组件新增一个 initDisplayedColumn
属性,通过它可以配置初次渲染的列,同时当用户修改了初次渲染的列,会在前端存储下来,便于下一次的渲染。
通过这种方式,我们就可以少渲染一些列。显然,列渲染少了,表格整体渲染的 DOM 数就会变少,对性能也会有一定的提升。
当然,仅仅通过优化列的渲染还是不够的,我们遇到的问题是当点选某一行引起的渲染卡顿,为什么会引起卡顿呢?
为了定位该问题,我用 Table
组件创建了一个 1000 * 7 的表格,开启了 Chrome 的 Performance 面板记录 checkbox
点选前后的性能。
在经过几次 checkbox
选择框的点选后,可以看到如下火焰图:
其中黄色部分是 Scripting
脚本的执行时间,紫色部分是 Rendering
所占的时间。我们再截取一次更新的过程:
然后观察 JS 脚本执行的 Call Tree,发现时间主要花在了 Table
组件的更新渲染上:
我们发现组件的 render to vnode
花费的时间约 600ms;vnode patch to DOM
花费的时间约 160ms。
为什么会需要这么长时间呢,因为点选了 checkbox
,在组件内部修改了其维护的选中状态数据,而整个组件的 render
过程中又访问了这个状态数据,因此当这个数据修改后,会引发整个组件的重新渲染。
而又由于有 1000 * 7 条数据,因此整个表格需要循环 1000 * 7 次去创建最内部的 td
,整个过程就会耗时较长。
那么循环的内部是不是有优化的空间呢?对于 ElementUI 的 Table
组件,这里有非常大的优化空间。
其实优化思路主要参考我之前写的 [《揭秘 Vue.js 九个性能优化技巧》] 其中的 Local variables
技巧。举个例子,在 ElementUI 的 Table
组件中,在渲染每个 td
的时候,有这么一段代码:
const data = {
store: this.store,
_self: this.context || this.table.$vnode.context,
column: columnData,
row,
$index
}
这样的代码相信很多小伙伴随手就写了,但却忽视了其内部潜在的性能问题。
由于 Vue.js 响应式系统的设计,在每次访问 this.store
的时候,都会触发响应式数据内部的 getter
函数,进而执行它的依赖收集,当这段代码被循环了 1000 * 7 次,就会执行 this.store 7000 次的依赖收集,这就造成了性能的浪费,而真正的依赖收集只需要执行一次就足够了。
解决这个问题其实也并不难,由于 Table
组件中的 TableBody
组件是用 render
函数写的,我们可以在组件 render
函数的入口处定义一些局部变量:
render(h) {
const { store /*...*/} = this
const context = this.context || this.table.$vnode.context
}
然后在渲染整个 render
的过程中,把局部变量当作内部函数的参数传入,这样在内部渲染 td
的渲染中再次访问这些变量就不会触发依赖收集了:
rowRender({store, context, /* ...其它变量 */}) {
const data = {
store: store,
_self: context,
column: columnData,
row,
$index,
disableTransition,
isSelectedRow
}
}
通过这种方式,我们把类似的代码都做了修改,就实现了 TableBody
组件渲染函数内部访问这些响应式变量,只触发一次依赖收集的效果,从而优化了 render
的性能。
来看一下优化后的火焰图:
从面积上看似乎 Scripting
的执行时间变少了,我们再来看它一次更新所需要的 JS 执行时间:
我们发现组件的 render to vnode
花费的时间约 240ms;vnode patch to DOM
花费的时间约 127ms。
可以看到,ZoomUI Table
组件的 render
的时间和 update
的时间都要明显少于 ElementUI 的 Table
组件。render
时间减少是由于响应式变量依赖收集的时间大大减少,update
的时间的减少是因为 fixed
表格渲染的 DOM 数量减少。
从用户的角度来看,DOM 的更新除了 Scripting
的时间,还有 Rendering
的时间,它们是共享一个线程的,当然由于 ZoomUI Table
组件渲染的 DOM 数量更少,执行 Rendering
的时间也更短。
仅仅从 Performance 面板的测试并不是一个特别精确的 benchmark,我们可以针对 Table
组件手写一个 benchmark。
我们可以先创建一个按钮,去模拟 Table
组件的选中操作:
<div>
<zm-button @click="toggleSelection(computedData[1])
">切换第二行选中状态
</zm-button>
</div>
<div>
更新所需时间: {{ renderTime }}
</div>
然后实现这个 toggleSelection
函数:
methods: {
toggleSelection(row) {
const s = window.performance.now()
if (row) {
this.$refs.table.toggleRowSelection(row)
}
setTimeout(() => {
this.renderTime = (window.performance.now() - s).toFixed(2) + 'ms'
})
}
}
我们在点击事件的回调函数中,通过 window.performance.now()
记录起始时间,然后在 setTimeout
的回调函数中,再去通过时间差去计算整个更新渲染需要的时间。
由于 JS 的执行和 UI 渲染占用同一线程,因此在一个宏任务执行过程中,会执行这俩任务,而 setTimeout 0
会把对应的回调函数添加到下一个宏任务中,当该回调函数执行,说明上一个宏任务执行完毕,此时做时间差去计算性能是相对精确的。
基于手写的 benchmark 得到如下测试结果:
ElementUI Table
组件一次更新的时间约为 900ms。
ZoomUI Table
组件一次更新的时间约为 280ms,相比于 ElementUI 的 Table
组件,性能提升了约三倍。
经过这一番优化,基本解决了文章开头提到的问题,在 200 * 20 的表格中去选中一列,已经并无明显的卡顿感了,但相比于 jQuery 实现的 Table,效果还是要差了一点。
虽然性能优化了三倍,但我还是有个心结:明明只更新了一行数据的选中状态,却还是重新渲染了整个表格,仍然需要在组件 render
的过程中执行多次的循环,在 patch
的过程中通过 diff
算法来对比更新。
最近我研究了 Vue.js 3.2 v-memo
的实现,看完源码后,我非常激动,因为发现这个优化技巧似乎可以应用到 ZoomUI 的 Table 组件中,尽管我们的组件库是基于 Vue 2 版本开发的。
我花了一个下午的时间,经过一番尝试,果然成功了,那么具体是怎么做的呢?先不着急,我们从 v-memo
的实现原理说起。
v-memo
是 Vue.js 3.2 版本新增的指令,它可以用于普通标签,也可以用于列表,结合 v-for
使用,在官网文档中,有这么一段介绍:
v-memo
仅供性能敏感场景的针对性优化,会用到的场景应该很少。渲染v-for
长列表 (长度大于 1000) 可能是它最有用的场景:
<div v-for="item in list" :key="item.id" v-memo="[item.id === selected]">
<p>ID: {{ item.id }} - selected: {{ item.id === selected }}</p>
<p>...more child nodes</p>
</div>
当组件的
selected
状态发生变化时,即使绝大多数item
都没有发生任何变化,大量的 VNode 仍将被创建。此处使用的v-memo
本质上代表着“仅在 item 从未选中变为选中时更新它,反之亦然”。这允许每个未受影响的item
重用之前的 VNode,并完全跳过差异比较。注意,我们不需要把item.id
包含在记忆依赖数组里面,因为 Vue 可以自动从item
的:key
中把它推断出来。
其实说白了 v-memo
的核心就是复用 vnode
,上述模板借助于在线模板编译工具,可以看到其对应的 render
函数:
import { renderList as _renderList, Fragment as _Fragment, openBlock as _openBlock, createElementBlock as _createElementBlock, toDisplayString as _toDisplayString, createElementVNode as _createElementVNode, isMemoSame as _isMemoSame, withMemo as _withMemo } from "vue"
const _hoisted_1 = /*#__PURE__*/_createElementVNode("p", null, "...more child nodes", -1 /* HOISTED */)
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}
基于 v-for
的列表内部是通过 renderList
函数来渲染的,来看它的实现:
function renderList(source, renderItem, cache, index) {
let ret
const cached = (cache && cache[index])
if (isArray(source) || isString(source)) {
ret = new Array(source.length)
for (let i = 0, l = source.length; i < l; i++) {
ret[i] = renderItem(source[i], i, undefined, cached && cached[i])
}
}
else if (typeof source === 'number') {
// source 是数字
}
else if (isObject(source)) {
// source 是对象
}
else {
ret = []
}
if (cache) {
cache[index] = ret
}
return ret
}
我们只分析 source
,也就是列表 list
是数组的情况,对于每一个 item
,会执行 renderItem
函数来渲染。
从生成的 render
函数中,可以看到 renderItem
的实现如下:
(item, __, ___, _cached) => {
const _memo = ([item.id === _ctx.selected])
if (_cached && _cached.key === item.id && _isMemoSame(_cached, _memo)) return _cached
const _item = (_openBlock(), _createElementBlock("div", {
key: item.id
}, [
_createElementVNode("p", null, "ID: " + _toDisplayString(item.id) + " - selected: " + _toDisplayString(item.id === _ctx.selected), 1 /* TEXT */),
_hoisted_1
]))
_item.memo = _memo
return _item
}
在 renderItem
函数内部,维护了一个 _memo
变量,它就是用来判断是否从缓存里获取 vnode
的条件数组;而第四个参数 _cached
对应的就是 item
对应缓存的 vnode
。接下来通过 isMemoSame
函数来判断 memo
是否相同,来看它的实现:
function isMemoSame(cached, memo) {
const prev = cached.memo
if (prev.length != memo.length) {
return false
}
for (let i = 0; i < prev.length; i++) {
if (prev[i] !== memo[i]) {
return false
}
}
// ...
return true
}
isMemoSame
函数内部会通过 cached.memo
拿到缓存的 memo
,然后通过遍历对比每一个条件来判断和当前的 memo
是否相同。
而在 renderItem
函数的结尾,就会把 _memo
缓存到当前 item
的 vnode
中,便于下一次通过 isMemoSame
来判断这个 memo
是否相同,如果相同,说明该项没有变化,直接返回上一次缓存的 vnode
。
那么这个缓存的 vnode
具体存储到哪里呢,原来在初始化组件实例的时候,就设计了渲染缓存:
const instance = {
// ...
renderCache: []
}
然后在执行 render
函数的时候,把这个缓存当做第二个参数传入:
const { renderCache } = instance
result = normalizeVNode(
render.call(
proxyToUse,
proxyToUse,
renderCache,
props,
setupState,
data,
ctx
)
)
然后在执行 renderList
函数的时候,把 _cahce
作为第三个参数传入:
export function render(_ctx, _cache, $props, $setup, $data, $options) {
return (_openBlock(true), _createElementBlock(_Fragment, null, _renderList(_ctx.list, (item, __, ___, _cached) => {
// renderItem 实现
}, _cache, 0), 128 /* KEYED_FRAGMENT */))
}
所以实际上列表缓存的 vnode
都保留在 _cache
中,也就是 instance.renderCache
中。
那么为啥使用缓存的 vnode
就能优化 patch
过程呢,因为在 patch
函数执行的时候,如果遇到新旧 vnode
相同,就直接返回,什么也不用做了。
const patch = (n1, n2, container, anchor = null, parentComponent = null, parentSuspense = null, isSVG = false, slotScopeIds = null, optimized = false) => {
if(n1 === n2) {
return
}
// ...
}
显然,由于使用缓存的 vnode
,它们指向同一个对象引用,直接返回,节约了后续执行 patch
过程的时间。
v-memo
的优化思路很简单,就是复用缓存的 vnode
,这是一种空间换时间的优化思路。
那么,前面我们提到在表格组件中选择状态没有变化的行,是不是也可以从缓存中获取呢?
顺着这思路,我给 Table
组件设计了 useMemo
这个 prop
,它其实是专门用于有选择列的场景。
然后在 TableBody
组件的 created
钩子函数中,创建了用于缓存的对象:
created() {
if (this.table.useMemo) {
if (!this.table.rowKey) {
throw new Error('for useMemo, row-key is required.')
}
this.vnodeCache = []
}
}
这里之所以把 vnodeCache
定义到 created
钩子函数中,是因为它并不需要变成响应式对象。
另外注意,我们会根据每一行的 key
作为缓存的 key
,因此 Table
组件的 rowKey
属性是必须的。
然后在渲染每一行的过程中,添加了 useMemo
相关的逻辑:
function rowRender({ /* 各种变量参数 */}) {
let memo
const key = this.getKeyOfRow({ row, rowIndex: $index, rowKey })
let cached
if (useMemo) {
cached = this.vnodeCache[key]
const currentSelection = store.states.selection
if (cached && !this.isRowSelectionChanged(row, cached.memo, currentSelection)) {
return cached
}
memo = currentSelection.slice()
}
// 渲染 row,返回对应的 vnode
const ret = rowVnode
if (useMemo && columns.length) {
ret.memo = memo
this.vnodeCache[key] = ret
}
return ret
}
这里的 memo
变量用于记录已选中的行数据,并且它也会在函数最后存储到 vnode
的 memo
,便于下一次的比对。
在每次渲染 row
的 vnode
前,会根据 row
对应的 key
尝试从缓存中取;如果缓存中存在,再通过 isRowSelectionChanged
来判断行的选中状态是否改变;如果没有改变,则直接返回缓存的 vnode
。
如果没有命中缓存或者是行选择状态改变,则会去重新渲染拿到新的 rowVnode
,然后更新到 vnodeCache
中。
当然,这种实现相比于 v-memo
没有那么通用,只去对比行选中的状态而不去对比其它数据的变化。你可能会问,如果这一行某列的数据修改了,但选中状态没变,再走缓存不就不对了吗?
确实存在这个问题,但是在我们的使用场景中,遇到数据修改,是会发送一个异步请求到后端,然获取新的数据再来更新表格数据。因此我只需要观测表格数据的变化清空 vnodeCache
即可:
watch: {
'store.states.data'() {
if (this.table.useMemo) {
this.vnodeCache = []
}
}
}
此外,我们支持列的可选则渲染功能,以及在窗口发生变化时,隐藏列也可能发生变化,于是在这两种场景下,也需要清空 vnodeCache
:
watch:{
'store.states.columns'() {
if (this.table.useMemo) {
this.vnodeCache = []
}
},
columnsHidden(newVal, oldVal) {
if (this.table.useMemo && !valueEquals(newVal, oldVal)) {
this.vnodeCache = []
}
}
}
以上实现就是基于 v-memo
的思路实现表格组件的性能优化。我们从火焰图上看一下它的效果:
我们发现黄色的 Scripting
时间几乎没有了,再来看它一次更新所需要的 JS 执行时间:
我们发现组件的 render to vnode
花费的时间约 20ms;vnode patch to DOM
花费的时间约 1ms,整个更新渲染过程, JS 的执行时间大幅减少。
另外,我们通过benchmark
测试,得到如下结果:
优化后,ZoomUI Table
组件一次更新的时间约为 80ms
,相比于 ElementUI 的 Table
组件,性能提升了约十倍。
这个优化效果还是相当惊人的,并且从性能上已经不输 jQuery Table 了,我两年的心结也随之解开了。
Table
表格性能提升主要是三个方面:减少 DOM 数量、优化 render
过程以及复用 vnode
。有些时候,我们还可以从业务角度思考,去做一些优化。
虽然 useMemo
的实现还比较粗糙,但它目前已满足我们的使用场景了,并且当数据量越大,渲染的行列数越多,这种优化效果就越明显。如果未来有更多的需求,更新迭代就好。
由于一些原因,我们公司仍然在使用 Vue 2,但这并不妨碍我去学习 Vue 3,了解它一些新特性的实现原理以及设计思想,能让我开拓不少思路。
从分析定位问题到最终解决问题,希望这篇文章能给你在组件的性能优化方面提供一些思路,并应用到日常工作中。
本文由哈喽比特于3年以前收录,如有侵权请联系我们。
文章来源:https://mp.weixin.qq.com/s/zsQlvct8gPXj5HneBM3L1Q
京东创始人刘强东和其妻子章泽天最近成为了互联网舆论关注的焦点。有关他们“移民美国”和在美国购买豪宅的传言在互联网上广泛传播。然而,京东官方通过微博发言人发布的消息澄清了这些传言,称这些言论纯属虚假信息和蓄意捏造。
日前,据博主“@超能数码君老周”爆料,国内三大运营商中国移动、中国电信和中国联通预计将集体采购百万台规模的华为Mate60系列手机。
据报道,荷兰半导体设备公司ASML正看到美国对华遏制政策的负面影响。阿斯麦(ASML)CEO彼得·温宁克在一档电视节目中分享了他对中国大陆问题以及该公司面临的出口管制和保护主义的看法。彼得曾在多个场合表达了他对出口管制以及中荷经济关系的担忧。
今年早些时候,抖音悄然上线了一款名为“青桃”的 App,Slogan 为“看见你的热爱”,根据应用介绍可知,“青桃”是一个属于年轻人的兴趣知识视频平台,由抖音官方出品的中长视频关联版本,整体风格有些类似B站。
日前,威马汽车首席数据官梅松林转发了一份“世界各国地区拥车率排行榜”,同时,他发文表示:中国汽车普及率低于非洲国家尼日利亚,每百户家庭仅17户有车。意大利世界排名第一,每十户中九户有车。
近日,一项新的研究发现,维生素 C 和 E 等抗氧化剂会激活一种机制,刺激癌症肿瘤中新血管的生长,帮助它们生长和扩散。
据媒体援引消息人士报道,苹果公司正在测试使用3D打印技术来生产其智能手表的钢质底盘。消息传出后,3D系统一度大涨超10%,不过截至周三收盘,该股涨幅回落至2%以内。
9月2日,坐拥千万粉丝的网红主播“秀才”账号被封禁,在社交媒体平台上引发热议。平台相关负责人表示,“秀才”账号违反平台相关规定,已封禁。据知情人士透露,秀才近期被举报存在违法行为,这可能是他被封禁的部分原因。据悉,“秀才”年龄39岁,是安徽省亳州市蒙城县人,抖音网红,粉丝数量超1200万。他曾被称为“中老年...
9月3日消息,亚马逊的一些股东,包括持有该公司股票的一家养老基金,日前对亚马逊、其创始人贝索斯和其董事会提起诉讼,指控他们在为 Project Kuiper 卫星星座项目购买发射服务时“违反了信义义务”。
据消息,为推广自家应用,苹果现推出了一个名为“Apps by Apple”的网站,展示了苹果为旗下产品(如 iPhone、iPad、Apple Watch、Mac 和 Apple TV)开发的各种应用程序。
特斯拉本周在美国大幅下调Model S和X售价,引发了该公司一些最坚定支持者的不满。知名特斯拉多头、未来基金(Future Fund)管理合伙人加里·布莱克发帖称,降价是一种“短期麻醉剂”,会让潜在客户等待进一步降价。
据外媒9月2日报道,荷兰半导体设备制造商阿斯麦称,尽管荷兰政府颁布的半导体设备出口管制新规9月正式生效,但该公司已获得在2023年底以前向中国运送受限制芯片制造机器的许可。
近日,根据美国证券交易委员会的文件显示,苹果卫星服务提供商 Globalstar 近期向马斯克旗下的 SpaceX 支付 6400 万美元(约 4.65 亿元人民币)。用于在 2023-2025 年期间,发射卫星,进一步扩展苹果 iPhone 系列的 SOS 卫星服务。
据报道,马斯克旗下社交平台𝕏(推特)日前调整了隐私政策,允许 𝕏 使用用户发布的信息来训练其人工智能(AI)模型。新的隐私政策将于 9 月 29 日生效。新政策规定,𝕏可能会使用所收集到的平台信息和公开可用的信息,来帮助训练 𝕏 的机器学习或人工智能模型。
9月2日,荣耀CEO赵明在采访中谈及华为手机回归时表示,替老同事们高兴,觉得手机行业,由于华为的回归,让竞争充满了更多的可能性和更多的魅力,对行业来说也是件好事。
《自然》30日发表的一篇论文报道了一个名为Swift的人工智能(AI)系统,该系统驾驶无人机的能力可在真实世界中一对一冠军赛里战胜人类对手。
近日,非营利组织纽约真菌学会(NYMS)发出警告,表示亚马逊为代表的电商平台上,充斥着各种AI生成的蘑菇觅食科普书籍,其中存在诸多错误。
社交媒体平台𝕏(原推特)新隐私政策提到:“在您同意的情况下,我们可能出于安全、安保和身份识别目的收集和使用您的生物识别信息。”
2023年德国柏林消费电子展上,各大企业都带来了最新的理念和产品,而高端化、本土化的中国产品正在不断吸引欧洲等国际市场的目光。
罗永浩日前在直播中吐槽苹果即将推出的 iPhone 新品,具体内容为:“以我对我‘子公司’的了解,我认为 iPhone 15 跟 iPhone 14 不会有什么区别的,除了序(列)号变了,这个‘不要脸’的东西,这个‘臭厨子’。