UNPKG

fs-virtual-waterfall

Version:

Vue3 虚拟瀑布流组件

913 lines (786 loc) 27.6 kB
# fs-virtual-waterfall Vue3 虚拟瀑布流组件,支持无限滚动加载。 ## 参考 [链接](https://www.bilibili.com/video/BV1qF411f7g7/?spm_id_from=333.1391.0.0&vd_source=e75c0a517670194c110d1fa53894e419) 修复了些 bug ## 版本 - v1.2.0 - 更新视频文件 - v1.1.9 - 更新文档 - v1.1.8 - 修复 bug - v1.1.7 - 添加示例视频 - v1.1.6 - 更新 示例代码 - v1.1.5 - 更新 示例代码 - v1.1.4 - 更新 md 文件 - v1.1.3 - 修复 bug,美化细节 - v1.1.2 - 添加声明 - v1.1.1 - 优化界面 - v1.1.0 - 修复 bug - v0.1.5 - 改为接收父组件传入数据,不再自行请求数据,更符合组件化设计 - v0.1.4 - 新增图片骨架屏加载效果、底部加载指示器、优化内容区域显示和修复卡片底部数据丢失问题 - v0.1.3 - 新增主题模式切换、卡片样式选择、增强响应式布局、性能优化和错误处理 - v0.1.2 - 添加 3D 悬浮效果,利用 GSAP 实现鼠标移动时的动态倾斜和缩放 - v0.1.1 - 优化滚动性能,修复滚动交互问题,提高组件稳定性 - v0.1.0 - 初始版本 ## 特性 - 基于 Vue3 和 TypeScript 开发 - 支持虚拟滚动,高效处理大量数据 - 自适应列数和间距 - 支持自定义内容渲染 - 支持懒加载和无限滚动 - 错误处理和容器自适应 - 支持 3D 悬浮效果,鼠标移动时带来视觉交互 - 支持亮色、暗色和自动主题模式 - 提供圆角和方形卡片样式选项 - 增强容错性和边界条件处理 - 图片加载骨架屏,提供视觉加载反馈 - 底部加载指示器,提示用户正在加载更多内容 - 智能卡片高度计算,确保内容完整显示 - **新增** - 支持父组件传入数据,更好的组件化设计 ## 效果预览 图片 ![](https://userplugin-1308962059.cos.ap-shanghai.myqcloud.com/page1.png) ![](https://userplugin-1308962059.cos.ap-shanghai.myqcloud.com/page2.png) ![](https://userplugin-1308962059.cos.ap-shanghai.myqcloud.com/page3.png) ![](https://userplugin-1308962059.cos.ap-shanghai.myqcloud.com/page4.png) [视频](https://userplugin-1308962059.cos.ap-shanghai.myqcloud.com/%E5%B1%95%E7%A4%BA%E8%A7%86%E9%A2%91.mp4) <video style="width: 100%; height: 400px;object-fit: cover; " src="https://userplugin-1308962059.cos.ap-shanghai.myqcloud.com/%E5%B1%95%E7%A4%BA%E8%A7%86%E9%A2%91.mp4" controls></video> ## 安装 ```bash npm install fs-virtual-waterfall # 或 yarn add fs-virtual-waterfall # 或 pnpm add fs-virtual-waterfall ``` ## 使用方法 > ✨ 直接 CV 大法即可 ### 全局注册 ```js import { createApp } from 'vue' import App from './App.vue' import FsVirtualWaterfall from 'fs-virtual-waterfall' import 'fs-virtual-waterfall/dist/index.css' const app = createApp(App) app.use(FsVirtualWaterfall) app.mount('#app') ``` ### 新版用法 父组件请求数据 #### 父组件 ```vue <template> <div class="app"> <header class="header"> <div class="logo"> <svg viewBox="0 0 24 24" width="24" height="24" stroke="#4299e1" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"> <rect x="3" y="3" width="18" height="18" rx="2" ry="2"></rect> <line x1="3" y1="9" x2="21" y2="9"></line> <line x1="9" y1="21" x2="9" y2="9"></line> </svg> <h1>虚拟瀑布流示例</h1> </div> <div class="theme-toggle" @click="toggleTheme"> <svg v-if="theme === 'light'" viewBox="0 0 24 24" width="22" height="22" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"> <circle cx="12" cy="12" r="5"></circle> <line x1="12" y1="1" x2="12" y2="3"></line> <line x1="12" y1="21" x2="12" y2="23"></line> <line x1="4.22" y1="4.22" x2="5.64" y2="5.64"></line> <line x1="18.36" y1="18.36" x2="19.78" y2="19.78"></line> <line x1="1" y1="12" x2="3" y2="12"></line> <line x1="21" y1="12" x2="23" y2="12"></line> <line x1="4.22" y1="19.78" x2="5.64" y2="18.36"></line> <line x1="18.36" y1="5.64" x2="19.78" y2="4.22"></line> </svg> <svg v-else viewBox="0 0 24 24" width="22" height="22" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"> <path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z"></path> </svg> </div> </header> <main class="workspace"> <div class="waterfall-container"> <virtual-waterfall-parent :waterfall-data="waterfallData" :is-loading="isLoading" :has-more-data="hasMoreData" @load-more="handleLoadMore" /> </div> </main> </div> </template> <script setup lang="ts"> import { ref, onMounted } from 'vue' import VirtualWaterfallParent from './views/virtual-waterfall-parent.vue' import type { IDataItem } from 'fs-virtual-waterfall' // 数据相关状态 const waterfallData = ref<IDataItem[]>([]) const isLoading = ref<boolean>(false) const hasMoreData = ref<boolean>(true) const currentPage = ref<number>(1) const pageSize = ref<number>(20) // 主题切换 const theme = ref(localStorage.getItem('theme') || 'light') const toggleTheme = () => { theme.value = theme.value === 'light' ? 'dark' : 'light' localStorage.setItem('theme', theme.value) document.documentElement.setAttribute('data-theme', theme.value) } // 初始化主题 document.documentElement.setAttribute('data-theme', theme.value) // 处理加载更多数据 const handleLoadMore = async () => { await fetchData() } // 数据请求函数 const fetchData = async () => { try { if (isLoading.value || !hasMoreData.value) return isLoading.value = true // 请求,并传入分页参数 const rep = await fetch(`https://www.vilipix.com/api/v1/picture/public?limit=${pageSize.value}&sort=hot&offset=${(currentPage.value - 1) * pageSize.value}`) if (!rep.ok) { throw new Error(`API request failed with status ${rep.status}`) } // 数据处理 const data = await rep.json() if (!data || !data.data || !Array.isArray(data.data.rows)) { console.warn('API response format unexpected:', data) hasMoreData.value = false return } let { rows, count } = data.data count = 100 // 强制宽高比例不为0,防止布局错误 const processedRows = rows.map((item: any) => { // 确保宽高都为有效的正数 const width = Math.max(parseInt(item.width) || 1, 1) const height = Math.max(parseInt(item.height) || 1, 1) // 更智能地处理宽高比 const aspectRatio = width / height // 对于宽高比超出正常范围的图片进行修正 let normalizedHeight = height // 减小最小卡片高度 const minCardHeight = 180 const baseWidth = 240 // 计算图片在实际显示宽度下的映射高度 const scaledHeight = (baseWidth * height) / width // 更严格控制宽高比例 if (aspectRatio > 2) { normalizedHeight = width / 2 } else if (aspectRatio < 0.3) { normalizedHeight = width / 0.3 } // 确保图片能完整显示,不要裁剪 const contentAreaHeight = 0 // 移除内容区域高度限制 if (scaledHeight < minCardHeight - contentAreaHeight) { normalizedHeight = Math.max(normalizedHeight, ((minCardHeight - contentAreaHeight) * width) / baseWidth) } // 不限制最大高度,确保图片完整显示 // normalizedHeight = Math.min(normalizedHeight, 900) return { id: item.picture_id, picture_id: item.picture_id, width: width, height: normalizedHeight, src: item.regular_url + '?x-oss-process=image/resize,w_240/format,jpg', } }) // 更新数据和状态 waterfallData.value = [...waterfallData.value, ...processedRows] currentPage.value++ // 检查是否还有更多数据 if (processedRows.length < pageSize.value || (count && waterfallData.value.length >= count)) { hasMoreData.value = false } } catch (error) { console.error('Failed to fetch data:', error) hasMoreData.value = false } finally { isLoading.value = false } } // 初始化加载第一页数据 onMounted(() => { fetchData() }) </script> <style> :root { --bg-color: #f6f8fa; --text-color: #24292e; --border-color: #e1e4e8; --header-bg: #ffffff; --header-shadow: rgba(0, 0, 0, 0.1); } [data-theme='dark'] { --bg-color: #0d1117; --text-color: #c9d1d9; --border-color: #30363d; --header-bg: #161b22; --header-shadow: rgba(0, 0, 0, 0.3); } * { box-sizing: border-box; margin: 0; padding: 0; } body { font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif; background-color: var(--bg-color); color: var(--text-color); transition: background-color 0.3s, color 0.3s; } </style> <style scoped lang="scss"> .app { width: 100vw; height: 98vh; overflow: hidden; display: flex; flex-direction: column; margin: 20px; box-sizing: border-box; } .header { height: 60px; display: flex; align-items: center; justify-content: space-between; padding: 0 20px; background-color: var(--header-bg); border-bottom: 1px solid var(--border-color); box-shadow: 0 1px 3px var(--header-shadow); z-index: 10; .logo { display: flex; align-items: center; gap: 10px; color: var(--text-color); svg { stroke: #4299e1; } h1 { font-size: 18px; font-weight: 600; } } .theme-toggle { display: flex; align-items: center; justify-content: center; width: 40px; height: 40px; border-radius: 50%; cursor: pointer; color: var(--text-color); &:hover { background-color: var(--bg-color); } svg { stroke: currentColor; } } } .workspace { flex: 1; display: flex; align-items: center; justify-content: center; padding: 20px; overflow: hidden; } .waterfall-container { width: 100%; max-width: 1200px; height: 100%; border-radius: 8px; overflow: hidden; box-shadow: 0 4px 6px rgba(0, 0, 0, 0.05), 0 1px 3px rgba(0, 0, 0, 0.1); // 确保容器不会有额外的内边距 padding: 0; margin: 0; } </style> ``` #### 子组件 ```vue <template> <div class="virtual-waterfall-container"> <div class="controls"> <div class="control-group"> <label>列数:</label> <select v-model="columnCount"> <option :value="2">2列</option> <option :value="3">3列</option> <option :value="4">4列</option> <option :value="5">5列</option> </select> </div> <div class="control-group"> <label>样式:</label> <select v-model="cardStyle"> <option value="rounded">圆角</option> <option value="square">方形</option> </select> </div> <div class="control-group"> <label>主题:</label> <select v-model="themeMode"> <option value="light">亮色</option> <option value="dark">暗色</option> <option value="auto">自动</option> </select> </div> <div class="control-group"> <label>调试:</label> <input type="checkbox" v-model="debugMode" /> </div> </div> <fs-virtual-water-fall :data="waterfallData" :loading="isLoading" :has-more="hasMoreData" :gap="15" :column="columnCount" :theme-mode="themeMode" :card-style="cardStyle" :debug="debugMode" :initial-height="800" :load-more-threshold="300" @load-more="handleLoadMore" class="waterfall-wrapper"> <template #item="{ item }"> <div class="waterfall-card"> <div class="waterfall-image-container"> <div class="waterfall-image-skeleton" v-if="!imageLoadStatus[item.id]"></div> <img class="waterfall-image" :src="item.src" loading="lazy" @error="handleImageError" @load="handleImageLoad(item.id)" :class="{ 'image-loaded': imageLoadStatus[item.id] }" /> <div class="waterfall-image-overlay"> <span class="waterfall-image-icon"> <svg viewBox="0 0 24 24" width="24" height="24" stroke="currentColor" stroke-width="2" fill="none" stroke-linecap="round" stroke-linejoin="round"> <circle cx="12" cy="12" r="10"></circle> <path d="M12 8v8"></path> <path d="M8 12h8"></path> </svg> </span> </div> </div> <div class="waterfall-content"> <h3 class="waterfall-title">id: {{ item.picture_id }}</h3> <!-- <div class="waterfall-description"> <button>Add</button> <button>Edit</button> <button>View</button> <button>Delete</button> </div> --> <div class="waterfall-info"> <span>{{ item.width }} × {{ item.height }}</span> <span class="waterfall-view-count"> <svg viewBox="0 0 24 24" width="16" height="16" stroke="currentColor" stroke-width="2" fill="none"> <path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path> <circle cx="12" cy="12" r="3"></circle> </svg> <!-- <span>{{ Math.floor(Math.random() * 1000) + 100 }}</span> --> </span> </div> </div> </div> </template> <template #footer> <div v-if="isLoading" class="waterfall-bottom-loading"> <div class="loading-spinner"></div> <div class="loading-text">加载中...</div> </div> </template> </fs-virtual-water-fall> </div> </template> <script setup lang="ts"> import { ref, reactive } from 'vue' import { FsVirtualWaterFall } from 'fs-virtual-waterfall' import type { IDataItem } from 'fs-virtual-waterfall' // 接收来自App.vue的props const props = defineProps<{ waterfallData: IDataItem[] isLoading: boolean hasMoreData: boolean }>() // 发射事件 const emit = defineEmits<{ loadMore: [] }>() // 瀑布流配置 const columnCount = ref<number>(4) const cardStyle = ref<'rounded' | 'square'>('rounded') const themeMode = ref<'light' | 'dark' | 'auto'>('auto') const debugMode = ref<boolean>(false) const imageLoadStatus = ref<Record<string | number, boolean>>({}) // 处理图片加载错误 const handleImageError = (e: Event) => { const imgElement = e.target as HTMLImageElement if (imgElement) { imgElement.src = 'https://via.placeholder.com/240x320?text=Image+Error' } } // 处理图片加载完成 // 修改图片加载处理函数 const handleImageLoad = (id: string | number) => { setTimeout(() => { imageLoadStatus.value[id] = true }, 100) } // 处理加载更多 - 现在只需要发送事件给App const handleLoadMore = (currentLength: number) => { emit('loadMore') } </script> <style scoped lang="scss"> .virtual-waterfall-container { width: 100%; height: 100%; min-height: 100vh; background-color: var(--bg-color, #f6f8fa); padding: 10px; box-sizing: border-box; display: flex; flex-direction: column; overflow: hidden; } .waterfall-wrapper { flex: 1; overflow: auto; position: relative; // 确保瀑布流容器不会有额外的内边距 padding: 0; margin: 0; } // 修改底部加载区域样式 :deep(.fs-virtual-waterfall-footer) { height: auto !important; min-height: 40px; display: flex; align-items: center; justify-content: center; background: transparent; } // 确保卡片底部没有多余空间 :deep(.fs-virtual-waterfall-item-inner) { height: 100%; display: flex; flex-direction: column; } .waterfall-card { height: 100%; display: flex; flex-direction: column; border-color: pink; overflow: hidden; position: relative; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); min-height: 200px; opacity: 0; /* 添加此行 */ animation: fadeIn 0.3s ease forwards; /* 添加此行 */ } @keyframes fadeIn { from { opacity: 0; transform: translateY(20px); } to { opacity: 1; transform: translateY(0); } } .waterfall-image { width: 100%; height: 100% !important; /* 修改此行 */ object-fit: cover; min-height: 160px; max-height: 600px; display: block; transition: transform 0.4s ease, opacity 0.3s ease; /* 修改此行 */ opacity: 0; position: relative; z-index: 2; &.image-loaded { opacity: 1; } } .controls { display: flex; gap: 20px; margin-bottom: 15px; padding: 10px; background-color: rgba(0, 0, 0, 0.03); border-radius: 8px; flex-wrap: wrap; } .control-group { display: flex; align-items: center; gap: 8px; label { font-size: 14px; color: var(--text-color, #333); } select { padding: 5px 10px; border-radius: 4px; border: 1px solid var(--border-color, #ddd); background-color: var(--card-bg, #fff); color: var(--text-color, #333); cursor: pointer; &:focus { outline: none; border-color: #4299e1; } } } .waterfall-card { height: 100%; display: flex; flex-direction: column; // background-color: var(--card-bg, #fff); border-color: pink; overflow: hidden; position: relative; box-shadow: 0 1px 3px rgba(0, 0, 0, 0.1); min-height: 200px; /* 确保卡片最小高度 */ } .waterfall-image-container { position: relative; overflow: hidden; width: 100%; flex: 1; /* 让图片容器根据内容高度灵活伸缩 */ min-height: 160px; /* 图片容器最小高度,确保足够空间 */ background-color: #f0f0f0; /* 图片加载前的背景色 */ display: flex; /* 添加此行 */ align-items: center; /* 添加此行 */ justify-content: center; /* 添加此行 */ &:hover { .waterfall-image-overlay { opacity: 1; } .waterfall-image { transform: scale(1.05); } } } .waterfall-image-skeleton { position: absolute; top: 0; left: 0; width: 100%; height: 100%; background: linear-gradient(90deg, var(--card-bg, #f0f0f0) 25%, rgba(240, 240, 240, 0.5) 50%, var(--card-bg, #f0f0f0) 75%); background-size: 200% 100%; animation: skeleton-loading 1.5s infinite; z-index: 1; } .waterfall-image { width: 100%; height: 100% !important; // ✨ object-fit: cover; /* 保持图片比例并填充容器 */ min-height: 160px; /* 确保图片最小高度 */ max-height: 600px; /* 限制图片最大高度,避免过长 */ display: block; transition: transform 0.4s ease; opacity: 0; position: relative; z-index: 2; &.image-loaded { opacity: 1; } } .waterfall-image-overlay { position: absolute; top: 0; left: 0; right: 0; bottom: 0; background: rgba(0, 0, 0, 0.3); display: flex; align-items: center; justify-content: center; opacity: 0; transition: opacity 0.3s ease; z-index: 3; } .waterfall-image-icon { width: 40px; height: 40px; border-radius: 50%; background: rgba(255, 255, 255, 0.9); display: flex; align-items: center; justify-content: center; color: #333; transform: translateY(10px); transition: transform 0.3s ease; .waterfall-image-overlay:hover & { transform: translateY(0); } svg { width: 20px; height: 20px; } } .waterfall-content { padding: 12px 15px; // height: 80% !important; min-height: 70px; /* 增加内容区域最小高度,确保显示完整内容 */ display: flex; flex-direction: column; justify-content: space-between; /* 确保标题和信息区域分布合理 */ flex-shrink: 0; /* 防止内容区被压缩 */ } .waterfall-title { margin: 10px 0 8px; font-size: 14px; font-weight: 500; color: var(--text-primary, #fff); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .waterfall-description { display: flex; flex-wrap: wrap; align-items: center; justify-content: space-between; button { padding: 4px 12px; /* 舒适的内边距[2,6](@ref) */ margin: 8px; /* 按钮间距 */ font-family: 'Segoe UI', system-ui; /* 现代字体 */ font-size: 14px; /* 适中字号[8](@ref) */ border: none; /* 移除默认边框[6](@ref) */ border-radius: 6px; /* 柔和圆角[8](@ref) */ cursor: pointer; /* 手型光标 */ transition: all 0.3s ease; /* 平滑过渡效果[6,8](@ref) */ text-transform: uppercase; /* 字母大写增强识别性[6](@ref) */ letter-spacing: 1px; /* 字符间距 */ box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); /* 基础阴影[6](@ref) */ } } .waterfall-info { display: flex; justify-content: space-between; align-items: center; margin: 0; font-size: 12px; color: var(--text-secondary, #6e7687); line-height: 1.5; /* 确保文本有足够行高 */ flex-shrink: 0; /* 防止信息区被压缩 */ } .waterfall-view-count { display: flex; align-items: center; gap: 4px; svg { stroke: currentColor; } p { margin: 0; /* 确保p标签没有默认边距 */ } } .waterfall-bottom-loading { width: 100%; display: flex; flex-direction: column; align-items: center; justify-content: center; padding: 20px 0; color: var(--text-secondary, #6e7687); .loading-spinner { width: 30px; height: 30px; border: 3px solid rgba(0, 0, 0, 0.1); border-radius: 50%; border-top-color: var(--text-primary, #333); animation: spin 1s linear infinite; margin-bottom: 10px; } .loading-text { font-size: 14px; } @keyframes spin { 0% { transform: rotate(0deg); } 100% { transform: rotate(360deg); } } } @keyframes skeleton-loading { 0% { background-position: 200% 0; } 100% { background-position: -200% 0; } } @media (prefers-color-scheme: dark) { .virtual-waterfall-container { --bg-color: #1a1a1a; --card-bg: #242424; --text-color: #e0e0e0; --text-primary: #e0e0e0; --text-secondary: #a0a0a0; --border-color: #333; } .waterfall-image-container { background-color: #333; } } </style> ``` ## Props 参数 | 参数 | 类型 | 默认值 | 说明 | | ----------------- | --------------------------- | ---------- | ------------------------------------------ | | data | Array<IDataItem> | [] | 数据源,由父组件传入 (v0.1.5+) | | loading | boolean | false | 是否正在加载中 (v0.1.5+) | | hasMore | boolean | true | 是否还有更多数据 (v0.1.5+) | | column | number | 4 | 瀑布流列数 | | gap | number | 20 | 间距 | | columnItemCount | number | 6 | 每列初始加载的项数 | | themeMode | 'light' \| 'dark' \| 'auto' | 'auto' | 主题模式,支持亮色、暗色和自动(跟随系统) | | cardStyle | 'rounded' \| 'square' | 'rounded' | 卡片样式,圆角或方形 | | scrollBehavior | 'smooth' \| 'auto' | 'smooth' | 滚动行为 | | initialHeight | number | 1000 | 初始高度(px) | | loadMoreThreshold | number | 200 | 加载更多阈值(px) | | hoverScale | number | 1.02 | 悬停时的缩放比例 | | debug | boolean | false | 是否启用调试模式,显示列边界 | | emptyText | string | '暂无数据' | 空状态文本 | **注意:** `request` 属性在 v0.1.5 版本已弃用,建议使用 `data`、`loading` 和 `hasMore` 属性代替。 ## Events 事件 | 事件名 | 参数 | 说明 | | ------------- | ----------------------- | -------------------------------------------------------------- | | loadMore | (currentLength: number) | 当需要加载更多数据时触发,参数为当前已加载的数据长度 (v0.1.5+) | | loadError | (error: Error) | 当加载数据出错时触发 | | finishGetList | - | 当数据全部加载完成时触发 (v0.1.4 及以下版本) | ## Slots 插槽 | 插槽名 | 参数 | 说明 | | ------ | ------------------- | -------------- | | item | { item: IDataItem } | 自定义项内容 | | footer | - | 自定义底部内容 | ## 数据格式 请求数据返回的列表项必须包含以下属性: ```ts interface IDataItem { id: number | string // 唯一标识 width: number // 原始宽度 height: number // 原始高度 [key: string]: any // 其他自定义属性 } ``` ## 主题和样式 组件提供了三种主题模式: - `light` - 亮色主题 - `dark` - 暗色主题 - `auto` - 自动主题(根据用户系统设置自动切换) 卡片样式支持: - `rounded` - 圆角卡片 - `square` - 方形卡片 ## 加载状态和用户体验 组件提供了两种加载状态的视觉反馈: 1. **图片骨架屏**:图片加载过程中显示的骨架屏动画效果,提供加载中的视觉反馈 2. **底部加载指示器**:在底部显示加载动画,当用户滚动到底部加载更多数据时提供反馈 这些加载状态提升了用户体验,可以: - 减少用户等待的焦虑感 - 提供明确的视觉反馈 - 防止在加载过程中出现布局抖动 - 确保用户理解应用正在响应他们的滚动操作 ## 性能优化和最佳实践 - 确保容器有明确的高度,例如:`height: 600px` - 设置适当的列数(column)以适应不同屏幕尺寸 - 对于大型应用,建议使用 `cardStyle="square"` 来提高渲染性能 - 在移动设备上推荐使用较小的 `loadMoreThreshold` 值以节省资源 - 使用组件提供的图片加载状态管理,提供更好的用户体验 - 为图片添加 `loading="lazy"` 属性以利用浏览器原生懒加载能力 - 3D 效果对性能有一定影响,在低性能设备上可以设置 `enable3DEffect="false"` - 为长列表设置合理的 `requestSize`,避免一次加载过多数据 - 合理设置内容区域最小高度,确保信息完整显示 ## License MIT