Taro 微信小程序图片提前加载问题排查

太阳作者太阳
原创内容采用 CC-4.0 协议发布,转载请注明出处
Taro微信小程序图片加载Swiper

我在微信开发者工具里检查网络请求时,发现一个弹窗里的图片已经下载了,但弹窗从未打开。继续往下查,又发现轮播第一页出现后,其余几张图片也在同一时间请求。

图片格式和尺寸都没有问题。真正的问题是组件已经挂载,只是用户暂时看不见。

这类请求很容易漏掉。界面看起来只有一张图,代码也写了 opacity-0lazyLoad,网络面板却是另一回事。排查时应先确认 <Image> 是否存在于组件树,再讨论缓存、WebP 或 CDN。

透明隐藏仍会创建 Image

下面的弹窗一直挂载。visible 只改变样式:

function ImageModal({ visible, imageUrl, onClose }: Props) {
  return (
    <View
      className={
        visible
          ? "fixed inset-0 opacity-100"
          : "fixed inset-0 opacity-0 pointer-events-none"
      }
      onClick={onClose}
    >
      <Image src={imageUrl} mode="aspectFit" />
    </View>
  );
}

opacity: 0 只影响绘制,pointer-events: none 只影响点击。两者都不会卸载 &lt;Image&gt;,所以运行时仍可能请求 imageUrl

如果图片只在弹窗打开后才有用,直接控制挂载:

function ImageModal({ visible, imageUrl, onClose }: Props) {
  if (!visible) return null;

  return (
    <RootPortal>
      <View className="fixed inset-0" onClick={onClose}>
        <Image src={imageUrl} mode="aspectFit" />
      </View>
    </RootPortal>
  );
}

关闭时组件返回 null,打开后才创建 &lt;Image&gt;。这时请求时机和用户操作是一致的。

这个改法会取消原来依赖隐藏状态完成的退出动画。如果必须保留动画,可以在动画结束后再卸载内容,但不要为了动画让网络资源长期留在组件树里。

Swiper 会渲染看不见的子项

轮播的情况更隐蔽。页面只显示当前项,不代表其他 SwiperItem 没有渲染:

<Swiper circular>
  {images.map((url) => (
    <SwiperItem key={url}>
      <Image src={url} mode="aspectFill" />
    </SwiperItem>
  ))}
</Swiper>

如果 images 有八项,上面的代码就创建八个 &lt;Image&gt;。是否能看到某一项,不会改变它已经挂载的事实。

一种直接的处理方式是只挂载当前项和相邻项:

function getAdjacentIndexes(index: number, length: number) {
  if (length <= 0) return new Set<number>();
  if (length === 1) return new Set([0]);

  return new Set([
    index,
    (index - 1 + length) % length,
    (index + 1) % length,
  ]);
}

组件中维护已经允许加载的索引:

const [loadedIndexes, setLoadedIndexes] = useState(
  () => getAdjacentIndexes(0, images.length),
);

function loadAdjacentImages(index: number) {
  setLoadedIndexes((current) => {
    const next = new Set(current);
    getAdjacentIndexes(index, images.length).forEach((item) => next.add(item));
    return next;
  });
}

渲染时,没有进入集合的项只保留占位:

<Swiper
  circular
  onChange={(event) => loadAdjacentImages(event.detail.current)}
>
  {images.map((url, index) => (
    <SwiperItem key={url}>
      {loadedIndexes.has(index) ? (
        <Image src={url} mode="aspectFill" />
      ) : (
        <View className="h-full bg-black" />
      )}
    </SwiperItem>
  ))}
</Swiper>

圆形轮播在第一页会准备末尾、当前和下一项,初次最多加载三张。用户滑动后,再把新的相邻项加入集合。已经加载过的索引不删除,回滑时不会因为组件反复卸载而重新闪烁。

如果图片数量很多,也可以只保留当前窗口,离开后卸载远处图片。这样更省内存,但回滑时可能重新请求或重新解码。是否需要做到这一步,要看图片数量和小程序的实际内存表现。

全屏预览不要再换一套图片

自定义全屏预览通常又有一个 Swiper。如果它在打开时直接渲染全部图片,前面的限制就失效了。

预览层应在打开后才挂载,并沿用普通轮播的 loadedIndexes。当前图片如果已经下载过,预览也尽量复用同一个 URL。为了全屏查看再生成另一套地址,往往会让点击动作触发第二次下载。

如果全屏必须使用更高清的资源,可以只替换当前项,等用户滑动后再加载新的当前项。不要在打开预览的一刻把整组高清图同时挂上去。

视频也要单独检查。autoplay={false} 只表示不自动播放,不一定表示组件不会读取封面、元数据或媒体内容。未进入加载窗口的视频最好连 &lt;Video&gt; 一起延后挂载。

lazyLoad 不是弹窗开关

Taro 的 &lt;Image lazyLoad /&gt; 适合滚动列表,但它不能表达“用户点击后才需要”。

固定定位的弹窗、透明容器和轮播子项是否进入懒加载判定范围,容易受到布局和运行时实现影响。既然代码已经有 visibleshowisOpen,就用这些状态控制组件是否存在。

我现在把两件事分开处理:

  • 滚动列表使用 lazyLoad,减少尚未进入视口的图片请求。
  • 弹窗、抽屉和全屏预览使用条件渲染,保证交互发生前没有媒体组件。

用网络面板验证

这类修改不能只看页面。微信开发者工具的 Network 面板更直接:

  1. 清空请求记录并重新进入页面。
  2. 不点击弹窗,确认弹窗图片没有请求。
  3. 查看轮播初次产生多少图片请求。
  4. 滑动一项,确认只增加新的相邻图片。
  5. 打开全屏预览,检查当前图片是否复用了原 URL。
  6. 关闭预览后再次打开,观察缓存和组件挂载是否符合预期。

还要留意请求发起者和 URL。相同内容如果带了不同尺寸、签名或查询参数,在缓存看来就是不同资源。

界面不可见和资源未加载是两回事。判断是否省下请求,最后还是要回到组件挂载状态和网络记录。