hookehuyr

fix: 修复 LoadMoreList 组件底部 padding 堆叠问题并记录经验教训

问题描述:
- .load-more-content 基础类有 padding: 32rpx(所有边)
- .load-more-content.scrollable 修饰符类添加 padding-bottom
- 两者堆叠导致底部 padding ≈ 192rpx + safe-area(过高)

解决方案:
- 修改 .scrollable 修饰符类,从只添加 padding-bottom 改为覆盖整个 padding 属性
- 使用 padding: 32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom))
- 防止与基础类的 padding 堆叠

影响文件:
- src/components/LoadMoreList/index.vue: 修复 padding 堆叠问题
- docs/lessons-learned.md: 添加 LESS 修饰符类样式堆叠坑的记录
- src/pages/search/index.config.js: 添加 disableScroll 配置
- src/pages/search/index.vue: 简化 shouldEnableScrollLoad 逻辑

测试:
- ✅ material-list 页面底部 padding 正常
- ✅ search 页面底部 padding 正常
- ✅ 所有使用 LoadMoreList 的页面都受益于这个修复

经验教训:
⚠️ LESS 嵌套选择器中,修饰符类的属性会与基础类堆叠
✅ 需要覆盖基础类的 padding/margin/border 等属性时,重写整个属性而不是只写子属性

Co-Authored-By: Claude Sonnet 4.5 <noreply@anthropic.com>
......@@ -235,6 +235,53 @@ setTimeout(() => {
- 任何需要在 `nut-popup` 内部再使用 `nut-popup` 的情况
- 特别是选择器(Picker)、对话框(Dialog)等弹窗组件
### ❌ 坑 4: scroll-view 必须使用 `:` 绑定语法(Taro/Vue 陷阱) ⭐ 2026-02-08 新增
**问题描述**:
```vue
<!-- ❌ 滚动不生效! -->
<scroll-view scroll-y>
内容...
</scroll-view>
<!-- ❌ 滚动也不生效! -->
<scroll-view scroll-y="true">
内容...
</scroll-view>
```
**错误表现**:
- scroll-view 组件显示正常,但无法滚动
- 没有报错信息,静默失败
- 调试时发现滚动事件不触发
**原因**: 在 Taro/Vue 中,布尔属性必须使用 `:` 绑定语法才能正确传递布尔值
- `scroll-y``scroll-y="true"` → 传递**字符串** `"true"`,scroll-view 无法识别为布尔值
- `:scroll-y="true"` → 传递**布尔值** `true`,scroll-view 正确识别为启用滚动
**解决方案**: 始终使用 `:` 绑定语法
```vue
<!-- ✅ 正确:使用 : 绑定 -->
<scroll-view :scroll-y="true">
内容...
</scroll-view>
```
**同样适用于其他 scroll-view 布尔属性**:
```vue
<!-- ✅ 横向滚动 -->
<scroll-view :scroll-x="true">
<!-- ✅ 返回顶部 -->
<scroll-view :scroll-y="true" :enable-back-to-top="true">
```
**关键点**:
- ⚠️ **永远不要**省略 `:` 符号,即使是布尔值
- ⚠️ **永远不要**使用 `scroll-y="true"` 字符串形式
-**始终使用** `:scroll-y="true"` 绑定形式
- ✅ 这也是 Taro 框架的一个重要陷阱,容易疏忽
### ✅ NutUI 最佳实践
1. **优先使用原生组件**: 当 NutUI 组件样式限制时
......@@ -439,6 +486,105 @@ export const getDocumentIcon = (type) => {
</style>
```
### ❌ 坑: LESS 修饰符类与基础类样式堆叠 ⭐ 2026-02-08 新增
**问题描述**:
在 LoadMoreList 组件中,基础类 `.load-more-content` 设置了 `padding: 32rpx`(所有边),修饰符类 `.load-more-content.scrollable` 又添加了 `padding-bottom: calc(160rpx + env(safe-area-inset-bottom))`,导致底部 padding 堆叠,约为 `32rpx + 160rpx + safe-area-inset-bottom ≈ 192rpx + safe-area`,底部空白过高。
**错误代码**:
```less
.load-more-content {
// 基础 padding: 所有 4 边都是 32rpx
padding: 32rpx;
// 可滚动状态
&.scrollable {
// ❌ 只添加 padding-bottom,会与基础 padding 堆叠
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
}
```
**错误表现**:
- 底部空白过高,约为正常的 2 倍
- 用户需要滚动很长距离才能看到最后的加载提示
- 在 iPhone X 等有安全区域的设备上更加明显
**原因**: LESS 嵌套选择器中,修饰符类的属性会与基础类**堆叠**(叠加)而不是覆盖
- 基础类: `padding: 32rpx` (上下左右都是 32rpx)
- 修饰符类: `padding-bottom: calc(160rpx + env(safe-area-inset-bottom))`
- **结果**: `padding-top: 32rpx`, `padding-left: 32rpx`, `padding-right: 32rpx`, `padding-bottom: 32rpx + 160rpx + safe-area`
**解决方案**: 在修饰符类中覆盖整个 `padding` 属性,而不是只添加子属性
```less
.load-more-content {
// 基础 padding: 所有 4 边都是 32rpx
padding: 32rpx;
// 可滚动状态
&.scrollable {
// ✅ 覆盖整个 padding 属性,防止堆叠
// 顶部 32rpx, 左右 32rpx, 底部 160rpx + safe-area
padding: 32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom));
}
}
```
**padding 简写格式说明**:
```
padding: [top] [right] [bottom] [left];
```
- `32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom))` =
- 顶部: 32rpx
- 右边: 32rpx
- 底部: calc(160rpx + env(safe-area-inset-bottom))
- 左边: 32rpx (与右边相同)
**关键点**:
- ✅ 使用 `padding` 简写属性覆盖整个 padding,而不是只写 `padding-bottom`
- ✅ 明确指定所有 4 个边的值,避免隐式继承
- ⚠️ **规则**: 需要覆盖基础类的 padding/margin/border 等属性时,重写整个属性而不是只写子属性
**适用场景**:
- ✅ 任何 LESS/SCSS 嵌套选择器中
- ✅ 修饰符类需要覆盖基础类的 padding、margin、border 等属性时
- ✅ 需要完全替换而不是堆叠样式的场景
**最佳实践**:
```less
// ✅ GOOD - 明确覆盖
.component {
padding: 16px;
&.modifier {
// 覆盖整个属性,防止堆叠
padding: 8px 16px;
}
}
// ❌ BAD - 可能堆叠
.component {
padding: 16px;
&.modifier {
// 只添加一个方向,其他方向会堆叠
padding-bottom: 8px;
}
}
```
**相关文件**:
- `src/components/LoadMoreList/index.vue:423` (已修复)
**历史记录**:
- **第 1 次**: 在 material-list 页面发现底部 padding 过高
- **教训**: ⚠️ **LESS 嵌套选择器中,修饰符类的属性会与基础类堆叠,需要覆盖整个属性而不是只写子属性**
---
### ⚠️ 双设计宽度系统
**项目配置**:
......
......@@ -31,12 +31,52 @@
<template>
<view class="load-more-list" :class="{ 'has-header': showHeader }">
<!-- 可选固定头部 -->
<view v-if="showHeader" class="load-more-header sticky top-0 z-10">
<view v-if="showHeader" class="load-more-header">
<slot name="header"></slot>
</view>
<!-- 列表容器 -->
<!-- 可滚动的列表容器 -->
<scroll-view
v-if="enableScrollLoad && list.length > 0"
class="load-more-content scrollable"
:class="{ 'no-padding': noPadding }"
:style="{ height: scrollHeight }"
:scroll-y="true"
lower-threshold="100"
@scrolltolower="handleScrollToLower"
>
<!-- 列表内容 -->
<view class="list-container">
<view
v-for="(item, index) in displayList"
:key="item[keyField] || index"
class="list-item"
:style="getAnimationDelay(index)"
>
<!-- 使用slot渲染每个列表项 -->
<slot name="item" :item="item" :index="index"></slot>
</view>
<!-- 加载更多提示 -->
<view v-if="list.length > 0" class="load-more-container">
<view v-if="loadingMore" class="load-more-loading">
<slot name="loading-more">
<view class="loading-spinner-small"></view>
<text class="ml-[16rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text>
</slot>
</view>
<view v-else-if="!hasMore" class="load-more-finished">
<slot name="no-more">
<text class="text-[#9CA3AF] text-[24rpx]">没有更多了</text>
</slot>
</view>
</view>
</view>
</scroll-view>
<!-- 不可滚动的列表容器 -->
<view
v-else
class="load-more-content"
:class="{ 'no-padding': noPadding }"
>
......@@ -88,7 +128,7 @@
<script setup>
import { computed } from 'vue'
import { useReachBottom, usePullDownRefresh, stopPullDownRefresh } from '@tarojs/taro'
import { usePullDownRefresh, stopPullDownRefresh } from '@tarojs/taro'
/**
* 通用加载更多列表组件
......@@ -247,6 +287,17 @@ const props = defineProps({
noPadding: {
type: Boolean,
default: false
},
/**
* 是否启用滚动加载更多
* @type {boolean}
* @default true
* @description 设为false时,禁用触底加载更多功能(适用于数据较少的情况)
*/
enableScrollLoad: {
type: Boolean,
default: true
}
})
......@@ -272,6 +323,20 @@ const emit = defineEmits({
const displayList = computed(() => props.list || [])
/**
* scroll-view 高度
* @description 动态计算 scroll-view 的高度
*/
const scrollHeight = computed(() => {
// 头部高度估算(rpx 单位)
// NavHeader(约88rpx) + SearchBar(约120rpx) + Tabs(约88rpx) + ResultCount(约60rpx) ≈ 356rpx
const headerHeight = props.showHeader ? '356rpx' : '0rpx'
// 使用 calc() 计算剩余高度
// 100vh 是视口高度,减去头部高度
return `calc(100vh - ${headerHeight})`
})
/**
* 获取动画延迟
* @description 只为每批的前10项使用动画延迟,避免累积延迟
* @param {number} index - 列表项索引
......@@ -287,27 +352,27 @@ function getAnimationDelay(index) {
}
/**
* 触底加载更多(使用防抖)
* @description 当滚动到底部时触发,300ms防抖避免频繁触发
* 处理 scroll-view 滚动到底部事件
* @description 当 scroll-view 滚动到底部时触发
*/
let loadMoreTimer = null
useReachBottom(() => {
const handleScrollToLower = () => {
console.log('[LoadMoreList] scroll-view 触底事件触发', {
loadingMore: props.loadingMore,
loading: props.loading,
hasMore: props.hasMore,
page: props.page
})
// 如果正在加载或没有更多数据,不执行
if (props.loadingMore || props.loading || !props.hasMore) {
console.log('[LoadMoreList] 跳过加载:正在加载或没有更多数据')
return
}
// 防抖:300ms 内只触发一次
if (loadMoreTimer) {
clearTimeout(loadMoreTimer)
}
loadMoreTimer = setTimeout(() => {
console.log('[LoadMoreList] 触底加载更多,当前页:', props.page, '下一页:', props.page + 1)
const nextPage = props.page + 1
emit('load-more', nextPage)
}, 300)
})
}
/**
* 下拉刷新
......@@ -324,22 +389,39 @@ if (props.enablePullDownRefresh) {
<style lang="less">
.load-more-list {
min-height: 100vh;
background-color: #F9FAFB;
height: 100vh;
overflow: hidden;
&.has-header {
padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
}
}
.load-more-header {
position: sticky;
top: 0;
z-index: 10;
background-color: #F9FAFB;
}
.load-more-content {
// 列表容器样式
min-height: calc(100vh - 200rpx);
width: 100%;
box-sizing: border-box;
padding: 32rpx;
&.no-padding {
padding: 0;
}
// 可滚动状态
&.scrollable {
// 高度通过 :style 动态绑定
box-sizing: border-box;
// 重置基础 padding,只保留顶部和左右,底部使用特殊计算值
padding: 32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom));
}
}
// 列表容器
......@@ -347,6 +429,8 @@ if (props.enablePullDownRefresh) {
display: flex;
flex-direction: column;
gap: 24rpx;
width: 100%;
box-sizing: border-box;
// 去除列表项的黑点
view {
......
......@@ -8,5 +8,6 @@
export default {
navigationBarTitleText: '搜索',
enablePullDownRefresh: true,
navigationStyle: 'custom'
navigationStyle: 'custom',
disableScroll: true // 禁用页面级滚动,使用 scroll-view 组件滚动
}
......
......@@ -4,7 +4,7 @@
* @description 支持产品和资料搜索,实时查询API,自动切换分类
-->
<template>
<view class="bg-[#FFF]">
<view class="bg-[#FFF] search-page-container">
<LoadMoreList
:list="currentList"
:page="currentPage"
......@@ -13,6 +13,7 @@
:loading="loading"
:loading-more="loadingMore"
:show-header="true"
:enable-scroll-load="shouldEnableScrollLoad"
key-field="id"
@load-more="handleLoadMore"
>
......@@ -206,6 +207,15 @@ const currentTotal = computed(() => {
})
/**
* 是否启用滚动加载更多
* @description 只要有数据就可以滚动加载
*/
const shouldEnableScrollLoad = computed(() => {
// 只要有数据就可以滚动
return currentList.value.length > 0
})
/**
* 执行搜索
*
* @param {string} keyword - 搜索关键字
......@@ -545,4 +555,10 @@ const handleCollectChanged = (item, newStatus) => {
}
/* LoadMoreList 组件已内置动画和加载状态,此处无需额外样式 */
/* 页面容器 - 固定高度,禁止页面级滚动 */
.search-page-container {
height: 100vh;
overflow: hidden;
}
</style>
......