评论

如何从零实现上拉无限加载瀑布流组件

利用css网格grid布局,通过js动态改变grid填充高度实现瀑布流,比较完美的实现,性能较优。

代码已优化请查看另外一篇文章

https://developers.weixin.qq.com/community/develop/article/doc/00026c521ece40c2d2db97f7156013

小程序瀑布流组件

前言:为了实现这个组件也花费了些时间,以前也做过瀑布流的功能,不过是利用 js 去
计算图片的高度,然后通过 css 的绝对定位去改变位置。不过这种要提前加载完一个列
表的图片,然后通过排列的算法生成排序的数组。总之就是太复杂了,后来在网上也看到
纯 css 实现,比如 flex 两列布局,columns 等,不做过多的阐述,下面分享下自己项
目中实现的瀑布流过程。

  1. Css Grid 布局
  2. Css3 变量属性
  3. Js 动态修改 css 变量属性
  4. Wxs 小程序脚本语言
  5. Wxml 节点 Api
  6. 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="grid-row: auto / span var(--grid-row-{{ index }})"
  >
    {{ item }}
  </view>
</view>
Page({
  data: {
    span: 20,
    style: ''
  },
  onReady() {
    this.setData({
      style: '--grid-row-0: 10;--grid-row-1: 10;' // 0-9...
    })
  }
})

显然通过这种方式去修改emmm,有点不尽人意,当view渲染的时候,通过index下标给每个view都设置独立的grid-row样式,然后在修改view父级的style,将--grid-row-xxx变量写进去实现子类继承,虽然比直接去修改每个view的样式要优雅些,但是一旦views的节点多了,100个、1000个、没上限呢,那这个父级的style真的惨不忍睹。。比如100个view,那么style将会是下面这样,所以需要换个思路还是得单独去设置view的样式。

const views = [...99].map((v, k) => `--grid-row-${k}: 10;`)
console.log(views)
// ["--grid-row-0: 10;", "--grid-row-1: 10;", ... "--grid-row-2: 10;", "--grid-row-3: 10;",  "--grid-row-98: 10;", "--grid-row-99: 10;"]

通过Wxs脚本语言来修改view的样式,相比较通过setData去修改view的样式,wxs的性能绝对比js强。

  1. WXS 不依赖于运行时的基础库版本,可以在所有版本的小程序中运行。
  2. WXS 与 JavaScript 是不同的语言,有自己的语法,并不和 JavaScript 一致。
  3. WXS 的运行环境和其他 JavaScript 代码是隔离的,WXS 中不能调用其他 JavaScript 文件中定义的函数,也不能调用小程序提供的API。
  4. WXS 函数不能作为组件的事件回调。
  5. 由于运行环境的差异,在 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获取节点的信息,用来后续计算grid-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,或者用户自己设置了高度。如果要实现瀑布流,还是需要通过计算图片的宽高比例得到图片在视图中宽高,然后再通过计算grid布局的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的值,这个公式也是花了我不少时间去研究的,对grid布局使用也不多,很多潜在用法并不知道,所以通过大量的随机数据对比查找规律所在。gap为数组[x, y],我们要取y计算,已知gap、rows求视图中节点高度(gap[y] + rows) * span - gap[y] = height,有了求height的公式,那么求span就简单了,(height + gap[y]) / (gap[y] + rows) = span,最终视图里的高度会跟计算出来的结果几个像素的误差,因为grid-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;
}

代码片段 https://developers.weixin.qq.com/s/Q02FETmW7ind

最后一次编辑于  2021-03-19  
点赞 11
收藏
评论

4 个评论

  • 阿旺
    阿旺
    2021-02-20

    宝藏博主……

    2021-02-20
    赞同
    回复
  • brave
    brave
    2021-02-05


    2021-02-05
    赞同
    回复
  • Shawshank_King
    Shawshank_King
    发表于移动端
    2021-02-05
    点赞,收藏,转发
    2021-02-05
    赞同
    回复
  • 拾忆
    拾忆
    2021-02-05

    三连

    2021-02-05
    赞同
    回复
登录 后发表内容