Horizon 的跨文档 View Transitions:渲染阻塞、放弃条件与 WebView 黑名单
本文基于 Horizon 4.2.0,源码固定到 5acd1b6(核验于 2026-10-07)。结论限于此提交;本篇为静态源码分析,未在店铺中实测。
Shopify 主题是典型的多页应用(MPA),每次跳转都是一次完整的文档加载。Horizon 用跨文档 View Transitions 实现了两种效果:普通页面切换的过渡,以及从商品卡片到商品详情页的主图"飞入"过渡。
@view-transition { navigation: auto } 一行就能开启,真正难的是在什么情况下不该开、开了之后如何不拖慢页面。涉及:
- snippets/view-transition-opt-in.liquid:开启开关与渲染阻塞
- assets/view-transitions.js:由 snippets/scripts.liquid 内联进
<head>的控制脚本 - assets/base.css:过渡样式
- assets/product-card.js:卡片触发
一、开关放在 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 的做法:
- 用户点击商品卡片时,
product-card.js的handleViewTransition给卡片画廊元素(ref="cardGallery")加上data-view-transition-type="product-image-transition"和data-view-transition-triggered;点在按钮、输入框等交互元素上,或事件已被preventDefault时不处理; - 旧页面
pageswap时,找到带 triggered 标记的元素,把类型写入viewTransition.types,同时写入 sessionStorage;没有触发元素时,类型为page-navigation并清除存储; - 新页面
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)),在跨文档过渡时会卡死或白屏。
命中黑名单时有两步处理:
- 注入一条
@view-transition { navigation: none; }。后声明的规则在层叠中胜出,覆盖前面的 opt-in; - 直接删除渲染阻塞的
<link>,保证首帧不会被一个注定失败的过渡卡住。
对于大量流量来自社交广告的电商站,这一条的价值远大于过渡动画本身。
七、页面内过渡:复用同一套判断
在对话框外修改筛选时,assets/facets.js 用的是同文档的 startViewTransition,封装在 utilities.js 中(筛选流程本身见筛选与分页):
- 同样检查 API 支持、低端设备、减少动效和 WebView 黑名单,任一命中就直接执行回调;
product-grid类型在过渡前,于空闲回调里用getCardsToAnimate估算可见区域能容纳多少张卡片(按网格"缩小"状态的卡片尺寸估算,筛选前后两种密度都能覆盖)。按 DOM 顺序,前 N 张设置view-transition-name,其余设为content-visibility: hidden,以减少快照数量;- 当前过渡的
finishedpromise 存在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 禁止分发基于其代码的衍生主题,本文只做源码分析,借鉴思路请自行实现。