news 2026/9/18 7:55:16

移动端列表容器四件套:List/Grid/Tabs/Swiper实战笔记

作者头像

张小明

前端开发工程师

1.2k 24
文章封面图
移动端列表容器四件套:List/Grid/Tabs/Swiper实战笔记

打卡第 11 天,终于开始系统啃移动端最常用的一类组件:列表容器。之前写页面总是拿到数据就往 Column 里堆 ForEach,滚动全靠 Scroll 包一层,页面一复杂就卡到想摔手机。今天把 List、Grid、Tabs、Swiper 四个容器全部过了一遍,才明白列表页那点事,核心不是“把内容放进去”,而是“让系统知道内容长什么样、怎么滚动、怎么复用”。

这篇文章不只是当日学习记录,更是把这四件套的底层分工、配置参数和踩过的坑整理成一份可复用的经验笔记。适合三种人看:一是刚开始接触声明式开发、分不清 List 和 Scroll 区别的初学者;二是已经会用 List 写列表,但遇到 Grid 排列错乱、Tabs 切换丢状态、Swiper 嵌套手势冲突的老问题需要排查的开发者;三是想系统梳理移动端容器组件选型思路的人。

1. 先搞清楚这四种容器到底谁管谁

1.1 从一段“暴力堆组件”的代码说起

我以前写信息流页面,第一反应是这样的:

Scroll() { Column() { ForEach(this.feedList, (item: FeedItem) => { FeedCard({ item: item }) }, (item: FeedItem) => item.id) } }

看起来没什么问题,逻辑也对。数据量小的时候一切正常,但一旦列表变成 8000 条,页面就废了:启动白屏好几秒,滑动掉帧严重,甚至直接闪退。

这里的问题不在 Scroll,而在 Column。Scroll 的职责非常单一——它只负责让超出屏幕的内容可以被拖动查看,它不会减少任何子节点的创建和测量工作量。Column 会把传入的 8000 个 FeedCard 全部创建出来,算出它们各自的位置和高度,哪怕这些卡片根本不在屏幕内。等于你请人吃席,明明桌子只能坐十个人,你却把 8000 道菜全部下锅炒熟了端上来摆着。

这就是列表容器存在的意义:让系统只创建正在看和即将看到的那部分内容。

1.2 各容器在页面里的分工边界

今天学的四个容器,名字容易记混,但它们的职责边界其实非常清晰:

  • List:负责“同构数据的序列化展示”,比如一列文章、一列消息、一列订单。它处理的是长列表场景,核心能力是懒加载和行复用。
  • Grid:负责“二维网格排列”,比如首页四宫格、商品瀑布流。它关心的是列数怎么算、行间距怎么控制、单元格怎么对齐。
  • Tabs:负责“页面级分组切换”,比如底部导航、顶部分类页签。它解决的是多个页面如何切换、切换时状态怎么保持的问题。
  • Swiper:负责“同一区域内内容的轮播展示”,比如顶部 Banner、推荐位。它强调的是自动播放、循环切换、指示器反馈。

如果一个页面里又要有轮播图、又要有分类宫格、又要滚动信息流、底部还要切页签,正确做法不是把四个容器全部堆进一个 Scroll 里,而是要有一个清晰的层级关系。具体的组合方式,本文第六部分会单独展开。

1.3 选型对照速查表

先把四个容器的基础差异放在一张表里,后面每个容器的细节再逐个拆:

容器直属子组件默认滚动方向懒加载典型场景
ListListItem垂直支持,默认启用信息流、聊天记录、订单列表
GridGridItem垂直支持,按单元格复用首页宫格、商品墙、图片瀑布流
TabsTabContent横向切换(无传统滚动条)延迟构建页面底部导航、顶部分类页签
Swiper任意子组件横向翻页按页缓存Banner 轮播、推荐卡片切换

注意:Tabs 和 Swiper 虽然视觉上也是“滑动”,但它们不是传统意义上的滚动列表容器。有人会把 Tabs 当成 Scroll 来用,用 Tabs 包一长串内容,这是不对的。

2. List 列表容器的性能关键:数据懒加载与滚动行缓存

2.1 同样是 ForEach,为什么有人滑动卡顿

List 和 Column 最大的区别在于,List 是一个带有虚拟渲染机制的容器。它只会构建当前视口附近的内容,当用户往上滑时,屏幕顶部的行会被销毁或复用给即将出现的新行。这个机制在文档里叫“懒加载”,在社区里更多人直接叫它“虚拟列表”。

我用一个直白的生活类比:Scroll + Column 就像把一整卷胶卷全部冲洗出来,一张一张摊在地上给你看;List 则是只裁出你眼前这一小段展示,你卷一下,它才把下一段冲洗出来。

所以判断一个页面该不该用 List,标准很简单:数据量是否可能超过一屏?任何可能超过一屏的重复数据列表,都应该用 List 而不是 Scroll + Column。这不只是性能问题,还是内存问题——8000 个带图片的节点同时存在,内存占用会非常难看。

2.2 item 复用的核心:ForEach 的第三个参数

List 的性能来自复用,而复用的前提是每一项都能被系统准确识别。ForEach 一共有三个参数:数据数组、item 构造函数、key 生成函数。大多数人只写前两个,第三个参数不写或随手写成(item, index) => index

问题就出在这。用数组下标当 key,一旦列表中间插入一条数据,后面所有 item 的 key 都会发生位移。系统按 key 做 diff 时,会认为原来的 item 还是原来的 item,只是位置变了,于是直接复用旧的组件实例。如果这个 item 内部有输入框、Switch 开关、滚动位置这类自身状态,状态就会跟着旧组件跑,造成数据错乱。

我在一个设置列表里踩过这个坑:用户在第 3 项开了一个开关,往前插入一条新数据后,开关的高亮跑到了第 4 项上。

正确写法是给每一项用一个稳定且唯一的业务 ID:

List() { ForEach(this.articleList, (article: ArticleModel) => { ListItem() { ArticleCard({ article: article }) } }, (article: ArticleModel) => article.id) }

如果服务端没有返回唯一 ID,可以在数据进入页面时主动生成一个,不要嫌麻烦。这个 key 是复用的命脉,图省事后面就得花更多时间排查莫名其妙的 UI 状态问题。

2.3 实测:Scroll 直出 8000 条 vs List 懒加载

为了搞清楚差距到底有多大,我今天拿同一个数据源做了个对比。8000 条简版卡片,每条包含一张网络图、两行文字。

Scroll + Column + ForEach 的实测结果是:页面加载耗时大约 1.4 秒,期间主线程一直处于高负载状态,滑动时明显能看到“先卡一下再动”的现象。改成 List + ListItem 之后,首帧时间压到 400 毫秒左右,滑动稳定在 60 帧附近,肉眼几乎感觉不到卡顿。

除了默认的懒加载,List 还有一个参数值得记住:cachedCount。它表示视口之外提前缓存的行数,默认值在不同版本里不完全一致,建议显式设置成 3 到 5。缓存太少,快速滑动时会出现白屏闪烁;缓存太多,会浪费内存。从实际体验来看,3 是一个很均衡的值。

List() { // ListItem... } .cachedCount(3) .width('100%') .layoutWeight(1)

3. Grid 网格布局的坑:列数计算、滚动方向与子项尺寸

3.1 columnsTemplate 与 rowsTemplate 的正确姿势

Grid 和 List 最大的不同在于,它需要告诉系统“一行放几列”。这个信息通过columnsTemplate声明,格式是一个空格分隔的字符串:

Grid() { // GridItem... } .columnsTemplate('1fr 1fr 1fr 1fr') .columnsGap(12) .rowsGap(12)

'1fr 1fr 1fr 1fr'表示四列,每列宽度占比相等。这里的1fr是分数单位,可以混合使用,比如'1fr 2fr 1fr'表示中间列宽度是两侧的两倍。

有个细节需要注意:columnsTemplate里写几列,最终就渲染几列,系统不会根据屏幕宽度自动调整。如果你需要根据屏幕宽度动态决定列数,正确做法是用屏幕宽度除以单个单元格的目标宽度,算出一个整数列数,再动态拼出模板字符串。

另外,当你的数据只有 9 条但模板写了 12 格时,剩下的格子会留白,不会自动移除。所以动态数据最好每次计算模板,不要硬编码一个永远不变的列数。

3.2 GridItem 的内容为什么不铺满

这是一个几乎每个人都会遇到的问题:Grid 布局正常,但 GridItem 里的文字和图片挤在左上角,撑不开整个单元格。

原因在于 GridItem 本身是容器,单元格的尺寸是由 Grid 的行列模板决定的,但它的子组件不会自动继承这个尺寸。子组件默认会按照自己的内容大小布局。

解决办法是让子组件显式撑满:

GridItem() { Column({ space: 8 }) { Image(menu.icon) .width(48) .height(48) Text(menu.name) .fontSize(14) .fontColor('#333333') } .width('100%') .height('100%') .justifyContent(FlexAlign.Center) }

这段代码里,Column 设置了宽高都占满 100%,再配合justifyContent(FlexAlign.Center)让内容居中。如果不写这两个百分比,Column 就会缩在左上角,看起来像“内容不居中”,实际是“容器没撑开”。

3.3 数据更新后“图片不变”的排查

第三个常见坑是:Grid 数据源更新了,页面上的图片却不刷新。排查下来往往是两个原因叠加。

第一个原因和 ForEach 的 key 有关。key 没有变化时,GridItem 会被直接复用,子组件内部图片地址的更新可能被跳过。解决方法是把图片 URL 参与进 key 的生成:

(gridItem: GridItemModel) => `${gridItem.id}_${gridItem.iconUrl}`

第二个原因是网络图片缓存。同样的 URL 换了新图,但本地缓存没失效,看起来就是“没更新”。这种问题会迷惑很多人,因为从代码层面看数据明明已经改了。遇到图片不变时,先判断是 key 的问题还是缓存的问题,再去动代码。

经验:Grid 的按单元格复用机制比 List 更激进。List 每一行至少是一个完整行,Grid 的单元格更小、复用更频繁,key 写错引起的诡异问题在 Grid 里会被放大。

4. Tabs 底部标签页的系统默认行为与定制思路

4.1 为什么不要用 if/else 自己写切换

没系统学 Tabs 之前,我做过一个“伪多页签”:一个 currentIndex 变量,一个 if/else 判断当前显示哪个页面,按钮点击时改索引。

这个方案问题不大,但它失去了三个系统能力:手势滑动切换、页面状态保持、切换动画。你自己如果要实现同样的体验,得额外处理滑动监听、动画状态、页面缓存,工程量一点不少。

Tabs 相当于一个封装好的页面容器管理器,它帮我们处理好了这些事情。四个容器里,Tabs 的“容器感”最强,因为它管理的是一整套页面结构,而不是简单的列表行。

基础写法是:

Tabs({ barPosition: BarPosition.End, controller: this.tabsController }) { TabContent() { HomePage() } .tabBar('首页') TabContent() { ProfilePage() } .tabBar('我的') } .barHeight(56) .onChange((index: number) => { this.currentTab = index })

4.2 TabContent 必须直系于 Tabs 的原因

Tabs 对子组件的要求非常严格:TabContent必须是 Tabs 的直接子组件,中间不能再包任何容器。

错误的写法:

Tabs() { Column() { TabContent() { HomePage() } .tabBar('首页') } }

一旦中间包了 Column,Tabs 就找不到 TabContent 了,表现是:页签文字能显示,但内容区空白,或者点击页签没有任何反应。这个报错信息不一定很明显,经常会让人怀疑是样式问题。

如果 TabContent 里面还需要做整体的上下留白或背景色,可以在 TabContent 内部再放 Column/List,而不是包在 TabContent 外面。

4.3 修改 tabBar 高亮样式时容易混淆的属性

Tabs 的 tabBar 相关样式分散在两个位置:一部分在 Tabs 根节点上,比如barHeightbarBackgroundColorscrollable;一部分在.tabBar()的入参配置里,比如文字颜色。很多人想改选中文字颜色时,会在 Tabs 上找selectedColor,但实际应该看 tabBar 的配置方式。

如果是系统默认 tabBar,可以直接通过.tabBar()传入 TabBar 样式的配置参数。常见做法有两种:简单场景直接传字符串标题,复杂场景用 @Builder 自定义整个页签视图。

用 @Builder 自定义时,需要自己根据选中状态切换颜色:

@Builder tabBuilder(title: string, index: number) { Column({ space: 4 }) { Text(title) .fontSize(this.currentTab === index ? 16 : 14) .fontColor(this.currentTab === index ? '#FF6A00' : '#999999') .fontWeight(this.currentTab === index ? FontWeight.Bold : FontWeight.Normal) } .width('100%') .justifyContent(FlexAlign.Center) }

这里要注意:@Builder 里的状态切换依赖this.currentTab的变化。如果 onChange 里更新了 currentTab,但页面没有重建页面内的 TabContent,那么 tabBuilder 需要依赖 Tabs 自身的重新渲染来刷新。实际使用时,把 currentTab 定义成 @State 才能驱动刷新。

网上搜“tabs标签页 样式修改”能看到大量类似问题,很大一部分是因为选的是自定义 tabBar,但又试图用系统属性去改。自定义和系统属性是两条路,别混着写。

5. Swiper 轮播图里的手势冲突与循环播放设置

5.1 autoPlay、loop、interval 的配合关系

Swiper 是四个容器里属性最“实心”的一个,因为它自带定时器和动画系统。核心参数有三个:

  • autoPlay:是否自动轮播
  • loop:是否循环播放,也就是最后一张之后是否回到第一张
  • interval:自动轮播的间隔时间

这三个参数的配合关系是:loop是循环的硬件开关,autoPlay是自动播放的总开关,interval只对 autoPlay 生效。如果把loop设为 false,即使 autoPlay 为 true,播到最后一张也会停下来。

一个常用的 Banner 配置:

Swiper() { ForEach(this.bannerList, (banner: BannerModel) => { Stack() { Image(banner.imageUrl) .width('100%') .height(160) .borderRadius(12) } .width('100%') .height(160) }, (banner: BannerModel) => banner.id) } .autoPlay(true) .interval(4000) .loop(true) .indicator(true) .cachedCount(1)

cachedCount(1)表示当前页左右各缓存一页。Banner 这种轻量内容不需要缓存太多,缓存过多反而会让首屏加载变慢。

5.2 轮播方向与外部容器同向时的冲突

这是今天花时间最长的一个问题。Swiper 默认横向轮播,外面套一个垂直 List,两个方向垂直,手势各管各的,相安无事。但如果把 Swiper 设成vertical(true),再把它包进一个垂直滚动的 List 里,冲突就来了。

表现非常典型:手指上下滑动时,页面抖一下,但轮播没翻页;或者反过来,想翻页时列表先滚动了。根本原因在于两个容器都在监听纵向手势,系统没法判断你到底想滚列表还是想翻轮播。

解决思路不是调手势优先级,而是避免同向嵌套。常见的做法:

  • 保持 Swiper 横向,List 垂直,两个方向天然不冲突。
  • 确实需要纵向翻页的组件,建议用 Tabs 或单独页面承载,不要塞进 List。
  • 如果只是想在 List 里放一个多屏内容模块,比如“猜你喜欢”横向滑动卡片,那应该用横向的 List 或 Scroll,而不是竖向 Swiper。

这个原则不仅适用于 Swiper,也适用于其他容器:同向嵌套是移动端滚动冲突的万恶之源。

5.3 指示器和数据更新的坑

Swiper 自带指示器,可以通过indicator属性配置参数而不是只置 true:

.indicator({ color: '#FFFFFFB3', selectedColor: '#FF6A00' })

这样在亮度高的图片上也能看清指示器。但要注意,系统的默认指示器样式比较固定,如果产品要求“指示器是进度条样式"或者"在轮播图底部居中悬浮”这种定制需求,一般还是要做自定义指示器,用onChange同步当前页索引。

数据更新方面,Swiper 和 Grid 有同样的坑。直接修改数组里某个对象的图片 URL,但 key 没变时,Swiper 可能不会刷新这一页。今天我把图片地址直接拼进 key 之后才解决:

(banner: BannerModel) => `${banner.id}_${banner.imageUrl}`

如果你遇到“换成新图但页面还是旧图”的问题,先检查是不是这个原因,再去怀疑图片缓存。

6. 今日实战:一个“首页信息流 + 底部标签 + 顶部轮播”的组合页面

6.1 先定层级,再写布局:四层结构的正确顺序

单独学四个容器都不难,难的是组合。今天我把一个典型的首页拆开来看:顶部有 Banner 轮播,中间有分类宫格,下面是无限滚动的信息流,整个页面挂在底部导航的第一个 Tab 上。

一开始我脑子里的结构是:

Column() { Swiper() // 轮播 Grid() // 宫格 Scroll() { List() // 信息流 } }

这个方案跑起来就出问题了:外层没有滚动容器,内部又没有统一的滚动逻辑。信息流的 List 能滚动,但顶部轮播和宫格会一直钉在屏幕顶部,信息流只在下面自己滚,不是常见的“头部跟着一起滚上去”的效果。

能实现“头部滚上去”的方案有两种。第一种是用 Scroll 包 Column,里面放 Swiper、Grid、卡片组件,放弃懒加载,适合数据量小且确定的情况。第二种是把 Swiper 和 Grid 作为 List 的第一个 ListItem,让 List 成为整个页面的唯一滚动主体,这样既能懒加载又能实现头部跟随滚动。今天推荐的是第二种。

6.2 为什么只写一个 Scroll 不够:懒加载失效问题

有人会问:那我不用 List,直接在 Scroll 里把 Swiper、Grid、ForEach 都写出来不行吗?数据量小的时候可以,数据量大的时候就废了,原因在本文第二部分已经讲过——Scroll 本身不具备虚拟化能力。

还有一种更高阶的错误姿势是从反面踩出来的:用 Scroll 包 List,希望外层 Scroll 统一滚动一切。实际表现是一层卡住另一层,List 经常只显示一屏,因为外层 Scroll 在计算高度时,无法正确预知 List 这个懒加载容器到底有多高,于是把 List 的内容高度当成固定值处理,导致 List 内部的滚动和外部滚动互相打架。

所以正确的组合结构是:整个页面只有一个滚动容器。要么是 List,要么是 Scroll。不要在外面再套一个冗余的滚动层。

6.3 组合页面的代码骨架

能用的结构是这样的:

Tabs({ barPosition: BarPosition.End }) { TabContent() { List() { // 第一个 ListItem 放轮播和宫格 ListItem() { Column({ space: 12 }) { this.buildBannerSwiper() this.buildMenuGrid() } .width('100%') .padding({ left: 12, right: 12 }) } // 后续 ListItem 放信息流卡片 ForEach(this.feedList, (feed: FeedModel) => { ListItem() { FeedCard({ feed: feed }) } .padding({ left: 12, right: 12 }) }, (feed: FeedModel) => feed.id) } .cachedCount(5) } .tabBar('首页') TabContent() { List() { ForEach(this.attentionList, (item: AttentionModel) => { ListItem() { AttentionItem({ item: item }) } }, (item: AttentionModel) => item.id) } } .tabBar('关注') }

注意轮播和宫格塞进 ListItem 之后,它们的高度应该是明确的,或者至少是固定比例计算出来的,否则 List 在计算内容高度时会出现跳动。Banner 高度我固定为 160,宫格高度固定为 200,这样整个 List 的滚动节奏才稳定。

6.4 三个 Bug 复盘

今天实际跑这个页面时踩了三个 Bug,记录一下排查链路,这个思路比 Bug 本身更值得复用。

Bug 1:外层的 Scroll 包住 List 之后,List 只显示一屏,无法继续滚动。排查时我先用“最小可复现”的思路逐层删除组件,删掉外层 Scroll 后 List 恢复正常,基本确认是嵌套滚动冲突。修复方式就是上面代码里的方案:去掉外层 Scroll,把头部内容并进 ListItem。

Bug 2:TabContent 外层包了一层 Column 之后,tabBar 文字能显示但页面内容空白。排查时打开布局检查器,发现 TabContent 根本没有被 Tabs 识别。对照文档确认 Tabs 的直系子组件必须是 TabContent,去掉中间层后正常。

Bug 3:Grid 数据更新后,界面上的图片和文字还是旧的。排查时先看数据源是否有变化,确认有变化后检查 ForEach key。原来的 key 是index,改成业务 ID 拼图片 URL 后刷新正常。这个 Bug 在 Grid 和 Swiper 里都会遇到,属于复用机制与不规范 key 的典型冲突。

排查思路总结:遇到容器行为诡异时,先做“剥洋葱”——把嵌套的容器一层层拆掉,每次拆掉后看问题是否消失。这比盯着代码干想要快得多。

7. 今天过完四件套之后,记下来的几行备忘

7.1 回到“选型”层面的一张总结表

四个容器学完,最后还是要回到选型。以后写页面,先拿着需求对比一下这张表,能省很多试错时间:

场景首选容器理由
单列长列表,数据可能超过一屏List懒加载 + 行复用,性能和内存最好
网格宫格、商品铺排Grid内置列模板和行列间距控制
多个页面之间的切换Tabs自带状态保持、手势滑动、动画
固定区域的图片/内容轮播Swiper自动播放、循环、指示器一体化

7.2 今天最值钱的几条经验

容器组件选对了,滚动性能问题少一半。Scroll + Column 只能用来写内容总量确定、不可能超过一屏的小模块,真正撑起业务的长列表一定走 List。

同向嵌套是万恶之源。Scroll 包 List、List 里套纵向 Swiper,都是同一方向滚动的冲突场景,能拆就拆,拆不掉就换结构。

ForEach 的 key 必须稳定且唯一。这个原则在 List、Grid、Swiper 三个容器里统统适用。用 index 当 key 等于把一个定时炸弹埋在未来某个排序操作上。

Tabs 是一个页面结构容器,不是普通列表容器。它管的是页面切换和状态保持,内部应该放 List、Grid 这些真滚动容器,而不是把内容平铺在 TabContent 里硬撑。

这些备忘我直接贴到了项目文档首页。下次再犯同样的错,先回来打脸再改代码。

版权声明: 本文来自互联网用户投稿,该文观点仅代表作者本人,不代表本站立场。本站仅提供信息存储空间服务,不拥有所有权,不承担相关法律责任。如若内容造成侵权/违法违规/事实不符,请联系邮箱:809451989@qq.com进行投诉反馈,一经查实,立即删除!
网站建设 2026/9/18 7:53:10

前端导出CSV与Excel实战:编码、性能与安全全解析

前两年做后台管理系统的时候,几乎每个月都要被“导出”这个需求绊一跤。今天产品说要导出 CSV,明天客户指名要 Excel,后天又有人说“你们导出的文件打开乱码”,大后天又来一个“几十万行数据一导出浏览器就卡死”。前端导出 CSV 和…

作者头像 李华
网站建设 2026/9/18 7:51:57

Windows网络编程--Iocp范式

IOCP(I/O Completion Port)的核心范式: CreateIoCompletionPort() // 创建完成端口 CreateIoCompletionPort(sock, iocp) // 把socket绑定到端口 bind/listen/accept // 服务端 WSASend/WSARecv (OVERLAPPED) …

作者头像 李华
网站建设 2026/9/18 7:51:55

小熊派HarmonyOS设备接入EMQX MQTT平台实战指南

1. 为什么小熊派接入 IoT 平台不是“烧录连网”就完事?小熊派(BearPi-HM Nano)刚拿到手时,我把它插上 USB 线、打开串口工具、看到OHOS>提示符跳出来,心里一松——鸿蒙设备跑起来了。但真正卡住我的,是接…

作者头像 李华
网站建设 2026/9/18 7:51:11

AI搜索时代下的GEO流量优化工具测评与实战

1. 项目概述:当AI搜索遇上GEO流量优化去年帮一家跨境电商客户做独立站诊断时,发现他们70%的自然流量都来自特定区域的本地化搜索。这个案例让我意识到:在AI搜索算法主导的2026年,传统SEO策略正在被地理定位(GEO&#x…

作者头像 李华