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(() => { ...@@ -235,6 +235,53 @@ setTimeout(() => {
235 - 任何需要在 `nut-popup` 内部再使用 `nut-popup` 的情况 235 - 任何需要在 `nut-popup` 内部再使用 `nut-popup` 的情况
236 - 特别是选择器(Picker)、对话框(Dialog)等弹窗组件 236 - 特别是选择器(Picker)、对话框(Dialog)等弹窗组件
237 237
238 +### ❌ 坑 4: scroll-view 必须使用 `:` 绑定语法(Taro/Vue 陷阱) ⭐ 2026-02-08 新增
239 +
240 +**问题描述**:
241 +```vue
242 +<!-- ❌ 滚动不生效! -->
243 +<scroll-view scroll-y>
244 + 内容...
245 +</scroll-view>
246 +
247 +<!-- ❌ 滚动也不生效! -->
248 +<scroll-view scroll-y="true">
249 + 内容...
250 +</scroll-view>
251 +```
252 +
253 +**错误表现**:
254 +- scroll-view 组件显示正常,但无法滚动
255 +- 没有报错信息,静默失败
256 +- 调试时发现滚动事件不触发
257 +
258 +**原因**: 在 Taro/Vue 中,布尔属性必须使用 `:` 绑定语法才能正确传递布尔值
259 +- `scroll-y``scroll-y="true"` → 传递**字符串** `"true"`,scroll-view 无法识别为布尔值
260 +- `:scroll-y="true"` → 传递**布尔值** `true`,scroll-view 正确识别为启用滚动
261 +
262 +**解决方案**: 始终使用 `:` 绑定语法
263 +```vue
264 +<!-- ✅ 正确:使用 : 绑定 -->
265 +<scroll-view :scroll-y="true">
266 + 内容...
267 +</scroll-view>
268 +```
269 +
270 +**同样适用于其他 scroll-view 布尔属性**:
271 +```vue
272 +<!-- ✅ 横向滚动 -->
273 +<scroll-view :scroll-x="true">
274 +
275 +<!-- ✅ 返回顶部 -->
276 +<scroll-view :scroll-y="true" :enable-back-to-top="true">
277 +```
278 +
279 +**关键点**:
280 +- ⚠️ **永远不要**省略 `:` 符号,即使是布尔值
281 +- ⚠️ **永远不要**使用 `scroll-y="true"` 字符串形式
282 +-**始终使用** `:scroll-y="true"` 绑定形式
283 +- ✅ 这也是 Taro 框架的一个重要陷阱,容易疏忽
284 +
238 ### ✅ NutUI 最佳实践 285 ### ✅ NutUI 最佳实践
239 286
240 1. **优先使用原生组件**: 当 NutUI 组件样式限制时 287 1. **优先使用原生组件**: 当 NutUI 组件样式限制时
...@@ -439,6 +486,105 @@ export const getDocumentIcon = (type) => { ...@@ -439,6 +486,105 @@ export const getDocumentIcon = (type) => {
439 </style> 486 </style>
440 ``` 487 ```
441 488
489 +### ❌ 坑: LESS 修饰符类与基础类样式堆叠 ⭐ 2026-02-08 新增
490 +
491 +**问题描述**:
492 +
493 +在 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`,底部空白过高。
494 +
495 +**错误代码**:
496 +```less
497 +.load-more-content {
498 + // 基础 padding: 所有 4 边都是 32rpx
499 + padding: 32rpx;
500 +
501 + // 可滚动状态
502 + &.scrollable {
503 + // ❌ 只添加 padding-bottom,会与基础 padding 堆叠
504 + padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
505 + }
506 +}
507 +```
508 +
509 +**错误表现**:
510 +- 底部空白过高,约为正常的 2 倍
511 +- 用户需要滚动很长距离才能看到最后的加载提示
512 +- 在 iPhone X 等有安全区域的设备上更加明显
513 +
514 +**原因**: LESS 嵌套选择器中,修饰符类的属性会与基础类**堆叠**(叠加)而不是覆盖
515 +- 基础类: `padding: 32rpx` (上下左右都是 32rpx)
516 +- 修饰符类: `padding-bottom: calc(160rpx + env(safe-area-inset-bottom))`
517 +- **结果**: `padding-top: 32rpx`, `padding-left: 32rpx`, `padding-right: 32rpx`, `padding-bottom: 32rpx + 160rpx + safe-area`
518 +
519 +**解决方案**: 在修饰符类中覆盖整个 `padding` 属性,而不是只添加子属性
520 +
521 +```less
522 +.load-more-content {
523 + // 基础 padding: 所有 4 边都是 32rpx
524 + padding: 32rpx;
525 +
526 + // 可滚动状态
527 + &.scrollable {
528 + // ✅ 覆盖整个 padding 属性,防止堆叠
529 + // 顶部 32rpx, 左右 32rpx, 底部 160rpx + safe-area
530 + padding: 32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom));
531 + }
532 +}
533 +```
534 +
535 +**padding 简写格式说明**:
536 +```
537 +padding: [top] [right] [bottom] [left];
538 +```
539 +
540 +- `32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom))` =
541 + - 顶部: 32rpx
542 + - 右边: 32rpx
543 + - 底部: calc(160rpx + env(safe-area-inset-bottom))
544 + - 左边: 32rpx (与右边相同)
545 +
546 +**关键点**:
547 +- ✅ 使用 `padding` 简写属性覆盖整个 padding,而不是只写 `padding-bottom`
548 +- ✅ 明确指定所有 4 个边的值,避免隐式继承
549 +- ⚠️ **规则**: 需要覆盖基础类的 padding/margin/border 等属性时,重写整个属性而不是只写子属性
550 +
551 +**适用场景**:
552 +- ✅ 任何 LESS/SCSS 嵌套选择器中
553 +- ✅ 修饰符类需要覆盖基础类的 padding、margin、border 等属性时
554 +- ✅ 需要完全替换而不是堆叠样式的场景
555 +
556 +**最佳实践**:
557 +```less
558 +// ✅ GOOD - 明确覆盖
559 +.component {
560 + padding: 16px;
561 +
562 + &.modifier {
563 + // 覆盖整个属性,防止堆叠
564 + padding: 8px 16px;
565 + }
566 +}
567 +
568 +// ❌ BAD - 可能堆叠
569 +.component {
570 + padding: 16px;
571 +
572 + &.modifier {
573 + // 只添加一个方向,其他方向会堆叠
574 + padding-bottom: 8px;
575 + }
576 +}
577 +```
578 +
579 +**相关文件**:
580 +- `src/components/LoadMoreList/index.vue:423` (已修复)
581 +
582 +**历史记录**:
583 +- **第 1 次**: 在 material-list 页面发现底部 padding 过高
584 +- **教训**: ⚠️ **LESS 嵌套选择器中,修饰符类的属性会与基础类堆叠,需要覆盖整个属性而不是只写子属性**
585 +
586 +---
587 +
442 ### ⚠️ 双设计宽度系统 588 ### ⚠️ 双设计宽度系统
443 589
444 **项目配置**: 590 **项目配置**:
......
...@@ -31,12 +31,52 @@ ...@@ -31,12 +31,52 @@
31 <template> 31 <template>
32 <view class="load-more-list" :class="{ 'has-header': showHeader }"> 32 <view class="load-more-list" :class="{ 'has-header': showHeader }">
33 <!-- 可选固定头部 --> 33 <!-- 可选固定头部 -->
34 - <view v-if="showHeader" class="load-more-header sticky top-0 z-10"> 34 + <view v-if="showHeader" class="load-more-header">
35 <slot name="header"></slot> 35 <slot name="header"></slot>
36 </view> 36 </view>
37 37
38 - <!-- 列表容器 --> 38 + <!-- 可滚动的列表容器 -->
39 + <scroll-view
40 + v-if="enableScrollLoad && list.length > 0"
41 + class="load-more-content scrollable"
42 + :class="{ 'no-padding': noPadding }"
43 + :style="{ height: scrollHeight }"
44 + :scroll-y="true"
45 + lower-threshold="100"
46 + @scrolltolower="handleScrollToLower"
47 + >
48 + <!-- 列表内容 -->
49 + <view class="list-container">
50 + <view
51 + v-for="(item, index) in displayList"
52 + :key="item[keyField] || index"
53 + class="list-item"
54 + :style="getAnimationDelay(index)"
55 + >
56 + <!-- 使用slot渲染每个列表项 -->
57 + <slot name="item" :item="item" :index="index"></slot>
58 + </view>
59 +
60 + <!-- 加载更多提示 -->
61 + <view v-if="list.length > 0" class="load-more-container">
62 + <view v-if="loadingMore" class="load-more-loading">
63 + <slot name="loading-more">
64 + <view class="loading-spinner-small"></view>
65 + <text class="ml-[16rpx] text-[#9CA3AF] text-[24rpx]">加载中...</text>
66 + </slot>
67 + </view>
68 + <view v-else-if="!hasMore" class="load-more-finished">
69 + <slot name="no-more">
70 + <text class="text-[#9CA3AF] text-[24rpx]">没有更多了</text>
71 + </slot>
72 + </view>
73 + </view>
74 + </view>
75 + </scroll-view>
76 +
77 + <!-- 不可滚动的列表容器 -->
39 <view 78 <view
79 + v-else
40 class="load-more-content" 80 class="load-more-content"
41 :class="{ 'no-padding': noPadding }" 81 :class="{ 'no-padding': noPadding }"
42 > 82 >
...@@ -88,7 +128,7 @@ ...@@ -88,7 +128,7 @@
88 128
89 <script setup> 129 <script setup>
90 import { computed } from 'vue' 130 import { computed } from 'vue'
91 -import { useReachBottom, usePullDownRefresh, stopPullDownRefresh } from '@tarojs/taro' 131 +import { usePullDownRefresh, stopPullDownRefresh } from '@tarojs/taro'
92 132
93 /** 133 /**
94 * 通用加载更多列表组件 134 * 通用加载更多列表组件
...@@ -247,6 +287,17 @@ const props = defineProps({ ...@@ -247,6 +287,17 @@ const props = defineProps({
247 noPadding: { 287 noPadding: {
248 type: Boolean, 288 type: Boolean,
249 default: false 289 default: false
290 + },
291 +
292 + /**
293 + * 是否启用滚动加载更多
294 + * @type {boolean}
295 + * @default true
296 + * @description 设为false时,禁用触底加载更多功能(适用于数据较少的情况)
297 + */
298 + enableScrollLoad: {
299 + type: Boolean,
300 + default: true
250 } 301 }
251 }) 302 })
252 303
...@@ -272,6 +323,20 @@ const emit = defineEmits({ ...@@ -272,6 +323,20 @@ const emit = defineEmits({
272 const displayList = computed(() => props.list || []) 323 const displayList = computed(() => props.list || [])
273 324
274 /** 325 /**
326 + * scroll-view 高度
327 + * @description 动态计算 scroll-view 的高度
328 + */
329 +const scrollHeight = computed(() => {
330 + // 头部高度估算(rpx 单位)
331 + // NavHeader(约88rpx) + SearchBar(约120rpx) + Tabs(约88rpx) + ResultCount(约60rpx) ≈ 356rpx
332 + const headerHeight = props.showHeader ? '356rpx' : '0rpx'
333 +
334 + // 使用 calc() 计算剩余高度
335 + // 100vh 是视口高度,减去头部高度
336 + return `calc(100vh - ${headerHeight})`
337 +})
338 +
339 +/**
275 * 获取动画延迟 340 * 获取动画延迟
276 * @description 只为每批的前10项使用动画延迟,避免累积延迟 341 * @description 只为每批的前10项使用动画延迟,避免累积延迟
277 * @param {number} index - 列表项索引 342 * @param {number} index - 列表项索引
...@@ -287,27 +352,27 @@ function getAnimationDelay(index) { ...@@ -287,27 +352,27 @@ function getAnimationDelay(index) {
287 } 352 }
288 353
289 /** 354 /**
290 - * 触底加载更多(使用防抖) 355 + * 处理 scroll-view 滚动到底部事件
291 - * @description 当滚动到底部时触发,300ms防抖避免频繁触发 356 + * @description 当 scroll-view 滚动到底部时触发
292 */ 357 */
293 -let loadMoreTimer = null 358 +const handleScrollToLower = () => {
294 -useReachBottom(() => { 359 + console.log('[LoadMoreList] scroll-view 触底事件触发', {
360 + loadingMore: props.loadingMore,
361 + loading: props.loading,
362 + hasMore: props.hasMore,
363 + page: props.page
364 + })
365 +
295 // 如果正在加载或没有更多数据,不执行 366 // 如果正在加载或没有更多数据,不执行
296 if (props.loadingMore || props.loading || !props.hasMore) { 367 if (props.loadingMore || props.loading || !props.hasMore) {
368 + console.log('[LoadMoreList] 跳过加载:正在加载或没有更多数据')
297 return 369 return
298 } 370 }
299 371
300 - // 防抖:300ms 内只触发一次
301 - if (loadMoreTimer) {
302 - clearTimeout(loadMoreTimer)
303 - }
304 -
305 - loadMoreTimer = setTimeout(() => {
306 console.log('[LoadMoreList] 触底加载更多,当前页:', props.page, '下一页:', props.page + 1) 372 console.log('[LoadMoreList] 触底加载更多,当前页:', props.page, '下一页:', props.page + 1)
307 const nextPage = props.page + 1 373 const nextPage = props.page + 1
308 emit('load-more', nextPage) 374 emit('load-more', nextPage)
309 - }, 300) 375 +}
310 -})
311 376
312 /** 377 /**
313 * 下拉刷新 378 * 下拉刷新
...@@ -324,22 +389,39 @@ if (props.enablePullDownRefresh) { ...@@ -324,22 +389,39 @@ if (props.enablePullDownRefresh) {
324 389
325 <style lang="less"> 390 <style lang="less">
326 .load-more-list { 391 .load-more-list {
327 - min-height: 100vh;
328 background-color: #F9FAFB; 392 background-color: #F9FAFB;
393 + height: 100vh;
394 + overflow: hidden;
329 395
330 &.has-header { 396 &.has-header {
331 padding-bottom: calc(160rpx + env(safe-area-inset-bottom)); 397 padding-bottom: calc(160rpx + env(safe-area-inset-bottom));
332 } 398 }
333 } 399 }
334 400
401 +.load-more-header {
402 + position: sticky;
403 + top: 0;
404 + z-index: 10;
405 + background-color: #F9FAFB;
406 +}
407 +
335 .load-more-content { 408 .load-more-content {
336 // 列表容器样式 409 // 列表容器样式
337 - min-height: calc(100vh - 200rpx); 410 + width: 100%;
411 + box-sizing: border-box;
338 padding: 32rpx; 412 padding: 32rpx;
339 413
340 &.no-padding { 414 &.no-padding {
341 padding: 0; 415 padding: 0;
342 } 416 }
417 +
418 + // 可滚动状态
419 + &.scrollable {
420 + // 高度通过 :style 动态绑定
421 + box-sizing: border-box;
422 + // 重置基础 padding,只保留顶部和左右,底部使用特殊计算值
423 + padding: 32rpx 32rpx calc(160rpx + env(safe-area-inset-bottom));
424 + }
343 } 425 }
344 426
345 // 列表容器 427 // 列表容器
...@@ -347,6 +429,8 @@ if (props.enablePullDownRefresh) { ...@@ -347,6 +429,8 @@ if (props.enablePullDownRefresh) {
347 display: flex; 429 display: flex;
348 flex-direction: column; 430 flex-direction: column;
349 gap: 24rpx; 431 gap: 24rpx;
432 + width: 100%;
433 + box-sizing: border-box;
350 434
351 // 去除列表项的黑点 435 // 去除列表项的黑点
352 view { 436 view {
......
...@@ -8,5 +8,6 @@ ...@@ -8,5 +8,6 @@
8 export default { 8 export default {
9 navigationBarTitleText: '搜索', 9 navigationBarTitleText: '搜索',
10 enablePullDownRefresh: true, 10 enablePullDownRefresh: true,
11 - navigationStyle: 'custom' 11 + navigationStyle: 'custom',
12 + disableScroll: true // 禁用页面级滚动,使用 scroll-view 组件滚动
12 } 13 }
......
...@@ -4,7 +4,7 @@ ...@@ -4,7 +4,7 @@
4 * @description 支持产品和资料搜索,实时查询API,自动切换分类 4 * @description 支持产品和资料搜索,实时查询API,自动切换分类
5 --> 5 -->
6 <template> 6 <template>
7 - <view class="bg-[#FFF]"> 7 + <view class="bg-[#FFF] search-page-container">
8 <LoadMoreList 8 <LoadMoreList
9 :list="currentList" 9 :list="currentList"
10 :page="currentPage" 10 :page="currentPage"
...@@ -13,6 +13,7 @@ ...@@ -13,6 +13,7 @@
13 :loading="loading" 13 :loading="loading"
14 :loading-more="loadingMore" 14 :loading-more="loadingMore"
15 :show-header="true" 15 :show-header="true"
16 + :enable-scroll-load="shouldEnableScrollLoad"
16 key-field="id" 17 key-field="id"
17 @load-more="handleLoadMore" 18 @load-more="handleLoadMore"
18 > 19 >
...@@ -206,6 +207,15 @@ const currentTotal = computed(() => { ...@@ -206,6 +207,15 @@ const currentTotal = computed(() => {
206 }) 207 })
207 208
208 /** 209 /**
210 + * 是否启用滚动加载更多
211 + * @description 只要有数据就可以滚动加载
212 + */
213 +const shouldEnableScrollLoad = computed(() => {
214 + // 只要有数据就可以滚动
215 + return currentList.value.length > 0
216 +})
217 +
218 +/**
209 * 执行搜索 219 * 执行搜索
210 * 220 *
211 * @param {string} keyword - 搜索关键字 221 * @param {string} keyword - 搜索关键字
...@@ -545,4 +555,10 @@ const handleCollectChanged = (item, newStatus) => { ...@@ -545,4 +555,10 @@ const handleCollectChanged = (item, newStatus) => {
545 } 555 }
546 556
547 /* LoadMoreList 组件已内置动画和加载状态,此处无需额外样式 */ 557 /* LoadMoreList 组件已内置动画和加载状态,此处无需额外样式 */
558 +
559 +/* 页面容器 - 固定高度,禁止页面级滚动 */
560 +.search-page-container {
561 + height: 100vh;
562 + overflow: hidden;
563 +}
548 </style> 564 </style>
......