Created
January 14, 2022 08:32
-
-
Save daidr/1b8686b4ab3fd6e5b738e483a3c817f4 to your computer and use it in GitHub Desktop.
Masonry layout in vue3
This file contains bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
<script setup> | |
import { onActivated, onDeactivated, onMounted, onBeforeUnmount, reactive, nextTick } from 'vue' | |
import { getCurrentInstance } from '@vue/runtime-core' | |
const self = getCurrentInstance() | |
const props = defineProps({ | |
defaultCol: { | |
type: Number, | |
default: 4 | |
}, | |
breakpoints: { | |
type: Object, | |
default: function () { | |
return { | |
xs: 1, | |
sm: 2, | |
md: 3, | |
lg: 3, | |
xl: 4, | |
'2xl': 4 | |
} | |
} | |
}, | |
onFetchRequest: { | |
type: Function, | |
default: function () {} | |
} | |
}) | |
// 节流函数 | |
function throttle(func, wait) { | |
let timeout | |
return function () { | |
let context = this | |
let args = arguments | |
if (!timeout) { | |
timeout = setTimeout(() => { | |
timeout = null | |
func.apply(context, args) | |
}, wait) | |
} | |
} | |
} | |
const data = reactive({ | |
lastPostId: 0, // 上一次刷新的最后一个帖子id | |
postArray: [], // 当前加载的全部帖子列表 | |
fetching: false, // 新数据请求锁 | |
rendering: false, // 渲染中,防止渲染未完成高度获取不准确而导致可连续触发请求 | |
renderList: [], // 渲染列表 | |
col: props.defaultCol, // 瀑布流列数 | |
isEventHandled: false | |
}) | |
let resizeHandler = function () {} // 窗体大小改变事件处理函数(节流) | |
// 用于清空渲染列表 | |
const clearRenderList = () => { | |
for (var i = 0; i < data.renderList.length; i++) { | |
data.renderList[i].splice(0) | |
} | |
} | |
// 用于清空瀑布流列表 | |
const clearPostList = () => { | |
data.postArray.splice(0) | |
clearRenderList() | |
} | |
// 窗体大小改变事件处理函数(非节流) | |
const onWidthChange = () => { | |
// 数据获取或元素渲染过程中不会触发瀑布流重布局 | |
if (data.fetching || data.rendering) return | |
// 储存旧列数 | |
let oldCol = data.col | |
// 获取窗体宽度以匹配断点 | |
let curWidth = window.innerWidth | |
if (curWidth < 360) { | |
data.col = props.breakpoints['xs'] | |
} else if (curWidth < 640) { | |
data.col = props.breakpoints['sm'] | |
} else if (curWidth < 768) { | |
data.col = props.breakpoints['md'] | |
} else if (curWidth < 1024) { | |
data.col = props.breakpoints['lg'] | |
} else if (curWidth < 1280) { | |
data.col = props.breakpoints['xl'] | |
} else { | |
data.col = props.breakpoints['2xl'] | |
} | |
// 若列数变更,则重新渲染所有卡片 | |
if (oldCol != data.col) { | |
// 加锁防止渲染过程中新的数据请求 | |
data.rendering = true | |
clearRenderList() | |
// 等待dom更新,填充数据 | |
nextTick(() => { | |
fillData() | |
}) | |
} | |
} | |
//新数据获取 | |
const getDataList = () => { | |
// 当前瀑布流不处于渲染状态 | |
if (!data.rendering) { | |
// 加锁,防止数据获取过程中触发渲染事件 | |
data.fetching = true | |
// 调用数据获取函数,该函数应该接受上次帖子列表的最后一个帖子id用于请求数据,并返回Promise | |
props.onFetchRequest(data.lastPostId).then((res) => { | |
data.rendering = true | |
if (res.length > 0) { | |
data.lastPostId = res[res.length - 1] | |
// 获取帖子总数(新数据除外) | |
let len = data.postArray.length | |
// 加入新帖子数据 | |
data.postArray.push(...res) | |
//仅渲染新帖子 | |
fillData(len) | |
} else { | |
// 没有数据,暂时没有额外的处理步骤 | |
data.rendering = false | |
} | |
data.fetching = false | |
}) | |
} else { | |
//处于请求状态,节流,或已出现无数据(over),忽略请求 | |
return false | |
} | |
} | |
// 数据填充函数,会将新卡片添加到高度最小的那一列,防止瀑布流高度差过大 | |
const fillData = (index = 0) => { | |
if (index < data.postArray.length) { | |
// 先对帖子图片进行预加载,防止卡片高度计算不准确 | |
let imgPreload = new Image() | |
imgPreload.onload = function () { | |
// 储存所有列的目前高度 | |
let colHeightList = [] | |
for (let i = 0; i < data.col; i++) { | |
colHeightList[i] = self.refs['col' + i].offsetHeight | |
} | |
//获取高度最小的列索引 | |
let min = colHeightList.indexOf(Math.min.apply(Math, colHeightList)) | |
// 将帖子卡片插入到该列 | |
data.renderList[min].push(data.postArray[index]) | |
// 等待dom更新再插入下一条帖子 | |
nextTick(() => { | |
fillData(index + 1) | |
}) | |
} | |
imgPreload.src = data.postArray[index].image | |
} else { | |
data.rendering = false | |
} | |
} | |
onMounted(() => { | |
//初始化瀑布流渲染列表 | |
for (var i = 0; i < data.col; i++) { | |
data.renderList[i] = [] | |
} | |
// 将事件处理做节流包装 | |
resizeHandler = throttle(onWidthChange, 200) | |
// 窗体大小改变事件监听 | |
window.addEventListener('resize', resizeHandler) | |
data.isEventHandled = true | |
// 初始化瀑布流列数 | |
onWidthChange() | |
nextTick(() => { | |
getDataList() | |
fillData(0) | |
}) | |
}) | |
onBeforeUnmount(() => { | |
// 取消窗体大小改变事件监听 | |
window.removeEventListener('resize', resizeHandler) | |
data.isEventHandled = false | |
}) | |
onDeactivated(() => { | |
// 取消窗体大小改变事件监听 | |
window.removeEventListener('resize', resizeHandler) | |
data.isEventHandled = false | |
}) | |
onActivated(() => { | |
if (!data.isEventHandled) { | |
window.addEventListener('resize', resizeHandler) | |
resizeHandler() | |
data.isEventHandled = true | |
} | |
}) | |
defineExpose({ | |
clearPostList, | |
getDataList | |
}) | |
</script> | |
<template> | |
<div v-show="data.postArray" ref="masonryContainer" class="masonry-container"> | |
<ul class="flex justify-around items-start gap-x-3 md:gap-x-5"> | |
<!--列的宽度应由计算的来--> | |
<li | |
v-for="(it, index) in data.col" | |
:ref="'col' + index" | |
:key="it" | |
class="gap-y-3 flex flex-col w-full md:gap-y-5" | |
> | |
<div v-for="item in data.renderList[index]" :key="item.id"> | |
<slot :item-data="item" /> | |
</div> | |
</li> | |
</ul> | |
<button v-if="!data.rendering" :disabled="data.fetching" class="fetch-btn" @click="getDataList"> | |
<span>加载更多</span> | |
<svg xmlns="http://www.w3.org/2000/svg" height="38" width="38" viewBox="0 0 120 120"> | |
<circle cx="50%" cy="50%" r="50" fill="none" class="stroke-primary-extralight stroke-15" /> | |
</svg> | |
</button> | |
</div> | |
</template> | |
<style lang="scss" scoped> | |
@keyframes loading { | |
0% { | |
stroke-dasharray: 314, 314; | |
stroke-dashoffset: 314; | |
} | |
50% { | |
stroke-dasharray: 314, 314; | |
stroke-dashoffset: 100; | |
} | |
100% { | |
stroke-dasharray: 314, 314; | |
stroke-dashoffset: -314; | |
} | |
} | |
@keyframes loading-rotate { | |
0% { | |
transform: rotateZ(0deg); | |
} | |
100% { | |
transform: rotateZ(360deg); | |
} | |
} | |
.masonry-container { | |
@apply w-full h-full flex flex-col items-center; | |
ul { | |
@apply px-2; | |
} | |
.fetch-btn { | |
@apply w-55 h-12 transition-all my-10 bg-primary-extralight text-lg text-primary border-primary border-2 py-2 px-18 rounded-full relative duration-300; | |
span { | |
@apply absolute top-1/2 left-1/2 transform-gpu -translate-x-1/2 -translate-y-1/2 opacity-100 transition-all; | |
} | |
svg { | |
@apply absolute top-1/2 left-1/2 transform-gpu -translate-x-1/2 -translate-y-1/2 opacity-0 transition-all stroke-cap-round; | |
} | |
} | |
.fetch-btn:hover { | |
@apply bg-primary text-primary-extralight; | |
} | |
.fetch-btn:disabled { | |
@apply bg-primary border-primary cursor-default w-12 p-0; | |
span { | |
@apply opacity-0; | |
} | |
svg { | |
@apply opacity-100; | |
animation: loading 2s infinite; | |
circle { | |
@apply origin-center; | |
animation: loading-rotate 2s infinite; | |
} | |
} | |
} | |
} | |
</style> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment