Просмотр исходного кода

perf(mallApp): 优化首页图片与模块按需加载

- 新增跨端懒加载图片组件与 OSS 缩略图处理,降低首屏图片流量和解码开销
- 轮播按当前帧预载相邻图片,首页模块及下拉商品随滚动分批挂载
- 首页配置请求与门店信息请求解耦,缩短首屏加载等待时间
shizhongqi 1 день назад
Родитель
Сommit
0a52eddc16

+ 185 - 0
mallApp/src/components/app-lazy-img.vue

@@ -0,0 +1,185 @@
+<!--
+  懒加载图片组件
+  用途:统一承担「进入视口才请求图片」+「按展示尺寸请求 OSS 缩略图」两件事。
+  谁用:店铺首页各模块(轮播/金刚区/秒杀团购/商品专区),后续列表页也可复用。
+  解决什么问题:
+    首页一次要展示几十张图,裸 <image> 会在渲染瞬间并发请求全部原图,
+    首屏流量与图片解码时间过高。本组件让屏幕外图片不发请求,屏幕内图片
+    只取够用的尺寸。
+
+  实现要点:
+    模板只有一个根 <image> 节点,尺寸完全交给调用方的 class 决定。
+    H5 / App 端根节点就是 image 本身,调用方 class 直接生效;
+    小程序端会多出一层自定义组件包裹节点,调用方 class 落在包裹节点上,
+    因此样式里额外让包裹节点块级化、并让内部 image 撑满(详见 style 注释)。
+    调用方需保证该 class 是块级且有确定尺寸,否则小程序端图片会塌陷成空白。
+-->
+<template>
+  <image
+    :class="['app-lazy-img', observerClass]"
+    :src="displaySrc"
+    :mode="mode"
+    lazy-load
+    @error="onError"
+    @load="onLoad"
+  />
+</template>
+
+<script>
+import { thumbUrl, rawUrl } from '@/utils/image'
+
+/** 为每个实例生成唯一 class,作为 IntersectionObserver 的选择器 */
+let lazyImgUid = 0
+
+/**
+ * 各端懒加载能力不同,初始可见性据此区分:
+ * 小程序端有原生 lazy-load(上下三屏内才真正发请求),直接给 src 即可;
+ * H5 / App 端 lazy-load 无效,必须等 IntersectionObserver 通知后再给 src。
+ */
+let INITIAL_VISIBLE = false
+// #ifdef MP
+INITIAL_VISIBLE = true
+// #endif
+
+export default {
+  name: 'appLazyImg',
+  props: {
+    /** 后端返回的图片地址(完整 http 地址) */
+    src: {
+      type: String,
+      default: ''
+    },
+    mode: {
+      type: String,
+      default: 'aspectFill'
+    },
+    /**
+     * 期望的缩略宽度(px),调用方按展示尺寸 ×2 DPR 传入;
+     * 传 0 表示不做缩略处理,直接用后端原始地址
+     */
+    thumbWidth: {
+      type: Number,
+      default: 0
+    },
+    /** fill=正方形填充裁剪(封面/图标);width=只限宽等比缩放(轮播) */
+    thumbMode: {
+      type: String,
+      default: 'fill'
+    },
+    /**
+     * 跳过视口观察,挂载即加载。
+     * 供调用方自己已经做了按需门控的场景使用(如轮播只渲染当前帧±1),
+     * 否则横向排布的相邻帧在视口外,观察器会一直不放行导致滑过去才空白加载。
+     */
+    eager: {
+      type: Boolean,
+      default: false
+    }
+  },
+  data() {
+    return {
+      observerClass: 'lazy-img-' + ++lazyImgUid,
+      visible: INITIAL_VISIBLE || this.eager,
+      /** 缩略图取不到时回退原图,避免个别图片空白 */
+      fallback: false,
+      _observer: null
+    }
+  },
+  computed: {
+    /**
+     * 真正写给 <image> 的地址
+     * 未进入视口时返回空串,从而不产生网络请求
+     */
+    displaySrc() {
+      if (!this.src || !this.visible) {
+        return ''
+      }
+      if (this.fallback) {
+        return rawUrl(this.src)
+      }
+      return thumbUrl(this.src, this.thumbWidth, this.thumbMode)
+    }
+  },
+  watch: {
+    // 列表复用时同一节点会被换成另一张图,需要重置回退状态重新尝试缩略图
+    src() {
+      this.fallback = false
+    }
+  },
+  mounted() {
+    // #ifndef MP
+    if (!this.eager) {
+      this.initObserver()
+    }
+    // #endif
+  },
+  beforeDestroy() {
+    this.disconnectObserver()
+  },
+  methods: {
+    /**
+     * H5 / App 端的视口观察:提前 300px 开始加载,滚动时不至于看到空白再补图。
+     * 观察器不可用时(极端环境)直接放行,宁可多加载也不能让图片显示不出来。
+     */
+    initObserver() {
+      if (!uni.createIntersectionObserver) {
+        this.visible = true
+        return
+      }
+      this.$nextTick(() => {
+        // 首次触发即可停止观察,图片加载过就不需要再感知进出视口
+        this._observer = uni.createIntersectionObserver(this, { thresholds: [0] })
+        this._observer.relativeToViewport({ bottom: 300, top: 300 }).observe('.' + this.observerClass, (res) => {
+          if (res.intersectionRatio > 0) {
+            this.visible = true
+            this.disconnectObserver()
+          }
+        })
+      })
+    },
+    disconnectObserver() {
+      if (this._observer) {
+        this._observer.disconnect()
+        this._observer = null
+      }
+    },
+    /**
+     * 缩略图加载失败时先回退到不带 OSS 参数的原图,仍失败才向外抛
+     * 只回退一次,避免同一张图反复重试
+     */
+    onError(e) {
+      if (!this.fallback && this.thumbWidth > 0) {
+        this.fallback = true
+        return
+      }
+      this.$emit('error', e)
+    },
+    onLoad(e) {
+      this.$emit('load', e)
+    }
+  }
+}
+</script>
+
+<style lang="scss" scoped>
+/*
+  只在小程序端做尺寸兜底。
+  小程序端自定义组件会额外生成一层包裹节点:调用方传入的 .grid-cover / .row-cover
+  等尺寸样式落在包裹节点上,内部 image 拿不到宽高就会塌陷成空白(图片地址本身是好的)。
+  这里让包裹节点参与块级布局并裁掉溢出,再让内部 image 撑满它。
+
+  H5 / App 端没有这层包裹,组件根节点就是 image 本身、直接继承调用方的 class,
+  若同样写 100% 会与调用方的固定尺寸同权重冲突(谁生效取决于打包顺序),所以必须排除。
+*/
+/* #ifdef MP */
+:host {
+  display: block;
+  overflow: hidden;
+}
+.app-lazy-img {
+  display: block;
+  width: 100%;
+  height: 100%;
+}
+/* #endif */
+</style>

+ 17 - 2
mallApp/src/components/home/activitySection.vue

@@ -35,7 +35,8 @@
     <!-- 1排1列:左图右文,底部价格与按钮同行 -->
     <!-- 1排1列:左图右文,底部价格与按钮同行 -->
     <view v-if="cols === 1" class="list-1">
     <view v-if="cols === 1" class="list-1">
       <view v-for="g in displayGoods" :key="g.id" class="row-card" @click="goDetail(g)">
       <view v-for="g in displayGoods" :key="g.id" class="row-card" @click="goDetail(g)">
-        <image class="row-cover" :src="g.coverUrl" mode="aspectFill" />
+        <!-- 横向小图只显示 140upx,按 180px 取缩略图 -->
+        <app-lazy-img class="row-cover" :src="g.coverUrl" :thumb-width="180" mode="aspectFill" />
         <view class="row-content">
         <view class="row-content">
           <text class="row-name">{{ g.name }}</text>
           <text class="row-name">{{ g.name }}</text>
           <text class="row-desc">{{ isGroupBuy ? (g.groupSize + '人团') : '限时特惠' }}</text>
           <text class="row-desc">{{ isGroupBuy ? (g.groupSize + '人团') : '限时特惠' }}</text>
@@ -60,7 +61,7 @@
     <view v-else class="goods-grid" :class="'cols-' + cols">
     <view v-else class="goods-grid" :class="'cols-' + cols">
       <view v-for="g in displayGoods" :key="g.id" class="grid-card" @click="goDetail(g)">
       <view v-for="g in displayGoods" :key="g.id" class="grid-card" @click="goDetail(g)">
         <view class="grid-cover-wrap">
         <view class="grid-cover-wrap">
-          <image class="grid-cover" :src="g.coverUrl" mode="aspectFill" />
+          <app-lazy-img class="grid-cover" :src="g.coverUrl" :thumb-width="coverThumbWidth" mode="aspectFill" />
           <view v-if="isGroupBuy" class="group-badge">{{ g.groupSize }}人团</view>
           <view v-if="isGroupBuy" class="group-badge">{{ g.groupSize }}人团</view>
         </view>
         </view>
         <text class="grid-name">{{ g.name }}</text>
         <text class="grid-name">{{ g.name }}</text>
@@ -75,8 +76,11 @@
 </template>
 </template>
 
 
 <script>
 <script>
+import AppLazyImg from '@/components/app-lazy-img.vue'
+
 export default {
 export default {
   name: 'homeActivitySection',
   name: 'homeActivitySection',
+  components: { AppLazyImg },
   props: {
   props: {
     data: {
     data: {
       type: Object,
       type: Object,
@@ -116,6 +120,14 @@ export default {
       const c = parseInt(this.data && this.data.layoutCols, 10)
       const c = parseInt(this.data && this.data.layoutCols, 10)
       return [1, 2, 3].includes(c) ? c : 3
       return [1, 2, 3].includes(c) ? c : 3
     },
     },
+    /**
+     * 网格封面缩略宽度:按各列实际展示宽度 ×2 DPR 取值
+     * 两列每张约 351upx(≈175px),三列每张约 234upx(≈117px)
+     * @returns {number}
+     */
+    coverThumbWidth() {
+      return this.cols === 2 ? 360 : 240
+    },
     /**
     /**
      * 仅展示有库存活动商品:stock <= 0 视为售罄,专区不展示
      * 仅展示有库存活动商品:stock <= 0 视为售罄,专区不展示
      * @returns {Array}
      * @returns {Array}
@@ -340,10 +352,13 @@ export default {
     border-bottom: none;
     border-bottom: none;
   }
   }
 }
 }
+/* display:block 让小程序端组件包裹节点接受宽高;overflow:hidden 由包裹节点裁出圆角 */
 .row-cover {
 .row-cover {
+  display: block;
   width: 140upx;
   width: 140upx;
   height: 140upx;
   height: 140upx;
   border-radius: 12upx;
   border-radius: 12upx;
+  overflow: hidden;
   flex-shrink: 0;
   flex-shrink: 0;
   background: #f5f5f5;
   background: #f5f5f5;
 }
 }

+ 81 - 2
mallApp/src/components/home/bannerSwiper.vue

@@ -16,10 +16,26 @@
       indicator-dots
       indicator-dots
       indicator-color="rgba(255, 255, 255, 0.55)"
       indicator-color="rgba(255, 255, 255, 0.55)"
       indicator-active-color="#FF4D6D"
       indicator-active-color="#FF4D6D"
+      @change="onSwiperChange"
     >
     >
       <swiper-item v-for="(item, index) in data.list" :key="index">
       <swiper-item v-for="(item, index) in data.list" :key="index">
-        <!-- 点击事件绑在 image 上:swiper-item 不支持 tap,且与 key 拼接会导致小程序 data-event-opts 编译异常 -->
-        <image class="home-banner-img" :src="item.imgUrl" mode="aspectFill" @click="onBannerTap(index)" />
+        <!--
+          点击事件绑在内层 view 上:swiper-item 不支持 tap;
+          且图片按帧懒加载后未加载的帧没有 image 节点,事件不能再挂在 image 上。
+          仍用数字下标做参数,避免与 key 拼接引发小程序 data-event-opts 编译异常。
+        -->
+        <view class="banner-slide" @click="onBannerTap(index)">
+          <!-- 只渲染当前帧及相邻帧,避免最多 10 张轮播原图在首屏一次性发请求 -->
+          <app-lazy-img
+            v-if="slideLoadFlags[index]"
+            class="home-banner-img"
+            :src="item.imgUrl"
+            :thumb-width="750"
+            thumb-mode="width"
+            mode="aspectFill"
+            :eager="true"
+          />
+        </view>
       </swiper-item>
       </swiper-item>
     </swiper>
     </swiper>
     <view v-if="showService" class="service-float" @click="goChat">
     <view v-if="showService" class="service-float" @click="goChat">
@@ -30,9 +46,11 @@
 
 
 <script>
 <script>
 import { parseFilterIds, encodeFilterValue, stashGoodsListFilter } from '@/utils/goodsListFilter'
 import { parseFilterIds, encodeFilterValue, stashGoodsListFilter } from '@/utils/goodsListFilter'
+import AppLazyImg from '@/components/app-lazy-img.vue'
 
 
 export default {
 export default {
   name: 'homeBannerSwiper',
   name: 'homeBannerSwiper',
+  components: { AppLazyImg },
   props: {
   props: {
     data: {
     data: {
       type: Object,
       type: Object,
@@ -66,7 +84,60 @@ export default {
       default: null
       default: null
     }
     }
   },
   },
+  data() {
+    return {
+      /**
+       * 已允许加载图片的帧下标集合(下标 -> true)。
+       * 只增不减:帧滑走后保留标记,来回滑动时不会重新请求、不会闪白底。
+       */
+      loadedMap: {}
+    }
+  },
+  computed: {
+    /**
+     * 各帧是否渲染图片,供模板直接取下标判断
+     * 用数组而不是模板里调方法,规避小程序模板复杂表达式的编译问题
+     * @returns {Array<boolean>}
+     */
+    slideLoadFlags() {
+      const list = (this.data && this.data.list) || []
+      return list.map((item, index) => !!this.loadedMap[index])
+    }
+  },
+  watch: {
+    // 首页配置刷新后轮播张数可能变化,重置为只加载首帧及相邻帧
+    'data.list': {
+      immediate: true,
+      handler() {
+        this.loadedMap = {}
+        this.markSlideLoad(0)
+      }
+    }
+  },
   methods: {
   methods: {
+    /**
+     * 标记某帧及其相邻帧可加载图片
+     * 轮播开了 circular,首尾相邻,所以用取模计算前后帧
+     * @param {number} index 当前帧下标
+     */
+    markSlideLoad(index) {
+      const total = ((this.data && this.data.list) || []).length
+      if (total <= 0) {
+        return
+      }
+      const current = Number(index) || 0
+      const targets = [current, (current - 1 + total) % total, (current + 1) % total]
+      targets.forEach((i) => {
+        if (!this.loadedMap[i]) {
+          this.$set(this.loadedMap, i, true)
+        }
+      })
+    },
+    /** 轮播切换:把新的当前帧与其相邻帧纳入加载范围 */
+    onSwiperChange(e) {
+      const index = (e && e.detail && e.detail.current) || 0
+      this.markSlideLoad(index)
+    },
     /**
     /**
      * 点击轮播图:按后台关联类型跳转
      * 点击轮播图:按后台关联类型跳转
      * index 为轮播项下标,避免模板内直接传 item 导致小程序事件参数序列化失败
      * index 为轮播项下标,避免模板内直接传 item 导致小程序事件参数序列化失败
@@ -177,7 +248,15 @@ export default {
   width: 100%;
   width: 100%;
   height: 360upx;
   height: 360upx;
 }
 }
+/* 撑满 swiper-item 承接点击,未加载图片的帧也能点 */
+.banner-slide {
+  width: 100%;
+  height: 100%;
+  background: #f5f5f5;
+}
+/* display:block 保证小程序端自定义组件包裹节点能接受宽高,否则内部图片会塌陷 */
 .home-banner-img {
 .home-banner-img {
+  display: block;
   width: 100%;
   width: 100%;
   height: 100%;
   height: 100%;
 }
 }

+ 32 - 4
mallApp/src/components/home/goodsSection.vue

@@ -17,7 +17,8 @@
     <!-- 1排1列:横向列表 -->
     <!-- 1排1列:横向列表 -->
     <view v-if="cols === 1" class="list-1">
     <view v-if="cols === 1" class="list-1">
       <view v-for="g in displayGoods" :key="g.id" class="row-card" @click="goDetail(g)">
       <view v-for="g in displayGoods" :key="g.id" class="row-card" @click="goDetail(g)">
-        <image class="row-cover" :src="g.coverUrl" mode="aspectFill" />
+        <!-- 横向小图只显示 140upx,按 180px 取缩略图 -->
+        <app-lazy-img class="row-cover" :src="g.coverUrl" :thumb-width="180" mode="aspectFill" />
         <view class="row-body">
         <view class="row-body">
           <text class="row-name">{{ g.name }}</text>
           <text class="row-name">{{ g.name }}</text>
           <text class="row-sold">已售 {{ formatSold(g.sold) }}</text>
           <text class="row-sold">已售 {{ formatSold(g.sold) }}</text>
@@ -31,7 +32,7 @@
     <view v-else class="goods-grid" :class="'cols-' + cols">
     <view v-else class="goods-grid" :class="'cols-' + cols">
       <view v-for="g in displayGoods" :key="g.id" class="grid-card" @click="goDetail(g)">
       <view v-for="g in displayGoods" :key="g.id" class="grid-card" @click="goDetail(g)">
         <view class="grid-cover-wrap">
         <view class="grid-cover-wrap">
-          <image class="grid-cover" :src="g.coverUrl" mode="aspectFill" />
+          <app-lazy-img class="grid-cover" :src="g.coverUrl" :thumb-width="coverThumbWidth" mode="aspectFill" />
         </view>
         </view>
         <view class="grid-info">
         <view class="grid-info">
           <text class="grid-name">{{ g.name }}</text>
           <text class="grid-name">{{ g.name }}</text>
@@ -53,10 +54,12 @@
 
 
 <script>
 <script>
 import productMins from '@/mixins/cgProduct'
 import productMins from '@/mixins/cgProduct'
+import AppLazyImg from '@/components/app-lazy-img.vue'
 
 
 export default {
 export default {
   name: 'homeGoodsSection',
   name: 'homeGoodsSection',
   mixins: [productMins],
   mixins: [productMins],
+  components: { AppLazyImg },
   props: {
   props: {
     data: {
     data: {
       type: Object,
       type: Object,
@@ -74,6 +77,15 @@ export default {
     hdId: {
     hdId: {
       type: [String, Number],
       type: [String, Number],
       default: ''
       default: ''
+    },
+    /**
+     * 本次最多渲染多少个商品,0 表示不限制。
+     * 供 pullGoods 模块做前端分页:该模块后端 displayCount=0(不截断),
+     * 关联大分类时可能一次返回整店商品,全量渲染会造成节点数与图片数失控。
+     */
+    limit: {
+      type: Number,
+      default: 0
     }
     }
   },
   },
   data() {
   data() {
@@ -88,12 +100,25 @@ export default {
       return [1, 2, 3].includes(c) ? c : 3
       return [1, 2, 3].includes(c) ? c : 3
     },
     },
     /**
     /**
-     * 仅展示有库存商品:stock <= 0 视为售罄,首页不展示避免用户点进无货详情
+     * 网格封面缩略宽度:按各列实际展示宽度 ×2 DPR 取值
+     * 两列每张约 351upx(≈175px),三列每张约 234upx(≈117px)
+     * @returns {number}
+     */
+    coverThumbWidth() {
+      return this.cols === 2 ? 360 : 240
+    },
+    /**
+     * 有库存且在本次渲染额度内的商品
+     * stock <= 0 视为售罄,首页不展示避免用户点进无货详情
      * @returns {Array}
      * @returns {Array}
      */
      */
     displayGoods() {
     displayGoods() {
       const list = (this.data && this.data.goods) || []
       const list = (this.data && this.data.goods) || []
-      return list.filter(g => Number(g && g.stock) > 0)
+      const inStock = list.filter(g => Number(g && g.stock) > 0)
+      if (this.limit > 0 && inStock.length > this.limit) {
+        return inStock.slice(0, this.limit)
+      }
+      return inStock
     },
     },
     showMore() {
     showMore() {
       return Number(this.data && this.data.goodsTotal || 0) > ((this.data && this.data.goods) || []).length
       return Number(this.data && this.data.goodsTotal || 0) > ((this.data && this.data.goods) || []).length
@@ -220,10 +245,13 @@ export default {
     border-bottom: none;
     border-bottom: none;
   }
   }
 }
 }
+/* display:block 让小程序端组件包裹节点接受宽高;overflow:hidden 由包裹节点裁出圆角 */
 .row-cover {
 .row-cover {
+  display: block;
   width: 140upx;
   width: 140upx;
   height: 140upx;
   height: 140upx;
   border-radius: 12upx;
   border-radius: 12upx;
+  overflow: hidden;
   flex-shrink: 0;
   flex-shrink: 0;
   background: #f5f5f5;
   background: #f5f5f5;
 }
 }

+ 6 - 1
mallApp/src/components/home/navGrid.vue

@@ -8,7 +8,8 @@
     <view v-for="item in data.list" :key="item.id" class="nav-item" @click="onTap(item)">
     <view v-for="item in data.list" :key="item.id" class="nav-item" @click="onTap(item)">
       <view class="nav-icon-wrap">
       <view class="nav-icon-wrap">
         <text v-if="isPresetIcon(item.icon)" class="nav-emoji">{{ getPresetEmoji(item.icon) }}</text>
         <text v-if="isPresetIcon(item.icon)" class="nav-emoji">{{ getPresetEmoji(item.icon) }}</text>
-        <image v-else class="nav-icon-img" :src="item.iconUrl" mode="aspectFill" />
+        <!-- 上传图标只显示 80upx,按 120px 取缩略图,不再拉取商家上传原图 -->
+        <app-lazy-img v-else class="nav-icon-img" :src="item.iconUrl" :thumb-width="120" mode="aspectFill" />
       </view>
       </view>
       <text class="nav-name">{{ item.name }}</text>
       <text class="nav-name">{{ item.name }}</text>
     </view>
     </view>
@@ -17,6 +18,7 @@
 
 
 <script>
 <script>
 import { parseFilterIds, encodeFilterValue, stashGoodsListFilter } from '@/utils/goodsListFilter'
 import { parseFilterIds, encodeFilterValue, stashGoodsListFilter } from '@/utils/goodsListFilter'
+import AppLazyImg from '@/components/app-lazy-img.vue'
 
 
 /** 预设图标 key → emoji,与 hdApp 后台 navGrid 编辑页 preset 列表一致 */
 /** 预设图标 key → emoji,与 hdApp 后台 navGrid 编辑页 preset 列表一致 */
 const PRESET_EMOJI_MAP = {
 const PRESET_EMOJI_MAP = {
@@ -36,6 +38,7 @@ const PRESET_EMOJI_MAP = {
 
 
 export default {
 export default {
   name: 'homeNavGrid',
   name: 'homeNavGrid',
+  components: { AppLazyImg },
   props: {
   props: {
     data: {
     data: {
       type: Object,
       type: Object,
@@ -113,7 +116,9 @@ export default {
 .nav-emoji {
 .nav-emoji {
   font-size: 53upx;
   font-size: 53upx;
 }
 }
+/* display:block 保证小程序端自定义组件包裹节点能接受宽高,否则内部图片会塌陷 */
 .nav-icon-img {
 .nav-icon-img {
+  display: block;
   width: 80upx;
   width: 80upx;
   height: 80upx;
   height: 80upx;
 }
 }

+ 125 - 7
mallApp/src/pages/home/index.vue

@@ -14,8 +14,13 @@
     />
     />
     <view v-if="loading" class="home-loading">加载中...</view>
     <view v-if="loading" class="home-loading">加载中...</view>
     <block v-else-if="pageData">
     <block v-else-if="pageData">
-      <block v-for="mod in modules" :key="mod.key">
-        <template v-if="mod.enabled == 1">
+      <block v-for="(mod, mIndex) in enabledModules" :key="mod.key">
+        <!--
+          超出渲染范围的模块不渲染任何节点(连占位高度都不留)。
+          留占位高度会把底部哨兵推出视口,加载器误判「内容已够长」而停止挂载,
+          用户看到的就是首屏底部那片空白占位,所以这里必须什么都不输出。
+        -->
+        <template v-if="mIndex < renderModuleCount">
           <home-top-nav
           <home-top-nav
             v-if="mod.key === 'topNav'"
             v-if="mod.key === 'topNav'"
             :data="pageData.topNav"
             :data="pageData.topNav"
@@ -74,10 +79,12 @@
             :hd-id="hdId"
             :hd-id="hdId"
             @cart-changed="refreshCartBadgeCount"
             @cart-changed="refreshCartBadgeCount"
           />
           />
+          <!-- 下拉商品后端不截断,用 limit 做前端分页,滚动接近底部再追加 -->
           <home-goods-section
           <home-goods-section
             v-else-if="mod.key === 'pullGoods'"
             v-else-if="mod.key === 'pullGoods'"
             :data="pageData.pullGoods"
             :data="pageData.pullGoods"
             module-key="pullGoods"
             module-key="pullGoods"
+            :limit="pullGoodsLimit"
             :account="account"
             :account="account"
             :hd-id="hdId"
             :hd-id="hdId"
             @cart-changed="refreshCartBadgeCount"
             @cart-changed="refreshCartBadgeCount"
@@ -121,6 +128,17 @@ import { getHdInfo } from '@/api/hd'
 import { getInfo } from '@/api/shop'
 import { getInfo } from '@/api/shop'
 import { checkArrival } from '@/api/hb'
 import { checkArrival } from '@/api/hb'
 
 
+/**
+ * 首屏先挂载的模块个数。
+ * 一屏(自定义导航 + 顶部搜索 + 轮播 + 金刚区)已基本占满,多给 1 个内容模块作缓冲。
+ * 这只是起点:真实高度不够时由底部哨兵观察器继续补,直到内容铺满首屏,
+ * 所以这里不需要(也不应该)为了怕留白而调大。
+ */
+const INITIAL_MODULE_COUNT = 4
+
+/** 下拉商品每次追加的条数 */
+const PULL_GOODS_PAGE_SIZE = 10
+
 export default {
 export default {
   name: 'shopHomeIndex',
   name: 'shopHomeIndex',
   components: {
   components: {
@@ -149,7 +167,13 @@ export default {
       hbArrivalShow: false,
       hbArrivalShow: false,
       hbArrivalInfo: {},
       hbArrivalInfo: {},
       hbArrivalIds: [],
       hbArrivalIds: [],
-      hbArrivalLoading: false
+      hbArrivalLoading: false,
+      /** 已挂载的模块个数,随滚动递增,控制首屏节点数与图片请求数 */
+      renderModuleCount: INITIAL_MODULE_COUNT,
+      /** 下拉商品当前渲染条数,滚动触底递增 */
+      pullGoodsLimit: PULL_GOODS_PAGE_SIZE,
+      /** 底部哨兵观察器,驱动模块与下拉商品的按需加载 */
+      _loadMoreObserver: null
     }
     }
   },
   },
   created() {
   created() {
@@ -158,6 +182,9 @@ export default {
       this.shopInfo = cached
       this.shopInfo = cached
     }
     }
   },
   },
+  onUnload() {
+    this.destroyLoadMoreObserver()
+  },
   /** 从分类/购物车返回时刷新角标,避免仍显示旧数量 */
   /** 从分类/购物车返回时刷新角标,避免仍显示旧数量 */
   onShow() {
   onShow() {
     this.syncLoginStyle()
     this.syncLoginStyle()
@@ -212,6 +239,23 @@ export default {
     modules() {
     modules() {
       return (this.pageData && this.pageData.modules) || []
       return (this.pageData && this.pageData.modules) || []
     },
     },
+    /**
+     * 已启用的模块,按后台配置顺序排列
+     * 把 enabled 过滤从模板上移到这里,模块下标才能与「已挂载个数」对齐
+     * @returns {Array}
+     */
+    enabledModules() {
+      return this.modules.filter(mod => mod && mod.enabled == 1)
+    },
+    /**
+     * 下拉商品可展示总数(已剔除售罄),用于判断前端分页是否已到底
+     * 过滤规则需与 goodsSection 的 displayGoods 一致
+     * @returns {number}
+     */
+    pullGoodsTotal() {
+      const list = (this.pageData && this.pageData.pullGoods && this.pageData.pullGoods.goods) || []
+      return list.filter(g => Number(g && g.stock) > 0).length
+    },
     showService() {
     showService() {
       const topNav = this.pageData && this.pageData.topNav
       const topNav = this.pageData && this.pageData.topNav
       return !!(topNav && topNav.enabled == 1 && topNav.customerServiceEnabled == 1)
       return !!(topNav && topNav.enabled == 1 && topNav.customerServiceEnabled == 1)
@@ -222,7 +266,12 @@ export default {
     }
     }
   },
   },
   methods: {
   methods: {
-    /** 页面初始化:并行拉取首页配置与门店信息(merchantName) */
+    /**
+     * 页面初始化
+     * 首屏只等 getHome:门店信息(syncMyCustomId)仅供分享参数与红包提醒使用,
+     * 且无 hdId 缓存时是 getHdInfo → getInfo 两跳串行请求,
+     * 放在 Promise.all 里会白白拖慢首屏渲染,因此改为并行且不阻塞 loading。
+     */
     init() {
     init() {
       this.loading = true
       this.loading = true
       this.refreshCartBadgeCount()
       this.refreshCartBadgeCount()
@@ -230,16 +279,85 @@ export default {
       if (account) {
       if (account) {
         uni.setStorageSync('account', account)
         uni.setStorageSync('account', account)
       }
       }
-      Promise.all([getHome(), this.syncMyCustomId()])
-        .then(([homeRes]) => {
+      getHome()
+        .then((homeRes) => {
           if (homeRes.code === 1 && homeRes.data) {
           if (homeRes.code === 1 && homeRes.data) {
+            // 配置可能变化(模块增删),按需渲染的进度需要跟着重置
+            this.renderModuleCount = INITIAL_MODULE_COUNT
+            this.pullGoodsLimit = PULL_GOODS_PAGE_SIZE
             this.pageData = homeRes.data
             this.pageData = homeRes.data
           }
           }
-          this.checkHbArrival()
         })
         })
         .finally(() => {
         .finally(() => {
           this.loading = false
           this.loading = false
+          // 必须等 loading 关掉、模块真正上屏后再观察,否则查不到底部哨兵节点
+          this.initLoadMoreObserver()
+        })
+      // 红包提醒依赖 shopInfo.id,必须等门店信息就绪后再查
+      this.syncMyCustomId()
+        .then(() => {
+          this.checkHbArrival()
         })
         })
+        .catch(() => {})
+    },
+    /**
+     * 建立「加载更多」的哨兵观察。
+     *
+     * 页面根节点 .app-content 被全局样式设为 height:100vh + overflow-y:auto,
+     * 滚动发生在这个容器内部而非页面级,因此 onReachBottom / onPageScroll 都不会触发,
+     * 只能靠观察底部节点是否接近可视区来驱动(复用 home-bottom-space,它本身有高度可被观察)。
+     *
+     * 参照区用视口而非 .app-content:视口对「容器滚动」和「页面滚动」两种情况都成立,
+     * 而以 .app-content 为参照时,若哪天改成页面级滚动,容器与节点会一起移动、相对位置不变,
+     * 观察器将永远不回调。
+     */
+    initLoadMoreObserver() {
+      this.destroyLoadMoreObserver()
+      // 观察器不可用时全部渲染:宁可牺牲按需加载,也不能把店铺内容永久藏起来
+      if (!uni.createIntersectionObserver) {
+        this.renderModuleCount = this.enabledModules.length
+        return
+      }
+      this.$nextTick(() => {
+        this._loadMoreObserver = uni.createIntersectionObserver(this, { thresholds: [0] })
+        // 提前 300px 触发,滚到底之前就把下一块内容准备好
+        this._loadMoreObserver
+          .relativeToViewport({ bottom: 300 })
+          .observe('.home-bottom-space', (res) => {
+            if (res.intersectionRatio > 0) {
+              this.loadMore()
+            }
+          })
+      })
+    },
+    destroyLoadMoreObserver() {
+      if (this._loadMoreObserver) {
+        this._loadMoreObserver.disconnect()
+        this._loadMoreObserver = null
+      }
+    },
+    /**
+     * 挂载下一个模块,模块全部就位后再翻下拉商品。
+     *
+     * 每次真正新增了内容就重建观察器:重新 observe 会立刻回调一次当前相交状态,
+     * 于是这里会自动循环到「内容高度超过一屏 + 预留距离」才停下。
+     * 这同时解决两件事:
+     *   1. 首屏铺满——起始模块数不够时持续补,不会在首屏底部留白;
+     *   2. 空模块不卡死——新挂载的模块可能渲染为空(专区不在活动期、商品全部售罄),
+     *      高度没增长时继续往下补,而不是停在哨兵一直可见却收不到新回调的状态。
+     */
+    loadMore() {
+      let grew = false
+      if (this.renderModuleCount < this.enabledModules.length) {
+        this.renderModuleCount += 1
+        grew = true
+      } else if (this.pullGoodsLimit < this.pullGoodsTotal) {
+        this.pullGoodsLimit += PULL_GOODS_PAGE_SIZE
+        grew = true
+      }
+      if (grew) {
+        this.initLoadMoreObserver()
+      }
     },
     },
     /** 按登录态驱动 wangCg:0 未登录弹层 / 1 已登录关闭 */
     /** 按登录态驱动 wangCg:0 未登录弹层 / 1 已登录关闭 */
     syncLoginStyle() {
     syncLoginStyle() {

+ 95 - 0
mallApp/src/utils/image.js

@@ -0,0 +1,95 @@
+/**
+ * 图片 URL 缩略处理工具
+ *
+ * 用途:把后端返回的图片地址按「实际展示尺寸」改写成 OSS 缩略图地址。
+ * 谁用:app-lazy-img 组件(首页各模块图片),以及需要手动拼缩略图的页面。
+ * 解决什么问题:
+ *   后端 banner.imgUrl / 活动 coverUrl 返回的是商家上传原图(无任何缩略参数),
+ *   hot/new/pullGoods 的 coverUrl 又固定拼了 700x700,与首页三列网格约 234upx
+ *   的实际展示尺寸严重不匹配。店铺首页一次要显示几十张图,直接用原图/大图会
+ *   造成首屏流量与图片解码时间成倍增加。
+ *
+ * 参数写法与后端保持一致(common/components/business.php formatUploadImg、
+ * biz-hd/homePageConfig/classes/HomePageModuleClass.php formatGoodsRows):
+ *   ?x-oss-process=image/resize,m_fill,h_{n},w_{n}
+ * OSS resize 默认 limit_1(只缩不放),小图不会被放大,无需前端额外判断。
+ */
+
+/** OSS 图片处理的 query 参数名,用于识别后端已拼好的缩略参数 */
+const OSS_PROCESS_KEY = 'x-oss-process'
+
+/** 缩略宽度上限:再大也超出手机屏幕可视需求,避免误传超大值退化成原图 */
+const MAX_THUMB_WIDTH = 1080
+
+/**
+ * 拆分 URL 的路径与 query,并剔除已有的 x-oss-process 参数
+ *
+ * 后端对同一张图有时已拼过缩略参数(hot/new/pullGoods 的 700x700),
+ * 直接再追加一个 x-oss-process 会出现重复参数、由 OSS 取首个值,
+ * 导致前端指定的尺寸无效,所以必须「替换」而不是「追加」。
+ * 其余 query 参数(例如将来可能出现的签名参数)必须原样保留,否则图片会取不到。
+ *
+ * @param {string} url 原始图片地址
+ * @returns {{ path: string, keptQuery: string }} path 为不含 query 的地址,keptQuery 为保留下来的其他参数串
+ */
+const splitUrl = (url) => {
+	const idx = url.indexOf('?')
+	if (idx < 0) {
+		return { path: url, keptQuery: '' }
+	}
+	const path = url.substring(0, idx)
+	const kept = url
+		.substring(idx + 1)
+		.split('&')
+		.filter(pair => pair && pair.split('=')[0] !== OSS_PROCESS_KEY)
+	return { path, keptQuery: kept.join('&') }
+}
+
+/**
+ * 生成按展示尺寸缩略后的图片地址
+ *
+ * @param {string} url 后端返回的图片地址(要求为 http/https 完整地址)
+ * @param {number} width 期望的缩略宽度(px,调用方按展示尺寸 ×2 DPR 传入)
+ * @param {string} mode fill=按正方形填充裁剪(封面/图标);width=只限宽等比缩放(轮播等)
+ * @returns {string} 缩略图地址;入参不满足处理条件时原样返回,保证不会因工具函数导致图片空白
+ */
+export const thumbUrl = (url, width, mode = 'fill') => {
+	if (!url || typeof url !== 'string') {
+		return ''
+	}
+	const w = parseInt(width, 10)
+	// 未指定尺寸时不做任何改写,交由调用方自行决定
+	if (!(w > 0)) {
+		return url
+	}
+	// 只处理本站 OSS 的 http(s) 地址;本地静态图、base64、相对路径原样返回
+	if (url.indexOf('http://') !== 0 && url.indexOf('https://') !== 0) {
+		return url
+	}
+	const size = Math.min(w, MAX_THUMB_WIDTH)
+	const { path, keptQuery } = splitUrl(url)
+	const resize = mode === 'width'
+		? `image/resize,w_${size}`
+		: `image/resize,m_fill,h_${size},w_${size}`
+	const query = keptQuery
+		? `${keptQuery}&${OSS_PROCESS_KEY}=${resize}`
+		: `${OSS_PROCESS_KEY}=${resize}`
+	return `${path}?${query}`
+}
+
+/**
+ * 去掉图片地址上的 OSS 处理参数,取回原始地址
+ * 用于缩略图加载失败时回退,避免个别图片因 OSS 参数取不到而整块空白
+ *
+ * @param {string} url 图片地址
+ * @returns {string} 不含 x-oss-process 的地址
+ */
+export const rawUrl = (url) => {
+	if (!url || typeof url !== 'string') {
+		return ''
+	}
+	const { path, keptQuery } = splitUrl(url)
+	return keptQuery ? `${path}?${keptQuery}` : path
+}
+
+export default { thumbUrl, rawUrl }