vue-stick 瀑布流的发展史

时间:2020-2-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版本博客

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

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

前两个版本可以说是 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这两个参数。结合模块所处容器大小,它们会转换成实例化后的以下几个属性:

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

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

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

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

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

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

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

this.last_row = [];    

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

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

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

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

第三步:将卡片的 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 。