下面按"真正有效的修改"一一说明。核心思想是:不要让旧图、旧 canvas、旧异步结果有任何机会被展示;只有当前目标面的最终渲染结果通过校验后,才把画面放出来。
- 用目标 Key 判断当前画面是不是"这一面"的
在 template-spec.vue (line 697) 里,我把一次预览渲染定义成一个完整目标:
ts
function getEffectPreviewTargetKey() {
return [
getExpectedEffectImageSrc(),
activeParentKey.value,
activeChildIndex.value,
selectedSpecCode.value,
].join('|');
}
const isEffectPreviewVisible = computed(
() =>
effectPreviewReady.value &&
effectPreviewReadyKey.value === getEffectPreviewTargetKey(),
);
也就是说,画面要展示,必须同时满足:
图片 URL 是当前目标面的 URL
当前父位置是目标位置,比如 背面
当前子区域是目标区域,比如 常规
当前规格是目标规格,比如 Newborn
渲染状态已经 ready
以前的问题是:切到背面时,状态已经是背面,但旧的正面 canvas 还可能在画面里短暂存在。现在即使某个旧异步回调回来,也不会匹配新的 target key,所以不能放行展示。
- 切换前立刻进入 pending,并清空旧画布
在 template-spec.vue (line 736):
ts
function markEffectPreviewPending() {
effectPreviewRenderVersion.value += 1;
effectPreviewReady.value = false;
effectPreviewReadyKey.value = '';
effectDisplayRect.value = { height: 0, width: 0 };
effectBaseSize.value = { height: 0, width: 0 };
clearRecolorCanvas();
if (effectRecolorCanvasRef.value) {
effectRecolorCanvasRef.value.width = 0;
effectRecolorCanvasRef.value.height = 0;
effectRecolorCanvasRef.value.style.width = '0px';
effectRecolorCanvasRef.value.style.height = '0px';
}
if (effectStage.value) {
effectStage.value.destroy();
effectStage.value = null;
}
effectLayer.value = null;
if (effectContainer.value) {
effectContainer.value.innerHTML = '';
}
if (effectResizeObserver.value) {
effectResizeObserver.value.disconnect();
effectResizeObserver.value = null;
}
}
这里做了几件关键事:
effectPreviewRenderVersion += 1:让旧异步渲染失效。
effectPreviewReady = false:立刻隐藏旧画面。
清空 recolor canvas:避免旧正面图继续留在 canvas。
把 canvas 尺寸设成 0:避免旧 canvas 还占位可见。
销毁 Konva stage:蓝色虚线框、设计区域也不会残留。
清空 effectContainer.innerHTML:旧覆盖层完全移除。
断开 ResizeObserver:防止旧尺寸回调重新触发旧布局。
这个是防跳动的第一道闸门:用户一点击切换,旧画面马上退出,不等新图加载。
- 切换位置时,先 pending,再改状态
在 template-spec.vue (line 777):
ts
function handleParentPositionChange(key: unknown) {
const nextKey = Array.isArray(key) ? String(key[0] || '') : String(key || '');
if (nextKey !== activeParentKey.value) {
prepareEffectPreviewTransition();
}
activeParentKey.value = nextKey;
}
子区域切换也是一样:
ts
function selectPreviewChild(parentKey: string, childIndex: number) {
if (
parentKey !== activeParentKey.value ||
childIndex !== activeChildIndex.value
) {
prepareEffectPreviewTransition();
}
activeParentKey.value = parentKey;
activeChildIndex.value = childIndex;
}
这点很重要:如果先改成"背面",再清旧图,中间会有一帧机会显示"背面状态 + 正面图"。现在顺序改成:先隐藏并清空旧渲染,再切状态。
- 图片必须确认是目标 URL,不能只看 complete
在 template-preview-layout.ts (line 128):
ts
export function isPreviewImageElementReady(options: {
complete?: boolean;
currentSrc?: string;
expectedSrc?: string;
naturalHeight?: number;
naturalWidth?: number;
src?: string;
}): boolean {
const expectedSrc = String(options.expectedSrc || '').trim();
if (!expectedSrc) return false;
const renderedSrc = String(options.currentSrc || options.src || '').trim();
if (!renderedSrc || renderedSrc !== expectedSrc) return false;
return (
Boolean(options.complete) &&
Number(options.naturalWidth || 0) > 0 &&
Number(options.naturalHeight || 0) > 0
);
}
以前只看 img.complete 是不够的。因为 可能还是旧的正面 URL,但它也是 complete。现在必须满足:
currentSrc/src === expectedSrc
complete === true
naturalWidth/naturalHeight > 0
这样能防止"正面图已经加载完成,所以被当成背面 ready"的错误。
- 未加载完成时不再用 fallback 尺寸布局
在 template-preview-layout.ts (line 97) 增加了统一布局函数:
ts
export function resolvePreviewImageLayout(options): null | PreviewLayout {
const baseSize = resolvePreviewBaseSize({
cachedSize: options.cachedSize,
configuredSize: options.configuredSize,
fallbackSize: options.fallbackSize,
naturalSize: options.naturalSize,
preferConfiguredSize: options.preferConfiguredSize,
secondaryConfiguredSize: options.secondaryConfiguredSize,
useFallbackSize: options.useFallbackSize,
});
if (!baseSize) return null;
return fitPreviewImageToFrame({
baseSize,
frameSize: options.frameSize || null,
horizontalPadding: options.horizontalPadding,
stretch: options.stretch,
});
}
对应单测里明确保证:未加载的效果图不应该用 fallback 尺寸去布局。这解决的是你最早说的"中间缩着的小图闪一下"。因为旧逻辑会在新图还没 natural size 时,用默认尺寸或者旧尺寸先算一版布局,于是画面就跳。
- recolor 资源加 Key,解决真正的异步竞态
这是这次反复修改后真正抓到的根因之一,在 template-spec.vue (line 1203):
ts
async function ensureRecolorAssets() {
const sources = effectRecolorSources.value;
if (!sources) {
recolorAssets.value = null;
recolorAssetsKey.value = '';
recolorSourceKey.value = '';
return;
}
const key = JSON.stringify(sources);
if (
key === recolorSourceKey.value &&
key === recolorAssetsKey.value &&
recolorAssets.value
) {
return;
}
recolorSourceKey.value = key;
try {
const assets = await loadRecolorAssets(sources);
if (recolorSourceKey.value !== key) return;
recolorAssets.value = assets;
recolorAssetsKey.value = key;
} catch {
if (recolorSourceKey.value !== key) return;
recolorAssets.value = null;
recolorAssetsKey.value = '';
}
}
这里解决的是:
用户在正面时,开始加载正面的 recolor 资源。
用户马上切到背面,又开始加载背面的 recolor 资源。
正面的请求可能比背面的请求晚回来。
如果不校验 key,晚回来的正面资源会覆盖当前背面资源。
然后就会出现你看到的:加载圈后先显示正面,过一会儿背面资源回来才正确。
现在每次异步返回都会检查:
ts
if (recolorSourceKey.value !== key) return;
也就是:这个异步结果如果不是当前目标资源,直接丢掉,不能写入状态。
- recolor canvas 绘制前再做最终校验
在 template-spec.vue (line 1251):
ts
async function drawRecolorWhenReady(version = effectPreviewRenderVersion.value) {
if (!shouldUseRecolor.value) return;
await ensureRecolorAssets();
const expectedAssetsKey = JSON.stringify(effectRecolorSources.value);
let retries = 0;
while (retries < 30) {
if (version !== effectPreviewRenderVersion.value) return;
const canvasReady = !!effectRecolorCanvasRef.value;
const hasSize =
effectDisplayRect.value.width > 0 && effectDisplayRect.value.height > 0;
if (
canvasReady &&
recolorAssets.value &&
recolorAssetsKey.value === expectedAssetsKey &&
hasSize &&
isEffectImageElementReady(effectImgRef.value)
) {
drawRecolorCanvasSafe();
markEffectPreviewReady(version);
return;
}
await new Promise((r) => setTimeout(r, 100));
retries += 1;
}
}
这里是最终保险:
版本必须还是当前版本。
canvas 必须存在。
尺寸必须已经算好。
recolor assets 必须是当前面的 assets。
图片必须是当前面的图片。
全部满足后才 drawRecolorCanvasSafe()。
画完后才 markEffectPreviewReady()。
所以不是"图片 load 了就显示",而是 图片、布局、canvas、recolor 资源全部一致后才显示。
- 展示层只认 ready,不 ready 就只显示加载圈
模板里在 template-spec.vue (line 2448):
vue
...
CSS:
css
.template-spec-effect-wrap--hidden {
visibility: hidden;
}
这层保证用户视觉上只有两种状态:
未完成:加载圈
完成:最终正确图
不会出现第三种状态:旧图、半成品图、缩放中的过程图。
- 其它相同问题也做了同类处理
除了主弹窗 template-spec.vue (line 697),还处理了类似预览链路:
template-select.vue (line 223):同样增加 previewReady / previewReadyKey,切换模板、位置、规格时隐藏旧图,等目标图 ready 后再显示。
apps/web-business/.../template-spec.vue (line 40):业务端简单效果图预览也增加了 effectImageReady 和加载圈,避免旧图过渡露出。
- 怎么确认这次是对的
我不是只靠肉眼看了一下。MCP 里做了 canvas 级别采样:
正面最终 canvas hash:54645ece:624x832
背面最终 canvas hash:93569b72:616x821
连续切换 4 次
每次切换期间都是:spinner=true、wrapVisible=false、canvasVisible=false
第一帧可见时,hash 已经是目标面的 hash
staleCount=0
这说明加载圈之后没有再出现旧面的 canvas。
本地校验也跑了:单测、git diff --check、业务端 build:test 都通过。