大数跨境

微信小程序Skyline实战:grid-builder实现等高双列商品流

微信小程序Skyline实战:grid-builder实现等高双列商品流 wordpress知识
2026-11-03
7

Skyline grid-builder:高性能双列等高瀑布流实战

本文介绍如何利用 Skyline 渲染引擎专属组件 grid-builder,构建性能优异、自动等高的双列商品瀑布流(对齐流 aligned)。

前置条件:必须开启 Skyline 渲染,且外层 scroll-view 需设置 type="custom"。旧的 WebView 渲染模式不支持此组件。

为何弃用传统 CSS Grid/Flex?

在小程序开发中,传统的 CSS 双列布局方案(如 display: grid)存在以下显著痛点:

  • 性能瓶颈:长列表渲染性能差,当数据量达到上千条时,容易出现明显卡顿甚至白屏。
  • 缺乏复用机制:一次性渲染所有节点,没有原生的虚拟列表复用能力,内存占用高。
  • 对齐困难:当图片加载高度不确定时,要实现严格的双列等高对齐,需要额外的 JS 计算或 CSS 兜底处理,复杂度较高。

Skyline grid-builder 核心优势

Skyline 的 grid-builder 是原生虚拟列表组件,具备以下特性:

  • 极致性能:自动节点复用,仅渲染可视区域内的商品,确保长列表滑动丝滑流畅。
  • 自动等高:内置 type="aligned" 模式,无需 JS 计算即可实现双列自动等高对齐。
  • 配置简便:原生支持行列间距、内边距等配置,开发效率高。
  • 动画协同:与 worklet 滚动动画完美搭配,实现滚动吸顶、渐变等效果且不卡顿。

核心配置解析

以下是 grid-builder 的关键属性说明:

  • list:数据源数组。
  • child-count:列表总数,必须传递,用于组件计算滚动尺寸。
  • type="aligned":等高对齐模式,确保同一行两个商品高度保持一致。
  • cross-axis-count=2:交叉轴数量,即设置为 2 列。
  • main-axis-gap:纵向间距。
  • cross-axis-gap:横向间距。
  • padding:容器内边距,格式为 [上, 右, 下, 左]。
  • slot:item / slot:index:循环插槽。通过 index 获取数组数据,严禁在内部直接使用 wx:for,由 grid-builder 内部接管循环逻辑。

完整代码实现

注意:包裹 grid-builder 的 scroll-view 必须添加 type="custom",否则组件将失效。

wxml:

<!-- 状态栏白色底层,永久不透明,盖住状态栏区域 -->
<view class="status-bar-bg" style="height:{{statusBarHeight}}px"></view>

<!-- 顶部固定区:初始透明,滚动后承载胶囊左侧搜索 -->
<view class="nav-sticky" style="height:{{statusBarHeight + 44}}px;padding-top:{{statusBarHeight}}px">
  <view class="search-wrap">
    <input class="search-input" placeholder="搜商品找好价" value="{{keyword}}" bindinput="onInputKeyword" confirm-type="search" bindconfirm="onSearch"/>
    <view wx:if="{{keyword}}" class="search-clear" bindtap="clearKeyword">×</view>
  </view>
</view>

<!-- 固定分类栏:滚动后吸附在搜索导航下方 -->
<view class="category-fixed" style="top:{{statusBarHeight + 44}}px">
  <scroll-view class="category-scroll" scroll-x>
    <view class="category-item {{activeCat===index?'active':''}}" wx:for="{{categoryList}}" wx:key="index" bindtap="changeCat" data-index="{{index}}">
       {{item.name}}
    </view>
  </scroll-view>
</view>

<!-- ✅ 重点:增加 type="custom"!这是grid-builder生效必要条件 -->
<scroll-view 
  class="scroll"
  style="padding-top:{{statusBarHeight}}px"
  scroll-y
  type="custom"
  refresher-enabled
  refresher-triggered="{{refreshing}}"
  bindrefresherrefresh="onRefresh"
  bindscrolltolower="loadMore"
  lower-threshold="150"
  worklet:onscrollupdate="onScrollUpdate"
>
  <!-- 初始最顶部:Logo + 标语 -->
  <view class="nav-origin">
    <view class="logo-row">
      <view class="logo">lg</view>
      <view class="title-text">小程序名称</view>
    </view>
    <view class="desc">这里是小程序宣传标语,认识小程序的作用</view>
  </view>

  <!-- 页面内搜索 + 分类 -->
  <view class="search-category-origin">
    <view class="search-bar">
      <view class="search-wrap">
        <input class="search-input" placeholder="搜商品找好价" value="{{keyword}}" bindinput="onInputKeyword" confirm-type="search" bindconfirm="onSearch"/>
        <view wx:if="{{keyword}}" class="search-clear" bindtap="clearKeyword">×</view>
      </view>
    </view>
    <scroll-view class="category-scroll" scroll-x>
      <view class="category-item {{activeCat===index?'active':''}}" wx:for="{{categoryList}}" wx:key="index" bindtap="changeCat" data-index="{{index}}">
        {{item.name}}
      </view>
    </scroll-view>
  </view>

  <view class="banner">
    <image class="banner-img" src="https://picsum.photos/id/1011/750/400" mode="aspectFill"></image>
  </view>

  <!-- Skyline grid-builder 双列等高 -->
  <grid-builder 
    list="{{goodsList}}"
    child-count="{{goodsList.length}}"
    type="aligned"
    cross-axis-count="2"
    main-axis-gap="10"
    cross-axis-gap="10"
    padding="{{[10,10,10,10]}}"
   >
    <view slot:item slot:index class="goods-item" bindtap="tapGoods" data-index="{{index}}">
      <image class="goods-img" src="{{goodsList[index].img}}" mode="aspectFill"></image>
      <view class="goods-info">
        <text max-lines="2" overflow="ellipsis" class="goods-name">{{goodsList[index].title}}</text>
        <view class="goods-price">¥{{goodsList[index].price}}</view>
      </view>
    </view>
  </grid-builder>

  <view wx:if="{{loading && !refreshing}}" class="loading-wrap">
    <view class="dot dot1"></view><view class="dot dot2"></view><view class="dot dot3"></view>
  </view>
  <view wx:if="{{noMore}}" class="nomore">没有更多了</view>
</scroll-view>

<view class="back-top" bindtap="tapBackTop">
  <view class="back-top-icon">↑</view>
</view>

js:

const { shared } = wx.worklet

Page({
  data: {
    goodsList: [],
    refreshing: false,
    loading: false,
    noMore: false,
    page: 1,
    statusBarHeight: 0,
    keyword: '',
    activeCat: 0,
    categoryList: [
      { name: '全部' },
      { name: '水果' },
      { name: '零食' },
      { name: '生鲜' },
      { name: '家电' },
      { name: '服饰' },
      { name: '美妆' }
    ]
  },
  onLoad() {
    const info = wx.getWindowInfo()
    this.setData({
      statusBarHeight: info.statusBarHeight
    })
    this.backTopOpacity = shared(0)
    this.stickyNavOpacity = shared(0)
    this.fixedCatOpacity = shared(0)
    this.applyAnimatedStyle('.back-top', () => {
      'worklet'
      return { opacity: this.backTopOpacity.value }
    })
    this.applyAnimatedStyle('.nav-sticky', () => {
      'worklet'
      return { opacity: this.stickyNavOpacity.value }
    })
    this.applyAnimatedStyle('.category-fixed', () => {
      'worklet'
      return { opacity: this.fixedCatOpacity.value }
    })
  },
  onReady() {
    this.loadData()
  },
  onScrollUpdate(evt) {
    'worklet'
    const scrollTop = evt.detail.scrollTop
    // Logo + 标语区域高度,单位 px
    const logoHeight = 140
    // 开始显示固定搜索导航的位置
    const startFade = logoHeight - 40
    // 完全显示固定搜索导航的位置
    const endFade = logoHeight
    let progress = 0
    if (scrollTop > startFade) {
      progress = Math.min((scrollTop - startFade) / (endFade - startFade), 1)
    }
    this.stickyNavOpacity.value = progress
    this.fixedCatOpacity.value = progress
    
    const backTopProgress = Math.min(Math.max((scrollTop - 500) / 100, 0), 1)
    this.backTopOpacity.value = backTopProgress
  },
  changeCat(e) {
    const idx = e.currentTarget.dataset.index
    this.setData({
      activeCat: idx,
      page: 1,
      noMore: false,
      goodsList: []
    }, () => {
      this.loadData()
    })
  },
  onInputKeyword(e) {
    this.setData({ keyword: e.detail.value })
  },
  clearKeyword() {
    this.setData({ keyword: '' }, () => {
      this.onSearch()
    })
  },
  onSearch() {
    this.setData({
      page: 1,
      noMore: false,
      goodsList: []
    }, () => {
      this.loadData()
    })
  },
  onRefresh() {
    this.setData({
      page: 1,
      noMore: false,
      refreshing: true,
      goodsList: []
    }, () => {
      this.loadData()
    })
  },
  loadMore() {
    if (this.data.loading || this.data.noMore) return
    this.loadData()
  },
  loadData() {
    this.setData({ loading: true })
    setTimeout(() => {
      const page = this.data.page
      const { keyword, activeCat, categoryList } = this.data
      const catName = categoryList[activeCat].name
      const mock = []
      for (let i = 0; i < 10; i++) {
        let title = catName + '商品,超长标题用来测试两行省略效果,Skyline max-lines测试'
        if (keyword) {
          title = keyword + '-匹配商品,超长标题用来测试两行省略效果'
        }
        mock.push({
          id: `${page}_${i}`,
          title: title,
          price: (Math.random() * 100).toFixed(2),
          img: 'https://picsum.photos/id/237/200/200'
        })
      }
      const list = [...this.data.goodsList, ...mock]
      const noMore = page >= 5
      this.setData({
        goodsList: list,
        page: page + 1,
        loading: false,
        refreshing: false,
        noMore
      })
    }, 600)
  },
  tapBackTop() {
    const query = this.createSelectorQuery()
    query.select('.scroll').node(res => {
      if (res?.node) {
        res.node.scrollTo({ top: 0 })
      }
    }).exec()
  },
  tapGoods(e) {
    const idx = e.currentTarget.dataset.index
    const item = this.data.goodsList[idx]
    console.log('点击商品:', item)
    if (!item || !item.id) {
      console.warn('商品数据异常,缺少id')
      return
    }
    wx.navigateTo({
      url: `/pages/detail/detail?id=${item.id}`,
      success(res) {
        console.log('✅跳转成功', res)
      },
      fail(err) {
        console.error('❌跳转失败', err)
      }
    })
  }
})

wxss:

page {
  background: #f5f5f5;
  height: 100%;
}

/* 状态栏底色层 */
.status-bar-bg {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  background-color: #ffffff;
  z-index: 98;
}

/* 顶部固定搜索导航:默认透明,滚动后显示 */
.nav-sticky {
  position: fixed;
  top: 0;
  left: 0;
  width: 100%;
  z-index: 99;
  background: #fff;
  box-sizing: border-box;
  padding-left: 20rpx;
  padding-right: 160rpx;
  display: flex;
  align-items: center;
  opacity: 0;
}

.nav-sticky .search-wrap {
  width: 100%;
  height: 56rpx;
  background: #f5f5f5;
  border-radius: 28rpx;
  display: flex;
  align-items: center;
  padding: 0 20rpx;
  box-sizing: border-box;
}

/* 初始最顶部:Logo + 标语 */
.nav-origin {
  background: #fff;
  box-sizing: border-box;
  padding: 24rpx;
}

.logo-row {
  display: flex;
  align-items: center;
  gap: 16rpx;
  margin-bottom: 8rpx;
}

.logo {
  width: 56rpx;
  height: 56rpx;
  border-radius: 50%;
  background: #ff4444;
  color: #fff;
  text-align: center;
  line-height: 56rpx;
  font-size: 32rpx;
  font-weight: bold;
}

.title-text {
  font-size: 36rpx;
  font-weight: bold;
  color: #333;
}

.desc {
  font-size: 26rpx;
  color: #666;
}

/* 页面内搜索 + 分类 */
.search-category-origin {
  background: #fff;
}

.search-bar {
  width: 100%;
  height: 88rpx;
  box-sizing: border-box;
  padding: 0 20rpx;
  display: flex;
  align-items: center;
}

.search-wrap {
  width: 100%;
  height: 64rpx;
  background: #f5f5f5;
  border-radius: 32rpx;
  display: flex;
  align-items: center;
  padding: 0 20rpx;
  box-sizing: border-box;
}

.search-input {
  flex: 1;
  font-size: 26rpx;
  color: #333;
  background: transparent;
}

.search-clear {
  width: 40rpx;
  height: 40rpx;
  text-align: center;
  line-height: 40rpx;
  font-size: 28rpx;
  color: #999;
  margin-left: 10rpx;
}

/* 横向分类 */
.category-scroll {
  white-space: nowrap;
  height: 70rpx;
  padding: 0 20rpx;
  box-sizing: border-box;
}

.category-item {
  display: inline-block;
  height: 70rpx;
  line-height: 70rpx;
  padding: 0 16rpx;
  font-size: 26rpx;
  color: #666;
}

.category-item.active {
  color: #ff4444;
  font-weight: bold;
  position: relative;
}

.category-item.active::after {
  content: '';
  position: absolute;
  bottom: 10rpx;
  left: 50%;
  transform: translateX(-50%);
  width: 32rpx;
  height: 4rpx;
  background: #ff4444;
  border-radius: 2rpx;
}

/* 固定分类栏 */
.category-fixed {
  position: fixed;
  left: 0;
  width: 100%;
  z-index: 98;
  background: #fff;
  opacity: 0;
  box-shadow: 0 2rpx 10rpx rgba(0,0,0,0.06);
}

.banner {
  width: 100%;
  height: 360rpx;
  border-radius: 16rpx;
  overflow: hidden;
  margin-top: 20rpx;
  margin-left: 20rpx;
  margin-right: 20rpx;
  box-sizing: border-box;
}

.banner-img {
  width: 100%;
  height: 100%;
}

.scroll {
  width: 100%;
  height: 100%;
  padding-bottom: env(safe-area-inset-bottom);
  box-sizing: border-box;
}

/* ========== 双列卡片样式 垂直排版 ========== */
.goods-item {
  background: #fff;
  border-radius: 16rpx;
  overflow: hidden;
  display: flex;
  flex-direction: column;
}

.goods-img {
  width: 100%;
  height: 280rpx;
  border-radius: 12rpx;
}

.goods-info {
  margin-top: 16rpx;
  flex: 1;
  display: flex;
  flex-direction: column;
  justify-content: space-between;
}

.goods-name {
  font-size: 28rpx;
  color: #333;
  line-height: 1.3;
}

.goods-price {
  margin-top: 12rpx;
  color: #ff4444;
  font-size: 30rpx;
  font-weight: bold;
}

.loading-wrap {
  height: 80rpx;
  display: flex;
  align-items: center;
  justify-content: center;
  gap: 10rpx;
}

.dot {
  width: 12rpx;
  height: 12rpx;
  border-radius: 50%;
  background: #999;
  animation: jump 0.6s infinite ease-in-out;
}

.dot1 { animation-delay: 0s; }
.dot2 { animation-delay: 0.2s; }
.dot3 { animation-delay: 0.4s; }

@keyframes jump {
  0%, 100% { transform: translateY(0); }
  50% { transform: translateY(-12rpx); }
}

.nomore {
  text-align: center;
  padding: 30rpx 0;
  color: #999;
  font-size: 26rpx;
}

.back-top {
  position: fixed;
  right: 30rpx;
  bottom: calc(140rpx + env(safe-area-inset-bottom));
  width: 80rpx;
  height: 80rpx;
  border-radius: 50%;
  background: #fff;
  box-shadow: 0 4rpx 20rpx rgba(0,0,0,0.15);
  display: flex;
  align-items: center;
  justify-content: center;
  z-index: 100;
  opacity: 0;
}

.back-top-icon {
  font-size: 40rpx;
  color: #333;
  font-weight: bold;
}

json:

{
  "renderer": "skyline",
  "navigationStyle": "custom"
}

开发避坑指南

  • Scroll-view 类型:必须设置 type="custom",否则 grid-builder 无法正常工作。
  • 循环方式:grid-builder 内部不要写 wx:for,应使用 slot:item 和 slot:index 进行数据渲染。
  • Worklet 限制:在 worklet 函数(如 onScrollUpdate)中,不能调用 wx API 或 setData,只能操作 shared 变量。
  • 回到顶部:在 custom 模式下,不能直接绑定 scroll-top 属性来控制滚动位置,需要获取 node 实例并调用其 scrollTo 方法。
【声明】内容源于网络
0
0
wordpress知识
各类跨境出海行业相关资讯
内容 391
粉丝 0
wordpress知识 各类跨境出海行业相关资讯
总阅读16.2k
粉丝0
内容391