时间:2020-02-16 作者:剧中人
vue-stick
一款基于 vue.js 的瀑布流组件。
在《剧中人的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"
}
小剧于2012年初创建博客,借助于帝国CMS实现了博客的第一个版本。不知道有多少小伙伴知道帝国CMS这个上古时期的产品。2013年夏天推翻原有版本,上线了NodeJS版本博客。
无论博客经历过多少次的改版,瀑布流一直以不同的实现方式,在博客的不同角落生根、发芽。直到成长为小剧客栈交互上的一大特色。
瀑布流在小剧客栈上经历过无数次的改进,大的版本有三个,分别是:
前两个版本可以说是 vue-stick
演进的基石,尤其是第二个版本对 vue-stick
的发布起到了很大的作用。
2012年十月前后,那还是一个 jQuery 大行其道的年代,同时也是小剧工作的第一个半年。
做为一个应届毕业生,对身边的同学、好友从事的各个行业都无限好奇。尤其是对那些从事电商、传媒行业的朋友更是向往。
基于收集、展示一些不同行业里好友的想法,小剧创建了【创业团】页面。这是小剧在博客里第一次使用瀑布流。
第一个版本的瀑布流整体非常简陋,强依赖于 jQuery,并且瀑布流逻辑和业务代码交织在一起。
整体没有很好的封装,甚至都不能称之为组件。
采用了固定插槽数量 + 最小高度检测的方法,完成瀑布流插入新元素的逻辑。
有关于这个版本就不详细介绍了,感兴趣的话可以移步在这里:简单的瀑布流框架。
大概在 2015 年左右,小剧决定在博客的博文列表页面,使用瀑布流的交互方式来实现页面布局。
虽然有了前期瀑布流的开发经验,但是呆板的布局、不必要的依赖以及简陋的封装都让小剧对它呲之以鼻。
为了体现瀑布流组件的独立性,这次开发小剧单独为它申请了一个仓库:
裸 JS 版本,无外部依赖
https://github.com/bh-lay/stick
可能你会感觉很奇怪,为什么要取 Stick
这个怪怪的名字?
因为瀑布流和其他列表不一样。在瀑布流的列表里,每一个卡片在插入页面的时候,都需要根据页面布局以及存在于页面的其他卡片,来决定自身的位置。
这个操作很像卡片粘贴的过程,于是根据【粘贴】这个动作,取名为 Stick
。
感兴趣的话可以点击上面的 Github 链接,了解具体的实现逻辑,这里只做一下简单的介绍。
代码采用了当时比较流行的 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);
}
瀑布流因为特殊的展示形态,经常会以接近满屏的宽度展示数据。
为了实现这一特性,在渲染前预先会执行 buildLayout
方法,可以让瀑布流的列数实现随外部容器自由变化。相比于第一版本的瀑布流组件,这个版本显得更加自由、灵活。
相信你注意到了param.column_gap
、param.column_width
这两个参数。结合模块所处容器大小,它们会转换成实例化后的以下几个属性:
this.column_gap
:是一个固定值,配置卡片之间的间距this.column_width_base
:是一个固定值,配置卡片的最小宽度this.column_num
:是一个变化的值,根据实际容器和最小卡片的宽度,计算最多可以放置多少列卡片this.column_width
:是一个变化的值,根据容器实际宽度和 this.column_num
、 this.column_gap
,计算出卡片的实际宽度这些就是 buildLayout
方法需要计算的数据。
前面有介绍过,瀑布流中的每张卡片在插入页面时,都需要根据页面布局以及存在于页面的其他卡片来决定。
根据瀑布流的展示形态可以知道,每个新卡片都会选择插入最短的那一列。
因为每张卡片都是独立的,想找到最短的一列并不简单。需要将所有卡片按列归类,再逐列遍历查找最后一张卡片,最后再进行高度比较。
这个逻辑能解决问题,但是效率却很低下。
为了降低查找的复杂度,提高计算效率,这里引入里另外一个字段:
this.last_row = [];
这个数组的长度和列数相等,每一项的值是一个 Number
类型,或者初始化的时候是undefined
。用来记录每一列最后一张卡片的底部离容器顶部的距离。
这样卡片添加过程就变得更简单了。
第一步:根据 this.last_row
找到最小的一项。
第二步:计算新卡片的坐标
this.column_gap
column_index
乘上 this.column_width
与 this.column_gap
之和。第三步:将卡片的 top 加上自身的高度得到新的值,更新到 this.last_row[column_index]
根据这三步即可以完成新卡片坐标的计算。
瀑布流的卡片因为相互独立,如若在放置到页面之后高度发生了变化,卡片之间可能会发生重叠或间距过大的问题。
为了解决这个问题,必须要找到高度发生变化的原因。
通过对瀑布流布局的了解之后发现,一般能够引起高度发生变化的原因是卡片内有图片,并且未明确指定宽和高。
因此在对新卡片进行坐标计算之前,需要预加载图片后再执行计算逻辑。
这里介绍下增加新卡片的方法,参数 item
可以是 html 字符串,也可以是 Element 节点。
第二个参数 cover
即为图片地址,这里需要考虑某张卡片可能没有图片的情况,因此在预加载方法里对此作了兼容处理。
addItem: function(item, cover){
if(typeof(item) == 'string'){
item = createDom(item);
}
loadImg(cover,function(){
// ... ...
// 计算新卡片的坐标,并插入页面
});
}
因为瀑布流需要监听页面的滚动,用来捕获加载新数据的时机;也需要监听页面的尺寸重置事件,用来更新瀑布流布局。
在瀑布流组件被销毁的时候如若不对这两个监听做处理,势必会引起内存泄漏或者其他潜在的风险。
在《对象的自我销毁》文章里小剧曾以瀑布流组件为例介绍过这类风险。
因此上层对象在获悉对象即将被销毁的时候,需要调用实例化后的瀑布流组件的 destroy
方法,以完成清理工作。
destroy: function(){
// ... ...
}
2019年夏天博客面临改版,技术选型是当下最为流行的 Vue。
因为小剧客栈里有大量自己造的轮子,改版工作量不小。最早的想法是将原生 JS 版本的各个模块用 Vue 模块包裹一层,事实上的确有部分模块采用了这个方案。
瀑布流却因为数据更新的实时性和卡片内视图的复杂性,需要一个纯正的 Vue 版本。
于是就有了基于上一个版本演绎出的 vue-stick
全新版本。
vue-stick
在内部实现上延续了自动扩展列数、新卡片坐标计算等特性,原理和上一个版本几乎保持一致。
得益于 Vue 良好的 UI 组件化设计,vue-stick
的使用十分简单。
更详细的使用方法可以参考文档。
<Stick
:list="list"
@onScrollEnd="loadMore"
>
<template slot-scope="scope">
<!-- 根据 scope.data 设计卡片内容 -->
</template>
</Stick>
上层组件仅需要在模版中使用 vue-stick
组件,并且传递一个瀑布流的数据 list
,就像操作普通列表一样。
另外 vue-stick
会根据页向下滚动的情况,判断是否需要加载更多的数据,如若需要则会通过事件 onScrollEnd
向上层发起通知。
只要在 slot
内部书写卡片内的模版,即可完成瀑布流组件的使用。
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
数据传递,其他参数并无增删,只是在名称上做了些许调整。
可能通过前面两部分介绍,你已经发现上个版本的 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
方法负责单向同步 list
到 localList
,包括增删。
通过 addItem
方法可以看出来 localList
的数据结构比原始数据多了一层,附加了坐标数据和节点状态数据。
计算新卡片的坐标逻辑和上一个版本完全一致,这里就不重复展开了。
你应该还记得,上一个版本在执行 addItem
时需要指定图片地址。
基于瀑布流的特殊性,这里更改为延迟至卡片渲染完毕,根据实际渲染结果查找图片标签,进而找到图片地址。
var widgetNode = me.$refs['widget-' + widget.id]
var imgNode = widgetNode.querySelector('img')
var imgSrc = imgNode ? imgNode.getAttribute('src') : ''
虽然在运行上略显复杂,但是减少了一个冗余配置之后,这样的操作让使用起来更加自由。
为了营造瀑布流无限加载的体验,瀑布流界面在浏览过程中需要一个自动加载的逻辑。
传统的自动加载触发逻辑,是滚动屏幕至页面底部,触发加载后续数据事件。
这里有两个问题需要特殊注意。
要满足这两点并不难。前者是通过通过触发时间间隔做事件截流,即可完成;后者通过配置触发间距,提早触发加载逻辑。
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 。