最近在開發一個批量展示圖片的頁面,圖片的自適應排列是一個無法避免的問題
在付出了許多頭發的代價之后,終于完成了圖片排列,并封裝成組件,最終效果如下

一、設計思路
為了使結構清晰,我將圖片串列處理成了二維陣列,第一維為行,第二維為列
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>
);
}
每一行的總寬度不能超過容器本身的寬度,當前行如果剩余寬度足夠,就可以追加新圖片
而這就需要算出圖片等比縮放后的寬度 imgWidth,前提條件是知道圖片的原始寬高和縮放后的高度 imgHeight
通過介面獲取到圖片串列的時候,至少是有圖片鏈接 url 的,通過 url 我們就能獲取到圖片的寬高
如果后端的同事更貼心一點,直接就回傳了圖片寬高,就想當優秀了
獲取到圖片的原始寬高之后,可以先預設一個圖片高度 imgHeight 作為基準值,然后算出等比縮放之后的圖片寬度
const imgWidth = Math.floor(item.width * imgHeight / item.height);
然后將單個圖片通過遞回的形式放到每一行進行校驗,如果當前行能放得下,就放在當前行,否則判斷下一行,或者直接開啟新的一行
二、資料結構
整體的方案設計好了之后,就可以確定最終處理好的圖片資料應該是這樣的:
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 函式,每次創建一個新實體后,會回傳一個全新副本資料物件,就不會有共同參考的問題
三、向當前行追加圖片
我設定了一個緩沖值,假如當前行的總寬度與容器寬度(每行的寬度上限)的差值在緩沖值之內,這一行就沒法再繼續添加圖片,可以直接將當前行的狀態標記為“已完成”
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);
}
}
四、處理圖片資料
大部分的準備作業已經完成,可以試著處理圖片資料了
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,
};
});
}
五、處理沒有原始寬高的圖片
上面處理圖片的主函式 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) });
}
})
})
}
六、完整代碼
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=https://www.cnblogs.com/wisewrong/p/{item.url} alt={item.title} />
