小程序瀑布流组件
前言:为了实现这个组件也花费了些时间,以前也做过瀑布流的功能,不过是利用 js 去
计算图片的高度,然后通过 css 的绝对定位去改变位置。不过这种要提前加载完一个列
表的图片,然后通过排列的算法生成排序的数组。总之就是太复杂了,后来在网上也看到
纯 css 实现,比如 flex 两列布局,columns 等,不做过多的阐述,下面分享下自己项
目中实现的瀑布流过程。
- Css Grid 布局
- Css3 变量属性
- Js 动态修改 css 变量属性
- Wxs 小程序脚本语言
- Wxml 节点 Api
- Component 自定义组件
效果图 代码片段
Css Grid 网格布局实现多列多行布局
<view class="c-waterfall">
<view
wx:for="{{ 10 }}"
wx:key="item"
class="view-container"
>
{{ item }}
</view>
</view>
.c-waterfall {
display: grid;
grid-template-columns: repeat(2, 1fr);
grid-auto-flow: row dense;
grid-auto-rows: 10px;
grid-gap: 10px;
}
.view-container {
width: 100%;
grid-row: auto / span 20;
}
Css3 变量,可以通过js动态
改变
.c-waterfall {
--grid-span: 10;
--grid-column: 2;
--grid-gap: 10px;
--grid-rows: 10px;
width: 100%;
display: grid;
grid-template-columns: repeat(var(--grid-column), 1fr);
grid-auto-flow: row dense;
grid-auto-rows: var(--grid-rows);
grid-gap: var(--grid-gap);
}
.view-container {
width: 100%;
grid-row: auto / span var(--grid-span);
}
动态修改 css 变量,实现遍历的节点都有独立的样式
<view class="c-waterfall" style="{{ style }}">
<view
wx:for="{{ 10 }}"
wx:key="item"
class="view-container"
style="gird-row: auto / span var(--gird-row-{{ index }})"
>
{{ item }}
</view>
</view>
Page({
data: {
span: 20,
style: ''
},
onReady() {
this.setData({
style: '--gird-row-0: 10;--gird-row-1: 10;' // 0-9...
})
}
})
显然通过这种方式去修改emmm,有点不尽人意,当view渲染的时候,通过index
下标给每个view都设置独立的gird-row
样式,然后在修改view父级的style,将--gird-row-xxx
变量写进去实现子类继承,虽然比直接去修改每个view的样式要优雅些,但是一旦views的节点多了,100个、1000个、没上限呢,那这个父级的style真的惨不忍睹。。比如100个view,那么style将会是下面这样,所以需要换个思路还是得单独去设置view的样式。
const views = [...99].map((v, k) => `--gird-row-${k}: 10;`)
console.log(views)
// ["--gird-row-0: 10;", "--gird-row-1: 10;", ... "--gird-row-2: 10;", "--gird-row-3: 10;", "--gird-row-98: 10;", "--gird-row-99: 10;"]
通过Wxs脚本语言来修改view的样式,相比较通过setData
去修改view的样式,wxs的性能绝对比js强。
- WXS 不依赖于运行时的基础库版本,可以在所有版本的小程序中运行。
- WXS 与 JavaScript 是不同的语言,有自己的语法,并不和 JavaScript 一致。
- WXS 的运行环境和其他 JavaScript 代码是隔离的,WXS 中不能调用其他 JavaScript 文件中定义的函数,也不能调用小程序提供的API。
- WXS 函数不能作为组件的事件回调。
- 由于运行环境的差异,在 iOS 设备上小程序内的 WXS 会比 JavaScript 代码快 2 ~ 20 倍。在 android 设备上二者运行效率无差异。
一般在对wxs的使用场景上大多数用来做computed
计算,因为在wxml
模板语法里只能进行简单的三元运算,所以一些复杂的运算、逻辑判断等都会放到wxs里面去处理,然后返回给wxml。
// index.wxs
var format = function(string) {
return string + 'px'
}
module.exports = {
format: format
}
<!-- index.wxml -->
<wxs src="./index.wxs" module="wxs"></wxs>
<view>{{ wxs.format('100') }}</view>
<view>{{ wxs.format(span) }}</view>
<button bind:tap="modifySpan">修改span的值</button>
// index.js
page({
data: {
span
},
modifySpan() {
this.setData({
span: '200'
})
}
})
通过WXS响应事件来修改视图层Webview
,跳过逻辑层App Service
,减少性能开销,比如一些频繁响应的事件监听,滚动条位置,手指滑动位置等,通过wxs来做视图层的修改,大大提升了流畅度。
- 通过wxs响应原生组件的事件,
image
组件的bind:load
事件
<!-- index.html -->
<wxs src="./index.wxs" module="wxs"></wxs>
<image
class="image"
src="https://hbimg.huabanimg.com/ccf4a904deaebc25990a47471c61ea1c765694f82633b-71iPZs_/fw/480/format/webp"
bind:load="{{ wxs.loadImg }}"
/>
// index.wxs
var loadImg = function(event, ownerInstance) {
// image组件load加载完返回图片的信息
var image = event.detail
// 获取image的实例
var imageDom = ownerInstance.selectComponent('.image')
// 设置image的样式
imageDom.setStyle({
height: image.height + 'px',
background: 'red'
// ...
})
// 给image添加class
imageDom.addClass('.loaded')
// 更多的功能请参考文档
// https://developers.weixin.qq.com/miniprogram/dev/framework/view/interactive-animation.html
}
module.exports = {
loadImg: loadImg
}
- wxs监听data的值
<!-- index.html -->
<wxs src="./index.wxs" module="wxs"></wxs>
<view class="container">
<view
change:text="{{ wxs.changeText }}"
text="{{ text }}"
class="text"
data-options="{{ options }}"
>
{{ text }}
</view>
<view class="child-node">
this is childNode
</view>
<!-- 某个自定义组件 -->
<test-component class="other-node" />
</view>
// index.wxs
var changeText = function(newValue, oldValue, ownerInstance, instance) {
// 获取修改后的text
var text = newValue
// 获取data-options
var options = instance.getDataset()
// 获取当前页面的任意节点实例
var childNode = instance.selectComponent('.container .child-node')
// 修改childNode样式
childNode.setStyle({ color: 'gree' })
// 获取页面的自定义组件
var otherNode = instance.selectComponent('.container .other-node')
// 获取自定义组件内的节点实例
// 通过css选择器 >
var otherChildNode = instance.selectComponent('.container .other-node >>> .other-child-node')
// 获取自定义组件内部节点的样式
var style = otherChildNode.getComputedStyle(['width', 'height'])
// 更多功能看文档
}
module.exports = {
changeText: changeText
}
通过createSelectorQuery
获取节点的信息,用来后续计算gird-row
的参数
Page({
onReady() {
wx.createSelectorQuery(this)
.select('.view-container')
.fields({size: true})
.exec((res) => {
console.log(res)
// [{width: 375, height: 390}]
})
}
})
创建waterfall自定义组件
waterfall组件的职责,做成组件有什么好处,不做成组件又有什么好处,以及通过抽象节点来实现多组件复用。
prop的基本设置参数
Component({
properties: {
views: Array, // 需要渲染的瀑布流视图列表
options: { // 瀑布流的参数定义
type: Object,
default: {
span: 20, // 节点高度比
column: 2, // 显示几列
gap: [10, 10], // xy轴边距,单位px
rows: 2, // 网格的高度,单位px
},
}
}
})
组件内部默认的样式
.c-waterfall {
--grid-span: 10;
--grid-column: 2;
--grid-gap: 10px;
--grid-rows: 10px;
width: 100%;
display: grid;
grid-template-columns: repeat(var(--grid-column), 1fr);
grid-auto-flow: row dense;
grid-auto-rows: var(--grid-rows);
grid-gap: var(--grid-gap);
}
.view-container {
width: 100%;
grid-row: auto / span var(--grid-span);
}
组件的骨架
<wxs
src="./index.wxs"
module="wx"
></wxs>
<!-- 样式承载节点 -->
<view
class="c-waterfall"
change:loadStatus="{{ wx.load }}"
loadStatus="{{ childNode }}"
data-options="{{ options }}"
style="{{ wx.setStyle(options) }}"
>
<!-- 抽象节点 -->
<selectable
class="view-container"
id="view-{{ index }}"
wx:for="{{ views }}"
wx:key="item"
value="{{ item }}"
index="{{ index }}"
bind:load="load"
>
</selectable>
</view>
抽象节点
{
"component": true,
"usingComponents": {},
"componentGenerics": {
"selectable": true
}
}
抽象节点应该遵循什么
Component({
properties: {
value: Object, // 组件自身需要的数据
index: Number, // 下标值
},
methods: {
load(event) { // load节点响应事件
this.triggerEvent('load', {
...this.data,
// value必填参数 {width,height}
value: { ...event.detail },
})
},
},
})
组件wxs响应事件
.c-waterfall
样式承载节点,主要是设置options传入的参数
var _getGap = function (gaps) {
return gaps
.map(function (v) {
return v + 'px'
})
.join(' ')
}
var setStyle = function (options) {
if (!options) return
var style = [
'--grid-span: ' + options.span || 10,
'--grid-column: ' + options.column || 2,
'--grid-gap: ' + _getGap(options.gap || [10, 10]),
'--grid-rows: ' + (options.rows || 10) + 'px',
]
return style.join(';')
}
获取瀑布流样式承载节点实例
var _getWaterfall = function (dom) {
var waterfallDom = dom.selectComponent('.c-waterfall')
return {
dom: waterfallDom,
options: waterfallDom.getDataset().options,
}
}
获取事件触发的节点实例
var _getView = function (index, dom) {
var viewDom = dom.selectComponent('.c-waterfall >>> #view-' + index)
return {
dom: viewDom,
style: viewDom.getComputedStyle(['width', 'height']),
}
}
获取虚拟节点自定义组件load节点实例,初始化渲染时,节点是未知的,比如image组件,图片的宽高是未知的,需要等到image加载完成才会知道宽高,该节点用于存放异步视图展示,然后通过事件回调计算出节点高度。
var _getLoadView = function (index, dom) {
return {
dom: dom.selectComponent(
'.c-waterfall >>> #view-' + index + '>>>.waterfall-load-node'
),
}
}
获取虚拟节点自定义组件other节点实例,初始化渲染就存在节点,比如一些文字就放在该节点,具体由组件的创造者去自定义。
var _getOtherView = function (index, dom) {
var other = dom.selectComponent(
'.c-waterfall >>> #view-' + index + '>>> .waterfall-load-other'
)
return {
dom: other,
style: other.getComputedStyle(['height', 'width']),
}
}
已知瀑布流样式承载节点的宽度,等load节点异步视图回调时,获取到load节点的实际高度,比如一张400*800的图片,如果要显示在一个宽度180px的视图里,注意:image
组件会有默认高度240px,或者用户自己设置了高度。如果要实现瀑布流,还是需要通过计算图片的宽高比例得到图片在视图中宽高,然后再通过计算gird布局的span值实现填充。
var fix = function (string) {
if (typeof string === 'number') return string
return Number(string.replace('px', ''))
}
var computedContainerHeight = function (node, view) {
var vW = fix(view.width)
var nW = fix(node.width)
var nH = fix(node.height)
var scale = nW / vW
return {
width: vW,
height: nH / scale,
}
}
通过公式计算span的值,这个公式也是花了我不少时间去研究的,对gird布局使用也不多,很多潜在用法并不知道,所以通过大量的随机数据对比查找规律所在。gap为数组[x, y]
,我们要取y计算,已知gap、rows求视图中节点高度(gap[y] + rows) * span - gap[y] = height
,有了求height的公式,那么求span就简单了,(height + gap[y]) / (gap[y] + rows) = span
,最终视图里的高度会跟计算出来的结果几个像素的误差,因为gird-row
设置span不能为小数,只能为整数,而我们瀑布流的高度是未知的,通过计算有多位浮点数,所以只能向上取整了导致有几个像素的误差。
var computedSpan = function (height, options) {
var rows = options.rows
var gap = options.gap[1]
var span = Math.ceil((height + gap) / (gap + rows))
return span
}
最后我们能得到span
的值了,只需要将load完成的视图修改样式即可
var load = function (node, oldNode, dom) {
if (!node.value) return false
var index = node.index
var waterfall = _getWaterfall(dom)
// 获取虚拟组件,通过index下标确认是哪个,获取宽度高度
var view = _getView(index, dom)
var otherView = _getOtherView(index, dom)
var otherViewHeight = fix(otherView.style.height)
// 计算虚拟组件的高度,其实就是计算图片在当前视图节点里的宽高比例
// image组件的mode="widthFix"也是这样计算的额
var virtualStyle = computedContainerHeight(node.value, view.style)
// span取值,此处计算的高度应该是整个虚拟节点视图的高度
// load事件回调里,我们只传了load视图节点的宽高
// 后续通过selectComponent获取到了other视图节点的高度
var span = computedSpan(
otherViewHeight + virtualStyle.height,
waterfall.options
)
// 设置虚拟组件的样式
view.dom.setStyle({
'grid-row': 'auto / span ' + span,
})
// 获取重新渲染后的虚拟组件高度
var viewHeight = view.dom.getComputedStyle(['width', 'height'])
viewHeight = fix(viewHeight.height)
// 上面说了因为浮点数的计算会导致有几个像素的误差
// 为了视图美观,我们将load视图节点的高度设置成虚拟视图节点的总高度减去静态节点的高度
var loadView = _getLoadView(index, dom)
loadView.dom.setStyle({
width: virtualStyle.width + 'px',
height: parseInt(viewHeight - otherViewHeight) + 'px',
opacity: 1,
visibility: 'visible',
})
return false
}
module.exports = {
load: load,
setStyle: setStyle,
}
抽离成虚拟节点自定义组件的利弊
-
利:
- 符合观察者模式的设计模式
- 将功能耦合度提升
- 扩展性强
- 代码清晰
-
弊:
- 节点增加,如果视图节点过多会造成小程序性能警告
- 样式编写不便捷,需要写过多的判断代码去实现外部样式覆盖
- wxs只能监听原生组件的事件,所以image的load事件触发时本可以直接去修改页面视图节点样式,不需要传回给父组件,然后父组件setData下标,wxs监听事件触发在去修改视图样式,多了一次setData的开销。
-
合:
-
时间有限没有扩展样式覆盖了,可以开启自定义组件的外部样式引入
-
节点过多的问题,在我自己电脑上,开发工具插入100个组件时,出现了卡顿,样式错乱,真机上目前还没发现上限。
-
后续想实现长列表功能,有回收机制,这样视图内的节点有限了,降低了性能开销,因为之前版本的长列表组件是通过
createSelectorQuery
获取节点信息,然后记录高度,通过创建createIntersectionObserver
监听视图节点是否在视图来判断是否渲染。但是瀑布流有异步视图,初次渲染的高度跟异步加载完的高度是不一样,所以创建监听事件高度会不准确,若等到load完再创建监听事件,父级容器的高度又要经过计算,因为子节点会去填充空白区域实现瀑布流,目前项目中为了避免节点过大造成性能警告,加了item的个数限制,如果超过100或者1000个就清空数组,类似分页的功能。不过上面总结的思路可以去试试。 -
等把功能完善了,发布npm依赖包安装。
-
后续有时间会将项目里比较实用的组件抽离出来。。
- 自定义tabbar
- 自定义navbar
- 长列表
- 下拉刷新
- 上拉加载
- 购物车sku
- …
-
Demo
page调用页面
<view class="container">
<waterfall
wx:if="{{ _type === 0 }}"
generic:selectable="test-view"
views="{{ views }}"
options="{{ options }}"
/>
<waterfall
wx:else
generic:selectable="image-view"
views="{{ images }}"
options="{{ options }}"
/>
</view>
<view class="btns">
<button bind:tap="loadView">模拟节点</button>
<button bind:tap="loadImage">远程图片</button>
</view>
Page({
data: {
views: [],
loading: false,
options: {
span: 30,
column: 2,
gap: [10, 10],
rows: 2,
},
images: [],
_page: 1,
_type: 0,
},
onLoad() {
// 生成随机数据
// this.generateViews()
// this.getHuaBanList()
},
loadView() {
this.data._page = 1
this.setData({ images: [], _type: 0 })
this.generateViews()
},
loadImage() {
this.data._type = 1
this.setData({ views: [], _type: 1 })
this.getHuaBanList()
},
getHuaBanList() {
let { images, _page } = this.data
wx.request({
url: `https://huaban.com/search/?q=随机&page=${_page}&per_page=10&wfl=1`,
header: {
accept: 'application/json',
'accept-language': 'zh-CN,zh;q=0.9',
'x-request': 'JSON',
'x-requested-with': 'XMLHttpRequest',
},
success: (res) => {
res.data.pins.map((v) => {
images.push({
url: `https://hbimg.huabanimg.com/${v.file.key}_/fw/480/format/webp`,
title: v.raw_text,
})
})
this.setData({ images, _page: ++_page })
wx.hideLoading()
},
})
},
generateViews() {
const { views } = this.data
for (let i = 0; i < 10; i++) {
views.push({
width: this._randomNum(150, 500) + 'px',
height: this._randomNum(200, 600) + 'px',
})
}
this.setData({
views,
})
},
_randomNum(minNum, maxNum) {
switch (arguments.length) {
case 1:
return parseInt(String(Math.random() * minNum + 1), 10)
break
case 2:
return parseInt(Math.random() * (maxNum - minNum + 1) + minNum, 10)
break
default:
return 0
break
}
},
onReachBottom() {
let { loading, _type } = this.data
if (!loading) {
wx.showLoading({
title: 'loading...',
})
loading = true
setTimeout(() => {
_type === 0 ? this.generateViews() : this.getHuaBanList()
wx.hideLoading()
loading = false
}, 1000)
}
},
})
{
"usingComponents": {
"waterfall": "/components/waterfall/index",
"test-view": "/components/test-view/index",
"image-view": "/components/image-view/index"
}
}
模拟load异步的自定义组件
<view class="c-test-view">
<view class="waterfall-load-node">
{{value.width}}*{{value.height}}
</view>
<view class="waterfall-load-other">模拟加载图片</view>
</view>
Component({
properties: {
value: Object,
index: Number,
},
lifetimes: {
ready() {
const { index } = this.data
const timer = 1000 + 300 * String(index).charAt(index.length - 1)
setTimeout(() => this.load(), timer)
},
},
methods: {
load() {
this.triggerEvent('load', {
...this.data,
})
},
},
})
.c-test-view {
width: 100%;
height: 100%;
display: flex;
flex-flow: column;
justify-content: center;
align-items: center;
background: white;
}
.c-test-view .waterfall-load-node {
height: 50%;
flex-grow: 1;
transition: all 0.3s;
display: inline-flex;
flex-flow: column;
justify-content: center;
align-items: center;
background: #eeeeee;
width: 100%;
opacity: 0;
}
.c-test-view .waterfall-load-other {
width: 100%;
height: 80rpx;
display: inline-flex;
justify-content: center;
align-items: center;
background: cornflowerblue;
color: white;
}
随机获取花瓣网图片的自定义组件
<view class="c-image-view">
<view class="waterfall-load-node">
<image
class="load-image"
src="{{ value.url }}"
bind:load="load"
/>
</view>
<view class="waterfall-load-other">{{ value.title }}</view>
</view>
Component({
properties: {
value: Object,
index: Number,
},
lifetimes: {
ready() {},
},
methods: {
load(event) {
this.triggerEvent('load', {
...this.data,
value: { ...event.detail },
})
},
},
})
.c-image-view {
width: 100%;
display: inline-flex;
flex-flow: column;
background: white;
border-radius: 10px;
overflow: hidden;
height: 100%;
}
.c-image-view .waterfall-load-node {
width: 100%;
height: 50%;
display: inline-flex;
flex-grow: 1;
background: gainsboro;
transition: opacity 0.3s;
opacity: 0;
overflow: hidden;
visibility: hidden;
}
.c-image-view .waterfall-load-node .load-image {
width: 100%;
height: 100%;
overflow: hidden;
}
.c-image-view .waterfall-load-other {
font-size: 30rpx;
background: white;
min-height: 60rpx;
padding: 10px;
display: flex;
align-items: center;
}