最近在開發一個批量展示圖檔的頁面,圖檔的自适應排列是一個無法避免的問題
在付出了許多頭發的代價之後,終于完成了圖檔排列,并封裝成元件,最終效果如下:
1、設計思路為了使結構清晰,我将圖檔清單處理成了二維數組,第一維為行,第二維為列
<
每一行的總寬度不能超過容器本身的寬度,目前行如果剩餘寬度足夠,就可以追加新圖檔。
而這就需要算出圖檔等比縮放後的寬度 imgWidth,前提條件是知道圖檔的原始寬高和縮放後的高度 imgHeight,通過接口擷取到圖檔清單的時候,至少是有圖檔連結 url 的,通過 url 我們就能擷取到圖檔的寬高。
如果後端的同僚更貼心一點,直接就傳回了圖檔寬高,就相當優秀了。
擷取到圖檔的原始寬高之後,可以先預設一個圖檔高度 imgHeight 作為基準值,然後算出等比縮放之後的圖檔寬度
const imgWidth = Math.floor(item.width * imgHeight / item.height);
然後将單個圖檔通過遞歸的形式放到每一行進行校驗,如果目前行能放得下,就放在目前行,否則判斷下一行,或者直接開啟新的一行
2、資料結構整體的方案設計好了之後,就可以确定最終處理好的圖檔資料應該是這樣的:
const list = [
[
{id: String, width: Number, height: Number, title: String, url: String},
{id: String, width: Number, height: Number, title: String, url: String},
],[
{id: String, width: Number, height: Number, title: String, url: String},
{id: String, width: Number, height: Number, title: String, url: String},
]
]
不過為了友善計算每一行的總寬度,并在剩餘寬度不足時提前完成目前行的排列,是以在計算的過程中,這樣的資料結構更合适:
const rows = [
{
img: [], // 圖檔資訊,最終隻保留該字段
total: 0, // 總寬度
over: false, // 目前行是否完成排列
},
{
img: [],
total: 0,
over: false,
}
]
最後隻需要将 rows 中的 img 提出來,生成二維數組 list 即可 。
基礎資料結構明确了之後,接下來先寫一個給新增行添加預設值的基礎函數
// 以函數的形式處理圖檔清單預設值
const defaultRow = () => ({
img: [], // 圖檔資訊,最終隻保留該字段
total: 0, // 總寬度
over: false, // 目前行是否完成
});
為什麼會采用函數的形式添加預設值呢?其實這和 vue 的 data 為什麼會采用函數是一個道理。
如果直接定義一個純粹的對象作為預設值,會讓所有的行資料都共享引用同一個資料對象。
而通過 defaultRow 函數,每次建立一個新執行個體後,會傳回一個全新副本資料對象,就不會有共同引用的問題。
3、向目前行追加圖檔我設定了一個緩沖值,假如目前行的總寬度與容器寬度(每行的寬度上限)的內插補點在緩沖值之内,這一行就沒法再繼續添加圖檔,可以直接将目前行的狀态标記為“已完成”。
const BUFFER = 30; // 單行寬度緩沖值
然後是将圖檔放到行裡面的函數,分為兩部分:遞歸判斷是否将圖檔放到哪一行,将圖檔添加到對應行。
/**
* 向某一行追加圖檔
* @param {Array} list 清單
* @param {Object} img 圖檔資料
* @param {Number} row 目前行 index
* @param {Number} max 單行最大寬度
*/
function addImgToRow(list, img, row, max) {
if (!list[row]) {
// 新增一行
list[row] = defaultRow();
}
const total = list[row].total;
const innerList = jsON.parse(jsON.stringify(list));
innerList[row].img.push(img);
innerList[row].total = total + img.width;
// 目前行若空隙小于緩沖值,則不再補圖
if (max - innerList[row].total < BUFFER) {
innerList[row].over = true;
}
return innerList;
}
/**
* 遞歸添加圖檔
* @param {Array} list 清單
* @param {Number} row 目前行 index
* @param {Objcet} opt 補充參數
*/
function pushImg(list, row, opt) {
const { maxWidth, item } = opt;
if (!list[row]) {
list[row] = defaultRow();
}
const total = list[row].total; // 目前行的總寬度
if (!list[row].over && item.width + total < maxWidth + BUFFER) {
// 寬度足夠時,向目前行追加圖檔
return addImgToRow(list, item, row, maxWidth);
} else {
// 寬度不足,判斷下一行
return pushImg(list, row + 1, opt);
}
}
4、處理圖檔資料 大部分的準備工作已經完成,可以試着處理圖檔資料了。
constructor(props) {
super(props);
this.containerRef = null;
this.imgHeight = this.props.imgHeight || 200;
this.state = {
imgs: null,
};
}
componentDidMount() {
const { list = mock } = this.props;
console.time('CalcWidth');
// 在構造函數 constructor 中定義 this.containerRef = null;
const imgs = this.calcWidth(list, this.containerRef.clientWidth, this.imgHeight);
console.timeEnd('CalcWidth');
this.setState({ imgs });
}
處理圖檔的主函數
/**
* 處理資料,根據圖檔寬度生成二維數組
* @param {Array} list 資料集
* @param {Number} maxWidth 單行最大寬度,通常為容器寬度
* @param {Number} imgHeight 每行的基準高度,根據這個高度算出圖檔寬度,最終為對齊圖檔,高度會有浮動
* @param {Boolean} deal 是否處理異常資料,預設處理
* @return {Array} 二維數組,按行儲存圖檔寬度
*/
calcWidth(list, maxWidth, imgHeight, deal = true) {
if (!Array.isArray(list) || !maxWidth) {
return;
}
const innerList = jsON.parse(jsON.stringify(list));
const remaindArr = []; // 相容不含寬高的資料
let allRow = [defaultRow()]; // 初始化第一行
for (const item of innerList) {
// 處理不含寬高的資料,統一延後處理
if (!item.height || !item.width) {
remaindArr.push(item);
continue;
}
const imgWidth = Math.floor(item.width * imgHeight / item.height);
item.width = imgWidth;
item.height = imgHeight;
// 單圖成行
if (imgWidth >= maxWidth) {
allRow = addImgToRow(allRow, item, allRow.length, maxWidth);
continue;
}
// 遞歸處理目前圖檔
allRow = pushImg(allRow, 0, { maxWidth, item });
}
console.log('allRow======>', maxWidth, allRow);
// 處理異常資料
deal && this.initRemaindImg(remaindArr);
return buildImgList(allRow, maxWidth);
}
主函數 calcWidth 的最後兩行,首先處理了沒有原始寬高的異常資料(下一部分細講),然後将帶有行資訊的圖檔資料處理為二維數組。
遞歸之後的圖檔資料按行儲存,但每一行的總寬度都和實際容器的寬度有出入,如果直接使用目前的圖檔寬高,會導緻每一行參差不齊。
是以需要使用 buildImgList 來整理圖檔,主要作用有兩個,第一個作用是将圖檔資料處理為上面提到的二維數組函數。
第二個作用則是用容器的寬度來重新計算圖檔高寬,讓圖檔能夠對齊容器:
// 提取圖檔清單
function buildImgList(list, max) {
const res = [];
Array.isArray(list) &&
list.map(row => {
res.push(alignImgRow(row.img, (max / row.total).toFixed(2)));
});
return res;
}
// 調整單行高度以左右對齊
function alignImgRow(arr, coeff) {
if (!Array.isArray(arr)) {
return arr;
}
const coe = +coeff; // 寬高縮放系數
return arr.map(x => {
return {
...x,
width: x.width * coe,
height: x.height * coe,
};
});
}
5、處理沒有原始寬高的圖檔 上面處理圖檔的主函數 calcWidth 在周遊資料的過程中,将沒有原始寬高的資料單獨記錄了下來,放到最後處理。
對于這一部分資料,首先需要根據圖檔的 url 擷取到圖檔的寬高。
// 根據 url 擷取圖檔寬高
function checkImgWidth(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function() {
const res = {
width: this.width,
height: this.height,
};
resolve(res);
};
img.src = url;
});
}
需要注意的是,這個過程是異步的,是以我沒有将這部分資料和上面的圖檔資料一起處理。
而是當所有圖檔寬高都查詢到之後,再額外處理這部分資料,并将結果拼接到之前的圖檔後面。
// 處理沒有寬高資訊的圖檔資料
initRemaindImg(list) {
const arr = []; // 擷取到寬高之後的資料
let count = 0;
list && list.map(x => {
checkImgWidth(x.url).then(res => {
count++;
arr.push({ ...x, ...res })
if (count === list.length) {
const { imgs } = this.state;
// 為防止資料異常導緻死循環,本次 calcWidth 不再處理錯誤資料
const imgs2 = this.calcWidth(arr, this.containerRef.clientWidth - 10, this.imgHeight, false);
this.setState({ imgs: imgs.concat(imgs2) });
}
})
})
}
6、完整代碼 import react from 'react';
const BUFFER = 30; // 單行寬度緩沖值
// 以函數的形式處理圖檔清單預設值
const defaultRow = () => ({
img: [], // 圖檔資訊,最終隻保留該字段
total: 0, // 總寬度
over: false, // 目前行是否完成
});
/**
* 向某一行追加圖檔
* @param {Array} list 清單
* @param {Object} img 圖檔資料
* @param {Number} row 目前行 index
* @param {Number} max 單行最大寬度
*/
function addImgToRow(list, img, row, max) {
if (!list[row]) {
// 新增一行
list[row] = defaultRow();
}
const total = list[row].total;
const innerList = jsON.parse(jsON.stringify(list));
innerList[row].img.push(img);
innerList[row].total = total + img.width;
// 目前行若空隙小于緩沖值,則不再補圖
if (max - innerList[row].total < BUFFER) {
innerList[row].over = true;
}
return innerList;
}
/**
* 遞歸添加圖檔
* @param {Array} list 清單
* @param {Number} row 目前行 index
* @param {Objcet} opt 補充參數
*/
function pushImg(list, row, opt) {
const { maxWidth, item } = opt;
if (!list[row]) {
list[row] = defaultRow();
}
const total = list[row].total; // 目前行的總寬度
if (!list[row].over && item.width + total < maxWidth + BUFFER) {
// 寬度足夠時,向目前行追加圖檔
return addImgToRow(list, item, row, maxWidth);
} else {
// 寬度不足,判斷下一行
return pushImg(list, row + 1, opt);
}
}
// 提取圖檔清單
function buildImgList(list, max) {
const res = [];
Array.isArray(list) &&
list.map(row => {
res.push(alignImgRow(row.img, (max / row.total).toFixed(2)));
});
return res;
}
// 調整單行高度以左右對齊
function alignImgRow(arr, coeff) {
if (!Array.isArray(arr)) {
return arr;
}
const coe = +coeff; // 寬高縮放系數
return arr.map(x => {
return {
...x,
width: x.width * coe,
height: x.height * coe,
};
});
}
// 根據 url 擷取圖檔寬高
function checkImgWidth(url) {
return new Promise((resolve, reject) => {
const img = new Image();
img.onload = function() {
const res = {
width: this.width,
height: this.height,
};
resolve(res);
};
img.src = url;
});
}
export default class ImageList extends react.Component {
constructor(props) {
super(props);
this.containerRef = null;
this.imgHeight = this.props.imgHeight || 200;
this.state = {
imgs: null,
};
}
componentDidMount() {
const { list } = this.props;
console.time('CalcWidth');
// 在構造函數 constructor 中定義 this.containerRef = null;
const imgs = this.calcWidth(list, this.containerRef.clientWidth, this.imgHeight);
console.timeEnd('CalcWidth');
this.setState({ imgs });
}
/**
* 處理資料,根據圖檔寬度生成二維數組
* @param {Array} list 資料集
* @param {Number} maxWidth 單行最大寬度,通常為容器寬度
* @param {Number} imgHeight 每行的基準高度,根據這個高度算出圖檔寬度,最終為對齊圖檔,高度會有浮動
* @param {Boolean} deal 是否處理異常資料,預設處理
* @return {Array} 二維數組,按行儲存圖檔寬度
*/
calcWidth(list, maxWidth, imgHeight, deal = true) {
if (!Array.isArray(list) || !maxWidth) {
return;
}
const innerList = jsON.parse(jsON.stringify(list));
const remaindArr = []; // 相容不含寬高的資料
let allRow = [defaultRow()]; // 初始化第一行
for (const item of innerList) {
// 處理不含寬高的資料,統一延後處理
if (!item.height || !item.width) {
remaindArr.push(item);
continue;
}
const imgWidth = Math.floor(item.width * imgHeight / item.height);
item.width = imgWidth;
item.height = imgHeight;
// 單圖成行
if (imgWidth >= maxWidth) {
allRow = addImgToRow(allRow, item, allRow.length, maxWidth);
continue;
}
// 遞歸處理目前圖檔
allRow = pushImg(allRow, 0, { maxWidth, item });
}
console.log('allRow======>', maxWidth, allRow);
// 處理異常資料
deal && this.initRemaindImg(remaindArr);
return buildImgList(allRow, maxWidth);
}
// 處理沒有寬高資訊的圖檔資料
initRemaindImg(list) {
const arr = []; // 擷取到寬高之後的資料
let count = 0;
list && list.map(x => {
checkImgWidth(x.url).then(res => {
count++;
arr.push({ ...x, ...res })
if (count === list.length) {
const { imgs } = this.state;
// 為防止資料異常導緻死循環,本次 calcWidth 不再處理錯誤資料
const imgs2 = this.calcWidth(arr, this.containerRef.clientWidth - 10, this.imgHeight, false);
this.setState({ imgs: imgs.concat(imgs2) });
}
})
})
}
handleSelect = item => {
console.log('handleSelect', item);
};
render() {
const { className } = this.props;
// imgs 為處理後的圖檔資料,二維數組
const { imgs } = this.state;
return (
<div
ref={ref => (this.containerRef = ref)}
className={className ? `w-image-list ${className}` : 'w-image-list'}
>
{Array.isArray(imgs) &&
imgs.map((row, i) => {
return ( // 渲染行
<div key={`image-row-${i}`} className="w-image-row">
{Array.isArray(row) &&
row.map((item, index) => {
return ( // 渲染列
<div
key={`image-${i}-${index}`}
className="w-image-item"
style={{
height: `${item.height}px`,
width: `${item.width}px`,
}}
onClick={() => {
this.handleSelect(item);
}}
>
<img src={item.url} alt={item.title} />
</div>
);
})}
</div>
);
})}
</div>
);
}
}
PS: 記得給每個圖檔 item 添加樣式 box-sizing: border-box。