这篇文章的目的在于总结实现瀑布流Web应用的方法,内容主要包括CSS实现与JS实现,其中在JS实现部分选用React+TS,使用无限列表模式提升性能。
代码仓库
https://github.com/fl427/masonry-photo-v1
瀑布流布局介绍
Pinterest是瀑布流页面布局(Masonry Layouts)的代表,表现为参差不齐的多栏布局,用户滚动时动态加载新的数据块并填充在页面尾部。瀑布流自2012年由Pinterest引入,此后成为了众多网站的外在表现形式。
瀑布流的优势在于:
- 节约空间。横向空间往往比纵向空间更宝贵,尤其是对于移动端场景,因而限制宽度同时放开高度的显式方式更加合理。
- 统一规格。需要展示的区块宽高不定,不加以约束就无法高效管理和展示海量图片,而统一限制宽高又会不可避免的进行裁剪或压缩,瀑布流的方式限制宽度而放开高度,保证了图片的原始比例,让内容最大程度的显示给用户,同时不会显得杂乱。
- 参与感强。用户的注意力被画面中视觉上最显著的部分吸引,交互复杂度的降低让用户能更高效地选择到优质的视觉内容,视觉体验(当然这主要来自于浏览的内容本身)驱使用户沉浸在探索与浏览当中。
![Image.png](https://p6-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/6ac3a2fee6b447fd9b437e967e22741f~tplv-k3u1fbpfcp-watermark.image)
CSS实现
1. column-count + column-gap
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
| .App { padding: 40px; column-count: 4; column-gap: 1rem;
.card { background-color: aqua; margin-bottom: 1rem; break-inside: avoid; } }
const genHeight = (): number => { return Math.floor(Math.random() * 200 + 100); }
function App() { return ( <div className="App"> {Array(16).fill(0).map((_, idx) => { const height = genHeight(); return ( <div key={idx} className='card' style={{ height }}> idx: {idx} height: {height} </div> ) })} </div> ); }
|
![Image.png](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/370197ee4bd24f66ac9cbbda9122783a~tplv-k3u1fbpfcp-watermark.image)
从代码实现难度来说,这是最简易的类瀑布流实现方式了。CSS的三个关键属性为column-count,column-gap和break-inside。其中:
- column-count: 描述元素的列数
- column-gap: 描述元素每列之间的间隙
- break-inside: break-inside属性描述了多列布局下内容盒子如何中断,将其设置为avoid来避免内容跨列
这种实现方式的弊端很明显,可以发现这些区块是由上至下排列的,不能做到由左至右排列,并且也不能智能识别哪块图片应该放在哪个合适的位置,这一点是非常致命的。瀑布流场景通常和动态加载绑定,不能识别图片应该放在哪个位置的特性搭配上动态加载的需求将会导致很糟糕的显式效果。
2. flexBox
使用第一种multi-columns方法时我们发现盒子是由上至下进行排列的,接下来试一试flex布局的wrap属性来让盒子折行。
flex-flow: row wrap
1 2 3 4 5 6 7 8 9 10 11 12
| .App { padding: 40px; display: flex; justify-content: space-between; flex-flow: row wrap;
.card { background-color: aqua; margin-bottom: 1rem; width: 20vw; } }
|
![Image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/3d4652660e8e4e2c8b7cc5f1734c76a7~tplv-k3u1fbpfcp-watermark.image)
以这种方式进行布局无法让新一行的盒子填充在上一行的空隙下方,显然是不符合要求的。这是因为flex布局是一个一维的布局系统,而瀑布流布局必然要求二维的布局系统,在flex布局中,折行的元素只能来到新的一行,而无法放到同一行的其他元素下方。
flex-flow: column wrap
以行为轴行不通,以列为轴呢?答案是依然不行。
1 2 3 4 5 6 7 8 9 10 11 12
| .App { padding: 40px; height: 600px; display: flex; flex-flow: column wrap;
.card { background-color: aqua; margin-bottom: 1rem; width: 20vw; } }
|
![Image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/723050a03354485581853667b0f70bc7~tplv-k3u1fbpfcp-watermark.image)
在这种情况下,盒子由上至下排列,同时必须指定父容器的高度,否则flex不知道什么时候要折行。而且父容器的撑开方向是平行方向,而不是垂直方向,这并不是瀑布流应该有的撑开方向(向pinterest那样撑开高度,而非宽度)。
由上可知,结论是flex布局不能实现瀑布流。
3. CSS实现:Grid布局
grid布局是一个二维布局方法,理论上它能够实现瀑布流,限制在于我们需要知道内部盒子的高度,这一点就断掉了我们我们利用Grid实现瀑布流的想法。不过,我们还是来看一下如果内部元素高度已知,grid能实现的效果。
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 39 40 41 42 43 44 45 46 47
| .App { padding: 40px; display: grid; grid-auto-rows: 100px; grid-gap: 10px; grid-template-columns: repeat(auto-fill, minmax(20%, 1fr));
.card { background-color: aqua; grid-row: span 1; }
.short { grid-row: span 1; } .tall { grid-row: span 2; } .taller { grid-row: span 3; } }
const genClass = (): string => { const classes = ['short', 'tall', 'taller']; const idx = Math.floor(Math.random() * 3); return classes[idx]; }
function App() { return ( <div className="App"> {Array(10).fill(0).map((_, idx) => { const height = genHeight(); return ( <div key={idx} className={`card ${genClass()}`}> idx: {idx} height: {height} </div> ) })} </div> ); }
|
![Image.png](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/065493fee92344c896bf208f16332c3b~tplv-k3u1fbpfcp-watermark.image)
4. CSS实现:Grid masonry
1 2
| grid-template-rows: masonry grid-template-columns: masonry
|
CSS有一个新的提案,使用masonry实现瀑布流,目前仅仅在Firefox的开发模式中启用。MDN链接。
JS实现
无限列表
为了实现瀑布流布局,无限列表是必不可少的。所谓无限列表指的是页面实际渲染的只是可视窗口加上上下缓冲区的部分元素,其余用户感知不到的元素不要渲染在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 29 30 31 32 33 34 35 36 37 38 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138
| import React, {useCallback, useEffect, useMemo, useRef, useState} from 'react'; import './App.css';
const itemHeight = 100; const total = 10000; let id = 0;
function App() { const ref = useRef<HTMLDivElement | null>(null);
const containerHeight = document.body.clientHeight; const visibleCount = Math.floor(containerHeight / itemHeight);
const [listData, setListData] = useState<{ id: number; content: number; top: number }[]>([]); const [startOffset, setStartOffset] = useState(0); const [start, setStart] = useState(0); const listHeight = useMemo(() => { return listData.length * itemHeight; }, [listData]);
const visibleData = useMemo(() => { const visibleStart = Math.max(0, start - visibleCount); const visibleEnd = Math.min(listData.length, start + visibleCount * 2); return listData.slice(visibleStart, visibleEnd); }, [listData, start, visibleCount]);
const genTenListData = useCallback((offset = 0) => { if (listData.length >= total) { return []; }
return new Array(10).fill({}).map((_, idx) => ({ id: id++, content: Math.random() * 1000, top: idx * itemHeight + offset, })); }, [listData]);
useEffect(() => { const data = genTenListData(); setListData(data); }, []);
const handleScroll = useCallback(() => { const dom = ref.current; if (dom) { const scrollTop = dom.scrollTop; const listTotalHeight = dom.scrollHeight;
const start = Math.floor(scrollTop / itemHeight); const end = start + visibleCount; setStart(start); if (end >= listData.length) { const data = listData.concat(genTenListData(listData.length * itemHeight)); setListData(data); }
setStartOffset(scrollTop); }
}, [containerHeight, genTenListData, listData, visibleCount]);
useEffect(() => { const dom = ref.current; if (dom) { dom.addEventListener('scroll', handleScroll); } return () => { if (dom) { dom.removeEventListener('scroll', handleScroll); } } }, [handleScroll]);
return ( <div className="App" ref={ref} > <div className={'list-wrapper'} style={{ height: Math.max(listHeight, containerHeight + 1) }}> <div className={'list'}> {visibleData.map((data) => ( <div key={data.id} className={'list-item'} style={{ height: `${itemHeight}px`, background: 'aqua', transform: `translateY(${data.top}px)` }}> <h1>{data.id}</h1> {data.top} </div> ))} </div> </div> </div> ); }
export default App;
.App { text-align: center; height: 100vh; width: 100vw; overflow: scroll; position: relative; }
.list-wrapper { position: relative; }
.list { position: relative; }
.list-item { position: absolute; left: 0; top: 0; width: 100%; border: 1px solid red; }
|
每一条数据保存自己的位置信息,我们在滚动过程中找到当前视口对应的startIdx,从总的数据列表中选出渲染的内容。
从中可以看出,比较重要的在于判断需要渲染的元素的起始和结束,start和end。
有两种可能的解法
- IntersectionObserver
- 分页思想,维护一个表格,pageIdx作为key,滚动过程中记录该页的start和end
分页思想
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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199
| import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { pageMap } from "./page-map";
import Card from "../../components/card";
import './index.scss';
const itemHeight = 100; const total = 10000; let id = 0;
const MasonryPage: React.FC = () => { const ref = useRef<HTMLDivElement | null>(null);
const [heights, setHeights] = useState<number[]>([0]); const [pageIdx, setPageIdx] = useState<number>(0);
const containerHeight = document.body.clientHeight; const visibleCount = Math.floor(containerHeight / itemHeight);
const [listData, setListData] = useState<{ id: number; content: number; top: number }[]>([]); const [startOffset, setStartOffset] = useState(0); const [start, setStart] = useState(0); const [end, setEnd] = useState(0); const visibleData = useMemo(() => { console.log('数据变化', start); const visibleStart = Math.max(0, start - visibleCount); const visibleEnd = Math.min(listData.length, end + visibleCount * 2); return listData.slice(visibleStart, visibleEnd); }, [listData, start, end, visibleCount]);
const genTenListData = useCallback((offset = 0) => { if (listData.length >= total) { return []; } let currHeights = [...heights];
const dataArr = new Array(10).fill({}).map((_, idx) => { currHeights[0] += itemHeight; return { id: id++, content: Math.random() * 1000, top: idx * itemHeight + offset, } }); setHeights(currHeights); return dataArr; }, [heights, listData.length]);
useEffect(() => { const data = genTenListData(); setListData(data); }, []);
const handleScroll = useCallback(() => { const dom = ref.current; if (dom) { const scrollTop = dom.scrollTop; const listTotalHeight = dom.scrollHeight;
const currPageIdx = Math.floor(scrollTop / containerHeight) setPageIdx(currPageIdx); console.log('info', currPageIdx, pageMap.getInfo(currPageIdx));
if (pageMap.has(currPageIdx)) { const storedCurrStartIdx = pageMap.getInfo(currPageIdx)?.startIdx!; const storedCurrEndIdx = pageMap.getInfo(currPageIdx)?.endIdx!; console.log('已经有了', storedCurrStartIdx, storedCurrEndIdx) setStart(storedCurrStartIdx); setEnd(storedCurrEndIdx); } else { let tempStartIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0; let tempEndIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0; for (let i = tempStartIdx + 1; i < listData.length; i++) { if (listData[i].top <= containerHeight * (currPageIdx + 1)) { tempEndIdx = i; } } pageMap.setInfo(currPageIdx, {startIdx: tempStartIdx, endIdx: tempEndIdx}); setStart(tempStartIdx); setEnd(tempEndIdx); console.log('添加有几次判断+++再次', listData.length - tempStartIdx - 1, tempStartIdx, tempEndIdx) }
if (listTotalHeight - scrollTop <= 1.5 * containerHeight) { console.log('继续加载', listTotalHeight, scrollTop) const data = listData.concat(genTenListData(listData.length * itemHeight)); setListData(data); }
setStartOffset(listTotalHeight); }
}, [containerHeight, genTenListData, listData, visibleCount]);
useEffect(() => { const dom = ref.current; if (dom) { dom.addEventListener('scroll', handleScroll); } return () => { if (dom) { dom.removeEventListener('scroll', handleScroll); } } }, [handleScroll]);
return ( <div className="masonry-page" ref={ref} > <div className={'masonry'} style={{ height: `${startOffset}px` }} > <div className={'masonry-list'}> {visibleData.map((data) => ( <Card className={'masonry-list-item'} key={data.id} data={data} itemHeight={itemHeight} /> ))} </div> </div> </div> ) };
export default MasonryPage;
export interface Info { startIdx: number; endIdx: number; }
class PageMap { map = new Map<number, Info>();
getInfo(idx: number): Info | undefined { return this.map.get(idx); }
setInfo(idx: number, info: Info): void { this.map.set(idx, info); }
has(idx: number): boolean { return this.map.get(idx) !== undefined; } }
export const pageMap = new PageMap();
import React, {useEffect, useRef} from 'react';
interface IProps { data: { content: number; id: number; top: number; }; itemHeight: number; className: string; } const Card: React.FC<IProps> = ({ data , itemHeight, className}) => { const ref = useRef<HTMLDivElement | null>(null);
return ( <div ref={ref} id={data.id.toString()} key={data.id} className={`${className} card`} style={{ height: `${itemHeight}px`, background: 'aqua', transform: `translateY(${data.top}px)` }} > <h1>{data.id}</h1> {data.top} </div> ) };
export default Card;
|
在之前的基础上,我们引入分页的思想,利用以pageIdx为key的map管理每一页所对应的卡片的startIdx和endIdx,滚动时只需要在第一次构造每一页的内容,之后就可以直接使用。
滚动时,利用scrollTop判断出当前属于哪一页,然后就可以找出当前页应当渲染的卡片的范围[startIdx ~ endIdx]。
完整代码
Masonry-Page
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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155 156 157 158 159 160 161 162 163 164 165 166 167 168 169 170 171 172 173 174 175 176 177 178 179 180 181 182 183 184 185 186 187 188 189 190 191 192 193 194 195 196 197 198 199 200 201 202 203 204 205 206 207 208 209 210 211 212 213 214 215 216 217 218 219 220 221 222 223 224 225 226 227 228 229 230 231 232 233 234 235 236 237 238 239 240 241 242 243 244 245 246 247 248 249 250 251 252 253 254 255 256 257 258 259 260 261 262 263 264 265 266 267 268 269 270 271 272 273 274 275
| import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { pageMap } from "./page-map"; import { MasonryImage } from "./masonry-image";
import Card from "../../components/card";
import { fakeImages } from "../../constants/images";
import './index.scss';
const xAxisGap = 4, yAxisGap = 10
const itemWidth = 235;
const getColumnAndPageWidth = (): { pageWidth: number; pageHeight: number; column: number; } => { const pageWidth = global.innerWidth; const pageHeight = global.innerHeight; return { pageWidth, pageHeight, column: Math.floor(pageWidth / (itemWidth + xAxisGap)), }; }
const getMinIndex = (array: number[]): number => { const min = Math.min(...array); return array.indexOf(min); }
const itemHeight = 100; let id = 0;
const handleGetImages = (params: { start: number, end: number }): Promise<string[]> => { return new Promise((resolve) => { if (params.start < fakeImages.length) { resolve(fakeImages.slice(params.start, params.end)); } else { resolve(fakeImages.slice(params.start % fakeImages.length, params.start % fakeImages.length + 10)); }
}); };
const loadImgHeights = (images: string[], itemWidth: number): Promise<MasonryImage[]> => { return new Promise((resolve, reject) => { const length = images.length const masonryImages: MasonryImage[] = [];
let count = 0 const load = (index: number) => { const img = new Image(); img.src = images[index]; const checkIfFinished = () => { count++ if (count === length) { resolve(masonryImages) } } img.onload = () => { const itemHeight = itemWidth * (img.height / img.width) const masonryImageIns = new MasonryImage(images[index], img, { sourceWidth: img.width, sourceHeight: img.height, masonryWidth: itemWidth, masonryHeight: itemHeight }, index + id); masonryImages[index] = masonryImageIns; checkIfFinished() } img.onerror = () => { masonryImages[index] = new MasonryImage(''); checkIfFinished() } img.src = images[index] } images.forEach((img, index) => load(index));
}) }
const MasonryPage: React.FC = () => { const ref = useRef<HTMLDivElement | null>(null);
const [heights, setHeights] = useState<number[]>([]); const [pageIdx, setPageIdx] = useState<number>(0);
const containerHeight = document.body.clientHeight; const visibleCount = Math.floor(containerHeight / itemHeight);
const [images, setImages] = useState<MasonryImage[]>([]); const [startOffset, setStartOffset] = useState(0); const [start, setStart] = useState(0); const [end, setEnd] = useState(0); const visibleData = useMemo(() => { console.log('数据变化', start, end, images); const visibleStart = Math.max(0, start - visibleCount); const visibleEnd = Math.min(images.length, end + visibleCount * 2); return images.slice(visibleStart, visibleEnd); }, [start, visibleCount, images, end]);
const genTenListImages = useCallback(async () => { isAdding.current = true; const imagesFromApi = await handleGetImages({start: images.length, end: images.length + 10});
const masonryImages = await loadImgHeights(imagesFromApi, itemWidth); id += masonryImages.length;
const { pageWidth, column } = getColumnAndPageWidth();
let heightArr = [...heights]; if (heights.length === 0) { heightArr = Array(column).fill(0); } for (let i = 0; i < masonryImages.length; i++) { const masonryImageInstance = masonryImages[i]; const minIndex = getMinIndex(heightArr); const imgTop = heightArr[minIndex] + yAxisGap; masonryImageInstance && masonryImageInstance.setAttributes('offsetY', imgTop); const leftOffset = (pageWidth - (column * (itemWidth + xAxisGap) - xAxisGap)) / 2; const imgLeft = leftOffset + minIndex * (itemWidth + xAxisGap); masonryImageInstance && masonryImageInstance.setAttributes('offsetX', imgLeft);
heightArr[minIndex] = imgTop + (masonryImageInstance.masonryHeight || 0); } setHeights(heightArr); setImages((prev) => ([...prev, ...masonryImages])); isAdding.current = false; }, [heights, images]);
useEffect(() => { genTenListImages().then(); }, []);
const isAdding = useRef(false);
const handleScroll = useCallback(async () => {
const dom = ref.current; if (dom) { const scrollTop = dom.scrollTop; const listTotalHeight = dom.scrollHeight;
const currPageIdx = Math.floor(scrollTop / containerHeight) setPageIdx(currPageIdx); console.log('info', currPageIdx, pageMap.getInfo(currPageIdx));
if (pageMap.has(currPageIdx)) { const storedCurrStartIdx = pageMap.getInfo(currPageIdx)?.startIdx!; const storedCurrEndIdx = pageMap.getInfo(currPageIdx)?.endIdx!; console.log('已经有了', storedCurrStartIdx, storedCurrEndIdx) setStart(storedCurrStartIdx); setEnd(storedCurrEndIdx); } else { let tempStartIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0; let tempEndIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0; for (let i = tempStartIdx + 1; i < images.length; i++) { if ((images[i].offsetY || Infinity) <= containerHeight * (currPageIdx + 1)) { tempEndIdx = i; } } pageMap.setInfo(currPageIdx, {startIdx: tempStartIdx, endIdx: tempEndIdx}); setStart(tempStartIdx); setEnd(tempEndIdx); } console.log('继续加载scroll', images.length, listTotalHeight - scrollTop, 1.5 * containerHeight) if (listTotalHeight - scrollTop <= 1.5 * containerHeight) { if (isAdding.current) { console.log('继续加载scroll正在添加'); return; } console.log('继续加载-1', listTotalHeight - scrollTop, 1.5 * containerHeight, Math.max(...heights)); await genTenListImages(); }
setStartOffset(listTotalHeight); }
}, [containerHeight, genTenListImages, heights, images]);
useEffect(() => { const dom = ref.current; if (dom) { dom.addEventListener('scroll', handleScroll); } return () => { if (dom) { dom.removeEventListener('scroll', handleScroll); } } }, [handleScroll]);
return ( <div className="masonry-page" ref={ref}> <div className={'masonry'} style={{ height: `${startOffset}px` }} > <div className={'masonry-list'}> {visibleData.map((data) => ( <Card className={'masonry-list-item'} key={data.id} image={data} itemHeight={itemHeight} /> ))} </div> </div> </div> ) };
export default MasonryPage;
.masonry-page { text-align: center; height: 100vh; width: 100vw; overflow: scroll; position: relative;
.masonry { position: relative; height: 100%;
&-list { position: relative; height: 100%;
&-item { position: absolute; left: 0; top: 0; } } } }
|
Card
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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58
| import React, {useEffect, useRef} from 'react'; import {MasonryImage} from "../../containers/masonry-page/masonry-image";
import './index.scss';
interface IProps { data?: { content: number; id: number; top: number; }; image?: MasonryImage; itemHeight: number; className: string; } const Card: React.FC<IProps> = ({ data , itemHeight, className, image}) => { const ref = useRef<HTMLDivElement | null>(null);
return ( <div ref={ref} key={image?.id} className={`${className} card`} style={{ // height: `${itemHeight}px`, // background: 'aqua', width: `${image?.masonryWidth}px`, height: `${image?.masonryHeight}px`, transform: `translate(${image?.offsetX}px, ${image?.offsetY}px)` }} > <div className={'card-info'}> <h1>{image?.id}</h1> {image?.offsetY} </div>
<img className={'card-img'} src={image?.src}/> </div> ) };
export default Card;
.card {
&-info { position: absolute; left: 0; top: 0; color: red; }
&-img { width: 100%; height: 100%; } }
|
PageMap
1 2 3 4 5 6 7 8 9 10 11 12 13 14 15 16 17 18 19 20 21 22 23
| export interface Info { startIdx: number; endIdx: number; }
class PageMap { map = new Map<number, Info>();
getInfo(idx: number): Info | undefined { return this.map.get(idx); }
setInfo(idx: number, info: Info): void { this.map.set(idx, info); }
has(idx: number): boolean { return this.map.get(idx) !== undefined; } }
export const pageMap = new PageMap();
|
![Image.png](https://p1-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/d9a073d028bf4b52bb59d57eaf1d43fc~tplv-k3u1fbpfcp-watermark.image)
从服务端获取内容
在瀑布流应用中,获取图片的宽高是一个大问题,因为图片加载是需要时间的,不知道图片的宽高就没办法给图片进行定位。在上述实现中,我们使用promise.all并行预加载图片,避免了串行加载每一张图片带来的过长加载时间。而一般来讲,瀑布流应用的服务端可以由我们自行控制,服务端返回图片的宽高信息就可以省下预加载的步骤,快速得到页面的布局。
和之前代码的主要不同点在于这里直接拿到了图片信息,于是省去了创建new Image()的步骤,较快速的完成页面布局。
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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'; import axios from '../../service';
import { pageMap } from "./page-map"; import { MasonryImage } from "./masonry-image";
import Card from "../../components/card";
import { fakeImages } from "../../constants/images";
import './index.scss';
type LocalImageType = { src: string; width: number; height: number; }
const xAxisGap = 4, yAxisGap = 10
const itemWidth = 235;
const getColumnAndPageWidth = (): { pageWidth: number; pageHeight: number; column: number; } => { const pageWidth = global.innerWidth; const pageHeight = global.innerHeight; return { pageWidth, pageHeight, column: Math.floor(pageWidth / (itemWidth + xAxisGap)), }; }
const getMinIndex = (array: number[]): number => { const min = Math.min(...array); return array.indexOf(min); }
const itemHeight = 100; let id = 0;
const handleGetLocalImages = async (params: { start: number, end: number }): Promise<LocalImageType[]> => { const result = await axios.get('/api', { params: { start: params.start, end: params.end, } }); return result.data; }
const loadLocalImgHeights = (images: LocalImageType[], itemWidth: number): MasonryImage[] => { const masonryImages: MasonryImage[] = [];
images.forEach((img, index) => { const itemHeight = itemWidth * (img.height / img.width); const masonryImageIns = new MasonryImage(img.src, new Image(), { sourceWidth: img.width, sourceHeight: img.height, masonryWidth: itemWidth, masonryHeight: itemHeight }, index + id); masonryImages[index] = masonryImageIns; });
return masonryImages; }
|
这里是主要的组件,图片数据来源是我本地的一个node服务,数据结构为
1 2 3 4 5
| type LocalImageType = { src: string; width: number; height: number; }
|
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 39 40 41 42 43 44 45 46 47 48 49 50 51 52 53 54 55 56 57 58 59 60 61 62 63 64 65 66 67 68 69 70 71 72 73 74 75 76 77 78 79 80 81 82 83 84 85 86 87 88 89 90 91 92 93 94 95 96 97 98 99 100 101 102 103 104 105 106 107 108 109 110 111 112 113 114 115 116 117 118 119 120 121 122 123 124 125 126 127 128 129 130 131 132 133 134 135 136 137 138 139 140 141 142 143 144 145 146 147 148 149 150 151 152 153 154 155
|
const MasonryPage: React.FC = () => {
const ref = useRef<HTMLDivElement | null>(null);
const [heights, setHeights] = useState<number[]>([]);
const containerHeight = document.body.clientHeight; const visibleCount = Math.floor(containerHeight / itemHeight);
const [images, setImages] = useState<MasonryImage[]>([]); const [startOffset, setStartOffset] = useState(0); const [start, setStart] = useState(0); const [end, setEnd] = useState(0); const visibleData = useMemo(() => { const visibleStart = Math.max(0, start - visibleCount); const visibleEnd = Math.min(images.length, end + visibleCount * 2); return images.slice(visibleStart, visibleEnd); }, [start, visibleCount, images, end]); const getLocalListImages = useCallback(async () => { isAdding.current = true; const imagesFromApi = await handleGetLocalImages({ start: images.length, end: images.length + 10 });
const masonryImages = loadLocalImgHeights(imagesFromApi, itemWidth); id += masonryImages.length;
const { pageWidth, column } = getColumnAndPageWidth();
let heightArr = [...heights]; if (heights.length === 0) { heightArr = Array(column).fill(0); } for (let i = 0; i < masonryImages.length; i++) { const masonryImageInstance = masonryImages[i]; const minIndex = getMinIndex(heightArr); const imgTop = heightArr[minIndex] + yAxisGap; masonryImageInstance && masonryImageInstance.setAttributes('offsetY', imgTop); const leftOffset = (pageWidth - (column * (itemWidth + xAxisGap) - xAxisGap)) / 2; const imgLeft = leftOffset + minIndex * (itemWidth + xAxisGap); masonryImageInstance && masonryImageInstance.setAttributes('offsetX', imgLeft);
heightArr[minIndex] = imgTop + (masonryImageInstance.masonryHeight || 0); } setHeights(heightArr); setImages((prev) => ([...prev, ...masonryImages])); isAdding.current = false; }, [heights, images]);
useEffect(() => { getLocalListImages().then(); }, []);
const isAdding = useRef(false);
const handleScroll = useCallback(async () => {
const dom = ref.current; if (dom) { const scrollTop = dom.scrollTop; const listTotalHeight = dom.scrollHeight;
const currPageIdx = Math.floor(scrollTop / containerHeight)
if (pageMap.has(currPageIdx)) { const storedCurrStartIdx = pageMap.getInfo(currPageIdx)?.startIdx!; const storedCurrEndIdx = pageMap.getInfo(currPageIdx)?.endIdx!; setStart(storedCurrStartIdx); setEnd(storedCurrEndIdx); } else { let tempStartIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0; let tempEndIdx = pageMap.getInfo(currPageIdx - 1)?.endIdx ?? 0; for (let i = tempStartIdx + 1; i < images.length; i++) { if ((images[i].offsetY || Infinity) <= containerHeight * (currPageIdx + 1)) { tempEndIdx = i; } } pageMap.setInfo(currPageIdx, { startIdx: tempStartIdx, endIdx: tempEndIdx }); setStart(tempStartIdx); setEnd(tempEndIdx); } if (listTotalHeight - scrollTop <= 1.5 * containerHeight) { if (isAdding.current) { return; } await getLocalListImages(); }
setStartOffset(listTotalHeight); }
}, [containerHeight, getLocalListImages, heights, images]);
useEffect(() => { const dom = ref.current; if (dom) { dom.addEventListener('scroll', handleScroll); } return () => { if (dom) { dom.removeEventListener('scroll', handleScroll); } } }, [handleScroll]);
return ( <div className="masonry-page" ref={ref}> <div className={'masonry'} style={{ height: `${startOffset}px` }} > <div className={'masonry-list'}> {visibleData.map((data) => ( <Card className={'masonry-list-item'} key={data.id} image={data} itemHeight={itemHeight} /> ))} </div> </div> </div> ) };
export default MasonryPage;
|
![Image.png](https://p3-juejin.byteimg.com/tos-cn-i-k3u1fbpfcp/914559ed6ef9444082762dcb3ba807fb~tplv-k3u1fbpfcp-watermark.image)
这里的实现还是存在一定问题,在滚动时会出现白屏的情况,同时缺少resize的能力。所以大家把这篇文章看做一个大致的参考就好,我计划去学习NestJS、Flutter、工程化相关的知识,或许几个月后我的能力有进一步的提升,会对这里的Web瀑布流应用做一次重构,解决现有的问题,写出更加规范,更加’工程化’的代码。
参考
https://www.favori.cn/react-virtuallist/
https://juejin.cn/post/6844903873237237767
https://www.zhihu.com/question/19971454
https://www.cnblogs.com/goloving/p/14882706.html
https://stackoverflow.com/questions/44377343/css-only-masonry-layout
https://juejin.cn/post/6844904004720263176
https://juejin.cn/post/6844904051310592014
https://juejin.cn/post/6844903944297136135
http://www.noobyard.com/article/p-ouaieffr-gr.html
https://cloud.tencent.com/developer/article/1347789
https://juejin.cn/post/7012924144618569764
https://blog.csdn.net/thickhair_cxy/article/details/108879467
https://juejin.cn/post/6963071339108237319
https://juejin.cn/post/6844904051310592014