Cell单元格组件开发与性能优化实战经验分享
项目初期的技术选型
上个月在做一个移动端的配置管理后台,需求是展示大量结构化的数据条目,每条包含标题、描述、状态、操作按钮等。UI 设计稿里用的是类似 iOS 设置页那种一行一行的单元格样式,产品经理直接叫它“Cell”。一开始我以为随便套个现成的组件库就行,结果一翻文档发现:主流 UI 库(比如 Vant、Ant Design Mobile)的 Cell 组件要么太重,要么定制性差,尤其在动态内容和交互反馈上卡得很死。
折腾了两天,最后决定自己写一个轻量级的 Cell 组件。核心诉求就三点:支持任意 slot 内容、点击反馈要快、能灵活控制左右布局。反正也不复杂,无非是 div 套 div,关键是怎么把坑避开。
核心代码就这几行
先上基础结构,其实真没多少东西:
<template>
<div
class="cell"
:class="{ 'cell--clickable': onClick }"
@click="handleClick"
>
<div class="cell__left">
<slot name="left" />
</div>
<div class="cell__center">
<slot name="title" />
<slot name="desc" />
</div>
<div class="cell__right">
<slot name="right" />
</div>
</div>
</template>
.cell {
display: flex;
padding: 12px 16px;
background: #fff;
border-bottom: 1px solid #eee;
}
.cell--clickable {
cursor: pointer;
transition: background-color 0.1s;
}
.cell--clickable:active {
background-color: #f5f5f5;
}
.cell__left,
.cell__right {
flex-shrink: 0;
margin-right: 12px;
}
.cell__right {
margin-right: 0;
margin-left: auto;
text-align: right;
}
逻辑部分也很简单,主要是透传点击事件:
export default {
props: {
onClick: Function
},
methods: {
handleClick(e) {
if (this.onClick) {
this.onClick(e);
}
}
}
}
就这么点代码,跑起来基本满足需求。左边放图标,中间标题+描述,右边放开关或箭头,很清爽。但问题很快来了——性能。
最大的坑:性能问题
页面要渲染 200+ 条 Cell,每条还带异步加载的状态(比如从 API 拿设备在线状态)。刚开始直接 v-for 渲染,结果滚动卡成 PPT,iOS Safari 尤其严重。我一开始以为是 Vue 的 diff 问题,后来用 Performance 面板一看,发现每次状态更新都触发了整行重排,因为 Cell 里嵌套了太多动态元素。
最头疼的是右边那个状态指示器,可能是个 loading 动画,也可能是个红绿灯图标。之前偷懒直接用 v-if 切换:
<template #right>
<div v-if="loading">加载中...</div>
<div v-else-if="online">🟢</div>
<div v-else>🔴</div>
</template>
结果每次 loading 或 online 变化,整个 .cell__right 都要重建,连带文字重排。后来改成用 CSS 控制显示隐藏,避免 DOM 结构变动:
<template #right>
<div class="status-indicator">
<span class="status-text" :class="{ hidden: !loading }">加载中...</span>
<span class="status-icon" :class="{ hidden: loading || !online }">🟢</span>
<span class="status-icon" :class="{ hidden: loading || online }">🔴</span>
</div>
</template>
.hidden {
display: none;
}
这招确实缓解了重排,但 200 条同时更新时还是掉帧。最后咬牙上了虚拟滚动,用 vue-virtual-scroll-list 包了一层。虽然引入了新依赖,但滚动流畅度直接拉满,实测 60fps 稳得很。不过虚拟滚动有个副作用:Cell 高度必须固定,或者至少能预估。我们设计稿里 Cell 高度是 60px,所以还好,加个 estimated-size="60" 就行。
踩坑提醒:这三点一定注意
- 不要滥用 slot:一开始为了让组件“通用”,我给 Cell 开了 5 个 slot(left、title、desc、extra、right)。结果业务方真的往 extra 里塞了个 mini 图表,导致每行高度不一致,虚拟滚动直接崩了。后来强制规定:只有 title 和 desc 能换行,其他一律单行,超出省略。
- 点击反馈别用 JS 动画:早期用 JS 改背景色,延迟明显。后来全切 CSS
:active,虽然 Android 有些机型有 300ms 延迟,但加个touch-action: manipulation就好了。 - 别在 Cell 里发请求:有同事图省事,在 Cell 组件里直接
created()里调 API 拿数据。结果 200 个 Cell 同时发请求,浏览器直接限流。后来统一改成父组件批量请求,通过 props 传状态。
最终的解决方案
综合下来,我们的 Cell 组件现在长这样:
<RecycleScroller
class="scroller"
:items="items"
:item-size="60"
key-field="id"
>
<template #default="{ item }">
<MyCell
:key="item.id"
@click="handleCellClick(item)"
>
<template #left>
<img :src="item.icon" width="24" height="24" />
</template>
<template #title>{{ item.name }}</template>
<template #desc>{{ item.description }}</template>
<template #right>
<StatusIndicator :status="item.status" />
</template>
</MyCell>
</template>
</RecycleScroller>
其中 StatusIndicator 是单独抽出来的无状态组件,只负责根据 props 渲染对应状态,避免 Cell 本身逻辑膨胀。数据全部由父组件通过 API 获取:
// 父组件
async fetchData() {
const res = await fetch('https://jztheme.com/api/devices');
this.items = res.data.map(item => ({
...item,
status: 'loading' // 初始状态
}));
// 然后再分批查状态,避免一次性请求太多
this.batchCheckStatus();
}
这样拆分后,Cell 本身变得非常轻,只负责展示,性能问题基本解决。
回顾与反思
现在回头看,这个 Cell 组件其实挺糙的,很多地方能优化。比如虚拟滚动虽然解决了性能,但增加了复杂度;状态指示器用 CSS 切换虽然快,但可访问性(a11y)没考虑,屏幕阅读器可能读不到状态变化。另外,如果未来需求变了,比如 Cell 高度要动态,那虚拟滚动方案就得重做。
但项目时间紧,这种“够用就好”的方案反而最实用。毕竟不是所有场景都需要完美架构,有时候快速交付比技术优雅更重要。这个 Cell 组件上线后没出过性能相关的 bug,用户反馈也说“滑得挺顺”,那就够了。
以上是我个人对这个 Cell 组件的完整踩坑记录,有更优的实现方式欢迎评论区交流。比如你们怎么处理动态高度的虚拟滚动?或者有没有不用虚拟滚动也能扛住 200+ 条的方案?

暂无评论