大家好,我是[晚风依旧似温柔],新人一枚,欢迎大家关注~

前言

说实话,现在要是做个电商 App 首页还写成“纯静态大图 + 一堆死列表”,真的会被用户一眼看出——“这应用有点土啊”。而且产品经理十有八九会在评审会上来一句:“能不能更灵动一点?有点氛围感那种。”
  好嘛,氛围感三个字,直接把前端和客户端全安排明白了:布局要丰富、滑动要顺、加载要优雅、动画要丝滑、骨架屏要到位,性能还不能崩,最好网络不好也要撑得住。

那问题来了——用鸿蒙 ArkUI,怎么从 0 到 1 搭一套“像样”的电商首页?而不是简单堆几个 Column + List 就算完事?
  这次就一步步把:Banner + 秒杀 + 商品卡片布局、瀑布流/Grid 视图、下拉刷新 & 分页、动画与骨架屏、性能优化全部串起来,做一套
真正能上线的电商首页雏形


一、整体思路先搞清:电商首页到底长啥样?

先别急着写代码,我们先来画个脑内草图。典型电商首页大概会是这样一个结构:

  1. 顶部:搜索栏 + 消息/购物车入口
  2. 轮播 Banner:运营最爱的广告位
  3. 秒杀/活动区:横向滑动、倒计时、小标签
  4. 商品列表:常见是宫格 / 瀑布流
  5. 下拉刷新:拉一下重新请求
  6. 上拉加载更多:分页加载 + 底部加载指示

在 ArkUI 里,我们可以这么拆:

  • 使用 Navigation / Scaffold 管一整个页面框架
  • 使用 Column 做整体结构
  • Swiper 负责 Banner
  • Row + List / Grid 实现秒杀区域
  • Grid / WaterFlow 做商品流展示
  • Refresh + 滚动监听 做刷新 + 分页加载

先上一个“粗糙但完整”的首页骨架代码感受一下。

// eTS 示例(ArkUI 声明式范式)
@Entry
@Component
struct ECommerceHomePage {
  @State bannerList: string[] = []
  @State seckillList: Goods[] = []
  @State goodsList: Goods[] = []
  @State isRefreshing: boolean = false
  @State isLoadingMore: boolean = false
  @State page: number = 1

  build() {
    Column() {
      this.buildHeader()
      this.buildContent()
    }
    .backgroundColor('#F6F6F6')
    .height('100%')
    .width('100%')
  }

  private buildHeader() {
    Row() {
      Text("商城")
        .fontSize(20)
        .fontWeight(FontWeight.Bold)

      Blank()

      Image($r('app.media.icon_cart'))
        .width(24)
        .height(24)
        .margin({ right: 12 })
    }
    .padding({ left: 16, right: 16, top: 12, bottom: 8 })
    .backgroundColor('#FFFFFF')
  }

  private buildContent() {
    // 这里稍后用 Refresh + Scroll/CustomScroll 实现整体内容区域
  }
}

先放个壳子,后面我们往里面塞:Banner、秒杀、商品瀑布流、刷新、分页、动画、骨架屏,一步步补完。


二、Banner + 秒杀 + 商品卡片:先把“脸面工程”搭起来

1. Banner 区:Swiper 走一走

Banner 在电商首页的地位,基本等于“客厅的电视机”——不显眼还不行。
ArkUI 通常会用类似 Swiper(或轮播组件)来实现 Banner 轮播。

private buildBannerArea() {
  if (this.bannerList.length === 0) {
    // 简单占位
    Rect()
      .width('100%')
      .height(160)
      .backgroundColor('#EDEDED')
      .borderRadius(12)
      .margin({ top: 8, bottom: 8, left: 16, right: 16 })
    return
  }

  Swiper() {
    ForEach(this.bannerList, (url: string) => {
      Image(url)
        .objectFit(ImageFit.Cover)
        .width('100%')
        .height(160)
        .borderRadius(12)
    })
  }
  .index(0)
  .autoPlay(true)
  .interval(3000)
  .indicator(true)
  .margin({ top: 8, bottom: 8, left: 16, right: 16 })
}

这里 Banner 只是一个“模块”,待会儿我们会把它塞进整体内容中。


2. 秒杀区:横向滑动 + 倒计时 + 价格标签

秒杀区的特点:

  • 内容少但很抢眼
  • 通常是横向滑动
  • 必须有“时间紧迫感”:倒计时 + 红色价格

我们可以用一个 Row + List/Scroll 来实现横向滑动。

private buildSeckillArea() {
  Column() {
    // 标题行
    Row() {
      Text("限时秒杀")
        .fontSize(18)
        .fontWeight(FontWeight.Medium)
        .fontColor('#FF3B30')

      Text("  距结束 00:12:35")
        .fontSize(12)
        .fontColor('#999999')

      Blank()

      Text("查看更多 >")
        .fontSize(12)
        .fontColor('#666666')
    }
    .margin({ left: 16, right: 16, bottom: 8 })

    // 横向商品滚动
    Scroll() {
      Row() {
        ForEach(this.seckillList, (item: Goods) => {
          this.buildSeckillItem(item)
        }, (item: Goods) => item.id.toString())
      }
    }
    .scrollable(ScrollDirection.Horizontal)
  }
  .padding({ top: 8, bottom: 8 })
  .backgroundColor('#FFFFFF')
}

单个秒杀商品卡片:

private buildSeckillItem(item: Goods) {
  Column() {
    Image(item.image)
      .width(80)
      .height(80)
      .borderRadius(8)

    Text(item.title)
      .fontSize(12)
      .maxLines(2)
      .textOverflow({ overflow: TextOverflow.Ellipsis })
      .width(80)
      .margin({ top: 4 })

    Row() {
      Text(`¥${item.seckillPrice}`)
        .fontSize(14)
        .fontColor('#FF3B30')
      Text(`¥${item.originPrice}`)
        .fontSize(10)
        .fontColor('#999999')
        .decoration({ type: TextDecorationType.LineThrough })
        .margin({ left: 4 })
    }
    .margin({ top: 4 })
  }
  .margin({ left: 16, right: 8 })
}

到这一步,我们已经有:Banner + 秒杀 两大“运营位”。接下来进入重头戏——商品卡片 + Grid/瀑布流


三、商品瀑布流 / Grid 视图:别再满屏一个一个长列表了

电商首页最核心的视觉部分,就是商品内容区。用户翻来覆去看的,也是这一块。
常见有两种展示方式:

  1. 规则宫格 Grid:高宽统一,整齐干净
  2. 瀑布流 Waterfall:高度不同,信息密度更高

我们先来一个 规则 Grid 商品展示,再说如何做“类瀑布流”。


1. Grid 商品卡片布局

我们假设用一个两列 Grid:

private buildGoodsGrid() {
  Grid() {
    ForEach(this.goodsList, (item: Goods) => {
      this.buildGoodsCard(item)
    }, (item: Goods) => item.id.toString())
  }
  .columnsTemplate("1fr 1fr") // 两列平分
  .rowsGap(8)
  .columnsGap(8)
  .padding({ left: 16, right: 16, bottom: 16 })
}

商品卡片组件:

private buildGoodsCard(item: Goods) {
  Column() {
    Image(item.image)
      .width('100%')
      .aspectRatio(1) // 正方形图
      .borderRadius(8)

    Text(item.title)
      .fontSize(14)
      .maxLines(2)
      .margin({ top: 4 })
      .textOverflow({ overflow: TextOverflow.Ellipsis })

    Row() {
      Text(`¥${item.price}`)
        .fontSize(16)
        .fontColor('#FF3B30')
        .fontWeight(FontWeight.Medium)

      Blank()

      if (item.soldCount > 0) {
        Text(`已售 ${item.soldCount}`)
          .fontSize(10)
          .fontColor('#999999')
      }
    }
    .margin({ top: 4 })
  }
  .backgroundColor('#FFFFFF')
  .borderRadius(10)
  .padding(8)
}

这样一个最基础的 Grid 商品展示就完成了,干净利落。


2. 类瀑布流的实现思路

瀑布流的难点不是布局,而是每个卡片高度不同的情况下仍然保证视觉平衡。
如果框架直接提供 WaterFlow/Masonry 那是最省事的;如果没有,我们可以从两种思路入手:

  1. 两列 Column 手动分配数据:根据预估高度分配到左右列
  2. 带高度字段的数据结构:后端提供图片比例和文案长度,提前估一个高度

伪代码示意:

@State leftColumn: Goods[] = []
@State rightColumn: Goods[] = []

distributeGoodsToColumns(goods: Goods[]) {
  let leftHeight = 0
  let rightHeight = 0
  goods.forEach(item => {
    // 简单估算高度:图片高度 + 文本长度 * 系数
    const estHeight = 200 + item.title.length * 2
    if (leftHeight <= rightHeight) {
      this.leftColumn.push(item)
      leftHeight += estHeight
    } else {
      this.rightColumn.push(item)
      rightHeight += estHeight
    }
  })
}

private buildWaterfall() {
  Row() {
    // 左列
    Column() {
      ForEach(this.leftColumn, (item: Goods) => {
        this.buildGoodsCard(item)
      })
    }
    .layoutWeight(1)

    // 右列
    Column() {
      ForEach(this.rightColumn, (item: Goods) => {
        this.buildGoodsCard(item)
      })
    }
    .layoutWeight(1)
  }
  .padding({ left: 16, right: 16 })
}

当然,真正上线时你肯定会根据 ArkUI 当前版本内置组件情况调整实现方式,但思路是通用的:数据预分配 + 两列布局


四、下拉刷新 & 分页加载:没有这一套就别说是“首页”

电商页面如果不能下拉刷新、不能上拉加载,用户基本会觉得你还是个 MVP。
我们来把:整体区域用 Refresh 包裹,下拉触发刷新,滚动到底触发分页加载


1. 下拉刷新:Refresh + 状态控制

整体内容区域可以这么写:

private buildContent() {
  Refresh({
    refreshing: this.isRefreshing,
    onRefresh: () => {
      this.handleRefresh()
    }
  }) {
    Scroll() {
      Column() {
        this.buildBannerArea()
        this.buildSeckillArea()
        this.buildGoodsGrid()
        this.buildLoadMoreFooter()
      }
    }
  }
}

刷新逻辑:

private handleRefresh() {
  this.isRefreshing = true
  this.page = 1

  // 模拟网络请求
  setTimeout(() => {
    // 重新拉取数据
    this.bannerList = mockBanners()
    this.seckillList = mockSeckill()
    this.goodsList = mockGoodsPage(1)

    this.isRefreshing = false
  }, 1000)
}

2. 分页加载:监听滚动到底 + 加载标记

分页加载一般有两种方式:

  1. 用户滑到接近底部时触发 loadMore
  2. 点击“加载更多”按钮

我们用滑动到底自动加载 + 底部 Loading 提示

思路:在 Scroll 上监听滚动事件,根据偏移判断何时触底。

private buildContent() {
  Refresh({
    refreshing: this.isRefreshing,
    onRefresh: () => this.handleRefresh()
  }) {
    Scroll() {
      Column() {
        this.buildBannerArea()
        this.buildSeckillArea()
        this.buildGoodsGrid()
        this.buildLoadMoreFooter()
      }
    }
    .onScroll((offset: ScrollOffset) => {
      this.handleScroll(offset)
    })
  }
}

private handleScroll(offset: ScrollOffset) {
  // 伪逻辑:距离底部小于某个值就触发加载更多
  if (!this.isLoadingMore && offset.end - offset.current < 200) {
    this.loadMore()
  }
}

private loadMore() {
  this.isLoadingMore = true
  this.page += 1

  setTimeout(() => {
    const newPageData = mockGoodsPage(this.page)
    this.goodsList = this.goodsList.concat(newPageData)
    this.isLoadingMore = false
  }, 1200)
}

底部加载提示 UI:

private buildLoadMoreFooter() {
  if (!this.isLoadingMore) {
    return
  }

  Row() {
    LoadingProgress()
      .width(20)
      .height(20)
      .margin({ right: 8 })

    Text("正在加载更多…")
      .fontSize(12)
      .fontColor('#888888')
  }
  .margin({ top: 8, bottom: 16 })
}

至此,我们已经有了一个功能完整的电商首页交互逻辑:下拉刷新 + 上拉分页。


五、动画与骨架屏:加载的时候不能“死给用户看”

你是不是也遇到过这种体验:点进一个页面,空白一大片,过两秒东西突然蹦出来——
这就是典型的“没有骨架屏 + 没配动画”的直男式加载。

我们要做的是:

  1. 首屏加载给出“骨架屏”占位
  2. 真数据回来后平滑过渡展示(淡入/位移动画)
  3. 异常情况也不能空

1. 骨架屏:假装数据已经在路上

简单搞一版列表骨架:灰色块 + 模拟图文结构。

@State isFirstLoading: boolean = true

private buildGoodsAreaWrapper() {
  if (this.isFirstLoading) {
    return this.buildSkeletonList()
  } else {
    return this.buildGoodsGrid() // 真正数据
  }
}

private buildSkeletonList() {
  Grid() {
    ForEach(new Array(6).fill(0), (_, index: number) => {
      this.buildSkeletonCard(index)
    })
  }
  .columnsTemplate("1fr 1fr")
  .rowsGap(8)
  .columnsGap(8)
  .padding({ left: 16, right: 16, bottom: 16 })
}

private buildSkeletonCard(index: number) {
  Column() {
    Rect()
      .width('100%')
      .aspectRatio(1)
      .backgroundColor('#E5E5E5')
      .borderRadius(8)

    Rect()
      .width('80%')
      .height(14)
      .backgroundColor('#E5E5E5')
      .borderRadius(4)
      .margin({ top: 8 })

    Rect()
      .width('60%')
      .height(12)
      .backgroundColor('#E5E5E5')
      .borderRadius(4)
      .margin({ top: 4 })
  }
  .backgroundColor('#FFFFFF')
  .borderRadius(10)
  .padding(8)
}

在数据请求完成后切换状态:

private fetchHomeData() {
  this.isFirstLoading = true

  // 模拟接口
  setTimeout(() => {
    this.bannerList = mockBanners()
    this.seckillList = mockSeckill()
    this.goodsList = mockGoodsPage(1)
    this.isFirstLoading = false
  }, 1500)
}

2. 动画加持:数据进来要“轻轻地长”出来

完全静态从骨架切成内容,会给人一种“画面突然换了一张”的割裂感。
我们可以在 goodsList 展示时配合淡入 + 轻微位移动画,让 UI 更有“呼吸感”。

一个简单思路:为商品卡片添加 opacitytranslateY,在数据就绪时对整个区块做一次 animateTo

@State goodsOpacity: number = 0
@State goodsTranslateY: number = 20

private onGoodsLoaded() {
  // 数据已经填充 goodsList 后调用
  animateTo({ duration: 400, curve: Curve.EaseOut }, () => {
    this.goodsOpacity = 1
    this.goodsTranslateY = 0
  })
}

private buildGoodsAreaWithAnimation() {
  Column() {
    this.buildGoodsGrid()
  }
  .opacity(this.goodsOpacity)
  .translate({ x: 0, y: this.goodsTranslateY })
}

这样第一屏加载的时候,商品会轻轻地从下方浮上来,视觉体验会明显更舒服。


六、性能处理与加载优化:让首页“看起来重,但跑得飞快”

电商首页是最容易变成性能黑洞的页面:大图、轮播、瀑布流、动画、骨架、刷新……
如果不注意性能,很容易就出现:

  • 滑动掉帧、卡顿
  • 内存暴涨
  • 图片闪烁、占位不稳

下面我们从几个点讲怎么优化。


1. 图片加载与占位优化

策略:

  1. 所有图片都给定明确尺寸(宽/高或者宽 + 比例)
  2. 使用压缩后的缩略图(后端配合)
  3. 需要时支持渐进式加载(先小图后大图)

示例:

Image(item.image)
  .width('100%')
  .aspectRatio(1)   // 固定比例,避免布局抖动
  .borderRadius(8)
  .backgroundColor('#F0F0F0') // 当图片未加载完成时的底色

2. 状态粒度控制:不要动不动就全页面刷新

很多人一开始喜欢写一个大组件,@State 全写在页面顶上,但只要一个小字段变化就会触发整棵树重 build。
对电商首页来说,这简直是性能自杀。

建议:

  • 把 Banner、秒杀区、商品列表拆成独立子组件
  • 哪块数据变,就只刷新那块
  • 对长列使用 ForEach + key,确保最小重建

例子:把商品卡片拆成单独组件,并用 @Prop 接入数据。

@Component
struct GoodsCard {
  @Prop item: Goods

  build() {
    // 这里就是之前 buildGoodsCard 的内容
  }
}

在 Grid 中使用:

Grid() {
  ForEach(this.goodsList, (item: Goods) => {
    GoodsCard({ item })
  }, (item: Goods) => item.id.toString())
}

3. 分页策略:不要一次性把 200 条全塞进来

电商首页常见错误之一:

“既然首页很重要,那我干脆一口气拉 100 条商品,用户想看就滑。”

然后滑动体验就直接“去世”。

实战推荐:

  • 每页 10–20 条
  • 优先保证首屏加载速度
  • 用户滑得快时可以“预加载”下一页

伪逻辑:

if (!this.isLoadingMore && offset.end - offset.current < 400) {
  // 提前触发下一页加载
  this.loadMore()
}

4. 动画适度节制:有节奏,但别乱炫技

动画确实能让 UI 看起来更高级,但“动画过度”也是一大性能/体验杀手:

  • 每个元素都动
  • 动画时长太长
  • 动画曲线过于夸张

经验法则:

  1. 动画只给关键区域:首屏商品、重要入口
  2. 避免对大列表里的每个 item 独立执行复杂动画
  3. 尽量使用简单的 opacity/translate,而不是复杂滤镜

七、组合起来:一个可用的电商首页雏形

把上面所有零散的模块缝在一起,你会发现:

  • 页面结构:清晰
  • 用户体验:完整
  • 细节体验:有动画、有骨架、不突兀
  • 开发上:易维护、易扩展

你已经拥有了一个这样的首页:

  1. 打开 App:看到骨架屏,页面不空
  2. 1 秒后:Banner 渐出,秒杀区出现,商品从下方轻轻浮现
  3. 下拉刷新:整个页面数据更新,有动画反馈
  4. 往下滑:更多商品按页加载,底部有 Loading 提示
  5. 网络不好:至少有骨架和旧数据,不至于惨白一片

这,才像一个真正“活着”的电商首页

结语:你是真的在做“电商首页”,还是只是“商品列表页”?

很多人写电商首页,只是换了一套皮的“商品列表”。
  但对于用户来说,首页应该是:氛围、节奏、信息密度、反馈、性能全部综合后的体验结果。

用鸿蒙 ArkUI,我们可以把这些拆解成:

  • 布局:Banner + 秒杀 + 商品 Grid / 瀑布流
  • 交互:下拉刷新 & 分页
  • 体验:动画过渡 & 骨架屏
  • 工程:性能优化 & 状态拆分

如果觉得有帮助,别忘了点个赞+关注支持一下~
喜欢记得关注,别让好内容被埋没~

Logo

讨论HarmonyOS开发技术,专注于API与组件、DevEco Studio、测试、元服务和应用上架分发等。

更多推荐