不完美的天使 發表於 2025-7-3 17:54:00

记录---grid实现瀑布流

<h1 data-id="heading-0">🧑‍💻 写在开头</h1>
<p>点赞 + 收藏 === 学会🤣🤣🤣</p>
<h3 data-id="heading-0">效果图</h3>
<p><img src="https://img2024.cnblogs.com/blog/2149129/202507/2149129-20250703175328509-978042051.png" alt="" loading="lazy"></p>
<div>
<div>
<h3 data-id="heading-1">瀑布流布局原理</h3>
<p>瀑布流布局(Waterfall Layout)是一种等宽不等高的多列布局方式,视觉上元素像瀑布一样逐列填充。核心原理:</p>
<ol>
<li><strong>等宽多列</strong>:将容器划分为多个等宽的列。</li>
<li><strong>动态填充</strong>:元素按顺序优先插入当前高度最短的列,保证布局紧凑。</li>
</ol>
<h3 data-id="heading-2">基于 CSS Grid 的实现思路</h3>
<p>CSS Grid 的&nbsp;<code>grid-auto-flow: dense</code>&nbsp;属性可实现密集填充模式,结合动态计算元素高度所占行数,实现近似瀑布流效果。</p>
<ol>
<li><strong>固定行高</strong>:使用&nbsp;<code>grid-auto-rows</code>&nbsp;定义基础行高。</li>
<li><strong>跨行计算</strong>:动态计算每个元素需要跨越的行数。</li>
<li><strong>响应式列数</strong>:通过媒体查询动态调整列数,适配不同屏幕尺寸</li>
</ol>
<h3 data-id="heading-3">实现步骤</h3>
<h4 data-id="heading-4">1. 代码实现</h4>
</div>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">&lt;template&gt;
&lt;div class="movie-app"&gt;
    &lt;header ref="headerRef"&gt;
      &lt;div class="header-wrap"&gt;
      &lt;h1&gt;Title&lt;/h1&gt;
      &lt;div class="input-container"&gt;
          &lt;n-input v-model:value="searchInput" round size="large" placeholder="Search"
                   @keyup.enter="searchHandler"&gt;&lt;/n-input&gt;
      &lt;/div&gt;
      &lt;/div&gt;
    &lt;/header&gt;
    &lt;main&gt;
      &lt;div class="movies-container"&gt;
      &lt;transition-group name="fade-bottom"&gt;
          &lt;div ref="cardsRef" class="card" v-for="item in movieList" :key="item.id"&gt;
            &lt;n-image :src="IMG_PATH+item.poster_path" preview-disabled width="100%" lazy :alt="item.title"/&gt;
            &lt;div class="card-detail"&gt;
            &lt;n-h2 class="card-title"&gt;{{ item.original_title }}&lt;/n-h2&gt;
            &lt;n-tag :bordered="false" :type="getTagType(item.vote_average)"&gt;{{ item.vote_average.toFixed(1) }}&lt;/n-tag&gt;
            &lt;/div&gt;
            &lt;n-p class="card-overview"&gt;{{ item.overview }}&lt;/n-p&gt;
          &lt;/div&gt;
      &lt;/transition-group&gt;
      &lt;/div&gt;
    &lt;/main&gt;
&lt;/div&gt;
&lt;/template&gt;
&lt;script setup lang="ts"&gt;
import {nextTick, onMounted, onUnmounted, ref, watch} from 'vue'
import {Movie, Result} from "@/components/MovieList/type";

const API_URL = 'https://api.themoviedb.org/3/discover/movie?sort_by=popularity.desc&amp;api_key=3fd2be6f0c70a2a598f084ddfb75487c&amp;page='
const IMG_PATH = 'https://image.tmdb.org/t/p/w1280'
const SEARCH_API = 'https://api.themoviedb.org/3/search/movie?api_key=3fd2be6f0c70a2a598f084ddfb75487c&amp;query='


const movieList = ref&lt;Movie[]&gt;([])
// 页码
const currentPage = ref(1)
// 加载状态
const isLoading = ref(false)
// 是否需要触底加载
const isNeedLoadingBottom = ref(true)

async function fetchMovies(page = 1) {
if (isLoading.value) return
isLoading.value = true
try {
    const res = await fetch(API_URL + page)
    const result: Result = await res.json()
    movieList.value.push(...result.results)
    currentPage.value = page
} catch (error) {
    console.error(error)
} finally {
    isLoading.value = false
}
}


// 搜索
const searchInput = ref&lt;string&gt;('')
const searchHandler = async () =&gt; {
if (!searchInput.value) {
    isNeedLoadingBottom.value = true
    movieList.value = []
    await fetchMovies(1)
    // 确保在数据加载后重新初始化瀑布流
    await nextTick(() =&gt; initObserve())
} else {
    isLoading.value = true
    isNeedLoadingBottom.value = false
    try {
      const res = await fetch(SEARCH_API + searchInput.value)
      const result: Result = await res.json()
      movieList.value = result.results
    } catch (error) {
      console.error(error)
    } finally {
      isLoading.value = false
    }
}
//滚动到顶部
window.scrollTo({
    top: 0,
    behavior: 'instant'
})

}

const getTagType = (vote: number): 'success' | 'warning' | 'error' =&gt; {
if (vote &gt;= 8) {
    return 'success'
} else if (vote &gt;= 5) {
    return 'warning'
} else {
    return 'error'
}

}
const ROW_HEIGHT = 20
const GAP = 20

const cardsRef = ref&lt;HTMLElement[]&gt;([])

// ResizeObserver接口监视Element内容盒或边框盒的变化
let observer: ResizeObserver

function initObserve() {
observer?.disconnect()
observer = new ResizeObserver((entries) =&gt; {
    entries.forEach(entry =&gt; {
      const card = entry.target as HTMLElement
      const height = card.offsetHeight
      //计算(当前卡片的实际高度+gap)/(隐式网格的行高+gap)行跨越网格数
      const span = Math.ceil((height + GAP) / (ROW_HEIGHT + GAP))
      card.style.gridRowEnd = `span ${span}`
    })
})
// 观察所有卡片
cardsRef.value.forEach(card =&gt; observer.observe(card))
}

// 触底加载功能
function handleScroll() {
// 滚动位置
if (window.innerHeight + window.scrollY &gt;= document.body.offsetHeight - 100 &amp;&amp; isNeedLoadingBottom.value) {
    if (!isLoading.value) {
      fetchMovies(currentPage.value + 1).then(() =&gt; {
      nextTick(() =&gt; {
          // 重新观察所有卡片,包括新添加的
          cardsRef.value.forEach(card =&gt; {
            if (!observer.observe) return
            observer.observe(card)
          })
      })
      })
    }
}
checkScroll()
}

// 监听movieList变化,确保新元素被观察
watch(movieList, () =&gt; {
nextTick(() =&gt; {
    // 确保所有卡片都被观察,包括新添加的
    cardsRef.value.forEach(card =&gt; {
      if (!observer || !card) return
      observer.observe(card)
    })
})
})

onMounted(async () =&gt; {
await fetchMovies()
await nextTick(() =&gt; {
// 确保DOM更新完成
    initObserve()
})
window.addEventListener('scroll', handleScroll)
})

// 组件卸载时清理
onUnmounted(() =&gt; {
observer?.disconnect()
window.removeEventListener('scroll', handleScroll)
})


const headerRef = ref&lt;HTMLElement | null&gt;(null)
//处理header粘性效果
function checkScroll() {
if (window.scrollY &gt; 20) {
    headerRef.value?.classList.add('active')
} else {
    headerRef.value?.classList.remove('active')
}
}
&lt;/script&gt;
&lt;style scoped lang="scss"&gt;
.movie-app {
width: 100%;
background: $primary-color;
min-height: 100vh;

header {
    position: sticky;
    z-index: 999;
    left: 0;
    top: 0;
    right: 0;
    transition: all .2s ease-in-out;
    padding: 16px;
    width: 100%;
    color: $--color-text-4;

    &amp;.active {
      background-color: $secondary-color;
      box-shadow: $--border-shadow;
    }

    .header-wrap {
      margin: 0 auto;
      @include flex-between;

      .input-container {
      width: 230px;
      }
    }
}

main {

    @media screen and (max-width: 1024px) {
      .movies-container {
      grid-template-columns: repeat(3, 1fr) !important;
      }
    }
    @media screen and (max-width: 768px) {
      .movies-container {
      grid-template-columns: repeat(2, 1fr) !important;
      }
    }

    .movies-container {
      padding: 16px;
      //grid实现瀑布流效果
      display: grid;
      //默认是4列
      grid-template-columns: repeat(4, 1fr);
      gap: v-bind('GAP+"px"');
      grid-auto-rows: v-bind('ROW_HEIGHT+"px"');
      //设置网格内容与网格区域的顶端对齐
      align-items: start;
      grid-auto-flow: dense;

      .card {
      width: 100%;
      background: $secondary-color;
      box-shadow: $--border-shadow;
      overflow: hidden;
      border-radius: $--border-radius-base;

      &amp;-detail {
          @include flex-between;
          padding: 8px;
      }

      &amp;-title {
          color: $--color-text-4;
          margin: 0;
          font-size: 20px;
      }

      &amp;-overview {
          color: $--color-text-4;
          font-size: 14px;
          padding: 0 8px 16px;
          margin: 0;
      }
      }
    }
}
}
&lt;/style&gt;</pre>
</div>
<div>
<div>
<h4 data-id="heading-5">2. 原理解析</h4>
<ul>
<li><strong>Grid 容器</strong>:通过&nbsp;<code>grid-template-columns</code>&nbsp;定义响应式列数,媒体查询动态调整。</li>
<li><strong>密集填充</strong>:<code>grid-auto-flow: dense</code>&nbsp;让元素尽可能紧凑排列,填补空白。</li>
<li><strong>动态行高</strong>:<code>grid-auto-rows</code>&nbsp;设置基础行高,元素通过&nbsp;<code>grid-row-end</code>&nbsp;跨越多行。</li>
<li><strong>高度计算</strong>:组件挂载时计算每个元素的实际高度,转换为跨越的行数。</li>
</ul>
<h4 data-id="heading-6">3. 动态高度计算</h4>
</div>
<div class="cnblogs_Highlighter">
<pre class="brush:csharp;gutter:true;">let observer: ResizeObserver

function initObserve() {
observer?.disconnect()
observer = new ResizeObserver((entries) =&gt; {
    entries.forEach(entry =&gt; {
      const card = entry.target as HTMLElement
      const height = card.offsetHeight
      //计算(当前卡片的实际高度+gap)/(隐式网格的行高+gap)行跨越网格数
      const span = Math.ceil((height + GAP) / (ROW_HEIGHT + GAP))
      card.style.gridRowEnd = `span ${span}`
    })
})
// 观察所有卡片
cardsRef.value.forEach(card =&gt; observer.observe(card))
}
</pre>
</div>
<p>  </p>
<div>
<h2>本文转载于:https://juejin.cn/post/7485998798655438858</h2>
</div>
<h3 id="tid-D8HBxE">如果对您有所帮助,欢迎您点个关注,我会定时更新技术文档,大家一起讨论学习,一起进步。</h3>
<p><img src="https://img2024.cnblogs.com/blog/2149129/202501/2149129-20250122165814748-630765389.png" alt="" loading="lazy"></p>
</div>
</div><br><br>
来源:https://www.cnblogs.com/smileZAZ/p/18964131
頁: [1]
查看完整版本: 记录---grid实现瀑布流