vue-stick 瀑布流的发展史

时间:2020年02月16日 作者:剧中人

vue-stick

一款基于 vue.js 的瀑布流组件。

Github:https://github.com/bh-lay/vue-stick

Npm:https://npmjs.com/package/vue-stick

一、为什么想起说 vue-stick ?

《剧中人的2019年》的文末里提到,小剧上线了 Vue 版本的博客。

其实这件事本身并不困难,毕竟博客页面一般都不会有太复杂的业务逻辑,也没有很多繁琐的交互要写。

对于 Vue 开发的熟练工来说,只要选择合适的 UI 框架,集中一周左右的时间就可以写完博客的全部前端功能。

那为什么小剧还要单独介绍这件事呢 ?

熟悉小剧的朋友应该知道,小剧非常热衷于造轮子,而且比较排斥大而全的框架类库。在博客的历次改版中,从路由管理到对话组件,从 Dom 操作到事件系统,都喜欢逐个尝试实现一遍。

这次改版虽然是基于 Vue 开发,但是在触及到具体形态的功能时,手痒的小剧还是忍不住自己操刀。

如果你打开博客的源代码会发现,除了辅助开发使用了大量的依赖包之外,前端运行时代码仅仅引入了以下五个包。

其中 vue-stick 就是本文要介绍的瀑布流组件。

"dependencies": {
 "vue": "^2.5.2",
 "vue-router": "^3.0.1",
 "vue-stick": "^1.0.4",
 "md5": "^2.2.1",
 "qrcode": "^1.4.4"
}

二、vue-stick 的前生今世

小剧于2012年初创建博客,借助于帝国CMS实现了博客的第一个版本。不知道有多少小伙伴知道帝国CMS这个上古时期的产品。2013年夏天推翻原有版本,上线了NodeJS版本博客

无论博客经历过多少次的改版,瀑布流一直以不同的实现方式,在博客的不同角落生根、发芽。直到成长为小剧客栈交互上的一大特色。

瀑布流在小剧客栈上经历过无数次的改进,大的版本有三个,分别是:

  • 第一版 jQuery 原始版本
  • 第二版原生 JS 版本
  • 最新的 Vue 版本

前两个版本可以说是 vue-stick 演进的基石,尤其是第二个版本对 vue-stick 的发布起到了很大的作用。

三、第一版瀑布流

2012年十月前后,那还是一个 jQuery 大行其道的年代,同时也是小剧工作的第一个半年。

做为一个应届毕业生,对身边的同学、好友从事的各个行业都无限好奇。尤其是对那些从事电商、传媒行业的朋友更是向往。

基于收集、展示一些不同行业里好友的想法,小剧创建了【创业团】页面。这是小剧在博客里第一次使用瀑布流。

第一版本瀑布流

第一个版本的瀑布流整体非常简陋,强依赖于 jQuery,并且瀑布流逻辑和业务代码交织在一起。

整体没有很好的封装,甚至都不能称之为组件

采用了固定插槽数量 + 最小高度检测的方法,完成瀑布流插入新元素的逻辑。

有关于这个版本就不详细介绍了,感兴趣的话可以移步在这里:简单的瀑布流框架

四、第二版瀑布流

大概在 2015 年左右,小剧决定在博客的博文列表页面,使用瀑布流的交互方式来实现页面布局。

虽然有了前期瀑布流的开发经验,但是呆板的布局、不必要的依赖以及简陋的封装都让小剧对它呲之以鼻。

为了体现瀑布流组件的独立性,这次开发小剧单独为它申请了一个仓库:

裸 JS 版本,无外部依赖

https://github.com/bh-lay/stick

4.1、Stick 名字的由来

可能你会感觉很奇怪,为什么要取 Stick 这个怪怪的名字?

因为瀑布流和其他列表不一样。在瀑布流的列表里,每一个卡片在插入页面的时候,都需要根据页面布局以及存在于页面的其他卡片,来决定自身的位置。

这个操作很像卡片粘贴的过程,于是根据【粘贴】这个动作,取名为 Stick

感兴趣的话可以点击上面的 Github 链接,了解具体的实现逻辑,这里只做一下简单的介绍。

4.2、代码结构长什么样?

代码采用了当时比较流行的 umd 模块封装方案,基于构造函数 + 原型链完成核心逻辑开发。

// 瀑布流类定义
function Stick(param){
 // ... ...
 this.container = param.container;
 this.onNeedMore = param.onNeedMore || null;
 this.column_gap = param.column_gap ? parseInt(param.column_gap) : 20;
 this.column_width_base = param.column_width ? parseInt(param.column_width) : 300;
 this.column_width;
 this.column_num;
 this.load_spacing = param.load_spacing || 300;
 this.list = [];
 this.last_row = [];
 // ... ...
 this.buildLayout();
}
Stick.prototype = {
 buildLayout : function(){
 // ... ... 
 },
 refresh: function(item){
 // ... ... 
 },
 addItem: function(item,cover){
 // ... ... 
 },
 destroy: function(){
 // ... ... 
 }
}

上面这段代码删除了具体的实现细节,仅展示了 Stick 类的内部变量和原型方法。

结合下面这段使用的代码,我们单纯聊一聊瀑布流的实现逻辑。

// 瀑布流组件具体使用
var stick = new Stick({
 container: document.getElementById('container'),
 column_width: 200,
 column_gap: 10,
 load_spacing: 200,
 onNeedMore: loadMore
})
//加载第一页
loadMore();
// 模拟数据加载
function loadMore(){
 setTimeout(function(){
 list = [{},{},{},{},{},{},{},{},{},{},{},{},{},{},{},{}];
 list.forEach(function(item) {
 var html= '<div class="item" style="height:' + Math.floor(Math.random() * 250 + 150) + 'px">';
 stick.addItem(html, item.cover);
 });
 },300);
}

4.2、如何实现自动扩展列数 ?

瀑布流因为特殊的展示形态,经常会以接近满屏的宽度展示数据。

为了实现这一特性,在渲染前预先会执行 buildLayout 方法,可以让瀑布流的列数实现随外部容器自由变化。相比于第一版本的瀑布流组件,这个版本显得更加自由、灵活。

相信你注意到了param.column_gapparam.column_width这两个参数。结合模块所处容器大小,它们会转换成实例化后的以下几个属性:

  • this.column_gap:是一个固定值,配置卡片之间的间距
  • this.column_width_base:是一个固定值,配置卡片的最小宽度
  • this.column_num :是一个变化的值,根据实际容器和最小卡片的宽度,计算最多可以放置多少列卡片
  • this.column_width:是一个变化的值,根据容器实际宽度和 this.column_numthis.column_gap ,计算出卡片的实际宽度

这些就是 buildLayout 方法需要计算的数据。

4.4、如何计算新卡片的坐标?

前面有介绍过,瀑布流中的每张卡片在插入页面时,都需要根据页面布局以及存在于页面的其他卡片来决定。

根据瀑布流的展示形态可以知道,每个新卡片都会选择插入最短的那一列。

因为每张卡片都是独立的,想找到最短的一列并不简单。需要将所有卡片按列归类,再逐列遍历查找最后一张卡片,最后再进行高度比较。

这个逻辑能解决问题,但是效率却很低下。

为了降低查找的复杂度,提高计算效率,这里引入里另外一个字段:

this.last_row = []; 

这个数组的长度和列数相等,每一项的值是一个 Number 类型,或者初始化的时候是undefined。用来记录每一列最后一张卡片的底部离容器顶部的距离。

这样卡片添加过程就变得更简单了。

第一步:根据 this.last_row 找到最小的一项。

第二步:计算新卡片的坐标

  • top 值为这一项的值再加上卡片间距 this.column_gap
  • left 值为所在列的 column_index 乘上 this.column_widththis.column_gap 之和。

第三步:将卡片的 top 加上自身的高度得到新的值,更新到 this.last_row[column_index]

根据这三步即可以完成新卡片坐标的计算。

4.5、注意卡片高度可能的变化

瀑布流的卡片因为相互独立,如若在放置到页面之后高度发生了变化,卡片之间可能会发生重叠或间距过大的问题。

为了解决这个问题,必须要找到高度发生变化的原因。

通过对瀑布流布局的了解之后发现,一般能够引起高度发生变化的原因是卡片内有图片,并且未明确指定宽和高。

因此在对新卡片进行坐标计算之前,需要预加载图片后再执行计算逻辑。

这里介绍下增加新卡片的方法,参数 item 可以是 html 字符串,也可以是 Element 节点。

第二个参数 cover 即为图片地址,这里需要考虑某张卡片可能没有图片的情况,因此在预加载方法里对此作了兼容处理。

addItem: function(item, cover){
 if(typeof(item) == 'string'){
 item = createDom(item);
 }
 loadImg(cover,function(){
 // ... ...
 // 计算新卡片的坐标,并插入页面
 });
}

4.6、注意完成清理工作

因为瀑布流需要监听页面的滚动,用来捕获加载新数据的时机;也需要监听页面的尺寸重置事件,用来更新瀑布流布局。

在瀑布流组件被销毁的时候如若不对这两个监听做处理,势必会引起内存泄漏或者其他潜在的风险。

《对象的自我销毁》文章里小剧曾以瀑布流组件为例介绍过这类风险。

因此上层对象在获悉对象即将被销毁的时候,需要调用实例化后的瀑布流组件的 destroy 方法,以完成清理工作。

destroy: function(){
 // ... ... 
}

五、第三版瀑布流

2019年夏天博客面临改版,技术选型是当下最为流行的 Vue。

因为小剧客栈里有大量自己造的轮子,改版工作量不小。最早的想法是将原生 JS 版本的各个模块用 Vue 模块包裹一层,事实上的确有部分模块采用了这个方案。

瀑布流却因为数据更新的实时性和卡片内视图的复杂性,需要一个纯正的 Vue 版本。

于是就有了基于上一个版本演绎出的 vue-stick 全新版本。

vue-stick 在内部实现上延续了自动扩展列数、新卡片坐标计算等特性,原理和上一个版本几乎保持一致。

5.1、如何使用?

得益于 Vue 良好的 UI 组件化设计,vue-stick 的使用十分简单。

更详细的使用方法可以参考文档

<Stick
 :list="list"
 @onScrollEnd="loadMore"
>
 <template slot-scope="scope">
 <!-- 根据 scope.data 设计卡片内容 -->
 </template>
</Stick>

上层组件仅需要在模版中使用 vue-stick 组件,并且传递一个瀑布流的数据 list,就像操作普通列表一样。

另外 vue-stick 会根据页向下滚动的情况,判断是否需要加载更多的数据,如若需要则会通过事件 onScrollEnd 向上层发起通知。

只要在 slot 内部书写卡片内的模版,即可完成瀑布流组件的使用。

5.2、支持的参数有变化么?

props: {
 list: {
 type: Array,
 default: []
 },
 columnWidth: {
 type: Number,
 default: 280
 },
 animationClass: {
 type: String,
 default: 'stick-fade-in'
 },
 loadTriggerDistance: {
 type: Number,
 default: 1000
 },
 columnSpacing: {
 type: Number,
 default: 10
 }
}

这个版本虽然和上一个版本相比,除了新增了一个 list 数据传递,其他参数并无增删,只是在名称上做了些许调整。

5.3、数据结构的差异

可能通过前面两部分介绍,你已经发现上个版本的 addItem 方法没了。其实这个方法还在,只是变成了一个私有方法而已。

这里就不得不提到 Vue 版本和上一版本的一个巨大的差异:数据结构的差异

上一个版本的数据结构是基于 Dom 的,在对卡片的定位以及布局刷新的时候,都是直接找到列表中的卡片 Dom 节点,将计算后的新的坐标等数据直接在 Dom 上更新。

Vue 版本瀑布流在对卡片做更新的时候需要避免直接操作 Dom。为了避免污染数据,对新卡片的位置等信息也不能在 list 上进行操作。

因此就需要有一个方法,既能同时能完成页面布局,又要兼顾数据的纯净。

var component = {
 props: {
 list: {
 type: Array,
 default: []
 }
 // ... ...
 },
 data: function () {
 return {
 // ... ...
 localList: [],
 widgetIDMax: 0,
 columnWidthInUse: 0
 }
 },
 mounted: function () {
 // ... ...
 this.syncList()
 },
 methods: {
 // ... ...
 syncList: function () {
 var me = this
 var listInProps = this.list
 var listInScreen = this.localList.map(function (item) {
 return item.data
 })
 // 查找增量数据
 listInProps.forEach(function (item) {
 if (listInScreen.indexOf(item) === -1) {
 me.addItem(item)
 }
 })
 // 逆序查找被删除的数据
 var hasDeletedData = false
 for (var index = listInScreen.length - 1; index >= 0; index--) {
 if (listInProps.indexOf(listInScreen[index]) === -1) {
 hasDeletedData = true
 this.localList.splice(index, 1)
 }
 }
 hasDeletedData && me.refresh()
 },
 addItem: function (item) {
 var widget = {
 id: this.widgetIDMax++,
 style: {
 position: 'relative',
 top: 0,
 left: 0,
 width: this.columnWidthInUse,
 visibility: 'hidden'
 },
 prepared: false,
 data: item
 }
 this.localList.push(widget)
 // ... ...
 },
 refresh: function () {
 // ... ...
 }
 },
 watch: {
 list: function () {
 this.syncList()
 }
 }
}

这是精简后的 vue-stick 代码,主要是用来解释外部数据到内部数据的转化逻辑。

外部 list 是传入数据,模块内部不对其做任何处理,每当 list 发生变化,或者模块初始化的时候,都会执行 syncList 方法。

再来看看内部数据,localList 是模块内部负责瀑布流界面的呈现的数据,数据的来源于 list

syncList 方法负责单向同步 listlocalList ,包括增删。

通过 addItem 方法可以看出来 localList 的数据结构比原始数据多了一层,附加了坐标数据和节点状态数据。

计算新卡片的坐标逻辑和上一个版本完全一致,这里就不重复展开了。

5.4、废弃指定图片地址

你应该还记得,上一个版本在执行 addItem 时需要指定图片地址。

基于瀑布流的特殊性,这里更改为延迟至卡片渲染完毕,根据实际渲染结果查找图片标签,进而找到图片地址。

var widgetNode = me.$refs['widget-' + widget.id]
var imgNode = widgetNode.querySelector('img')
var imgSrc = imgNode ? imgNode.getAttribute('src') : ''

虽然在运行上略显复杂,但是减少了一个冗余配置之后,这样的操作让使用起来更加自由。

5.5、加载触发逻辑

为了营造瀑布流无限加载的体验,瀑布流界面在浏览过程中需要一个自动加载的逻辑。

传统的自动加载触发逻辑,是滚动屏幕至页面底部,触发加载后续数据事件。

这里有两个问题需要特殊注意。

  • 一次滚动行为会触发多次滚动事件,需要避免重复的数据加载
  • 滚动不宜到页面底部再触发,提早触发会给用户更顺畅的加载体验

要满足这两点并不难。前者是通过通过触发时间间隔做事件截流,即可完成;后者通过配置触发间距,提早触发加载逻辑。

scrollListener: function () {
 var now = new Date().getTime()
 if (now - this.lastTriggerScrollTime > 500 && (getScrollTop() + window.innerHeight + this.loadTriggerDistance >= document.body.scrollHeight)) {
 this.$emit('onScrollEnd')
 this.lastTriggerScrollTime = now;
 }
},


到这里,vue-stick 的发展史就全部介绍完了,也附带介绍了一些内部的实现逻辑。

如果对你有所帮助,小剧很荣幸。

从第一版本的简陋,第二版的稳健,再到第三版本的快速开发,看似是三个版本的相互迭代。实则是小剧工作八年以来在博客里一个很小的局部的一些演化。



题外话

vue-stick 一款基于 vue.js 的瀑布流组件。

自从2019年5月底,将 Vue 版本瀑布流发布到 npm 包管理平台,一直没有主动对外做过宣传。不清楚某种原因,至今竟然累积了 357 次 Downloads。如果可能的话,希望你也能为我 +1 。

本文关键字:vue-stick 模块开发 Vue 瀑布流
转载请注明来源:http://bh-lay.com/blog/12bg7zqfwie

小剧客栈

小剧客栈是剧中人在成长路上的一个缩影,也希望借此结交更多前辈好友。分享小剧在前端、nodeJS、设计和web的各个细节上的点点滴滴,愿与你共同分享,一起进步!

相关链接

关于

关于剧中人 关于小剧客栈

design & code by @剧中人,base on nodeJS + mongoDB

感谢七牛提供近乎免费的CDN,阿里云提供的优质云服务,以及360云加速的给力支持。

皖ICP备14001331号-1

AltStyle によって変換されたページ (->オリジナル) /