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方法。

