最近看taobaoued的一篇博客关于瀑布流布局,之前也看过同学公司的网站用的这种布局,很新颖但不知道是什么名字,为提升实践能力,我就打算写一个瀑布流插件,弄了一天,现在放在github waterfallplugin上了。
Pinterest最早开始使用这种布局,后来国内的蘑菇街之类的网站开始跟风。这种布局明显适合那种大量图片显示的网站

实现方式

一.多列浮动

使用这种方式的网站有:
蘑菇街,我同事的公司网站

这种方式最容易想到,让几个div float在一个container中,每次加载出新内容时按计算结果向不同div中添加新的模块

优点:

  1. 简单易实现
  2. 不用明确知道数据块高度,当数据块中有图片时,就不需要指定图片高度。(这一点很重要)
    缺点:
  3. 列数固定,当浏览器窗口变化时,只能固定n列,出现滚动条

二.css3多栏布局

可查询column-count了解不多叙述。

优点:

  1. 简单不用写代码
    缺点:
  2. css3兼容性问题

三.绝对定位

这种方式很高端,有难度,我写的插件也是采用的这种方式。使用这种方式的有:Pinterest, KISSY
使用我的WaterFall需要一句初始化代码即可

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
new WaterFall({
container: "#main-container", //父容器
minColumn: 2, //resize window时保留的最小列数
colWidth: 208, //每列宽度,当前列数就去 max(container.width/(colWidth+colGap), minColumn)
colGap: 10, //单元格列间距
rowGap: 10, //单元格行坚决
load: function(successCallBack){ //每次加载固定数据的函数,success为调整位置的函数
$.ajax({
url: 'items.json',
data: {},
type: 'get',
dataType: 'json',
success: function(result){
if(result.code == 0)
successCallBack(result.data, '#watertmpl');
}
});
}
})

从这里也大概可以看出代码的核心:

1. 获取列数,用container宽度除以设置的(列宽+列间隙)与设置的最小列数进行比较取最大; 这样保证了可以在window resize时自动变化列数,同时避免过窄保证有基本的列数

2. 保存每一列的高度,记录最高高度列和最低高度列;在最低高度列中插入新的元素(设置position:absolute; left:index*(colWidth+colGap); top: colsHeight[index]); 然后更新整个高度数组,并将容器高度设置为最高高度列的高度;

有以下几点值得思考:

(1). 插入元素没有使用拼接字符串,而是采用doT模板引擎;这样是为了可扩展性,因为不知道在每个item内部dom结构,这个需要用户自己来填写; 限定定了容器id=”main-container”,单个item class=”waterfall-item”

1
2
3
4
5
6
7
8
<script id="watertmpl" type="text/x-dot-template">
<div class="waterfall-item">
<img src="{{= it.imgUrl}}"/>
<p>{{= it.briefIntro}}</p>
</div>
</script>
<div id="main-container">
</div>

(2). 使用模板引擎之后在元素填充到dom之前,需要修改其style定义left和top值将元素插入到相应的列。因为采用了分批加载,每次获得的都是一组数组,所以要对数组数据进行分批处理。

这里就出现了一个问题:
只有在每个元素插入到dom中之后才能获取它实际的高度,进而更新每个列的高度,进而进行下一个元素的插入计算,但如果item中含有图片,则在图片没有加载完之前获取的offsetHeight是不准确的,怎么办???
解决方法有两个:
一.服务器端返回item中图片的高度。
二.服务器端不支持,客户端在加载一张图片结束之后进行下一个元素的插入。
实际效果可想而知,明显第一个方法体验更好,性能更好。
但是我的插件这里采用的第二种方法,为了更加独立的完成这个效果:
代码如下:

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
var loadingTag = (function(){ //串行渲染标识
var isLoading = false;
return function(il){
if(il != undefined){
isLoading = il;
}
return isLoading;
};
})();
/**
* 每次加载一页数据之后,添加到dom中的函数。
* 此方法不能并行被调用,即一组数据还没有彻底填充完,另一组数据不能开始填充。这也是由图片高度未知导致的
* @param arr
* @param tmplId
*/
function addItems(arr, tmplSelector){
if(!isArray(arr))
return;
var waterTmpl = doT.template(document.querySelector(tmplSelector).innerText);
addBundleItems(arr, 0, waterTmpl);
}
function addBundleItems(arr, i, waterTmpl){
if(i >= arr.length){
loadingTag(false);
return;
}
var newItemText = waterTmpl(arr[i]);
var posItemText = calculatePosition(newItemText);
container.innerHTML += posItemText;
var imgs = document.querySelectorAll('.waterfall-item img');
imgs[imgs.length-1].onload = function(){
updateHeight(this.offsetHeight + 30); //在服务器没有返回图片高度的情况下。之后自己计算高度,等到图片加载完成之后才能确切高度;进行下一个item插入
i++;
addBundleItems(arr, i, waterTmpl);
}
};

由于存在onload事件,所以一定会用到递归调用。另外这个方法是不能并行被调用的,即必须在一批数组全部填充到dom中之后才能开始填充下一批数据。这也是由于图片加载完成之前高度未知决定的。所以这里使用了一个标记位。isLoading放到闭包中,避免被误修改。

(3). calculatePosition函数用于向doT渲染出来的html字符串中插入left和top属性值。之后将修改的字符串返回填充到dom中

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
/**
*将html字符串中加入left和top值
* @param newItemText
*/
function calculatePosition(newItemText){
var leftStyle = /[^<]*(style\s*=\s*").*waterfall-item[^>]*/ig;
var rightStyle = /[^<]*waterfall-item.*(style\s*=\s*")[^>]*/ig;
var leftResult = leftStyle.exec(newItemText); //匹配结果:[" div class="waterfall-item" style = "" ", "style = ""]
var rightResult = rightStyle.exec(newItemText); //匹配结果:[" div class="waterfall-item" style = "" ", "style = ""]
var leftPos = minIndex * (configs.colWidth + configs.colGap);
var topPos = colsHeight[minIndex] + configs.rowGap;
if(!!leftResult && leftResult.length != 0){ //style在class左侧
newItemText = newItemText.replace(leftResult[1].toString(), leftResult[1].toString() + "left:" + leftPos + "px; top:" + topPos + "px; width:" + configs.colWidth + "px;");
} else if(!!rightResult && rightResult.length != 0){ //style在class右侧
newItemText = newItemText.replace(rightResult[1].toString(), rightResult[1].toString() + "left:" + leftPos + "px; top:" + topPos + "px; width:" + configs.colWidth + "px;");
} else { //没有style
var temp = newItemText.match(/[^<]*waterfall-item[^>]*/i)[0];
newItemText = newItemText.replace(temp.toString(), temp.toString() + 'style = "top:' + topPos + 'px; left:' + leftPos + 'px; width:' + configs.colWidth + 'px;"');
}
return newItemText;
}

分为

  1. class=”waterfall-item” style=””
  2. style=”” class=”waterfall-item”
  3. class=”waterfall-item”
    三种情况进行正则匹配,之后修改style属性值添加left和top属性

(4). updateHeight方法用于在填充dom之后,同步每一列的高度数据。提供给下一次的calculatePosition方法执行调用

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
/**
* 更新列高度数组,和最小高度列的下标
* @param newItemHeight
*/
function updateHeight(newItemHeight){
newItemHeight = newItemHeight || 0;
colsHeight[minIndex] += newItemHeight;
for(var i in colsHeight){
colsHeight = colsHeight; //没有初始化的进行初始化
if(colsHeight[i] < colsHeight[minIndex]){
minIndex = i;
}
if(colsHeight[i] > colsHeight[maxIndex]){
maxIndex = i;
}
}
container.style.height = colsHeight[maxIndex] + "px"; //更改容器高度为所有列中最大
}

3. 设置window.onresize,动态改变列数

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
/**
* 窗口变化时,动态改变已有元素排列,只对已有元素
*/
function reflow(){
colNum = Math.max(parseInt(container.offsetWidth/(configs.colWidth+configs.colGap)), configs.minColumn); //列数
colsHeight.length = 0;
minIndex = 0;
maxIndex = 0;
//初始化高度数组都为0
for(var i=0; i<colNum; i++){
colsHeight[i] = 0;
}
var items = document.querySelectorAll('.waterfall-item');
Array.prototype.forEach.call(items, function(item, i){
var leftPos = minIndex * (configs.colWidth + configs.colGap);
var topPos = colsHeight[minIndex] + configs.rowGap;
item.style.left = leftPos + "px";
item.style.top = topPos + "px";
updateHeight(item.offsetHeight);
});
}
var timer;
window.onresize = function(){
clearTimeout(timer);
timer = setTimeout(function(){
reflow();
}, 300);
};

resize时使用setTimeout优化性能;对已有元素重排

4. 设置window.onscroll来控制下一页加载时机

1
2
3
4
5
6
7
8
9
10
11
/**
* 滚动条是html.clientHeight < html.offsetHeight 所以是由于html过长导致的滚动条出现
* @param e
*/
window.onscroll = function(e){
var t = document.documentElement.scrollTop || document.body.scrollTop;
if(!loadingTag() && t + document.documentElement.clientHeight > container.offsetHeight - configs.loadDistance){
loadingTag(true);
configs.load(addItems);
}
}

普通html页面由于过长产生的滚动条是由于html元素太长在window中显示出的滚动条;

5. 设置首屏加载。注意临界条件的判断

1
2
3
4
5
6
7
8
9
10
11
12
/**
* 加载第一屏
*/
var firstPageInterval;
firstPageInterval = setInterval(function(){
if(!loadingTag() && container.offsetHeight < document.documentElement.clientHeight) {
loadingTag(true);
configs.load(addItems);
} else if(container.offsetHeight > document.documentElement.clientHeight){
clearInterval(firstPageInterval);
}
}, 200);

以下是最终效果