时间:2024-12-20 作者:剧中人
这篇文章算是上半年《More CSS,Less JS》这篇文章的下篇。
在写《More CSS,Less JS》时,介绍了在博客上做的一些改动。主要体现在用 CSS 的新特性替代之前用 JS 实现的一些功能。
如 CSS 模糊替代 JS + canvas 实现模糊,position: sticky 替代 JS Sticky 效果。利用 Scroll-driven Animation 实现导航粘滞效果。
时隔半年,小剧又尝试了 View Transition API 这个大杀器。
点击下面视频,可以预览小剧使用 View Transition API 做的一些效果。
【转场切换视频】
当然,如果你在使用的是 PC 最新版的 Chrome,也可以在小剧客栈界面上点来点去,更直观的感受下。
小剧入行前端开发,其实是从设计师这个身份转过来的。虽然在毕业后就从设计师角色"逃离"到了更为苦逼的研发岗位,但这些年对视觉、交互上的东西还是兴趣满满。
最初入行前端时还是 jQuery 的时代,界面通常是多页面硬切。完全没有"过场动画"这个概念。
之后 History API 普及后,小剧曾写过《web 设计中的过场动画》,畅想过在 web 中实现视图切换的可能性。并且在小剧客栈中做了尝试,用了非常简单的淡入淡出的效果。
一个月前第一次接触 View Transition API 时,一下子就惊艳到了我。
很多之前想都不敢想,或者实现难度很大,再或者为了点动效会让代码的可维护性变得极低的动画,现在可以轻松地实现了。
面对如此"魅惑",小剧自然要花点时间学上一学。
在开始介绍小剧客栈中的转场动画实现之前,先简单下 View Transition API 的执行步骤。
docuement.startViewTransition()
方法,浏览器会开始准备动画,并对需要做转场动画的 dom 截图备用,并将界面"冻结",开始处于不可点击操作的状态。startViewTransition
方法传入的回调函数,执行视图切换逻辑,函数执行后浏览器同样会执行截图逻辑。view-tansition-name
的动画做了定义,渲染会遵循定制后的动画逻辑。默认则会使用淡入淡出叠加位置尺寸的变化,很像 KeyNote 里的"神奇移动"。小剧向来不喜欢讲解 CSS、JS 特性细节,这是 MDN、caniuse 之类的平台擅长且权威的。因此上面的介绍省略了 startViewTransition
返回值的描述、新旧视图中缺失了对应 view-transition-name
的处理情况、CSS 伪元素树结构等部分。
如果你对 View Transition API 的更多细节感兴趣,可以点击 MDN - View Transition API 获取更详细的介绍。
这次小剧客栈的改动其实只有一处,就是 router-view 切换动画。
因为 View Transition API 对 CSS、JS 代码的侵入性极低,可以很方便的对不同的视图切换动画做分别实现。
小剧客栈并没有复杂的页面层级,核心逻辑都在博文部分。因而这次视图切换动画主要的精力都花在了"博文列表 → 博文详情"部分。
其余的视图切换保持了以前的旧视图缩小退出,新视图向上渐显的交互。区别是新的交互借助于 View Transition API 实现,原有逻辑则依赖 Vue Router 切换时的 transition。
博文列表 → 博文详情切换动画,参考前文的视频
这里以【博文列表 → 博文详情】视图切换的动画实现方式来做示例。
View Transition API 的逻辑部分其实比较简单,就是找到合适的 docuement.startViewTransition()
调用时机。这次实践是在 Vue2 的版本上,因此 router 拦截器就是最好的地方。
唯一麻烦的是要区分当前需要使用哪种视图切换模式。
const isSupportViewTransition = !!document.startViewTransition;
const baseRouterTransitionClass = "base-router-transition"
const articleRouterTransitionClass = "article-router-transition"
let hasClickArticleBefore = false;
let lastClickedArticleData = null;
export function markArticleClick(clickedNode, articleData) {
if (!clickedNode) {
return
}
hasClickArticleBefore = true;
lastClickedArticleData = articleData
clickedNode.classList.add("router-transition-article-item")
}
export function getLastClickedArticle() {
const lastData = lastClickedArticleData
lastClickedArticleData = null
return lastData
}
function getScrollTop () {
return Math.max(document.documentElement.scrollTop, document.body.scrollTop)
}
function setBodyScrollToRouteView () {
const node = document.querySelector(".view-page")
let scrollTop = getScrollTop()
node.style.height = "100vh"
node.style.overflow = "hidden"
node.scrollTop = scrollTop
}
export function beforeRouterChange (to, from, next) {
const isFirstPage = !from.name;
const isSameView = to.name === from.name;
if (isFirstPage || isSameView || !isSupportViewTransition) {
return next()
}
const addClassForTransition = hasClickArticleBefore ? articleRouterTransitionClass : baseRouterTransitionClass;
setBodyScrollToRouteView()
document.documentElement.classList.add(addClassForTransition)
const viewTransition = document.startViewTransition(() => {
window.scrollTo(0, 0)
next()
})
viewTransition.finished.finally(() => {
document.documentElement.classList.remove(addClassForTransition)
hasClickArticleBefore = false
})
}
import { beforeRouterChange } from "@/common/view-transition/"
router.beforeEach(beforeRouterChange)
// Navigation
.navigation .navigation-body
view-transition-name navigation-view;
// Root View
.base-router-transition .view-page
view-transition-name root-view;
@keyframes root-view-in
0%
opacity 0
transform translate(0, 15vh)
100%
opacity 1
transform translate(0, 0)
@keyframes root-view-out
0%
transform scale(1)
100%
transform scale(0.9)
::view-transition-old(root-view)
transform-origin 50vw 100vh
animation root-view-out 0.5s ease-in-out forwards
::view-transition-new(root-view)
opacity 0
animation root-view-in 0.8s 0.4s ease-in-out
// Article View
.article-router-transition
.router-transition-article-item img
view-transition-name article-head-view;
.blog-detail header
view-transition-name article-head-view;
.section-article
view-transition-name article-body-view;
::view-transition-group(article-head-view)
animation-duration 0.4s
animation-timing-function ease-out
::view-transition-old(article-head-view)
height 100%
overflow clip
object-fit cover
::view-transition-new(article-head-view)
height 100%
overflow clip
::view-transition-new(article-body-view)
opacity 0
animation article-body-transition-in 0.4s 0.3s ease-out forwards
@keyframes article-body-transition-in
0%
opacity 0
transform translate(0, 8vh)
100%
opacity 1
transform translate(0, 0)
合理利用缓存数据提高体验。
因为动画的切换在点击前后两个 Tick 内完成,新视图中的数据必然没有加载完成。
因此合理的利用缓存可以极大提升动画的流畅度。例如前面 JS 部分的 markArticleClick
方法,完成了标记点击节点的同时,也缓存了文章的缩略信息。
减少大面积视图重绘,增强视图的简洁稳定。
起初博文详情的 loading 状态为全视图蒙层,画面动画较为割裂,不同阶段的动画闪烁严重。
因此借助于 View Transition API 实现动画的前提下,缩小 loading 区域也能是动画更简洁稳定。
如果你了解 Vue 的 transition 组件的实现逻辑,会发现 View Transition API 的步骤和 Vue 的实现极其相似。
Vue transition 组件使用旧视图 Dom 延迟销毁的方案,因此它拥有更好的兼容性,几乎可以兼容所有支持 Vue 的浏览器。
在动画过程中界面始终处于可交互的状态。
通过这样对比,是不是 Vue transition 组件更胜一筹?
确实是这样的,对于转场动画中,把视图当作一个整体来处理时,Vue transition 组件是最方便的,并且兼容性最好的,甚至借助于这个思路使用原生 JS 实现都不是难事。
前文提到小剧客栈十年前第一次尝试转场动画,当时就是借助于原生 JS 实现的。在动画开始前保留老的 DOM,动画结束后销毁老的 DOM,中间过程组织动画。
后来迁移到 Vue 版本后,一直使用 Vue transition 组件实现转场动画,维持了近五年时间。
前面介绍 Vue transition 组件的优势,提到了把视图当作一个整体处理时极其方便,并且兼容性好。
但如果转场动画较为细腻,包含多个子动画,并且转场前后 DOM 结构千差万别,想实现类似于 KeyNote 的"神奇移动"之类的效果还是很难的,甚至不可实现。
并且因为 View transition API 实现转场动画的逻辑在 JS、CSS 中,不需要动 HTML 或者 template 结构,逻辑组织起来非常灵活,可以更方便地根据数据、状态应用不同的效果。
最后 View Transition API 的视图处理是浏览器原生实现,相比于 Vue transition 组件拥有很好的性能。
很多年前小剧曾写过一篇《谈谈小剧对渐进增强与平稳退化的理解【上】》,可惜十二年后的今天依旧没有水出【下】篇。
渐进增强与平稳退化是前端处理兼容性问题的两种思维逻辑。
对于转场动画这种锦上添花的功能,有或无对主体功能没有丝毫影响。因此使用渐进增强的思路去处理最合适了。
上面是理由一,非常冠冕堂皇且能唬住人。
理由二就很简单了:对于小剧来说 View Transition API 是个完全没了解过的新特性,看起来炫,不学手痒。
对于讨好用户的微交互来说,Apple 是最无所不用其极的。
自从 IOS12 开始就有很多划时代的酷炫界面切换动效。比如桌面 App 文件夹的打开关闭特效、桌面最右屏的 App 资源库交互。 这些动效看起来是很连贯,实际分析初始、终止界面又有很大差异。
小剧很久之前小就想在【小剧起始页】中模拟这类交互效果。但因为传统动画实现方案需要要对结构做很多僵化的改动,后期新功能实现的灵活度和多样性都会大打折扣,所以一直都是放弃的。
通过小剧客栈这次的尝试,小剧对 View Transition API 可以实现场景有了更大的想象空间。
但愿后面可以在更多的地方尝试 Web 动画的可能性。