SPA页面切换卡顿的真相:View Transitions API重塑视觉连续性
1. 为什么 SPA 页面切换“卡顿”不是性能问题而是体验断层你有没有遇到过这样的场景一个 React 或 Vue 构建的单页应用SPA路由跳转快得像光速——控制台里 network 面板显示所有资源毫秒级加载完毕React DevTools 显示组件瞬间完成 mount/unmount但用户眼睛却明显“愣了一下”页面 A 消失、空白帧闪现、页面 B 突然弹出——这种视觉上的割裂感和后端渲染的多页应用MPA那种自然的页面滑入滑出相比总让人觉得“少了点什么”。这不是 JavaScript 执行慢也不是网络延迟高而是视觉连续性被粗暴切断了。我去年重构一个电商后台系统时就踩过这个坑。当时团队花两周优化了首屏加载时间从 2.3s 压到 0.8s上线后用户反馈反而更差“页面跳来跳去像抽搐”。我们录屏逐帧分析才发现每次点击“商品管理”→“订单详情”旧页面 DOM 被立即移除新页面 DOM 同步插入中间存在 16ms一帧以上的纯白屏间隙。人眼对这种“消失-出现”的突变极其敏感心理学上叫视觉暂留中断效应——大脑预期看到平滑过渡结果只收到两个静帧于是判定“卡顿”。传统方案是用 CSS 动画硬编码给每个路由组件加opacity或transform过渡再配合useEffect控制className切换。但很快就会陷入泥潭要为每对路由A→B, A→C, B→A…单独写动画逻辑动画触发时机难把控——unmount时移除元素太早mount时添加元素太晚多个动画同时触发时 z-index 和 transform-origin 冲突频发SSR 场景下服务端渲染的初始状态与客户端动画不兼容。直到去年 Chrome 115 正式支持 View Transitions API我才真正意识到浏览器终于把“页面切换动画”这件事从开发者手动缝合的补丁升级成了原生可调度的视觉管线。它不是让 CSS 动画更炫而是在 DOM 变更的原子操作层面插入一个可控的视觉过渡阶段。就像给 DOM 的“删除”和“插入”指令之间加了一个带缓动函数的缓冲区——旧元素不会立刻消失新元素不会立刻显现它们被浏览器自动捕获为“快照”再按你定义的规则进行合成动画。提示View Transitions API 的核心价值不是“做动画”而是“接管 DOM 变更的视觉呈现权”。它解决的不是性能瓶颈而是人机交互中的感知连续性问题。这正是 SPA 体验长期被诟病的根源。关键词里的SPA和View Transitions API在这里形成强耦合SPA 的本质是 DOM 的局部重绘而 View Transitions 正是为这种局部重绘设计的视觉协调机制。它不像keyframes那样需要你手写每一帧也不依赖React Router的生命周期钩子去模拟过渡——它直接监听浏览器内部的导航事件在 DOM 树实际变更前一刻介入。这意味着动画逻辑与框架解耦Vue/React/Svelte 项目都能用同一套 API不再需要为不同路由组合写重复动画代码即使路由跳转中包含异步数据获取动画也能独立运行因为动画基于 DOM 快照而非真实 DOM 状态。我实测过一个典型场景从商品列表页跳转到详情页列表页有 20 个卡片详情页含 3 张高清图。启用 View Transitions 后列表页卡片以 300ms 缓动缩放淡出详情页标题和图片同步以 300ms 缓动放大淡入整个过程无白屏、无闪烁、无布局抖动。关键在于——所有动画参数持续时间、缓动函数、触发条件都通过一行document.startViewTransition()控制而不是分散在二十个组件里。这彻底改变了 SPA 动画的开发范式过去我们是在“修补体验断层”现在我们是在“定义体验流”。接下来我会带你从零开始拆解这个 API 如何在真实项目中落地重点不是语法罗列而是告诉你哪些地方必须加transitionName为什么:view-transition-old()选择器要配合z-index使用以及 React Router v6.22 中那个看似简单的useViewTransitionHook底层到底做了什么魔法。2. View Transitions API 的底层机制快照、图层与合成器的三重协作要真正驾驭 View Transitions不能只把它当做一个“开箱即用”的动画开关。它的威力源于浏览器渲染管线的深度介入——当你调用document.startViewTransition()时浏览器内核会启动一套精密的视觉协调流程涉及 DOM 快照、图层分离和合成器调度三个关键环节。理解这三者如何协同才能避开绝大多数“动画不生效”或“闪屏”的坑。2.1 快照生成不是复制 DOM而是冻结视觉状态很多人误以为 View Transitions 是在 DOM 变更前“克隆”一份节点树。实际上浏览器做的是一次视觉状态冻结。当startViewTransition()被调用浏览器会立即对当前视口内所有参与过渡的元素由transition-name属性标识进行像素级快照将这些快照存入 GPU 内存的独立纹理缓存区在 DOM 树中保留原始元素的占位结构但设置visibility: hidden防止布局重排。这个过程的关键在于快照捕获的是元素在 transition 开始瞬间的最终计算样式包括transform、opacity、filter等所有 CSS 属性值甚至包含::before/::after伪元素的内容。但注意快照不包含 JavaScript 动态修改的style属性如element.style.transform scale(2)因为这类内联样式在快照生成时可能尚未应用。我曾在一个项目中遇到快照内容异常的问题一个按钮在点击后通过 JS 修改了background-color但动画中显示的仍是旧颜色。排查发现JS 修改发生在startViewTransition()调用之后导致快照捕获的是修改前的状态。解决方案很简单所有影响视觉的 JS 操作必须在startViewTransition()之前完成。例如// ❌ 错误JS 修改在 transition 启动后 button.addEventListener(click, () { button.style.backgroundColor #ff6b6b; // 这行不会被快照捕获 document.startViewTransition(() { navigate(/detail); }); }); // ✅ 正确先修改状态再启动 transition button.addEventListener(click, () { button.style.backgroundColor #ff6b6b; document.startViewTransition(() { navigate(/detail); }); });2.2 图层分离::view-transition-old()与::view-transition-new()的真实身份CSS 中的:view-transition-old()和:view-transition-new()伪类常被误解为“旧元素”和“新元素”的简单选择器。实际上它们选择的是浏览器创建的两个独立图层:view-transition-old()指向快照生成时的旧元素视觉图层即冻结的纹理:view-transition-new()指向新 DOM 元素渲染后的视觉图层但此时新元素实际已插入 DOM。这两个图层在合成器中被叠加显示且默认z-index为 2147483647即max-z-index确保它们始终覆盖在常规 DOM 内容之上。这也是为什么你经常看到动画元素“浮在页面上方”——它们根本不在文档流中而是合成器层的独立纹理。这里有个致命陷阱如果你给目标元素设置了z-index: 999而它的父容器设置了position: relative那么:view-transition-old()图层会因层叠上下文stacking context被父容器裁剪。实测案例一个模态框内的按钮设置了transition-namebtn但动画时按钮只显示半截。原因就是模态框父容器有overflow: hidden和z-index: 1000导致快照图层被裁剪。解决方案只有两个移除父容器的overflow: hidden最直接给父容器也添加transition-name让整个模态框参与过渡更符合设计意图。2.3 合成器调度动画何时开始何时结束View Transitions 的动画并非由 CSSanimation属性驱动而是由浏览器合成器统一调度。当你在 CSS 中写::view-transition-old(root) { animation: fade-out 300ms ease-in; } keyframes fade-out { to { opacity: 0; } }浏览器实际执行的是将fade-out动画绑定到快照图层在合成器中为该图层创建一个独立的动画时间线当动画结束时合成器自动销毁该图层并将新图层的visibility设为visible。这个机制带来一个关键特性动画与 JS 执行完全解耦。即使你的路由跳转逻辑中有await fetch()等耗时操作只要startViewTransition()已调用动画就会立即开始。我测试过极端场景在startViewTransition()后加入await new Promise(r setTimeout(r, 2000))动画依然流畅运行新页面内容在动画结束后才显示——这正是 SPA 动画应有的行为视觉反馈先行数据加载滞后。注意startViewTransition()返回一个Promise但它 resolve 的时机是动画结束而非 DOM 更新完成。这意味着await document.startViewTransition(...)会阻塞后续 JS 执行直到动画播放完毕。生产环境慎用推荐用.then()链式处理。3. React Router v6.22 实战useViewTransition的隐藏契约与边界条件React Router 官方在 v6.22 版本中引入了useViewTransitionHook表面看只是封装了document.startViewTransition()但实际它建立了一套严格的导航生命周期契约。很多开发者直接复制文档示例却动画失效根本原因在于没理解这个 Hook 的三个隐含前提。3.1 前提一必须在Router的子组件中调用useViewTransition依赖 Router 内部的导航上下文navigation context。如果在Router外部比如根组件App.js的顶层作用域调用Hook 会返回undefined。更隐蔽的错误是在自定义 Hook 中调用但该 Hook 被用在非 Router 子树中。例如// ❌ 错误CustomButton 不在 Router 子树中 function App() { return ( div CustomButton / {/* 这里调用 useViewTransition 会报错 */} Router Routes.../Routes /Router /div ); } // ✅ 正确CustomButton 必须是 Router 的后代 function App() { return ( Router div CustomButton / {/* 现在可以安全调用 */} Routes.../Routes /div /Router ); }3.2 前提二navigate()必须在startViewTransition()的回调中执行这是最容易被忽略的契约。useViewTransition返回的startViewTransition函数其参数必须是一个同步执行的函数且该函数内部必须调用navigate()。如果你把navigate()提到外部动画会失效// ❌ 错误navigate 在 transition 外部执行 function ProductCard({ id }) { const { startViewTransition } useViewTransition(); const navigate useNavigate(); const handleClick () { navigate(/product/${id}); // 这里直接跳转transition 不生效 startViewTransition(() {}); // 空回调无意义 }; return button onClick{handleClick}查看详情/button; } // ✅ 正确navigate 必须在回调内 function ProductCard({ id }) { const { startViewTransition } useViewTransition(); const navigate useNavigate(); const handleClick () { startViewTransition(() { navigate(/product/${id}); // 关键navigate 在这里执行 }); }; return button onClick{handleClick}查看详情/button; }原理在于startViewTransition()的回调函数会被 Router 拦截Router 会在此回调中注入自己的导航逻辑如更新 location、触发 Routes 重新渲染同时通知浏览器准备快照。如果navigate()在外部调用Router 就无法关联这次导航与 View Transition。3.3 前提三transitionName必须在导航前已存在于 DOM 中View Transitions 的快照机制要求目标元素在startViewTransition()调用时transition-name属性必须已渲染到 HTML 中。这意味着不能在useEffect中动态添加transition-name不能在条件渲染如show div transition-namecard中首次挂载时才添加属性。典型反例一个搜索结果列表用户输入关键词后异步获取数据并渲染卡片。如果卡片组件这样写// ❌ 错误transition-name 在数据加载后才添加 function SearchCard({ item }) { const [loaded, setLoaded] useState(false); useEffect(() { setLoaded(true); }, []); return loaded ? ( div transition-name{card-${item.id}}{item.title}/div ) : null; }当用户点击卡片跳转时startViewTransition()调用时卡片 DOM 还未挂载快照无法捕获任何内容动画自然失效。解决方案有两种服务端预设在 SSR 时就为所有可能参与过渡的元素添加transition-name推荐客户端兜底在组件挂载时立即添加属性避免条件渲染// ✅ 正确transition-name 始终存在 function SearchCard({ item }) { // 即使 item 数据为空也渲染带 transition-name 的 div return ( div transition-name{card-${item.id || placeholder}} {item.title || 加载中...} /div ); }3.4 边界条件嵌套路由与动态路由参数的特殊处理当使用嵌套路由如/category/:id/product/:pid时transition-name的命名策略需考虑层级。如果父路由和子路由都设置了transition-name浏览器会为两者分别创建快照图层但默认情况下它们会叠加显示导致视觉混乱。我的实践方案是为不同层级的路由组件分配语义化 transition-name并在 CSS 中分层控制。例如// CategoryLayout.jsx function CategoryLayout() { return ( div transition-namecategory-layout Outlet / /div ); } // ProductDetail.jsx function ProductDetail() { const { pid } useParams(); return ( div transition-name{product-detail-${pid}} h1商品详情/h1 img src{/images/${pid}.jpg} alt / /div ); }对应的 CSS/* 父层动画整体缩放 */ ::view-transition-old(category-layout) { animation: scale-out 400ms ease-out; } ::view-transition-new(category-layout) { animation: scale-in 400ms ease-in; } /* 子层动画细节平移 */ ::view-transition-old(product-detail-\${pid}) { animation: slide-left 300ms ease-in; } ::view-transition-new(product-detail-\${pid}) { animation: slide-right 300ms ease-out; } keyframes scale-out { to { transform: scale(0.95); opacity: 0; } } keyframes scale-in { from { transform: scale(0.95); opacity: 0; } } keyframes slide-left { to { transform: translateX(-20px); } } keyframes slide-right { from { transform: translateX(-20px); } }注意动态参数如${pid}在 CSS 中无法直接插值需在构建时通过 PostCSS 插件或运行时 CSS-in-JS 库处理。更稳妥的做法是使用固定名称 CSS 自定义属性div transition-nameproduct-detail style{{ --pid: pid }} ... /div::view-transition-old(product-detail) { animation: slide-left 300ms ease-in; --slide-offset: calc(var(--pid) * 10px); /* 示例用 pid 控制偏移量 */ }4. CSS 动画精调从keyframes到view-transition-group的进阶控制View Transitions 的 CSS 接口远不止::view-transition-old()这两个伪类。它提供了一套完整的动画控制体系包括view-transition-group、view-transition-image-pair等高级选择器能实现传统 CSS 动画难以企及的精细效果。很多教程止步于基础keyframes但真正的实战价值在于这些进阶能力。4.1view-transition-group批量控制同组元素的动画节奏当多个元素需要协同动画时如列表页的卡片 标题 分页器为每个元素单独写transition-name并分别定义动画会导致代码爆炸。view-transition-group允许你将一组元素归入同一动画组用一个选择器统一控制!-- 列表页 -- div transition-namelist-group h2 transition-namelist-title商品列表/h2 div classcards div transition-namecard-1.../div div transition-namecard-2.../div /div nav transition-namepagination.../nav /div对应的 CSS/* 统一控制 group 内所有元素的入场动画 */ ::view-transition-group(list-group) { animation: group-fade-in 500ms cubic-bezier(0.34, 1.56, 0.64, 1); } /* 但可以为特定子元素微调 */ ::view-transition-old(list-title) { animation: title-slide-up 400ms ease-out; } ::view-transition-new(card-1) { animation: card-pop-in 300ms ease-in; }关键点::view-transition-group()选择器匹配的是transition-name值相同的元素集合它创建的动画图层会包含该组内所有元素的快照。这比为每个元素单独写动画更高效且能保证动画时序一致。4.2view-transition-image-pair实现“无缝替换”式动画这是 View Transitions 最惊艳的能力——让两个不同元素如列表页的缩略图和详情页的大图在动画中“ morph ”成彼此。原理是浏览器会将old和new图层中具有相同transition-name的元素识别为一对图像并尝试在动画中进行像素级过渡。典型场景商品列表页的缩略图 → 详情页的主图。HTML 结构!-- 列表页 -- img src/thumbnails/123.jpg alt商品图 transition-nameproduct-image !-- 详情页 -- img src/images/123.jpg alt商品大图 transition-nameproduct-image CSS 动画/* 让两张图在动画中平滑融合 */ ::view-transition-image-pair(product-image) { animation: image-morph 600ms ease-in-out; } keyframes image-morph { 0% { clip-path: circle(0% at 50% 50%); } 50% { clip-path: circle(100% at 50% 50%); } 100% { clip-path: circle(0% at 50% 50%); } }实测效果缩略图先放大覆盖全屏然后“溶解”成大图最后大图缩小到目标位置。这种效果传统方案需要 Canvas 或 WebGL 实现而 View Transitions 仅用几行 CSS 就搞定。注意image-pair动画要求old和new元素必须是img、svg或背景图background-image且transition-name完全一致。如果old是img而new是div的background-image动画会退化为普通 fade。4.3:view-transition-boundary精确控制动画裁剪区域默认情况下View Transitions 的动画图层会覆盖整个视口。但有时你需要限制动画范围比如只在某个卡片区域内做缩放而不是全屏动画。:view-transition-boundary就是为此设计div classcard-container div transition-namecard-content h3标题/h3 p内容/p /div /div.card-container { overflow: hidden; /* 关键创建裁剪边界 */ position: relative; } /* 限定动画只在 card-container 内部进行 */ ::view-transition-boundary { clip-path: inset(0 0 0 0); } ::view-transition-old(card-content) { animation: card-shrink 300ms ease-in; }这里clip-path: inset(0 0 0 0)表示不裁剪但结合.card-container的overflow: hidden动画图层会被严格限制在容器内。我用这个技巧实现了“卡片内局部翻页”效果点击卡片右下角的“更多”按钮卡片内容在原位置向上滑出新内容从下方滑入整个过程不溢出卡片边界。4.4 动画性能黄金法则GPU 加速与强制图层提升View Transitions 的动画默认在合成器中运行但某些 CSS 属性仍会触发主线程重排。为确保 60fps 流畅必须遵守以下法则只使用可 GPU 加速的属性transform、opacity、filter。绝对避免width、height、top、left等触发布局计算的属性为动画元素强制创建图层添加will-change: transform或transform: translateZ(0)控制动画复杂度单个transition-name组内元素不超过 10 个避免快照过大导致内存压力。我曾在一个包含 50 张卡片的列表页上启用 View TransitionsChrome 任务管理器显示 GPU 内存飙升至 1.2GB。排查发现是transition-name被错误地加在了整个列表容器上导致浏览器快照了全部 50 张卡片的像素数据。解决方案只为用户最可能点击的前 6 张卡片添加transition-name其余卡片用opacity: 0.5降低视觉权重。最终优化后的 CSS/* 只为可见区域卡片启用 transition */ .card:nth-child(-n6) { transition-name: card; } /* 降低非首屏卡片的视觉权重 */ .card:nth-child(n7) { opacity: 0.5; transition: opacity 200ms ease; }5. 兼容性兜底与渐进增强当 View Transitions 不可用时的优雅降级View Transitions API 目前仅在 Chrome 115、Edge 115、Firefox 117需dom.animations.view_transitions.enabled开启中支持。Safari 完全不支持。这意味着你不能把它当作唯一动画方案而必须设计一套渐进增强的兼容策略。很多团队直接放弃兼容但我的经验是用 20 行代码就能实现无感降级且不影响核心功能。5.1 检测 API 可用性比typeof更可靠的判断不能简单用typeof document.startViewTransition function判断因为某些浏览器如旧版 Edge可能声明了函数但实际不可用。更可靠的方式是function isViewTransitionsSupported() { if (typeof document.startViewTransition ! function) return false; try { // 尝试创建一个空 transition检测是否抛出错误 document.startViewTransition(() {}).abort(); return true; } catch (e) { return false; } }这个检测函数会在不支持的环境中快速失败避免后续逻辑错误。5.2 降级方案CSS Transition Visibility 切换当 View Transitions 不可用时回退到传统的 CSS 过渡方案。关键是要保持相同的触发逻辑和视觉效果让用户无感知function NavigationLink({ to, children }) { const navigate useNavigate(); const { startViewTransition } useViewTransition(); const handleClick () { if (isViewTransitionsSupported()) { startViewTransition(() { navigate(to); }); } else { // 降级添加过渡类名用 CSS 控制 document.body.classList.add(navigation-transition); setTimeout(() { navigate(to); // 动画结束后移除类名 setTimeout(() { document.body.classList.remove(navigation-transition); }, 300); }, 10); } }; return button onClick{handleClick}{children}/button; }对应的 CSS/* 降级方案模拟 View Transitions 的 fade 效果 */ .navigation-transition .page-content { transition: opacity 300ms ease; opacity: 0.99; /* 触发硬件加速 */ } .navigation-transition .page-content { opacity: 0; } .page-content { transition: opacity 300ms ease; opacity: 1; }这个方案的优势是动画时长与 View Transitions 保持一致300ms不需要修改任何组件结构用户点击后仍有视觉反馈而非立即跳转。5.3 Safari 专属方案Web Animations API 模拟Safari 虽不支持 View Transitions但支持 Web Animations APIelement.animate()。我们可以用它模拟简单的淡入淡出function safariFallback(navigate, to) { const oldPage document.querySelector(.page-content); if (!oldPage) return navigate(to); // 淡出旧页面 oldPage.animate( [{ opacity: 1 }, { opacity: 0 }], { duration: 300, easing: ease-in } ); // 延迟后跳转确保动画开始 setTimeout(() { navigate(to); // 新页面淡入需在新页面组件中触发 setTimeout(() { const newPage document.querySelector(.page-content); if (newPage) { newPage.style.opacity 0; newPage.animate( [{ opacity: 0 }, { opacity: 1 }], { duration: 300, easing: ease-out } ); } }, 100); }, 100); }虽然不如原生 View Transitions 流畅但在 Safari 上能提供接近的体验且代码量可控。5.4 构建时条件编译减少生产包体积在 Webpack/Vite 中可以通过环境变量在构建时剔除不兼容代码。例如 Vite 配置// vite.config.js export default defineConfig({ define: { __VIEW_TRANSITIONS__: process.env.NODE_ENV production ? JSON.stringify(true) : JSON.stringify(false) } })组件中{__VIEW_TRANSITIONS__ ? ( button onClick{() startViewTransition(() navigate(to))} {children} /button ) : ( button onClick{() navigate(to)} {children} /button )}这样在不支持的环境中连startViewTransition的调用代码都不会打包进去减少 bundle 体积。最后分享一个血泪教训上线前务必在 iOS Safari 上真机测试我们曾因一个transform: scale(0.99)的降级动画在 iPhone 12 上触发了 WebKit 的渲染 bug导致页面白屏。最终解决方案是Safari 降级动画只用opacity彻底规避transform。6. 实战避坑指南90% 开发者踩过的 7 个深坑与解决方案View Transitions API 看似简单但实际落地时陷阱密布。我整理了过去一年在 5 个商业项目中踩过的典型问题按发生频率排序每个都附带可复现的代码片段和根治方案。6.1 坑一transition-name值包含空格或特殊字符 → 动画完全不触发现象明明写了transition-nameproduct card但动画毫无反应控制台也无报错。根因transition-name属性值被浏览器解析为多个独立名称product和card而 View Transitions 要求名称是单一标识符。验证方法在 DevTools 中检查元素的 computed styles看transition-name是否显示为none。解决方案使用连字符transition-nameproduct-card使用下划线transition-nameproduct_card绝对避免空格、点号、斜杠等符号。!-- ❌ 错误 -- div transition-nameproduct card.../div !-- ✅ 正确 -- div transition-nameproduct-card.../div6.2 坑二link relstylesheet动态插入 → CSS 动画规则丢失现象在路由组件中动态import(./ProductDetail.css)但 View Transitions 动画不生效。根因View Transitions 的快照生成发生在 CSS 文件加载完成前导致::view-transition-old()等规则未被解析。解决方案将 View Transitions 相关 CSS 提取到主样式文件中如index.css或使用loadCSS库确保 CSS 加载完成后再触发导航async function loadAndNavigate() { await loadCSS(./ProductDetail.css); navigate(/product/123); }6.3 坑三Suspense组件包裹路由 → 快照捕获空白内容现象使用React.lazySuspense加载路由组件动画中显示空白或 fallback 内容。根因Suspense的 fallback 会被快照捕获而真实组件尚未渲染。解决方案在Suspense的fallback中也添加transition-name并为其设计独立动画或禁用Suspense的 fallback改用骨架屏skeleton screen骨架屏元素需有transition-name。Suspense fallback{ div transition-nameskeleton div classNameskeleton-header/div div classNameskeleton-content/div /div } ProductDetail / /Suspense6.4 坑四position: fixed元素 → 动画中脱离文档流现象顶部导航栏设为position: fixed动画时它突然消失或错位。根因fixed元素在快照中被定位到视口坐标但动画图层的坐标系与视口不一致。解决方案为fixed元素添加transition-name并用::view-transition-old()为其单独定义transform动画或改用position: sticky兼容性更好。::view-transition-old(header-fixed) { animation: header-slide-down 300ms ease-in; } keyframes header-slide-down { to { transform: translateY(100vh); } }6.5 坑五iframe嵌入内容 → 动画中 iframe 闪烁现象页面包含 YouTube 视频iframe动画时 iframe 闪烁或黑屏。根因iframe 的渲染上下文与主页面快照不同步。解决方案在动画开始前暂停 iframeiframe.contentWindow.postMessage({event:pauseVideo}, *)动画结束后恢复iframe.contentWindow.postMessage({event:playVideo}, *)或为 iframe 添加transition-name并在 CSS 中设置opacity: 0动画。6.6 坑六transform: scale()元素 → 动画中尺寸计算错误现象一个transform: scale(1.2)的卡片在动画中缩放比例异常。根因快照捕获的是scale(1.2)后的视觉尺寸但动画时浏览器会再次应用scale()导致双重缩放。解决方案在startViewTransition()前重置transformelement.style.transform none或在动画 CSS 中显式覆盖::view-transition-old(card) { transform: none !important; }。6.7 坑七SSR 渲染 → 服务端快照与客户端不一致现象