如何保证不同位置切换合成底图的渲染顺序

下面按"真正有效的修改"一一说明。核心思想是:不要让旧图、旧 canvas、旧异步结果有任何机会被展示;只有当前目标面的最终渲染结果通过校验后,才把画面放出来。

  1. 用目标 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,所以不能放行展示。

  1. 切换前立刻进入 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:防止旧尺寸回调重新触发旧布局。

这个是防跳动的第一道闸门:用户一点击切换,旧画面马上退出,不等新图加载。

  1. 切换位置时,先 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;

}

这点很重要:如果先改成"背面",再清旧图,中间会有一帧机会显示"背面状态 + 正面图"。现在顺序改成:先隐藏并清空旧渲染,再切状态。

  1. 图片必须确认是目标 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"的错误。

  1. 未加载完成时不再用 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 时,用默认尺寸或者旧尺寸先算一版布局,于是画面就跳。

  1. 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;

也就是:这个异步结果如果不是当前目标资源,直接丢掉,不能写入状态。

  1. 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 资源全部一致后才显示。

  1. 展示层只认 ready,不 ready 就只显示加载圈

模板里在 template-spec.vue (line 2448):

vue
...

CSS:

css

.template-spec-effect-wrap--hidden {

visibility: hidden;

}

这层保证用户视觉上只有两种状态:

未完成:加载圈

完成:最终正确图

不会出现第三种状态:旧图、半成品图、缩放中的过程图。

  1. 其它相同问题也做了同类处理

除了主弹窗 template-spec.vue (line 697),还处理了类似预览链路:

template-select.vue (line 223):同样增加 previewReady / previewReadyKey,切换模板、位置、规格时隐藏旧图,等目标图 ready 后再显示。

apps/web-business/.../template-spec.vue (line 40):业务端简单效果图预览也增加了 effectImageReady 和加载圈,避免旧图过渡露出。

  1. 怎么确认这次是对的

我不是只靠肉眼看了一下。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 都通过。

相关推荐
欢璃4 小时前
笔试强训练习
java·开发语言·jvm·数据结构·算法·贪心算法·动态规划
海上彼尚4 小时前
Nodejs也能写Agent - 3.基础篇 - Tools 与 Tool Calling
前端·人工智能·后端·node.js
用户125758524364 小时前
GoFrame + Vue3 后台管理框架,CRUD 代码生成器一键搭 RBAC 权限系统
前端
七十二時_阿川4 小时前
Electron 如何自定义菜单?这篇帮你实现原生体验!
前端·electron
Dicky-_-zhang4 小时前
Go语言内存管理与GC机制深度解析
java·jvm
bot5556664 小时前
企业微信ipad协议的消息引用与回复机制
javascript
七十二時_阿川4 小时前
Electron App 速查表:生命周期事件、方法、平台差异
前端·electron
七十二時_阿川4 小时前
Electron 多显示器开发?这篇帮你搞定屏幕坐标与窗口定位!
前端·electron
七十二時_阿川4 小时前
Electron Tray API 详解:托盘图标、右键菜单、气泡通知
前端·electron