[贝聊科技]小动画大学问

2,508 阅读9分钟

对于移动端的Web单页应用来说,为了达到媲美原生应用的效果,页面过渡动画是必不可少的。常用的页面过渡动画包括:

  1. 位移——当前页向左侧或右侧水平移出可视区,下一页由反方向移入可视区。
  2. 不透明度变化——当前页淡出,下一页淡入。
  3. 1和2同时进行。

(注意:以下讨论和实验均在 Chrome 68 浏览器环境下进行)

目前大多数设备的屏幕刷新率为60次/秒,算下来每个帧的预算时间约为16.66毫秒(1/60秒)。考虑到浏览器还有其他工作要执行,实际上预算时间只有10毫秒。跟此预算时间的差值越大,用户就会觉得动画过程越卡。那么,在这10毫秒内要完成什么事情呢?当使用JavaScript实现视觉交互效果时,一般要经过以下流程:

JavaScript视觉交互执行流程

  1. JavaScript的执行。例如修改元素的样式,或者给元素添加/删除样式类。
  2. 样式计算。根据样式规则计算出元素的最终样式。
  3. 布局(layout)。根据上一步的结果,计算元素占据的空间大小及其在屏幕的位置。注意,一个元素布局上的变化有可能会引发其他元素的联动变化。
  4. 绘制(paint)。填充像素的过程,包括元素的每个可视部分。一般来说,绘制是在多个层上进行的。
  5. 合成(composite)。把各层按正确顺序合并成一个层,显示到屏幕上。

值得注意的是,并非每一帧都会经过上述每一个步骤的处理。如果元素的几何属性(尺寸、位置)没有变化,就不需要进行布局;如果连元素的外观都没有改变,就不需要绘制。所以,实现流畅动画的关键就在于如何减少布局和绘制

位移

对于位移动画来说,最直接的实现方式,就是把元素设成绝对定位,然后去改变它的left样式值。例如:

<!DOCTYPE html>
<html>
<head>
<style>
.page {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    min-height: 100%;
    background: #ddd;
    transition-duration: 2s;
    transition-property: left;
}
.leave {
    left: -100%;
}
</style>
</head>

<body>
<div id="page" class="page"></div>
<script>
var page = document.getElementById('page');
setTimeout(function() {
    page.classList.add('leave');
}, 2000);
</script>
</body>
</html>

使用Chrome开发者工具中的Performance面板录制动画过程的性能日志,如下图所示:

left动画过程性能日志

可见,元素在移动的过程中不断触发了布局和绘制。所以,这种实现方式的性能是极低的。网上诸多文献会推荐以transform的变化代替left的变化,而实际情况又是怎么样呢?把样式代码稍作修改:

.page {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    min-height: 100%;
    background: #ddd;
    transition-duration: 2s;
    transition-property: transform;
}
.leave {
    transform: translateX(-100%);
}

录制性能日志如下图所示:

transform动画过程性能日志

可见,仅仅是在动画开始和结束两个时间点触发了绘制,而布局则完全没有触发。这样一来,性能就有了很大的提升。但是,这里还有两个疑问:

  • 为什么transform动画过程没有触发布局和绘制?
  • 为什么动画开始前触发了两次绘制,动画结束之后触发了一次绘制?

要回答这两个问题,就得了解合成层。

合成层

当满足某些条件的时候,元素在渲染时会被分配到一个独立的层中进行渲染,只要该层的内容不发生改变,就不会触发绘制,浏览器会直接通过合成形成一个新的帧。常见的提升为合成层的条件包括:

  • 对opacity或transform应用了animation或transition;
  • 有 3D transform ;
  • will-change设置为opacity或transform。

很明显,上一节的transform位移动画满足了第一个条件。所以整个动画的渲染过程是这样的:

  • 动画开始时,由于div.page被提升为独立的合成层,所以它要重新绘制;而document所在层相当于少了一块内容,也得重新绘制;
  • 动画过程中,div.page没有其他变化,所以不触发布局和绘制;
  • 动画结束后,div.page不再是独立的合成层,回到了document所在层,所以document又重新绘制了一遍。

如果让div.page一直在独立的合成层中渲染,则可以省掉上述过程中绘制的环节。在样式代码添加「will-change: transform」:

.page {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    min-height: 100%;
    background: #ddd;
    transition-duration: 2s;
    transition-property: transform;
    will-change: transform;
}

录制性能日志如下:

合成层transform动画过程性能日志

可见,已经不存在绘制的步骤了。

顺带一提,Chrome开发者工具中有一个Layers面板,可以方便地查看页面上合成层以及成为合成层的原因。

Layers面板

(注意:由于低版本浏览器不支持will-change,所以实际应用中,如果想把元素提升到独立的合成层中渲染,可以用「transform: translateZ(0)」)

不透明度

众所周知,不透明度就是通过opacity样式来控制的。那么opacity的变化是否会触发布局和绘制呢?把样式代码修改如下:

.page {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    min-height: 100%;
    background: #ddd;
    transition-duration: 2s;
    transition-property: opacity;
}
.leave {
    opacity: 0;
}

录制性能日志如下图所示:

opacity动画过程性能日志

在常规认知中,opacity的变化并不会导致元素位置和尺寸的变化,理应不会触发布局。但上述过程中确实触发了一次布局,表现较为诡异。接下来给div.page添加「will-change: opacity」使其一直在独立的合成层中渲染。录制性能日志如下:

合成层opacity动画过程性能日志

可见,还是会触发一次绘制。而针对这「一次的布局」和「一次的绘制」,我进行了进一步的实验,得出的结论是:opacity从1(包括未设置的情况,下同)变更到小于1,以及从小于1变更到1,都会触发布局和绘制;即使在独立的合成层中渲染,也只能省掉布局,无法省掉绘制。

由于在opacity动画过程中从1到小于1的变更只会有一次,所以上述的布局和绘制都只触发一次。

位移和不透明度

同时使用两种动画,修改样式代码如下:

.page {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    min-height: 100%;
    background: #ddd;
    transition-duration: 2s;
    transition-property: transform, opacity;
}
.leave {
    transform: translateX(-100%);
    opacity: 0;
}

按照前文的描述,动画过程会触发:

  • 一次布局,在动画开始时触发,由opacity引起;
  • 两次绘制,在动画开始时触发,因opacity以及提升为独立合成层引起;
  • 由独立合成层回到document所在层时引起。

倘若加上「will-change: transform, opacity」,使div.page一直在独立的合成层中渲染,则只触发一次绘制,由opacity引起。

然而,创建一个新的合成层并不是免费的,它会导致额外的内存开销。在单页应用中,应用页面过渡动画的元素是页面的最外层容器,包含了该页面所有内容结构。如果让其长期在独立的合成层中渲染,那内存的消耗是非常大的。

所以,可以仅在动画过程中让其在独立的合成层中渲染,而在其他情况下则维持常规状态。

transform和fixed的冲突

如果用transform实现页面过渡动画,想必大家都遇到过一个问题:页面上固定定位的元素,其位置变得不太正常了。

下面通过一段代码模拟页面进入的过程,来演示这个问题:

<!DOCTYPE html>
<html>
<head>
<style>
.page {
    position: absolute;
    left: 0;
    top: 0;
    width: 100%;
    height: 150%;
    background: #ddd;
    transition-duration: 3s;
    transition-timing-function: cubic-bezier(.55, 0, .1, 1);
    transition-property: transform, opacity;
}
.before-enter {
    transform: translateX(100%);
    opacity: 0;
}
.fixed {
    position: fixed;
    right: 0;
    bottom: 0;
    width: 100%;
    height: 160px;
    background: #ffc100;
}
</style>
</head>

<body>
<div id="page" class="page before-enter">
    <div class="fixed"></div>
</div>
<script>
var page = document.getElementById('page');
setTimeout(() => {
    page.classList.remove('before-enter');
}, 2000);
</script>
</body>
</html>

运行效果如下:

transform与fixed的冲突

可以看到,固定定位的黄色元素是在动画结束后才突然出现的。那在这之前它跑到哪去了呢?

如果给一个固定定位元素的任意一个祖先元素设置样式「transform」或者「will-change: transform」,那么该元素就会相对于最近的设置了上述样式的祖先元素定位。

因为div.page的高度设成了150%,所以,在动画过程中,黄色元素实际上是跑到了页面的最底下(超出了浏览器可视范围)去了。而在某些比较旧(如 iOS 9 的Safari)的移动端浏览器中,问题更为严重,固定定位的元素可能会消失掉再也不出现。

网上能查到的解决方案有两种:

  • 通过绝对定位模拟固定定位。虽然是可行的,但是在移动端浏览器内,交互上会有一些细节问题,而且元素内部的滚动很容易与页面滚动冲突。
  • 把固定定位的元素放到应用transform动画的元素外。但这对使用「Vue.js」这类框架开发的单页应用来说可行性较低,因为在这类框架中,一个页面就是一个组件,单独把页面中的某个元素抽离出来是比较麻烦的。

所以,这里介绍第三种方案——在页面过渡动画结束之后(此时transform样式已被移除,不再影响fixed),再让固定定位的元素插入到页面容器。并且,为了让它的出现显得不那么突然,增加缓动动画。代码主要修改点如下:

@keyframes kf-move-in {
    0% { transform: translateY(100%); }
    100% { transform: translateY(0); }
}
.move-in {
    animation-name: kf-move-in;
    animation-duration: 0.45s;
}
<div id="page" class="page before-enter"></div>
<script>
var page = document.getElementById('page');
setTimeout(function() {
    // 监听过渡结束
    page.addEventListener('transitionend', function() {
        // 创建、插入固定定位元素
        var div = document.createElement('div');
        div.className = 'fixed move-in';
        page.appendChild(div);
    });

    page.classList.remove('before-enter');
}, 2000);
</script>

运行效果如下:

解决transform与fixed的冲突

这样一来,整个交互就较为友好了。这同时也说明:技术上的问题,不一定只能通过技术去解决,也可以从交互上去寻求解决方案。

参考文献

本文同时发布于作者个人博客 mrluo.life/article/det…