feat(image-cache): 新增图片本地缓存并全站接入
引入 cached-image 组件与 image-cache 工具,实现签名 URL 稳定 key 剥离、downloadFile 落盘及 TTL/LRU 清理,替换 11 个页面与 clothes-card 共 22 处网络图片渲染点,减少 CDN 重复流量。
This commit is contained in:
@@ -0,0 +1,4 @@
|
||||
{
|
||||
"component": true,
|
||||
"styleIsolation": "isolated"
|
||||
}
|
||||
@@ -0,0 +1,77 @@
|
||||
// cached-image —— 通用图片组件:网络图经 utils/image-cache 落盘后展示(命中时零网络请求),
|
||||
// 本地路径(wxfile:// / http://tmp / 包内路径等)原样直出;
|
||||
// 未命中缓存时先骨架占位、下载完成后显示本地文件,失败自动回退网络地址,功能不受影响。
|
||||
|
||||
import { getCachedLocalPath, resolveImage, dropImageCache } from '../../utils/image-cache'
|
||||
|
||||
Component({
|
||||
properties: {
|
||||
src: { type: String, value: '' },
|
||||
// 与 image 组件对齐:未显式传入时保持 scaleToFill 默认行为
|
||||
mode: { type: String, value: 'scaleToFill' },
|
||||
lazyLoad: { type: Boolean, value: false },
|
||||
},
|
||||
data: {
|
||||
// 实际渲染地址:本地文件路径或回退的网络地址;空串 = 骨架占位中
|
||||
shown: '',
|
||||
},
|
||||
observers: {
|
||||
src() {
|
||||
this.refresh()
|
||||
},
|
||||
},
|
||||
lifetimes: {
|
||||
attached() {
|
||||
;(this as any)._alive = true
|
||||
this.refresh()
|
||||
},
|
||||
detached() {
|
||||
;(this as any)._alive = false
|
||||
},
|
||||
},
|
||||
methods: {
|
||||
refresh() {
|
||||
const src = this.data.src
|
||||
const seq = (((this as any)._seq as number) || 0) + 1
|
||||
;(this as any)._seq = seq
|
||||
|
||||
if (!src) {
|
||||
this.setData({ shown: '' })
|
||||
return
|
||||
}
|
||||
if (src.indexOf('https://') !== 0) {
|
||||
// 本地/包内路径直接渲染,不进入缓存流程
|
||||
this.setData({ shown: src })
|
||||
return
|
||||
}
|
||||
const hit = getCachedLocalPath(src)
|
||||
if (hit) {
|
||||
this.setData({ shown: hit })
|
||||
return
|
||||
}
|
||||
// 未命中:先占位,下载落盘后显示本地文件(期间不渲染网络地址,避免额外请求抵消缓存收益)
|
||||
if (this.data.shown) this.setData({ shown: '' })
|
||||
resolveImage(src).then(
|
||||
(localPath) => {
|
||||
if ((this as any)._alive === false || (this as any)._seq !== seq) return
|
||||
this.setData({ shown: localPath })
|
||||
},
|
||||
() => {
|
||||
if ((this as any)._alive === false || (this as any)._seq !== seq) return
|
||||
// 缓存不可用时回退网络地址,保证功能可用
|
||||
this.setData({ shown: src })
|
||||
}
|
||||
)
|
||||
},
|
||||
onImgError() {
|
||||
const shown = this.data.shown
|
||||
const src = this.data.src
|
||||
// 网络地址自身失败或非网络图:不再处理,避免循环回退
|
||||
if (!shown || !src || shown.indexOf('https://') === 0) return
|
||||
;(this as any)._seq = (((this as any)._seq as number) || 0) + 1
|
||||
// 本地文件失效(被系统清理/损坏):清理缓存并回退网络地址一次
|
||||
dropImageCache(src)
|
||||
this.setData({ shown: src })
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -0,0 +1,10 @@
|
||||
<!-- 通用图片:缓存命中直出本地文件,未命中先骨架占位,失败回退网络地址 -->
|
||||
<view wx:if="{{!shown}}" class="ci-ph"></view>
|
||||
<image
|
||||
wx:if="{{shown}}"
|
||||
class="ci-img"
|
||||
src="{{shown}}"
|
||||
mode="{{mode}}"
|
||||
lazy-load="{{lazyLoad}}"
|
||||
binderror="onImgError"
|
||||
/>
|
||||
@@ -0,0 +1,26 @@
|
||||
/* 宿主节点:对齐 image 组件默认 inline-block 布局,尺寸与圆角由调用方 class 控制 */
|
||||
:host {
|
||||
display: inline-block;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
/* 内层图片:填满宿主并继承圆角,保证既有版式零回归 */
|
||||
.ci-img {
|
||||
display: block;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: inherit;
|
||||
}
|
||||
|
||||
/* 骨架占位:与全局 .sk 同款视觉,组件内独立定义避免依赖外部样式加载顺序 */
|
||||
.ci-ph {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
background: linear-gradient(90deg, #F2F2F0 25%, #E9E9E6 37%, #F2F2F0 63%);
|
||||
background-size: 400% 100%;
|
||||
animation: ciSkLoad 1.5s ease infinite;
|
||||
}
|
||||
@keyframes ciSkLoad {
|
||||
0% { background-position: 100% 50%; }
|
||||
100% { background-position: 0 50%; }
|
||||
}
|
||||
@@ -1,4 +1,7 @@
|
||||
{
|
||||
"component": true,
|
||||
"styleIsolation": "apply-shared"
|
||||
"styleIsolation": "apply-shared",
|
||||
"usingComponents": {
|
||||
"cached-image": "/components/cached-image/cached-image"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -1,12 +1,12 @@
|
||||
<!-- v2 衣物瓦片:无卡片容器,图下两行(衬线品名 + 灰色属性行) -->
|
||||
<view class="tile {{selected ? 'is-picked' : ''}}" bindtap="onTap">
|
||||
<view class="tile-img">
|
||||
<image
|
||||
<cached-image
|
||||
wx:if="{{item.processed_image}}"
|
||||
class="pic"
|
||||
src="{{item.processed_image}}"
|
||||
mode="aspectFill"
|
||||
lazy-load
|
||||
lazy-load="{{true}}"
|
||||
/>
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
<view wx:if="{{statusText}}" class="tile-flag {{statusType === 'ink' ? 'is-ink' : ''}}">{{statusText}}</view>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"cached-image": "/components/cached-image/cached-image",
|
||||
"empty-state": "/components/empty-state/empty-state",
|
||||
"v2-overlay": "/components/v2-overlay/index"
|
||||
}
|
||||
|
||||
@@ -63,7 +63,7 @@
|
||||
<!-- 无缝拼图封面 -->
|
||||
<view class="collage {{item._collageCls}}">
|
||||
<view class="cg" wx:for="{{item._covers}}" wx:for-item="c" wx:key="k">
|
||||
<image wx:if="{{c.img}}" class="pic" src="{{c.img}}" mode="aspectFill" lazy-load />
|
||||
<cached-image wx:if="{{c.img}}" class="pic" src="{{c.img}}" mode="aspectFill" lazy-load="{{true}}" />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
<view wx:if="{{item._moreCount > 0}}" class="cg-more">+{{item._moreCount}}</view>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"cached-image": "/components/cached-image/cached-image",
|
||||
"empty-state": "/components/empty-state/empty-state",
|
||||
"v2-overlay": "/components/v2-overlay/index"
|
||||
}
|
||||
|
||||
@@ -44,7 +44,7 @@ module.exports = { cls: cls }
|
||||
<view class="ai-cover">
|
||||
<view class="collage {{cl.cls(clothes.length)}}">
|
||||
<view class="cg" wx:for="{{clothes}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
|
||||
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
<view wx:if="{{clothes.length > 4}}" class="cg-more">+{{clothes.length - 4}}</view>
|
||||
@@ -77,7 +77,7 @@ module.exports = { cls: cls }
|
||||
<view class="ai-items-row">
|
||||
<view class="ai-chip" wx:for="{{clothes}}" wx:key="clothes_uuid">
|
||||
<view class="ai-thumb">
|
||||
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
<text class="ai-chip-n">{{item.name || '未命名'}}</text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"cached-image": "/components/cached-image/cached-image",
|
||||
"clothes-card": "/components/clothes-card/clothes-card",
|
||||
"v2-overlay": "/components/v2-overlay/index"
|
||||
}
|
||||
|
||||
@@ -95,7 +95,7 @@
|
||||
<view class="csc-preview">
|
||||
<view class="csc-preview-hd">
|
||||
<view class="csc-av">
|
||||
<image wx:if="{{avatar}}" class="pic" src="{{avatar}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{avatar}}" class="pic" src="{{avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="icon icon-user"></view>
|
||||
</view>
|
||||
<text class="csc-preview-n">{{nickname}} 向你分享了 {{selectedCount || '全部'}} 件衣物</text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"cached-image": "/components/cached-image/cached-image",
|
||||
"empty-state": "/components/empty-state/empty-state",
|
||||
"v2-overlay": "/components/v2-overlay/index"
|
||||
}
|
||||
|
||||
@@ -28,7 +28,7 @@
|
||||
|
||||
<view class="csd-owner">
|
||||
<view class="csd-av">
|
||||
<image wx:if="{{detail.owner_avatar}}" class="pic" src="{{detail.owner_avatar}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{detail.owner_avatar}}" class="pic" src="{{detail.owner_avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="icon icon-user"></view>
|
||||
</view>
|
||||
<view class="csd-owner-body">
|
||||
@@ -41,7 +41,7 @@
|
||||
<view class="csd-grid">
|
||||
<view class="csd-tile" wx:for="{{detail.clothes}}" wx:key="clothes_uuid">
|
||||
<view class="tile-img">
|
||||
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" lazy-load />
|
||||
<cached-image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" lazy-load="{{true}}" />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
<view class="tile-name">{{item.name || '未命名'}}</view>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"cached-image": "/components/cached-image/cached-image",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,13 +59,13 @@
|
||||
</view>
|
||||
<!-- 穿搭分享:封面 -->
|
||||
<view class="cs-thumb is-photo" wx:else>
|
||||
<image wx:if="{{item.cover}}" class="pic" src="{{item.cover}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{item.cover}}" class="pic" src="{{item.cover}}" mode="aspectFill" />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
|
||||
<view class="cs-body">
|
||||
<view class="cs-owner" wx:if="{{activeTab === 1}}">
|
||||
<image wx:if="{{item.owner_avatar}}" class="oav" src="{{item.owner_avatar}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{item.owner_avatar}}" class="oav" src="{{item.owner_avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="oav">
|
||||
<view class="icon icon-user"></view>
|
||||
</view>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"cached-image": "/components/cached-image/cached-image",
|
||||
"empty-state": "/components/empty-state/empty-state",
|
||||
"v2-overlay": "/components/v2-overlay/index"
|
||||
}
|
||||
|
||||
@@ -110,7 +110,7 @@ module.exports = { hex: hex, tags: tags }
|
||||
<block wx:else>
|
||||
<!-- 大图:买手店目录式线稿/实拍 -->
|
||||
<view class="cd-photo">
|
||||
<image
|
||||
<cached-image
|
||||
wx:if="{{detail.processed_image}}"
|
||||
class="pic"
|
||||
src="{{detail.processed_image}}"
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"cached-image": "/components/cached-image/cached-image",
|
||||
"empty-state": "/components/empty-state/empty-state",
|
||||
"v2-overlay": "/components/v2-overlay/index"
|
||||
}
|
||||
|
||||
@@ -57,7 +57,7 @@ module.exports = { cls: cls }
|
||||
<view class="od-cover">
|
||||
<view class="collage {{cl.cls(clothes.length)}}">
|
||||
<view class="cg" wx:for="{{clothes}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
|
||||
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
<view wx:if="{{clothes.length > 4}}" class="cg-more">+{{clothes.length - 4}}</view>
|
||||
@@ -100,7 +100,7 @@ module.exports = { cls: cls }
|
||||
bindtap="onItemTap"
|
||||
>
|
||||
<view class="od-thumb">
|
||||
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
<text class="od-chip-n">{{item.name || '未命名'}}</text>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"cached-image": "/components/cached-image/cached-image",
|
||||
"clothes-card": "/components/clothes-card/clothes-card",
|
||||
"v2-overlay": "/components/v2-overlay/index"
|
||||
}
|
||||
|
||||
@@ -32,7 +32,7 @@ module.exports = { cls: cls }
|
||||
bindtap="openSelect"
|
||||
>
|
||||
<view class="cg" wx:for="{{selectedItems}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
|
||||
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
<view wx:if="{{selectedItems.length > 4}}" class="cg-more">+{{selectedItems.length - 4}}</view>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"cached-image": "/components/cached-image/cached-image",
|
||||
"empty-state": "/components/empty-state/empty-state",
|
||||
"v2-overlay": "/components/v2-overlay/index"
|
||||
}
|
||||
|
||||
@@ -38,7 +38,7 @@ module.exports = { cls: cls }
|
||||
|
||||
<view class="osd-owner">
|
||||
<view class="osd-av">
|
||||
<image wx:if="{{detail.owner_avatar}}" class="pic" src="{{detail.owner_avatar}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{detail.owner_avatar}}" class="pic" src="{{detail.owner_avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="icon icon-user"></view>
|
||||
</view>
|
||||
<view class="osd-owner-body">
|
||||
@@ -51,7 +51,7 @@ module.exports = { cls: cls }
|
||||
<view class="osd-cover">
|
||||
<view class="collage {{cl.cls(outfit.items.length)}}">
|
||||
<view class="cg" wx:for="{{outfit.items}}" wx:key="clothes_uuid" wx:if="{{index < 4}}">
|
||||
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
<view wx:if="{{outfit.items.length > 4}}" class="cg-more">+{{outfit.items.length - 4}}</view>
|
||||
@@ -84,7 +84,7 @@ module.exports = { cls: cls }
|
||||
bindtap="onItemTap"
|
||||
>
|
||||
<view class="osd-thumb">
|
||||
<image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{item.processed_image}}" class="pic" src="{{item.processed_image}}" mode="aspectFill" />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
</view>
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"cached-image": "/components/cached-image/cached-image",
|
||||
"empty-state": "/components/empty-state/empty-state"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -68,7 +68,7 @@
|
||||
<view class="of-card" bindtap="onGuestOutfitTap">
|
||||
<view class="collage collage--2">
|
||||
<view class="cg" wx:for="{{guestOutfit.items}}" wx:key="clothes_uuid">
|
||||
<image class="pic" src="{{item.processed_image}}" mode="aspectFit" />
|
||||
<cached-image class="pic" src="{{item.processed_image}}" mode="aspectFit" />
|
||||
</view>
|
||||
</view>
|
||||
<view class="of-trow">
|
||||
@@ -98,7 +98,7 @@
|
||||
>
|
||||
<!-- 作者行:仅社区分段 -->
|
||||
<view class="of-author" wx:if="{{activeTab === 2}}">
|
||||
<image wx:if="{{item.owner_avatar}}" class="of-av" src="{{item.owner_avatar}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{item.owner_avatar}}" class="of-av" src="{{item.owner_avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="of-av">
|
||||
<view class="icon icon-user"></view>
|
||||
</view>
|
||||
@@ -109,7 +109,7 @@
|
||||
<!-- 无缝拼图封面(_covers 由 outfitCover 组装,缺失时整体不渲染,避免 1:1 空白方块撑满屏幕) -->
|
||||
<view wx:if="{{item._covers}}" class="collage {{item._collageCls}}">
|
||||
<view class="cg" wx:for="{{item._covers}}" wx:for-item="c" wx:key="k">
|
||||
<image wx:if="{{c.img}}" class="pic" src="{{c.img}}" mode="aspectFill" lazy-load />
|
||||
<cached-image wx:if="{{c.img}}" class="pic" src="{{c.img}}" mode="aspectFill" lazy-load="{{true}}" />
|
||||
<view wx:else class="ph icon icon-hanger"></view>
|
||||
</view>
|
||||
<view wx:if="{{item._moreCount > 0}}" class="cg-more">+{{item._moreCount}}</view>
|
||||
@@ -172,7 +172,7 @@
|
||||
<view class="gd-items">
|
||||
<view class="gd-item" wx:for="{{guestOutfit.items}}" wx:key="clothes_uuid">
|
||||
<view class="gd-item-img">
|
||||
<image class="pic" src="{{item.processed_image}}" mode="aspectFit" />
|
||||
<cached-image class="pic" src="{{item.processed_image}}" mode="aspectFit" />
|
||||
</view>
|
||||
<view class="tile-name">{{item.name}}</view>
|
||||
<view class="tile-sub">{{item.category_main}}{{item.category_sub ? ' · ' + item.category_sub : ''}}</view>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{
|
||||
"usingComponents": {
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar"
|
||||
"navigation-bar": "/components/navigation-bar/navigation-bar",
|
||||
"cached-image": "/components/cached-image/cached-image"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -27,7 +27,7 @@
|
||||
<!-- 资料区:大号头像 + 衬线昵称 + 胶囊标签 -->
|
||||
<view wx:else class="pf-hero">
|
||||
<view class="pf-av">
|
||||
<image wx:if="{{userInfo.avatar}}" class="pf-av-img" src="{{userInfo.avatar}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{userInfo.avatar}}" class="pf-av-img" src="{{userInfo.avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="icon icon-user"></view>
|
||||
</view>
|
||||
<view class="pf-info">
|
||||
@@ -150,7 +150,7 @@
|
||||
|
||||
<view class="sheet-body">
|
||||
<view class="avatar-wrap">
|
||||
<image wx:if="{{editForm.avatar}}" class="avatar-img" src="{{editForm.avatar}}" mode="aspectFill" />
|
||||
<cached-image wx:if="{{editForm.avatar}}" class="avatar-img" src="{{editForm.avatar}}" mode="aspectFill" />
|
||||
<view wx:else class="icon icon-user avatar-placeholder"></view>
|
||||
<button class="avatar-btn" open-type="chooseAvatar" bindchooseavatar="onChooseAvatar"></button>
|
||||
</view>
|
||||
|
||||
@@ -0,0 +1,433 @@
|
||||
// 图片本地缓存:网络图片首次展示时下载落盘,后续直接复用本地文件,减少 CDN 重复流量。
|
||||
// 背景:接口返回的图片是带签名/时间戳的临时 URL,每次请求都会变化;微信 image 自带的
|
||||
// 缓存按「完整 URL(含 query)」记录,签名一变即失效,同一张图会被反复下载。
|
||||
// 本模块按「剥离签名参数后的稳定地址」作为缓存 key 落盘,同一张图只请求 CDN 一次。
|
||||
//
|
||||
// 命中链路:内存 Map → 磁盘文件 → downloadFile 落盘(并发 ≤4、同 key 任务去重)。
|
||||
// 失败处理:单图失败由组件回退网络地址;检测到 downloadFile 域名未配置时整会话熔断,
|
||||
// 避免继续发起必然失败的请求。
|
||||
// 空间治理:TTL + 容量上限,空闲时异步清扫,不阻塞渲染。
|
||||
|
||||
import { logger } from './logger'
|
||||
|
||||
// 日志模块名
|
||||
const LOG = 'image-cache'
|
||||
|
||||
// 缓存目录(本地用户文件,随小程序代码包生命周期清理)
|
||||
const CACHE_DIR = `${wx.env.USER_DATA_PATH}/imgcache`
|
||||
// 过期时间:超过 7 天未修改的缓存文件优先淘汰
|
||||
const TTL_MS = 7 * 24 * 60 * 60 * 1000
|
||||
// 目录容量上限:本地文件总量上限约 200MB,自设 80MB 留足业务余量
|
||||
const MAX_BYTES = 80 * 1024 * 1024
|
||||
// 文件数量上限
|
||||
const MAX_FILES = 300
|
||||
// 内存映射上限,超出时整体重建(后续重新走磁盘命中,代价可控)
|
||||
const MAX_MEM_ENTRIES = 500
|
||||
// downloadFile 并发上限(微信侧总上限 10,留余量给业务请求)
|
||||
const MAX_CONCURRENCY = 4
|
||||
// 清扫延迟:首次解析后延迟执行,避免与首屏图片加载抢资源
|
||||
const PRUNE_DELAY_MS = 5000
|
||||
// 运行期每成功落盘 50 张再触发一次清扫
|
||||
const PRUNE_EVERY_SAVES = 50
|
||||
|
||||
// 签名类参数名(小写,集中维护):签名 URL 每次返回都不同,必须从缓存 key 中剥离,否则永远无法命中
|
||||
const SIGN_PARAMS = new Set([
|
||||
// 通用签名 / 时间戳
|
||||
'sign',
|
||||
'signature',
|
||||
'sig',
|
||||
't',
|
||||
'ts',
|
||||
'timestamp',
|
||||
'time',
|
||||
'expires',
|
||||
'expire',
|
||||
'auth',
|
||||
'token',
|
||||
'nonce',
|
||||
// 腾讯云 COS
|
||||
'authorize',
|
||||
'x-cos-security-token',
|
||||
'q-sign-algorithm',
|
||||
'q-sign-time',
|
||||
'q-key-time',
|
||||
'q-signature',
|
||||
'q-header-list',
|
||||
'q-url-param-list',
|
||||
// 阿里云 OSS
|
||||
'ossaccesskeyid',
|
||||
'security-token',
|
||||
'auth_key',
|
||||
// AWS S3 / CloudFront
|
||||
'x-signature',
|
||||
'x-amz-algorithm',
|
||||
'x-amz-credential',
|
||||
'x-amz-date',
|
||||
'x-amz-expires',
|
||||
'x-amz-signature',
|
||||
'x-amz-security-token',
|
||||
'x-amz-signedheaders',
|
||||
'key-pair-id',
|
||||
'policy',
|
||||
])
|
||||
|
||||
// ===== 运行状态 =====
|
||||
|
||||
// 稳定 key → 本地文件路径(内存快速通道)
|
||||
const memCache = new Map<string, string>()
|
||||
// 稳定 key → 进行中的下载任务(并发复用,避免同一张图重复下载)
|
||||
const inflight = new Map<string, Promise<string>>()
|
||||
// 下载域名未配置等不可恢复失败的会话级熔断
|
||||
let disabled = false
|
||||
// 缓存目录就绪状态与初始化 Promise
|
||||
let dirReady = false
|
||||
let dirPromise: Promise<void> | null = null
|
||||
// downloadFile 并发闸门
|
||||
let running = 0
|
||||
const waiters: Array<() => void> = []
|
||||
// 清扫节流状态
|
||||
let firstPruneDone = false
|
||||
let pruneScheduled = false
|
||||
let savesSincePrune = 0
|
||||
|
||||
function fs(): WechatMiniprogram.FileSystemManager {
|
||||
return wx.getFileSystemManager()
|
||||
}
|
||||
|
||||
// 仅 https 网络图进入缓存流程;本地路径(wxfile:// / http://tmp / http://usr / 包内路径)原样返回
|
||||
function isRemote(src: string): boolean {
|
||||
return src.indexOf('https://') === 0
|
||||
}
|
||||
|
||||
// ===== 稳定 key 与文件指纹 =====
|
||||
|
||||
// 剥离签名/时间戳参数,其余 query 排序后与 host+path 拼接;同一张图签名变化也命中同一缓存
|
||||
function stableKey(src: string): string {
|
||||
const idx = src.indexOf('?')
|
||||
if (idx < 0) return src
|
||||
const base = src.slice(0, idx)
|
||||
const kept: string[] = []
|
||||
for (const part of src.slice(idx + 1).split('&')) {
|
||||
if (!part) continue
|
||||
const eq = part.indexOf('=')
|
||||
const rawKey = (eq >= 0 ? part.slice(0, eq) : part).toLowerCase()
|
||||
let name = rawKey
|
||||
try {
|
||||
name = decodeURIComponent(rawKey)
|
||||
} catch (e) {
|
||||
// 参数名解码失败按原样处理
|
||||
}
|
||||
if (SIGN_PARAMS.has(name)) continue
|
||||
kept.push(part)
|
||||
}
|
||||
if (!kept.length) return base
|
||||
kept.sort()
|
||||
return `${base}?${kept.join('&')}`
|
||||
}
|
||||
|
||||
function hex32(n: number): string {
|
||||
return ('00000000' + (n >>> 0).toString(16)).slice(-8)
|
||||
}
|
||||
|
||||
// FNV-1a 32 位:不引入依赖的快速哈希
|
||||
function fnv1a(str: string): string {
|
||||
let h = 0x811c9dc5
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h ^= str.charCodeAt(i)
|
||||
h = Math.imul(h, 0x01000193) >>> 0
|
||||
}
|
||||
return hex32(h)
|
||||
}
|
||||
|
||||
// djb2 32 位:与 FNV-1a 拼接为 16 位十六进制文件名,降低碰撞概率
|
||||
function djb2(str: string): string {
|
||||
let h = 5381
|
||||
for (let i = 0; i < str.length; i++) {
|
||||
h = (Math.imul(h, 33) + str.charCodeAt(i)) >>> 0
|
||||
}
|
||||
return hex32(h)
|
||||
}
|
||||
|
||||
function hashKey(key: string): string {
|
||||
return fnv1a(key) + djb2(key)
|
||||
}
|
||||
|
||||
// 扩展名取自 URL 路径,白名单外统一按 .jpg 处理(仅影响本地文件名,不影响渲染)
|
||||
function extOf(src: string): string {
|
||||
const path = src.split('?')[0].split('#')[0]
|
||||
const m = /\.(jpg|jpeg|png|webp|gif)$/i.exec(path)
|
||||
if (!m) return '.jpg'
|
||||
const ext = m[1].toLowerCase()
|
||||
return ext === 'jpeg' ? '.jpg' : `.${ext}`
|
||||
}
|
||||
|
||||
function localPathOf(key: string, src: string): string {
|
||||
return `${CACHE_DIR}/${hashKey(key)}${extOf(src)}`
|
||||
}
|
||||
|
||||
// ===== 文件系统操作(全部异步包装,失败静默由调用方决定兜底) =====
|
||||
|
||||
function ensureDir(): Promise<void> {
|
||||
if (dirReady) return Promise.resolve()
|
||||
if (!dirPromise) {
|
||||
dirPromise = new Promise<void>((resolve) => {
|
||||
fs().mkdir({
|
||||
dirPath: CACHE_DIR,
|
||||
recursive: true,
|
||||
success: () => resolve(),
|
||||
// 目录已存在等失败一律放行,落盘失败会在下载阶段回退
|
||||
fail: () => resolve(),
|
||||
})
|
||||
}).then(() => {
|
||||
dirReady = true
|
||||
})
|
||||
}
|
||||
return dirPromise
|
||||
}
|
||||
|
||||
function accessFile(path: string): Promise<boolean> {
|
||||
return new Promise((resolve) => {
|
||||
fs().access({
|
||||
path,
|
||||
success: () => resolve(true),
|
||||
fail: () => resolve(false),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function unlinkQuiet(path: string): Promise<void> {
|
||||
return new Promise((resolve) => {
|
||||
fs().unlink({
|
||||
filePath: path,
|
||||
success: () => resolve(),
|
||||
fail: () => resolve(),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function readdir(): Promise<string[]> {
|
||||
return new Promise((resolve, reject) => {
|
||||
fs().readdir({
|
||||
dirPath: CACHE_DIR,
|
||||
success: (res) => resolve(res.files || []),
|
||||
fail: (err) => reject(new Error((err && err.errMsg) || 'readdir fail')),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function statFile(path: string): Promise<{ size: number; mtime: number } | null> {
|
||||
return new Promise((resolve) => {
|
||||
fs().stat({
|
||||
path,
|
||||
success: (res) => {
|
||||
const s = res.stats
|
||||
if (typeof s.isFile === 'function' && !s.isFile()) {
|
||||
resolve(null)
|
||||
return
|
||||
}
|
||||
// 官方单位是秒,但个别端返回毫秒,统一归一化为毫秒
|
||||
const raw = Number(s.lastModifiedTime) || 0
|
||||
const mtime = raw > 1e11 ? raw : raw * 1000
|
||||
resolve({ size: Number(s.size) || 0, mtime })
|
||||
},
|
||||
fail: () => resolve(null),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 下载:并发闸门 + 直写目标文件 =====
|
||||
|
||||
function acquireSlot(): Promise<void> {
|
||||
if (running < MAX_CONCURRENCY) {
|
||||
running++
|
||||
return Promise.resolve()
|
||||
}
|
||||
return new Promise((resolve) => {
|
||||
waiters.push(() => {
|
||||
running++
|
||||
resolve()
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
function releaseSlot(): void {
|
||||
running--
|
||||
const next = waiters.shift()
|
||||
if (next) next()
|
||||
}
|
||||
|
||||
function download(url: string, filePath: string): Promise<void> {
|
||||
return new Promise((resolve, reject) => {
|
||||
wx.downloadFile({
|
||||
url,
|
||||
filePath,
|
||||
success: (res) => {
|
||||
if (res.statusCode === 200) resolve()
|
||||
else reject(new Error(`http ${res.statusCode}`))
|
||||
},
|
||||
fail: (err) => reject(new Error((err && err.errMsg) || 'download fail')),
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 空闲清扫:TTL 过期 + 超容量/超数量按最旧优先删除 =====
|
||||
|
||||
async function pruneCache(): Promise<void> {
|
||||
try {
|
||||
const names = await readdir()
|
||||
const now = Date.now()
|
||||
const alive: Array<{ path: string; size: number; mtime: number }> = []
|
||||
let expired = 0
|
||||
|
||||
for (const name of names) {
|
||||
const path = `${CACHE_DIR}/${name}`
|
||||
const st = await statFile(path)
|
||||
if (!st) continue
|
||||
if (now - st.mtime > TTL_MS) {
|
||||
await unlinkQuiet(path)
|
||||
expired++
|
||||
continue
|
||||
}
|
||||
alive.push({ path, size: st.size, mtime: st.mtime })
|
||||
}
|
||||
|
||||
alive.sort((a, b) => a.mtime - b.mtime)
|
||||
let bytes = 0
|
||||
for (const it of alive) bytes += it.size
|
||||
let removed = 0
|
||||
let i = 0
|
||||
while (i < alive.length && (bytes > MAX_BYTES || alive.length - i > MAX_FILES)) {
|
||||
await unlinkQuiet(alive[i].path)
|
||||
bytes -= alive[i].size
|
||||
removed++
|
||||
i++
|
||||
}
|
||||
|
||||
logger.debug(LOG, 'prune done, total=', names.length, 'expired=', expired, 'overflow=', removed)
|
||||
} catch (e) {
|
||||
// 目录尚未创建等情况直接跳过,不重试不报错
|
||||
logger.debug(LOG, 'prune skipped')
|
||||
}
|
||||
}
|
||||
|
||||
function notePrune(): void {
|
||||
// 首次解析后延迟清扫一次
|
||||
if (!firstPruneDone && !pruneScheduled) {
|
||||
pruneScheduled = true
|
||||
setTimeout(runPrune, PRUNE_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function noteSaved(): void {
|
||||
savesSincePrune++
|
||||
// 运行期累计落盘后再触发一次
|
||||
if (firstPruneDone && !pruneScheduled && savesSincePrune >= PRUNE_EVERY_SAVES) {
|
||||
pruneScheduled = true
|
||||
setTimeout(runPrune, PRUNE_DELAY_MS)
|
||||
}
|
||||
}
|
||||
|
||||
function runPrune(): void {
|
||||
pruneCache().then(() => {
|
||||
firstPruneDone = true
|
||||
savesSincePrune = 0
|
||||
pruneScheduled = false
|
||||
})
|
||||
}
|
||||
|
||||
// ===== 对外接口 =====
|
||||
|
||||
function memSet(key: string, localPath: string): void {
|
||||
if (memCache.size >= MAX_MEM_ENTRIES) memCache.clear()
|
||||
memCache.set(key, localPath)
|
||||
}
|
||||
|
||||
/** 内存命中快速通道:命中返回本地路径,否则 null(供组件首帧零延迟直出) */
|
||||
export function getCachedLocalPath(src: string): string | null {
|
||||
if (!src || !isRemote(src) || disabled) return null
|
||||
return memCache.get(stableKey(src)) || null
|
||||
}
|
||||
|
||||
async function loadOrDownload(key: string, src: string): Promise<string> {
|
||||
const localPath = localPathOf(key, src)
|
||||
const id = hashKey(key)
|
||||
|
||||
if (await accessFile(localPath)) {
|
||||
logger.debug(LOG, 'hit disk', id)
|
||||
return localPath
|
||||
}
|
||||
|
||||
await ensureDir()
|
||||
await acquireSlot()
|
||||
try {
|
||||
await download(src, localPath)
|
||||
noteSaved()
|
||||
logger.debug(LOG, 'saved', id)
|
||||
return localPath
|
||||
} catch (e) {
|
||||
// 清理可能残留的半截文件,交由组件回退网络地址
|
||||
await unlinkQuiet(localPath)
|
||||
const msg = e instanceof Error ? e.message : String(e)
|
||||
if (!disabled && /not in domain list/i.test(msg)) {
|
||||
disabled = true
|
||||
logger.warn(LOG, 'downloadFile 域名未配置,停用图片缓存并回退网络图(请在小程序后台配置 downloadFile 合法域名)')
|
||||
} else {
|
||||
logger.debug(LOG, 'download fail, fallback network, id=', id, 'reason=', msg)
|
||||
}
|
||||
throw e
|
||||
} finally {
|
||||
releaseSlot()
|
||||
}
|
||||
}
|
||||
|
||||
/** 解析图片:磁盘命中直接返回本地路径;未命中入队下载落盘后返回;失败 reject(调用方回退网络地址) */
|
||||
export function resolveImage(src: string): Promise<string> {
|
||||
if (!src || !isRemote(src)) return Promise.resolve(src)
|
||||
if (disabled) return Promise.reject(new Error('image cache disabled'))
|
||||
|
||||
const key = stableKey(src)
|
||||
const hit = memCache.get(key)
|
||||
if (hit) {
|
||||
logger.debug(LOG, 'hit mem', hashKey(key))
|
||||
return Promise.resolve(hit)
|
||||
}
|
||||
|
||||
const pending = inflight.get(key)
|
||||
if (pending) return pending
|
||||
|
||||
notePrune()
|
||||
|
||||
const task = loadOrDownload(key, src).then(
|
||||
(localPath) => {
|
||||
memSet(key, localPath)
|
||||
inflight.delete(key)
|
||||
return localPath
|
||||
},
|
||||
(err) => {
|
||||
inflight.delete(key)
|
||||
throw err
|
||||
}
|
||||
)
|
||||
inflight.set(key, task)
|
||||
return task
|
||||
}
|
||||
|
||||
/** 删除某个网络图对应的本地缓存(组件自愈用:本地文件损坏时清理后回退网络地址) */
|
||||
export function dropImageCache(src: string): void {
|
||||
if (!src || !isRemote(src)) return
|
||||
const key = stableKey(src)
|
||||
const localPath = memCache.get(key) || localPathOf(key, src)
|
||||
memCache.delete(key)
|
||||
unlinkQuiet(localPath).then(() => undefined)
|
||||
}
|
||||
|
||||
/** 清空全部图片缓存(预留维护入口) */
|
||||
export function clearImageCache(): Promise<void> {
|
||||
memCache.clear()
|
||||
return readdir()
|
||||
.then((names) => Promise.all(names.map((name) => unlinkQuiet(`${CACHE_DIR}/${name}`))))
|
||||
.then(() => {
|
||||
logger.debug(LOG, 'clear done')
|
||||
})
|
||||
.catch(() => undefined)
|
||||
}
|
||||
Reference in New Issue
Block a user