从浏览器渲染原理说一说如何实现高效的动画

36次阅读

共计 4451 个字符,预计需要花费 12 分钟才能阅读完成。

写在前面

在平时的工作中,页面的动画效果是很常见的需求。那么,怎么样实现一个高效的动画呢?

本文首发于公众号:符合预期的 CoyPan

注,本文谈到的浏览器,均为基于 Chromium 的现代浏览器。

页面渲染原理

一个页面展示在用户面前,简单来说,会经历以上 5 个步骤。我们可以把上面这个图称为 像素管道

  • Javascript: 执行 js 逻辑,修改 DOM,修改 CSS 等。
  • Style:计算样式。
  • Layout:在知道对一个元素应用哪些规则之后,浏览器即可开始计算它要占据的空间大小及其在屏幕的位置。这个步骤,就是我们常说的重排。
  • Paint: 绘制是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。绘制一般是在多个表面(通常称为层)上完成的。这个步骤,就是我们常说的重绘。
  • Composite:渲染层合并,由上一步可知,对页面中 DOM 元素的绘制是在多个层上进行的。在每个层上完成绘制过程之后,浏览器会将所有层按照合理的顺序合并成一个图层,然后显示在屏幕上。

在浏览器中,页面的渲染由浏览器的渲染进程完成,而渲染进程中,包含了主线程,worker 线程,Compositer 线程,Raster 线程。上述像素管道的 5 个过程中,前 4 个过程,都由主线程完成,最后一个步骤,主要由 Raster 线程、Compositer 线程完成

JavaScript、Style、Layout

像素管道中的前三个步骤,大家都很熟悉了。JavaScript、Style 两个步骤,一图以蔽之:

接着是 Layout,浏览器遍历 render tree 的每一个节点,计算其确切大小和位置。最终形成一个 Layout Tree。

Paint

在 Paint 之前,浏览器会根据 Layout Tree,确定需要绘制的对象的层级,我们可以把这个层级叫做 渲染层,最终生成 Layer Tree。这个阶段被称作:Update Layer Tree

在 Paint 这个阶段,浏览器会根据 Layer Tree,生成 Paint Records。

Paint Records 就是描述先画什么,再画什么的记录,跟我们写 canvas 代码时很像。Paint Records 是根据渲染层划分的的。来看一个 Paint Records 的实例:

尽管生成了 Pain Records,真正的绘制并不在 Paint 这个阶段完成的,而是在 Composite 阶段由 Raster 线程完成的。

Composite

经过之前的几个步骤,浏览器主线程已经将页面的内容分成了若干渲染层。为了提升性能,某些特定的渲染层,会被提升为 合成层。我们可以通过下面两个 css 属性,将某个元素强制提升为合成层:

will-change: transform;

// 或者
transform: translateZ(0);

注:提升为合成层的条件比较复杂,这里就不一一展开了。可以参考这篇文章:

http://taobaofed.org/blog/201…

主线程在处理完所有的所有的数据后,会把数据提交到 Compositer 线程。Composite 线程会利用 Raster 线程来做光栅化处理,并将处理好的内容存入内存中。随着 Composite 线程完成渲染层合成操作,扔给 GPU,页面最终被渲染到屏幕上。

可以通过 Chrome 开发者工具中的 Layer 来查看合成层:

其他运行方式的像素管道

上文中的像素管道共有 5 个步骤。不一定每帧都总是会经过管道每个部分的处理。实际上,不管是使用 JavaScript、CSS 还是网络动画,在实现视觉变化时,管道针对指定帧的运行还有其他两种方式:

第一种就是我们所说的页面没有进行重排,只进行了重绘;第二种就是页面既没有进行重排,也没有进行重绘。

最后这种运行方式的开销最小,适合于页面上的动画效果。

实现动画效果

不考虑 canvas 等,有三种常见的方式来实现页面上的动效,

  1. 完全不用 css3 相关属性,仅使用 setTimeout, setInterval, requestAnimationFrame,通过 js 修改 DOM 的样式来实现动画。
  2. 使用纯 css3 来实现动画。
  3. js 与 css3 相结合来实现动画。

一般情况下,使用第一种方式的时候,虽然有的动画效果在进行过程中不会触发像素管道中的 Layout,但是 Paint 往往是避免不了的。而使用 css3 来实现动画时,我们可以跳过 Layout 和 Paint 步骤。

下面,来看看三种实现方式下,浏览器的处理过程。

完全由 JS 驱动的动画

代码如下:

<html>
    <head>
        <style type="text/css">
            #test2 {
                margin-top: 100px;
                width: 100px;
                height: 100px;
                position: relative;
                background-color: black;
            }
        </style>
    </head>
    <body>
        <p> 
            这是一段无用的文字,这是一段无用的文字,这是一段无用的文字,这是一段无用的文字,这是一段无用的文字,这是一段无用的文字
        </p>
        <div id="test2"></div>
        <script type="text/javascript">
            window.onload = function() {const el = document.getElementById('test2');
                let left = 0;
        const startTimeStamp = Date.now();
                const fn = function() {
                    left += 2;
                    if(Date.now() - startTimeStamp > 2000) {return;}
                    el.style.left = left + 'px';
                    return window.requestAnimationFrame(fn);
                }
                window.requestAnimationFrame(fn)
            }
        </script>
    </body>
</html>

选取动画过程中的一帧,浏览器的处理过程如下:

可以看到,在这里帧里,浏览器走完了完整的像素管道:JavaScript ->Style->Layout->Paint->Composite。

纯 CSS 动画

我们用纯 css 来实现动画:

<html>
    <head>
        <style type="text/css">
            #test2 {
                margin-top: 100px;
                width: 100px;
                height: 100px;
                position: relative;
                background-color: black;
                animation: move 2s;
                animation-fill-mode: forwards;
            }
            @keyframes move {
                0% {transform: translate(0);
                }
                100% {transform: translate(200px);
                }
            }
        </style>
    </head>
    <body>
        <p> 
        这是一段无用的文字,这是一段无用的文字,这是一段无用的文字,这是一段无用的文字,这是一段无用的文字,这是一段无用的文字
        </p>
        <div id="test2"></div>
    </body>
</html>

我们来看看动画进行过程中:

可以看到,主线程里没有任务在执行,而 Composite 线程、Raster 线程以及 GPU 在工作。

JS 与 CSS3 相结合

<html>
    <head>
        <style type="text/css">
            #test2 {
                margin-top: 100px;
                width: 100px;
                height: 100px;
                position: relative;
                background-color: black;
            }
        </style>
    </head>
    <body>
        <p> 
            这是一段无用的文字,这是一段无用的文字,这是一段无用的文字,这是一段无用的文字,这是一段无用的文字,这是一段无用的文字
        </p>
        <div id="test2"></div>
        <script type="text/javascript">
            window.onload = function() {const el = document.getElementById('test2');
                let left = 0;
            const startTimeStamp = Date.now();
                const fn = function() {
                    left += 2;
                    if(Date.now() - startTimeStamp > 2000) {return;}
                    el.style.transform = `translate(${left}px)`;
                    return window.requestAnimationFrame(fn);
                }
                window.requestAnimationFrame(fn);
            }
        </script>
    </body>
</html>

动画运行时,浏览器的处理过程如下图所示,并没有触发 Layout 和 paint。

小结

从上面的几个实例可以看到,在仅使用 css 动画时,动画过程完全交由 Composite 线程处理,释放了主线程。事实上,在执行纯 css3 动画时,浏览器会将响应的元素提升到一个单独的合成层,不会影响到页面上的其他元素。

使用 js 操作 css3 属性,也可以跳过 Layout 和 Paint。

当然,并不是所有的 css 属性都可以跳过 Layout 和 Paint 仅触发 Composite,常见的属性是:transformopacity。具体属性可以到下面的网址查看:

CSS Triggers List – What Kind of Changes You Can Make

这里还有几点补充的地方:

  1. 动画开始时,都会触发一次 paint。
  2. 对于纯 css3 操作 transform 和 opacity 的动画,在动画开始时 ,浏览器会自动将动画元素提升为合成层,但是在 动画结束后 ,合成层会失效。在动画结束后(合成层失效) 的那一帧,浏览器是会触发 Paint 的。如果我们强制将动画元素提升为合成层,动画结束后的那一帧,就不会触发 Paint 了。
  3. 对于 js 操作 css3 的 transform 和 opacity 的动画,在动画过程中,浏览器不会自动将动画元素提升为合成层,但是也不会触发 Paint。在 动画结束的那一帧,不管我们是否强制将动画元素提升为合成层,当页面动画元素嵌套复杂时,可能会触发 Paint。

总结

想要实现高性能的动画,尽量使用 css 动画或者使用 js 操作 css3 属性的方式,同时,要注意动画用到的 css3 属性。动画的目标就是跳过浏览器的 Layout 和 Paint,仅触发 Composite。

对于特定的动画元素,我们可以适当将其提升到合成层,这样该元素不会影响到页面其他地方。当然,合成层的使用要适当,因为合成层会带来内存压力。

写在后面

本文从浏览器渲染原理入手,谈到了如何实现一个高效的动画。在写作本文的过程中,学习、巩固了很多的知识。还有一些更深入的点值得去继续研究。符合预期。

参考资料:

  • https://developers.google.com…
  • http://taobaofed.org/blog/201…
  • https://developers.google.com…
  • https://juejin.im/entry/59080…

正文完
 0