自己在寫的小專案中有瀑布流的需求,不久之前剛剛完成瀑布流的佈局部分,這部分代碼也已經上傳到了Github gist。寫的時候我就在思考:如果能有更優雅的方式快速實現瀑布流佈局該多好。於是,我便想到了之前無聊時翻看 MDN 時,CSS Houdini 裡邊所描述的 CSS Layout API。正好最近剛寫完瀑布流,實踐起來比較方便。
警告
CSS Layout API 目前還是 First Public Working Draft,本文所述內容在將來隨時可能過時。
警告
目前沒有 ** 任何 ** 瀏覽器支持該特性,為了正常展示本文所述的所有 demo,你需要使用 edge/chrome 瀏覽器並在 flags 中將 Experimental Web Platform features 啟用。
〇. 結果#
因為這篇文章前戲很長,所以將結果放在了最前面呈現,完整的示例可以前往 https://masonry.daidr.me 查看。
如果將來瀏覽器支持了該特性,那麼使用瀑布流佈局將會是一件易如反掌的事情,你需要做的,僅僅是
- 引入 masonry.js
- 準備一個父級容器,和一些瀑布流元素(例如卡片)
- 為這個父級元素加上一個佈局樣式。
<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裡面的內容一直在更新,這才讓我有了繼續寫下去的動力。那么,讓我們開始吧!
Typed OM#
不知道大家在使用 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 Properties and Values API#
這個接口能夠讓我們註冊一些自定義的 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的一個子集。更詳細的資料,可以去草案的第五節詳細了解。
CSS Layout API#
終於到了咱們的重頭戲!佈局的相關邏輯需要使用瀏覽器提供的 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 的用法)
children#
是一個許多LayoutChild對象組成的數組,代表著容器內的所有子元素。LayoutChild主要包含下面這些屬性或方法
LayoutChild.intrinsicSizes()
返回一個 promise,用以得到IntrinsicSizes對象,可以獲取元素的最大 / 最小尺寸
LayoutChild.layoutNextFragment(constraints, breakToken)
返回一個 promise,用以得到LayoutFragment對象,LayoutFragment對象主要包含下面這些屬性:
- LayoutFragment.inlineSize:子元素內聯方向上的尺寸,即寬度(只讀)
- LayoutFragment.blockSize:子元素塊級方向上的尺寸,即高度(只讀)
- LayoutFragment.inlineOffset:子元素內聯方向上的偏移
- LayoutFragment.blockOffset:子元素塊級方向上的偏移,佈局主要就靠這兩個偏移了
LayoutChild.styleMap
返回一個StylePropertyMapReadOnly對象,用來操作子元素的樣式
edges#
是一個LayoutEdges對象(屬性均只讀),用來獲取容器內外邊距、滾動條導致的 content box 與 border box 產生的距離
- LayoutEdges.inlineStart:內聯起始方向的距離
- LayoutEdges.inlineEnd:內聯結束方向的距離
- LayoutEdges.blockStart:塊級起始方向的距離
- LayoutEdges.blockEnd:塊級結束方向的距離
- LayoutEdges.inline:內聯方向的距離和
- LayoutEdges.block:塊級方向的距離和
可能不是很直觀,這裡放一張草案裡提供的 rtl 方向下的圖(和 ltr 正好相反):
constraints#
是一個LayoutConstraints對象(屬性均只讀),用來獲取元素(這裡是指容器)的尺寸信息
- LayoutConstraints.availableInlineSize:內聯方向上的可用尺寸
- LayoutConstraints.availableBlockSize:塊級方向上的可用尺寸
- LayoutConstraints.fixedInlineSize:內聯方向上的確定尺寸
- LayoutConstraints.fixedBlockSize:塊級方向上的確定尺寸
- LayoutConstraints.percentageInlineSize:內聯方向上的尺寸(百分比表示)
- LayoutConstraints.percentageBlockSize:塊級方向上的尺寸(百分比表示)
不過似乎目前瀏覽器提供的 LayoutConstraints 對象只能獲取到 fixedInlineSize 和 fixedBlockSize 這兩個屬性…
styleMap#
是一個 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 };
完整的代碼可以在文章開頭的倉庫中找到。-