EN
Shopify 知识库 · 指南

Horizon 的跨文档 View Transitions:渲染阻塞、放弃条件与 WebView 黑名单

基于 Horizon 4.2.0 源码,拆解多页面主题里的跨文档 View Transitions:按设置输出的 opt-in、等主内容的 render blocking 与 1.8 秒上限、不可能过渡时立即放手、用户交互即跳过、按类型切换动画并跨页传递,以及内嵌 WebView 黑名单与页面内过渡。
历史资料
请结合文中的适用版本和来源阅读。

本文基于 Horizon 4.2.0,源码固定到 5acd1b6(核验于 2026-10-07)。结论限于此提交;本篇为静态源码分析,未在店铺中实测。

Shopify 主题是典型的多页应用(MPA),每次跳转都是一次完整的文档加载。Horizon 用跨文档 View Transitions 实现了两种效果:普通页面切换的过渡,以及从商品卡片到商品详情页的主图"飞入"过渡。

@view-transition { navigation: auto } 一行就能开启,真正难的是在什么情况下不该开、开了之后如何不拖慢页面。涉及:

一、开关放在 Liquid,而不是 CSS 文件

{% if settings.transition_to_main_product or settings.page_transition_enabled %}
  {% style %}
    @media (prefers-reduced-motion: no-preference) {
      @view-transition { navigation: auto; }
    }
  {% endstyle %}
  <link rel="expect" href="#MainContent" blocking="render" id="view-transition-render-blocker">
{% endif %}

base.css 是 theme.liquid 与 password.liquid 两个布局共用的静态文件,读不到主题设置,所以 opt-in 由两个布局各自 render 这个片段。base.css 里的过渡规则只在 opt-in 生效时才起作用,没有开启时它们是惰性的。

控制脚本 view-transitions.js 也挂在同一个设置条件下,并且用 inline_asset_content 内联进 <head>。注释写的是避免为一个阻塞渲染的资源多一次网络往返。

二、blocking="render":等主内容,但设上限

跨文档过渡需要新页面"准备好"才能截取快照。如果浏览器在 <main> 还没解析时就渲染首帧,过渡的就是一个半成品页面。

<link rel="expect" href="#MainContent" blocking="render"> 让浏览器推迟首帧,直到 #MainContent 被解析。代价是 FCP 变慢。Horizon 给阻塞设了上限:

const RENDER_BLOCKER_TIMEOUT_MS = Math.max(0, 1800 - performance.now());
setTimeout(() => viewTransitionRenderBlocker?.remove(), RENDER_BLOCKER_TIMEOUT_MS);

源码注释说目标是让 FCP 低于 1.8 秒(这也是 web.dev 给 FCP"良好"划的线),并且从导航开始计时(performance.now()),不是从脚本执行开始计时。解析慢的页面最多被拖到导航开始后 1.8 秒,之后无论如何都先让用户看到内容。

三、不可能有过渡时,立刻放手

很多情况下根本不会发生过渡,阻塞就是纯粹的浪费。Horizon 会在以下情况立即移除阻塞,此时也不再设定时器:

const activation = window.navigation?.activation;
const noTransitionPossible =
  !!activation && (activation.from === null || activation.navigationType === 'reload');

if (window.matchMedia('(prefers-reduced-motion: reduce)').matches || isLowPowerDevice() || noTransitionPossible) {
  viewTransitionRenderBlocker?.remove();
}
  • activation.from === null:本次访问的第一个页面,没有可以截图的旧文档;
  • navigationType === 'reload':注释说明浏览器的 navigation: auto 本来就排除刷新;
  • 用户偏好减少动效;
  • 低端设备:hardwareConcurrency <= 2 或 deviceMemory <= 2。

前两条用的是 Navigation API 的 navigation.activation;不支持该 API 的浏览器上 activation 为 undefined,这两条判断不起作用,退回到 1.8 秒兜底。首次访问往往是性能指标最受关注的一次加载,恰恰也是最不可能有过渡的一次,这一条判断的影响最大。

四、用户一交互就放弃过渡

window.addEventListener('pageswap', (event) => {
  const { viewTransition } = event;
  // 先用 shouldSkipViewTransition 排除低端设备、减少动效与黑名单
  ['pointerdown', 'keydown'].forEach((name) => {
    document.addEventListener(name, () => viewTransition.skipTransition(), { once: true });
  });
});

过渡进行中,用户如果点击或按键,就立即跳过动画。注释写明目的是改善 INP:不让交互等动画播完。

CSS 一侧也有配合::root 默认 view-transition-name: none,只在 page-navigation 或 product-image-transition 类型下才给根元素命名为 root-custom;::view-transition 设为 pointer-events: none。注释写的是 "Keep page interactive while view transitions are running"。

五、按类型切换动画,并跨页传递类型

View Transitions 的 types 可以让一套 CSS 支持多种过渡:

html:active-view-transition-type(page-navigation) main[data-page-transition-enabled='true'] {
  view-transition-name: main-content;
}
html:active-view-transition-type(product-image-transition) {
  [data-view-transition-type='product-image-transition'] { view-transition-name: product-image-transition; }
  [data-view-transition-type='product-details']          { view-transition-name: product-details; }
}

问题在于:类型在旧页面的 pageswap 里决定,新页面的 pagereveal 也需要知道是哪种类型。Horizon 的做法:

  1. 用户点击商品卡片时,product-card.js 的 handleViewTransition 给卡片画廊元素(ref="cardGallery")加上 data-view-transition-type="product-image-transition" 和 data-view-transition-triggered;点在按钮、输入框等交互元素上,或事件已被 preventDefault 时不处理;
  2. 旧页面 pageswap 时,找到带 triggered 标记的元素,把类型写入 viewTransition.types,同时写入 sessionStorage;没有触发元素时,类型为 page-navigation 并清除存储;
  3. 新页面 pagereveal 时,从 sessionStorage 读出类型并设置。过渡结束后把类型切回 page-navigation,再在空闲回调里清掉存储和页面上的 data-view-transition-type。

还有一个清理细节:如果用户直接落地在商品详情页,媒体画廊的首张图会预先带着 data-view-transition-type。在 pageswap 时,Horizon 先移除所有没有 triggered 标记的同类属性,避免出现重复的 view-transition-name;名字重复会导致过渡失败。

卡片图和详情页主图要是同一张

商品卡片支持多图浏览,用户点击时,卡片上显示的可能是第二张图,而详情页首屏是主图。如果直接过渡,会出现一张图"变成"另一张图。

product-card.js 在点击时找到当前可见的那张图,把它的 currentSrc 与 data-featured-media-url 比较。不一致时,先播一段 125ms 的不透明度动画(0.8 到 1),结束后把 srcset 换成主图,共享元素两端的内容因此保持一致。商品卡本身的结构见商品卡。

六、内嵌 WebView 的黑名单

function shouldDisableCrossDocumentViewTransitions(ua = navigator.userAgent) {
  const androidWebView = /\bAndroid\b/i.test(ua) && /;\s?wv\)/i.test(ua);
  const knownInAppBrowser = /\b(FBAN|FBAV|FB_IAB|FBIOS|Instagram|musical_ly|Bytedance|BytedanceWebview|trill|TikTok)(?:\b|_)/i.test(ua);
  return androidWebView || knownInAppBrowser;
}

源码注释说,2026 年 6 月测试发现:Android 上 Facebook、Instagram、TikTok 等应用的内嵌浏览器所用的 Chromium WebView(UA 中带 ; wv)),在跨文档过渡时会卡死或白屏。

命中黑名单时有两步处理:

  1. 注入一条 @view-transition { navigation: none; }。后声明的规则在层叠中胜出,覆盖前面的 opt-in;
  2. 直接删除渲染阻塞的 <link>,保证首帧不会被一个注定失败的过渡卡住。

对于大量流量来自社交广告的电商站,这一条的价值远大于过渡动画本身。

七、页面内过渡:复用同一套判断

在对话框外修改筛选时,assets/facets.js 用的是同文档的 startViewTransition,封装在 utilities.js 中(筛选流程本身见筛选与分页):

  • 同样检查 API 支持、低端设备、减少动效和 WebView 黑名单,任一命中就直接执行回调;
  • product-grid 类型在过渡前,于空闲回调里用 getCardsToAnimate 估算可见区域能容纳多少张卡片(按网格"缩小"状态的卡片尺寸估算,筛选前后两种密度都能覆盖)。按 DOM 顺序,前 N 张设置 view-transition-name,其余设为 content-visibility: hidden,以减少快照数量;
  • 当前过渡的 finished promise 存在 viewTransition.current 中,Scheduler 等代码可以等过渡结束后再执行 DOM 操作(见布局抖动治理)。

值得商榷的地方

  • 三段判断各有两份。view-transitions.js 是内联的经典脚本,不能 import 模块,所以 isLowPowerDevice、prefersReducedMotion 和 WebView 检测在 utilities.js 里各有一份副本,只靠 "keep in sync" 注释维持一致。Horizon 没有构建步骤,要从同一份源码生成内联脚本,就得先引入构建。
  • 注释已经落后于实现。disableCrossDocumentViewTransitions 的注释说注入的规则 "defeats base.css",但 opt-in 已经移到 view-transition-opt-in.liquid,base.css 的注释自己也是这么写的。
  • UA 黑名单需要持续维护。注释写着 "Remove check if ever resolved",但没有对应的跟踪链接或版本条件,很容易变成永久代码。
  • 卡片估算按 DOM 顺序取前 N 张。N 是按网格在视口内的可见高度算出来的,但具体取哪些卡片时,没有跳过已经滚出视口上方的那些。网格向下滚动较多后再筛选,真正可见的卡片可能不在前 N 张里,也就不会参与过渡。
  • 低端设备判断偏粗。deviceMemory 只有 Chromium 系浏览器支持,Safari 上实际只依赖 hardwareConcurrency。

小结

Horizon 对跨文档过渡的态度可以概括为:按设置开启,但处处留退路。

场景处理
主内容解析慢最多阻塞到导航开始后 1.8 秒
首次访问、刷新立即取消阻塞
减少动效、低端设备不阻塞,也不过渡
用户交互立即跳过动画
已知有问题的 WebView从 CSS 层面关闭 opt-in

如果要在 Shopify 主题里加跨文档过渡,这张表比动画本身更值得照抄。

Horizon 的 LICENSE.md 禁止分发基于其代码的衍生主题,本文只做源码分析,借鉴思路请自行实现。

源码基线