自己在写的小项目中有瀑布流的需求,不久之前刚刚完成瀑布流的布局部分,这部分代码也已经上传到了Github gist。写的时候我就在思考:如果能有更优雅的方式快速实现瀑布流布局该多好。于是,我便想到了之前无聊时翻看MDN时,CSS Houdini里边所描述的CSS Layout API。正好最近刚写完瀑布流,实践起来比较方便。
warning CSS Layout API目前还是First Public Working Draft,本文所述内容在将来随时可能过时。
warning 目前没有**任何**浏览器支持该特性,为了正常展示本文所述的所有demo,你需要使用edge/chrome浏览器并在flags中将Experimental Web Platform features启用。
因为这篇文章前戏很长,所以将结果放在了最前面呈现,完整的示例可以前往 https://masonry.daidr.me 查看。
如果将来浏览器支持了该特性,那么使用瀑布流布局将会是一件易如反掌的事情,你需要做的,仅仅是
<script src="masonry.js" />
<div class="container">
<div class="card">瀑布流元素</div>
<div class="card">瀑布流元素</div>
<div class="card">瀑布流元素</div>
<!-- ... -->
</div>
<style>
.container {
display: layout(masonry);
}
</style>
我兴致冲冲地去MDN翻阅与CSS Layout API相关的文档,结果发现…居然什么都没有
…既然没有的话,直接去w3c上看看吧,于是,我打开了https://www.w3.org/TR/css-layout-api-1,结果经过我的一番尝试,连里边的示例都没法正常使用,才发现这个文档也过时了
不过好在Editor’s Draft里面的内容一直在更新,这才让我有了继续写下去的动力。那么,让我们开始吧!
不知道大家在使用js操作样式时,是否会感到百般别扭:
let newWidth = 10;
element1.style.width = `${newWidth}px`
因为返回的是字符串,进行运算的时候总是很狼狈,傻傻搞不清楚font-size
/fontSize
/margin-top
/marginTop
,更别提各种数值和单位的拼接,我已经不止一次犯过下面这样的错误了:
element2.style.opacity += 0.1;
Typed OM便可以来解决我们直接操作CSSOM时发生的诸多不愉快。你可以通过元素的attributeStyleMap属性获取到一个StylePropertyMap对象,之后,便可以以map的方式读取元素的样式了。
element3.attributeStyleMap.get('opacity'); // CSSUnitValue {value: 0.5, unit: 'number'}
element3.attributeStyleMap.get('width'); // CSSUnitValue {value: 10, unit: 'px'}
返回的是一个CSSUnitValue对象(也可能是CSSMathValue或其子类的对象),我们可以很轻松地获取到属性值的数值部分,简化我们的操作。浏览器甚至能够自动转换em、rem等相对单位,得到绝对单位数值。我们还可以通过CSSUnitValue内置的to方法,进行快速的单位转换。不仅如此,浏览器还提供了大量的工厂方法来规范化表达css的属性值,比如我们的第一个例子,使用Typed OM进行操作就会是下面的样子。
let newWidth = 10;
element1.attributeStyleMap.set('width', CSS.px(newWidth));
舒服多了。在使用CSS Layout API的过程中,我们会经常看到Typed OM的身影。在MDN可以找到Typed OM相关的文档
这个接口能够让我们注册一些自定义的css属性,并定义格式和默认值。
CSS.registerProperty({
name: "--masonry-gap", // 自定义属性的名称
syntax: "<number>", // 自定义属性的格式
initialValue: 4, // 默认值
inherits: false // 是否从父元素继承
});
不仅可以在JavaScript中使用该接口,浏览器也提供了自定义属性值的 At Rule
@property --masonry-gap {
syntax: '<number>';
initial-value: 4;
inherits: false;
}
自定义属性注册完成后,之后再通过Typed OM操作样式,浏览器便会按照你所提供的格式,返回对应的CSSUnitValue(或CSSMathValue)对象。倘若不这么做,浏览器将会返回一个携带原始css属性值的CSSUnparsedValue对象。
syntax字符串的内容其实很简单,syntax由一堆syntax component组成,默认情况下,syntax字段的内容是*。除此之外,还可以使用 | 来表示或, + 来表示接受使用空格分割的属性值, # 表示接受使用逗号分割的属性值。这里的syntax仅仅是Value Definition Syntax的一个子集。更详细的资料,可以去草案的第五节详细了解。
终于到了咱们的重头戏!布局的相关逻辑需要使用浏览器提供的Worklet接口,这个接口允许脚本独立于js运行环境,进行诸如绘图、布局、音频处理等需要高性能的操作。所以,我们需要一个脚本,用于将布局逻辑相关的代码载入到LayoutWorklet中。(别忘了检查一下浏览器兼容性)
// masonry.js
if ('layoutWorklet' in CSS) {
CSS.layoutWorklet.addModule('layout-masonry.js');
}
接下来就是需要被载入到LayoutWorklet中的代码
// layout-masonry.js
registerLayout('masonry', class {
// 在这里声明之后你需要读取的css属性
static inputProperties = ['--masonry-gap', '--masonry-column'];
// 这个方法用于在弹性布局中确定元素尺寸,可以空着,但不能没有
async intrinsicSizes(children, edges, styleMap) { }
// 布局逻辑
async layout(children, edges, constraints, styleMap, breakToken) { }
});
这样我们就创建了一个名为masonry的布局方式,上面两段代码可以看作是一套模板,直接拿来用就行。
接下来就是噩梦了
,layout的这几个参数是什么,该如何操作?好在草案写得足够详细,也提供了一些示例以供参考。(这篇文章不会讨论breakToken的用法)
是一个许多LayoutChild对象组成的数组,代表着容器内的所有子元素。LayoutChild主要包含下面这些属性或方法
LayoutChild.intrinsicSizes()
返回一个promise,用以得到IntrinsicSizes对象,可以获取元素的最大/最小尺寸
LayoutChild.layoutNextFragment(constraints, breakToken)
返回一个promise,用以得到LayoutFragment对象,LayoutFragment对象主要包含下面这些属性:
LayoutChild.styleMap
返回一个StylePropertyMapReadOnly对象,用来操作子元素的样式
是一个LayoutEdges对象(属性均只读),用来获取容器内外边距、滚动条导致的content box与border box产生的距离
可能不是很直观,这里放一张草案里提供的rtl方向下的图(和ltr正好相反):
是一个LayoutConstraints对象(属性均只读),用来获取元素(这里是指容器)的尺寸信息
不过似乎目前浏览器提供的 LayoutConstraints 对象只能获取到 fixedInlineSize 和 fixedBlockSize 这两个属性…
是一个 StylePropertyMapReadOnly 对象,用来操作容器的样式
使用CSS Layout API实现瀑布流的基本逻辑其实和其他实现方式基本是一致的。
我们先来定义两个自定义属性,方便之后进行属性值的格式化。
顺便把layout-masonry.js载入到layoutWorklet中
// masonry.js
if ('layoutWorklet' in CSS) {
CSS.registerProperty({
name: '--masonry-column',
syntax: '<number>',
inherits: false,
initialValue: 4
});
CSS.registerProperty({
name: '--masonry-gap',
syntax: '<length-percentage>',
inherits: false,
initialValue: '20px'
});
CSS.layoutWorklet.addModule('layout-masonry.js');
}
接下来的所有代码若没有额外说明则均在layout-masonry.js的layout逻辑内部。
首先,我们来获取容器的内容盒子宽度:
// 获取容器的可用宽度(水平尺寸 - 左右内边距之和)
const availableInlineSize = constraints.fixedInlineSize - edges.inline;
接下来,我们来获取瀑布流列数(因为值是整数且默认值为4,我们无需做任何处理,读进来就好)
//获取定义的瀑布流列数
const column = styleMap.get('--masonry-column').value;
接着,我们需要得到每列的间距,此时情况就复杂了。不过好在所有相对单位和绝对单位在传入时都会自动转换成px,所以实际上我们只需要处理百分比和calc函数,css里边的calc函数是支持嵌套的,所以我们这里使用递归来完成计算,同时将百分比转换为像素值。
// layout-masonry.js 外部
function calc(obj, inlineSize) {
if (obj instanceof CSSUnitValue && obj.unit == 'px') {
return obj.value;
} else if (obj instanceof CSSMathNegate) {
return -obj.value;
} else if (obj instanceof CSSUnitValue && obj.unit == 'percent') {
return obj.value * inlineSize / 100;
} else if (obj instanceof CSSMathSum) {
return Array.from(obj.values).reduce((total, item) => total + calc(item, inlineSize), 0);
} else if (obj instanceof CSSMathProduct) {
return Array.from(obj.values).reduce((total, item) => total * calc(item, inlineSize), 0);
} else if (obj instanceof CSSMathMax) {
let temp = Array.from(obj.values).map((item) => calc(item, inlineSize));
return Math.max(...temp);
} else if (obj instanceof CSSMathMin) {
let temp = Array.from(obj.values).map((item) => calc(item, inlineSize));
return Math.min(...temp);
} else {
throw new TypeError('Unsupported expression or unit.')
}
}
// 获取定义的瀑布流间距
let gap = styleMap.get('--masonry-gap');
// 将计算属性和百分比处理成像素值
gap = calc(gap, availableInlineSize);
我们需要根据列数和间隔计算出子元素的宽度
// 计算子元素的宽度
const childAvailableInlineSize = (availableInlineSize - ((column + 1) * gap)) / column;
下面的代码可以算是模板,我们需要获取子元素的fragment,只有这样我们才可以修改子元素的偏移
// 设定子元素宽度,获取fragments
let childFragments = await Promise.all(children.map((child) => {
return child.layoutNextFragment({ availableInlineSize: childAvailableInlineSize });
}));
紧接着,就是瀑布流的逻辑了,基本上所有瀑布流的逻辑是类似的。在我的Github gist中vue的版本也是这么实现的。我们需要记录每一列的当前高度,在布局新元素时,选取其中最短的一列进行插入操作(倘若按照顺序插入会导致每列的高度差距过大)
// 设定子元素宽度,获取fragments
let childFragments = await Promise.all(children.map((child) => {
return child.layoutNextFragment({ availableInlineSize: childAvailableInlineSize });
}));
let autoBlockSize = 0; //初始化容器高度
const columnHeightList = Array(column).fill(edges.blockStart); //初始化每列的高度,用容器的上边距填充
for (let childFragment of childFragments) {
// 得到当前高度最小的列
const shortestColumn = columnHeightList.reduce((curShortestColumn, curValue, curIndex) => {
if (curValue < curShortestColumn.value) {
return { value: curValue, index: curIndex };
}
return curShortestColumn;
}, { value: Number.MAX_SAFE_INTEGER, index: -1 });
// 计算子元素的位置
childFragment.inlineOffset = gap + shortestColumn.index * (childAvailableInlineSize + gap) + edges.inlineStart;
childFragment.blockOffset = gap + shortestColumn.value;
// 更新当前列的高度(原高度 + 子元素高度)
columnHeightList[shortestColumn.index] = childFragment.blockOffset + childFragment.blockSize;
// 更新容器高度(若最短列的高度没有超过容器原高度,则容器高度保持不变)
autoBlockSize = Math.max(autoBlockSize, columnHeightList[shortestColumn.index] + gap);
}
与普通瀑布流唯一的不同可能是在最后一步,我们需要更新容器的高度,所以每布局一个子元素,都尝试记录目前最高那列的高度。
最后,我们需要固定返回一个包含容器高度和子元素fragment的对象
注:按照草案中的描述,此处应该返回一个FragmentResult对象,但是目前没有任何一个浏览器实现了这个类…
// 固定返回一个包含autoBlockSize和childFragments的对象
return { autoBlockSize, childFragments };
完整的代码可以在文章开头的仓库中找到。
code{background: #f5f2f0;}