渲染品质,浏览器渲染

Canvas 最佳实践(性能篇)

2016/02/23 · HTML5 · Canvas

原文出处: 淘宝前端团队(FED)- 叶斋   

图片 1

Canvas 想必前端同学们都不陌生,它是 HTML5 新增的「画布」元素,允许我们使用 JavaScript 来绘制图形。目前,所有的主流浏览器都支持 Canvas。

图片 2

Canvas 最常见的用途是渲染动画。渲染动画的基本原理,无非是反复地擦除和重绘。为了动画的流畅,留给我渲染一帧的时间,只有短短的 16ms。在这 16ms 中,我不仅需要处理一些游戏逻辑,计算每个对象的位置、状态,还需要把它们都画出来。如果消耗的时间稍稍多了一些,用户就会感受到「卡顿」。所以,在编写动画(和游戏)的时候,我无时无刻不担忧着动画的性能,唯恐对某个 API 的调用过于频繁,导致渲染的耗时延长。

为此,我做了一些实验,查阅了一些资料,整理了平时使用 Canvas 的若干心得体会,总结出这一片所谓的「最佳实践」。如果你和我有类似的困扰,希望本文对你有一些价值。

本文仅讨论 Canvas 2D 相关问题。

上篇文章《网站性能优化——CRP》已经介绍过网站性能优化中的关键渲染路径部分,相当于从一个“宏观”的角度去优化性能,当然,这个角度也是最重要的优化。本篇就从一个“微观”的层面去优化——浏览器渲染。

在浏览器渲染过程与性能优化一文中(建议先去看一下这篇文章再来阅读本文),我们了解与认识了浏览器的关键渲染路径以及如何优化页面的加载速度。在本文中,我们主要关注的是如何提高浏览器的渲染性能(浏览器进行布局计算、绘制像素等操作)与效率。

计算与渲染

把动画的一帧渲染出来,需要经过以下步骤:

  1. 计算:处理游戏逻辑,计算每个对象的状态,不涉及 DOM 操作(当然也包含对 Canvas 上下文的操作)。
  2. 渲染:真正把对象绘制出来。
    2.1. JavaScript 调用 DOM API(包括 Canvas API)以进行渲染。
    2.2. 浏览器(通常是另一个渲染线程)把渲染后的结果呈现在屏幕上的过程。

图片 3

之前曾说过,留给我们渲染每一帧的时间只有 16ms。然而,其实我们所做的只是上述的步骤中的 1 和 2.1,而步骤 2.2 则是浏览器在另一个线程(至少几乎所有现代浏览器是这样的)里完成的。动画流畅的真实前提是,以上所有工作都在 16ms 中完成,所以 JavaScript 层面消耗的时间最好控制在 10ms 以内。

虽然我们知道,通常情况下,渲染比计算的开销大很多(3~4 个量级)。除非我们用到了一些时间复杂度很高的算法(这一点在本文最后一节讨论),计算环节的优化没有必要深究。

我们需要深入研究的,是如何优化渲染的性能。而优化渲染性能的总体思路很简单,归纳为以下几点:

  1. 在每一帧中,尽可能减少调用渲染相关 API 的次数(通常是以计算的复杂化为代价的)。
  2. 在每一帧中,尽可能调用那些渲染开销较低的 API。
  3. 在每一帧中,尽可能以「导致渲染开销较低」的方式调用渲染相关 API。

在视频领域,电影、电视、数字视频等可视为随时间连续变换的许多张画面,而帧则指这些画面当中的每一张。——维基百科

网页上来说,其实就是指浏览器渲染出的页面。目前大多数设备的屏幕刷新频率为60次/秒(60fps),每一帧所消耗的时间约为16ms(1000 ms / 60 = 16.66ms),但实际上,浏览器还有一些整理工作要做,因此开发者所做的所有工作需要在10ms内完成。

如果不能完成,帧率将会下降,网页会在屏幕上抖动,也就是通常所说的卡顿,这会对用户体验产生严重的负面影响。所以如果一个页面中有动画效果或者用户正在滚动页面,那么浏览器渲染动画或页面的速率也要尽可能地与设备屏幕的刷新频率保持一致,以保证良好的用户体验。

很多网页都使用了看起来效果非常酷炫的动画与用户进行交互,这些动画效果显著提高了用户的体验,但如果因为性能原因导致动画的每秒帧数太低,反而会让用户体验变得更差(如果一个酷炫的动画效果运行起来总是经常卡顿或者看起来反应很慢,这些都会让用户感觉糟透了)。

Canvas 上下文是状态机

Canvas API 都在其上下文对象 context 上调用。

JavaScript

var context = canvasElement.getContext('2d');

1
var context = canvasElement.getContext('2d');

我们需要知道的第一件事就是,context 是一个状态机。你可以改变 context 的若干状态,而几乎所有的渲染操作,最终的效果与 context 本身的状态有关系。比如,调用 strokeRect 绘制的矩形边框,边框宽度取决于 context 的状态 lineWidth,而后者是之前设置的。

JavaScript

context.lineWidth = 5; context.strokeColor = 'rgba(1, 0.5, 0.5, 1)'; context.strokeRect(100, 100, 80, 80);

1
2
3
4
context.lineWidth = 5;
context.strokeColor = 'rgba(1, 0.5, 0.5, 1)';
 
context.strokeRect(100, 100, 80, 80);

图片 4

说到这里,和性能貌似还扯不上什么关系。那我现在就要告诉你,对 context.lineWidth 赋值的开销远远大于对一个普通对象赋值的开销,你会作如何感想。

当然,这很容易理解。Canvas 上下文不是一个普通的对象,当你调用了 context.lineWidth = 5 时,浏览器会需要立刻地做一些事情,这样你下次调用诸如 strokestrokeRect 等 API 时,画出来的线就正好是 5 个像素宽了(不难想象,这也是一种优化,否则,这些事情就要等到下次 stroke 之前做,更加会影响性能)。

我尝试执行以下赋值操作 106 次,得到的结果是:对一个普通对象的属性赋值只消耗了 3ms,而对 context 的属性赋值则消耗了 40ms。值得注意的是,如果你赋的值是非法的,浏览器还需要一些额外时间来处理非法输入,正如第三/四种情形所示,消耗了 140ms 甚至更多。

JavaScript

somePlainObject.lineWidth = 5; // 3ms (10^6 times) context.lineWidth = 5; // 40ms context.lineWidth = 'Hello World!'; // 140ms context.lineWidth = {}; // 600ms

1
2
3
4
somePlainObject.lineWidth = 5;  // 3ms (10^6 times)
context.lineWidth = 5;  // 40ms
context.lineWidth = 'Hello World!'; // 140ms
context.lineWidth = {}; // 600ms

context 而言,对不同属性的赋值开销也是不同的。lineWidth 只是开销较小的一类。下面整理了为 context 的一些其他的属性赋值的开销,如下所示。

属性 开销 开销(非法赋值)
line[Width/Join/Cap] 40 100
[fill/stroke]Style 100 200
font 1000 1000
text[Align/Baseline] 60 100
shadow[Blur/OffsetX] 40 100
shadowColor 280 400

与真正的绘制操作相比,改变 context 状态的开销已经算比较小了,毕竟我们还没有真正开始绘制操作。我们需要了解,改变 context 的属性并非是完全无代价的。我们可以通过适当地安排调用绘图 API 的顺序,降低 context 状态改变的频率。

像素管道

提高帧率,其实就是优化浏览器渲染页面的过程。当你在工作时,需要了解并注意五个主要的区域,这些区域是你能在最大程度上去控制的地方,当然,也就是优化性能、提高帧率的地方。

  • JavaScript:一般情况下,我们会使用JS去处理一些导致视觉变化的工作,比如动画或者增加DOM元素等。当然,除了JS,还有其他一些方法,比如:CSS Animations、Transitions、 Web Animation API
  • Style calculations:这个过程是根据匹配选择器(.nav > .nav-item)计算出哪些CSS规则应用在哪些元素上面的过程
  • Layout:浏览器知道对一个元素应用哪些规则之后,就可以开始计算这个元素占据的空间大小及其在屏幕上的位置
  • Paint:绘制是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包含了元素的每个可视部分。绘制一般是在多个上完成的
  • Compositing(合成):由于页面的不同部分可能被绘制到多个上,因此它们需要按照正确的顺序绘制到屏幕上以正确渲染页面

像素管道的每个部分都有可能产生卡顿,因此,准确了解你的代码会触发管道的哪些部分十分重要。
帧不一定都会经过管道每个部分的处理。实际上,在改变视觉呈现时,针对指定帧,管道的运行通常有三种方式:

  • JS / CSS > Style > Layout > Paint > Composite

    当改变了某个元素的几何属性(如width、height,或者表示位置的left、top等)——即修改了该元素的“布局(layout)”属性,那么浏览器将会检查所有其他元素,然后对页面进行“重排(reflow)”。任何受到影响的区域都需要重新绘制,然后进行合成。

  • JS / CSS > Style > Paint > Composite
当改变了只与绘制相关的属性(如背景图片、文字颜色或阴影等),即不会影响页面的布局,则浏览器会跳过布局阶段,但仍需要执行绘制、合成。
  • JS / CSS > Style > Composite
当改变了一个既不需要“重排”也不需要“重绘”的属性(如transform),则浏览器将跳过布局、绘制阶段,直接执行合成。

一个流畅的动画需要保持在每秒60帧,换算成毫秒浏览器需要在10毫秒左右完成渲染任务(每秒有1000毫秒,1000/60 约等于 16毫秒一帧,但浏览器还有其他工作需要占用时间,所以估算为10毫秒),如果能够理解浏览器的渲染过程并发现性能瓶颈对其优化,可以使你的项目变得具有交互性且动画效果如飘柔般顺滑。

分层 Canvas

分层 Canvas 在几乎任何动画区域较大,动画较复杂的情形下都是非常有必要的。分层 Canvas 能够大大降低完全不必要的渲染性能开销。分层渲染的思想被广泛用于图形相关的领域:从古老的皮影戏、套色印刷术,到现代电影/游戏工业,虚拟现实领域,等等。而分层 Canvas 只是分层渲染思想在 Canvas 动画上最最基本的应用而已。

图片 5

分层 Canvas 的出发点是,动画中的每种元素(层),对渲染和动画的要求是不一样的。对很多游戏而言,主要角色变化的频率和幅度是很大的(他们通常都是走来走去,打打杀杀的),而背景变化的频率或幅度则相对较小(基本不变,或者缓慢变化,或者仅在某些时机变化)。很明显,我们需要很频繁地更新和重绘人物,但是对于背景,我们也许只需要绘制一次,也许只需要每隔 200ms 才重绘一次,绝对没有必要每 16ms 就重绘一次。

对于 Canvas 而言,能够在每层 Canvas 上保持不同的重绘频率已经是最大的好处了。然而,分层思想所解决的问题远不止如此。

使用上,分层 Canvas 也很简单。我们需要做的,仅仅是生成多个 Canvas 实例,把它们重叠放置,每个 Canvas 使用不同的 z-index 来定义堆叠的次序。然后仅在需要绘制该层的时候(也许是「永不」)进行重绘。

JavaScript

var contextBackground = canvasBackground.getContext('2d'); var contextForeground = canvasForeground.getContext('2d'); function render(){ drawForeground(contextForeground); if(needUpdateBackground){ drawBackground(contextBackground); } requestAnimationFrame(render); }

1
2
3
4
5
6
7
8
9
10
var contextBackground = canvasBackground.getContext('2d');
var contextForeground = canvasForeground.getContext('2d');
 
function render(){
  drawForeground(contextForeground);
  if(needUpdateBackground){
    drawBackground(contextBackground);
  }
  requestAnimationFrame(render);
}

记住,堆叠在上方的 Canvas 中的内容会覆盖住下方 Canvas 中的内容。

浏览器渲染优化

本文作者为: SylvanasSun(sylvanas.sun@gmail.com).转载请务必将本段话置于文章开头处(保留超链接).
本文首发自SylvanasSun Blog,原文链接: https://sylvanassun.github.io/2017/10/08/2017-10-08-BrowserRenderOptimization/

绘制图像

目前,Canvas 中使用到最多的 API,非 drawImage 莫属了。(当然也有例外,你如果要用 Canvas 写图表,自然是半句也不会用到了)。

drawImage 方法的格式如下所示:

JavaScript

context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

1
context.drawImage(image, sx, sy, sWidth, sHeight, dx, dy, dWidth, dHeight);

图片 6

1. JavaScript

像素管道


所谓像素管道其实就是浏览器将渲染树绘制成像素的流程。管道的每个区域都有可能产生卡顿,即管道中的某一区域如果发生变化,浏览器将会进行自动重排,然后重新绘制受影响的区域。

图片 7

像素管道

  • JavaScript:该区域其实指的是实现动画效果的方法,一般使用JavaScript来实现动画,例如JQueryanimate函数、对一个数据集进行排序或动态添加一些DOM节点等。当然,也可以使用其他的方法来实现动画效果,像CSSAnimationTransitionTransform

  • Style:该区域为样式计算阶段,浏览器会根据选择器(就是CSS选择器,如.td)计算出哪些节点应用哪些CSS规则,然后计算出每个节点的最终样式并应用到节点上。

  • Layout:该区域为布局计算阶段,浏览器会在该过程中根据节点的样式规则来计算它要占据的空间大小以及在屏幕中的位置

  • Paint:该区域为绘制阶段,浏览器会先创建绘图调用的列表,然后填充像素。绘制阶段会涉及到文本、颜色、图像、边框和阴影,基本上包括了每个可视部分。绘制一般是在多个图层(用过Photoshop等图片编辑软件的童鞋一定很眼熟图层这个词,这里的图层的含义其实是差不多的)上完成的。

  • Composite:该区域为合成阶段,浏览器将多个图层按照正确顺序绘制到屏幕上。

假设我们修改了一个几何属性(例如宽度、高度等影响布局的属性),这时Layout阶段受到了影响,浏览器必须检查所有其他区域的元素,然后自动重排页面,任何受到影响的部分都需要重新绘制,并且最终绘制的元素还需要重新进行合成(简单地说就是整个像素管道都要重新执行一遍)。

如果我们只修改了不会影响页面布局的属性,例如背景图片、文字颜色等,那么浏览器会跳过布局阶段,但仍需要重新绘制。

图片 8

又或者,我们只修改了一个不影响布局也不影响绘制的属性,那么浏览器将跳过布局与绘制阶段,显然这种改动是性能开销最小的。

图片 9

如果想要知道每个CSS属性将会对哪个阶段产生怎样的影响,请去CSS Triggers,该网站详细地说明了每个CSS属性会影响到哪个阶段。

数据源与绘制的性能

由于我们具备「把图片中的某一部分绘制到 Canvas 上」的能力,所以很多时候,我们会把多个游戏对象放在一张图片里面,以减少请求数量。这通常被称为「精灵图」。然而,这实际上存在着一些潜在的性能问题。我发现,使用 drawImage 绘制同样大小的区域,数据源是一张和绘制区域尺寸相仿的图片的情形,比起数据源是一张较大图片(我们只是把数据扣下来了而已)的情形,前者的开销要小一些。可以认为,两者相差的开销正是「裁剪」这一个操作的开销。

我尝试绘制 104 次一块 320×180 的矩形区域,如果数据源是一张 320×180 的图片,花费了 40ms,而如果数据源是一张 800×800 图片中裁剪出来的 320×180 的区域,需要花费 70ms。

虽然看上去开销相差并不多,但是 drawImage 是最常用的 API 之一,我认为还是有必要进行优化的。优化的思路是,将「裁剪」这一步骤事先做好,保存起来,每一帧中仅绘制不裁剪。具体的,在「离屏绘制」一节中再详述。

使用 requestAnimationFrame

requestAnimationFrame应该作为开发者在创建动画时的必备工具,它会确保JS尽早在每一帧的开始执行。

之前我们可能看到过很多用setTimeoutsetInterval创建的动画,比如老版本的jQuery。但是使用这两个函数创建的动画效果可能不够流畅,JS引擎在安排这两个函数时根本不会关注渲染通道,参考《Html5 Canvas核心技术》中的论述:

1.即使向其传递毫秒为单位的参数,它们也不能达到ms的准确性。这是因为javascript是单线程的,可能会发生阻塞。
2.没有对调用动画的循环机制进行优化。
3.没有考虑到绘制动画的最佳时机,只是一味地以某个大致的事件间隔来调用循环。

使用RequestAnimationFrame函数实现动画


我们经常使用JavaScript来实现动画效果,然而时机不当或长时间运行的JavaScript可能就是导致你性能下降的原因。

避免使用setTimeout()或者setInterval()函数来实现动画效果,这种做法的主要问题是回调将会在帧中的某个时间点运行,这可能会刚好在末尾(会丢失帧导致发生卡顿)。

图片 10

有些第三方库仍在使用setTimeout()&setInterval()函数来实现动画效果,这会产生很多不必要的性能下降,例如老版本的JQuery,如果你使用的是JQuery3,那么不必为此担心,JQuery3已经全面改写了动画模块,采用了requestAnimationFrame()函数来实现动画效果。但如果你使用的是之前版本的JQuery,那么就需要jquery-requestAnimationFrame来将setTimeout()替换为requestAnimationFrame()函数。

读到这里,想必一定会对requestAnimationFrame()产生好奇。要想得到一个流畅的动画,我们希望让视觉变化发生在每一帧的开头,而保证JavaScript在帧开始时运行的方式则是使用requestAnimationFrame()函数,本质上它与setTimeout()没有什么区别,都是在递归调用同一个回调函数来不断更新画面以达到动画的效果,requestAnimationFrame()的使用方法如下:

function updateScreen(time) {
    // 这是你的动画效果函数
}

// 将你的动画效果函数放入requestAnimationFrame()作为回调函数
requestAnimationFrame(updateScreen);

并不是所有浏览器都支持requestAnimationFrame()函数,如IE9(又是万恶的IE),但基本上现代浏览器都会支持这个功能的,如果你需要兼容老旧版本的浏览器,可以使用以下函数。

// 本段代码截取自Paul Irish : https://gist.github.com/paulirish/1579671
(function() {
    var lastTime = 0;
    var vendors = ['ms', 'moz', 'webkit', 'o'];
    for(var x = 0; x < vendors.length && !window.requestAnimationFrame;   x) {
        window.requestAnimationFrame = window[vendors[x] 'RequestAnimationFrame'];
        window.cancelAnimationFrame = window[vendors[x] 'CancelAnimationFrame'] 
                                   || window[vendors[x] 'CancelRequestAnimationFrame'];
    }

    // 如果浏览器不支持,则使用setTimeout()
    if (!window.requestAnimationFrame)
        window.requestAnimationFrame = function(callback, element) {
            var currTime = new Date().getTime();
            var timeToCall = Math.max(0, 16 - (currTime - lastTime));
            var id = window.setTimeout(function() { callback(currTime   timeToCall); }, 
              timeToCall);
            lastTime = currTime   timeToCall;
            return id;
        };

    if (!window.cancelAnimationFrame)
        window.cancelAnimationFrame = function(id) {
            clearTimeout(id);
        };
}());

视野之外的绘制

有时候,Canvas 只是游戏世界的一个「窗口」,如果我们在每一帧中,都把整个世界全部画出来,势必就会有很多东西画到 Canvas 外面去了,同样调用了绘制 API,但是并没有任何效果。我们知道,判断对象是否在 Canvas 中会有额外的计算开销(比如需要对游戏角色的全局模型矩阵求逆,以分解出对象的世界坐标,这并不是一笔特别廉价的开销),而且也会增加代码的复杂程度,所以关键是,是否值得。

我做了一个实验,绘制一张 320×180 的图片 104 次,当我每次都绘制在 Canvas 内部时,消耗了 40ms,而每次都绘制在 Canvas 外时,仅消耗了 8ms。大家可以掂量一下,考虑到计算的开销与绘制的开销相差 2~3 个数量级,我认为通过计算来过滤掉哪些画布外的对象,仍然是很有必要的。

使用 Web Worker

前面讨论过刷新一帧消耗的最佳时间大概在10ms左右,但是一帧里面通常又包括JS处理、样式处理、布局、渲染等等,所以JS执行的时间最好控制在3~4ms。JS在浏览器的主线程上运行,如果运行时间过长,就会阻塞样式计算、布局等工作,这样可能导致帧丢失。

许多情况下,可以将纯计算性的工作移到Web Worker,比如,不需要访问DOM的时候。数据操作或者遍历(如排序或搜索)往往很适合这种模型,加载和模型生成也是如此。

Web Workers


我们知道JavaScript是单线程的,但浏览器可不是单线程的JavaScript在浏览器的主线程上运行,这恰好与样式计算、布局等许多其他情况下的渲染操作一起运行,如果JavaScript的运行时间过长,就会阻塞这些后续工作,导致帧丢失。

使用Chrome开发者工具的Timeline功能可以帮助我们查看每个JavaScript脚本的运行时间(包括子脚本),帮助我们发现并突破性能瓶颈。

图片 11

数据采自掘金

在找到影响性能的JavaScript脚本后,我们可以通过Web Workers进行优化。Web WorkersHTML5提出的一个标准,它可以让JavaScript脚本运行在后台线程(类似于创建一个子线程),而后台线程不会影响到主线程中的页面。不过,使用Web Workers创建的线程是不能操作DOM树的(这也是Web Workers没有颠覆JavaScript是单线程的原因,JavaScript之所以一直是单线程设计主要也是因为为了避免多个脚本操作DOM树的同步问题,这会提高很多复杂性),所以它只适合于做一些纯计算的工作(数据的排序、遍历等)。

如果你的JavaScript必须要在主线程中执行,那么只能选择另一种方法。将一个大任务分割为多个小任务(每个占用时间不超过几毫秒),并且在每帧的requestAnimationFrame()函数中运行:

var taskList = breakBigTaskIntoMicroTasks(monsterTaskList);
requestAnimationFrame(processTaskList);

function processTaskList(taskStartTime) {
  var taskFinishTime;

  do {
    // 从列表中弹出任务
    var nextTask = taskList.pop();

    // 执行任务
    processTask(nextTask);

    // 如果有足够的时间进行下一个任务则继续执行
    taskFinishTime = window.performance.now();
  } while (taskFinishTime - taskStartTime < 3);

  if (taskList.length > 0)
    requestAnimationFrame(processTaskList);

}

创建一个Web Workers对象很简单,只需要调用Worker()构造器,然后传入指定脚本的URI。现代主流浏览器均支持Web Workers,除了Internet Explorer(又是万恶的IE),所以我们在下面的示例代码中还需要检测浏览器是否兼容。

var myWorker;

if (typeof(Worker) !== "undefined") {
    // 支持Web Workers
    myWorker = new Worker("worker.js");
} else {
    // 不支持Web Workers
}

Web Workers与主线程之间通过postMessage()函数来发送信息,使用onmessage()事件处理函数来响应消息(主线程与子线程之间并没有共享数据,只是通过复制数据来交互)。

main.js: 
// 在主线程js中发送数据到myWorker绑定的js脚本线程
myWorker.postMessage("Hello,World");
console.log('Message posted to worker');

worker.js:
// onmessage处理函数允许我们在任何时刻,
// 一旦接收到消息就可以执行一些代码,代码中消息本身作为事件的data属性进行使用。
onmessage = function(data) {
    console.log("Message received from main script.");
    console.log("Posting message back to main script.");
    postMessage("Hello~");
}

main.js:
// 主线程使用onmessage接收消息
myWorker.onmessage = function(data) {
    console.log("Received message: "   data);
}

如果你需要从主线程中立刻终止一个运行中的worker,可以调用worker的terminate()函数:

myWorker.terminate();

myWorker会被立即杀死,不会有任何机会让它继续完成剩下的工作。而在worker线程中也可以调用close()函数进行关闭:

close();

有关更多的Web Workers使用方法,请参考Using Web Workers - Web APIs | MDN。

离屏绘制

上一节提到,绘制同样的一块区域,如果数据源是尺寸相仿的一张图片,那么性能会比较好,而如果数据源是一张大图上的一部分,性能就会比较差,因为每一次绘制还包含了裁剪工作。也许,我们可以先把待绘制的区域裁剪好,保存起来,这样每次绘制时就能轻松很多。

drawImage 方法的第一个参数不仅可以接收 Image 对象,也可以接收另一个 Canvas 对象。而且,使用 Canvas 对象绘制的开销与使用 Image 对象的开销几乎完全一致。我们只需要实现将对象绘制在一个未插入页面的 Canvas 中,然后每一帧使用这个 Canvas 来绘制。

JavaScript

// 在离屏 canvas 上绘制 var canvasOffscreen = document.createElement('canvas'); canvasOffscreen.width = dw; canvasOffscreen.height = dh; canvasOffscreen.getContext('2d').drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh); // 在绘制每一帧的时候,绘制这个图形 context.drawImage(canvasOffscreen, x, y);

1
2
3
4
5
6
7
8
// 在离屏 canvas 上绘制
var canvasOffscreen = document.createElement('canvas');
canvasOffscreen.width = dw;
canvasOffscreen.height = dh;
canvasOffscreen.getContext('2d').drawImage(image, sx, sy, sw, sh, dx, dy, dw, dh);
 
// 在绘制每一帧的时候,绘制这个图形
context.drawImage(canvasOffscreen, x, y);

离屏绘制的好处远不止上述。有时候,游戏对象是多次调用 drawImage 绘制而成,或者根本不是图片,而是使用路径绘制出的矢量形状,那么离屏绘制还能帮你把这些操作简化为一次 drawImage 调用。

第一次看到 getImageDataputImageData 这一对 API,我有一种错觉,它们简直就是为了上面这个场景而设计的。前者可以将某个 Canvas 上的某一块区域保存为 ImageData 对象,后者可以将 ImageData 对象重新绘制到 Canvas 上面去。但实际上,putImageData 是一项开销极为巨大的操作,它根本就不适合在每一帧里面去调用。

使用Timeline分析JS

当觉察到页面有卡顿的时候但又不知道是哪部分的JS造成的,这时可以打开Timeline录制时间轴,查看、分析是哪个地方的JS造成了页面卡顿,然后做针对性的JS优化。有关Timeline的使用,请参考《Chrome DevTools - Timeline》。

降低样式计算的复杂度


每次修改DOMCSS都会导致浏览器重新计算样式,在很多情况下还会对页面或页面的一部分重新进行布局计算。

计算样式的第一部分是创建一组匹配选择器(用于计算哪些节点应用哪些样式),第二部分涉及从匹配选择器中获取所有样式规则,并计算出节点的最终样式。

通过降低选择器的复杂性可以提升样式计算的速度。

下面是一个复杂的CSS选择器:

.box:nth-last-child(-n 1) .title {
  /* styles */
}

浏览器如果想要找到应用该样式的节点,需要先找到有.title类的节点,然后其父节点正好是负n个子元素 1个带.box类的节点。浏览器计算此结果可能需要大量的时间,但我们可以把选择器的预期行为更改为一个类:

.final-box-title {
  /* styles */
}

我们只是将CSS的命名模块化(降低选择器的复杂性),然后只让浏览器简单地将选择器与节点进行匹配,这样浏览器计算样式的效率会提升许多。

BEM是一种模块化的CSS命名规范,使用这种方法组织CSS不仅结构上十分清晰,也对浏览器的样式查找提供了帮助。

BEM其实就是Block,Element,Modifier,它是一种基于组件的开发方式,其背后的思想就是将用户界面划分为独立的块。这样即使是使用复杂的UI也可以轻松快速地开发,并且模块化的方式可以提高代码的复用性。

Block是一个功能独立的页面组件(可以被重用),Block的命名方式就像写Class名一样。如下面的.button就是代表<button>Block

.button {
    background-color: red;
}

<button class="button">I'm a button</button>

Element是一个不能单独使用的Block的复合部分。可以认为ElementBlock的子节点。

<!-- `search-form`是一个block -->
<form class="search-form">
    <!-- 'search-form__input'是'search-form' block中的一个element -->
    <input class="search-form__input">

    <!-- 'search-form__button'是'search-form' block中的一个element  -->
    <button class="search-form__button">Search</button>
</form>

Modifier是用于定义BlockElement的外观、状态或行为的实体。假设,我们有了一个新的需求,对button的背景颜色使用绿色,那么我们可以使用Modifier.button进行一次扩展:

.button {
    background-color: red;
}

.button--secondary {
    background-color: green;
}

第一次接触BEM的童鞋可能会对这种命名方式感到奇怪,但BEM重要的是模块化与可维护性的思想,至于命名完全可以按照你所能接受的方式修改。限于篇幅,本文就不再继续探讨BEM了,感兴趣的童鞋可以去看BEM的官方文档。

避免「阻塞」

所谓「阻塞」,可以理解为不间断运行时间超过 16ms 的 JavaScript 代码,以及「导致浏览器花费超过 16ms 时间进行处理」的 JavaScript 代码。即使在没有什么动画的页面里,阻塞也会被用户立刻察觉到:阻塞会使页面上的对象失去响应——按钮按不下去,链接点不开,甚至标签页都无法关闭了。而在包含较多 JavaScript 动画的页面里,阻塞会使动画停止一段时间,直到阻塞恢复后才继续执行。如果经常出现「小型」的阻塞(比如上述提及的这些优化没有做好,渲染一帧的时间超过 16ms),那么就会出现「丢帧」的情况,

CSS3 动画(transitionanimate)不会受 JavaScript 阻塞的影响,但不是本文讨论的重点。

图片 12

偶尔的且较小的阻塞是可以接收的,频繁或较大的阻塞是不可以接受的。也就是说,我们需要解决两种阻塞:

  • 频繁(通常较小)的阻塞。其原因主要是过高的渲染性能开销,在每一帧中做的事情太多。
  • 较大(虽然偶尔发生)的阻塞。其原因主要是运行复杂算法、大规模的 DOM 操作等等。

对前者,我们应当仔细地优化代码,有时不得不降低动画的复杂(炫酷)程度,本文前几节中的优化方案,解决的就是这个问题。

而对于后者,主要有以下两种优化的策略。

  • 使用 Web Worker,在另一个线程里进行计算。
  • 将任务拆分为多个较小的任务,插在多帧中进行。

Web Worker 是好东西,性能很好,兼容性也不错。浏览器用另一个线程来运行 Worker 中的 JavaScript 代码,完全不会阻碍主线程的运行。动画(尤其是游戏)中难免会有一些时间复杂度比较高的算法,用 Web Worker 来运行再合适不过了。

图片 13

然而,Web Worker 无法对 DOM 进行操作。所以,有些时候,我们也使用另一种策略来优化性能,那就是将任务拆分成多个较小的任务,依次插入每一帧中去完成。虽然这样做几乎肯定会使执行任务的总时间变长,但至少动画不会卡住了。

图片 14

看下面这个 Demo,我们的动画是使一个红色的 div 向右移动。Demo 中是通过每一帧改变其 transform 属性完成的(Canvas 绘制操作也一样)。

然后,我创建了一个会阻塞浏览器的任务:获取 4×106Math.random() 的平均值。点击按钮,这个任务就会被执行,其结果也会打印在屏幕上。

图片 15

如你所见,如果直接执行这个任务,动画会明显地「卡」一下。而使用 Web Worker 或将任务拆分,则不会卡。

以上两种优化策略,有一个相同的前提,即任务是异步的。也就是说,当你决定开始执行一项任务的时候,你并不需要立刻(在下一帧)知道结果。比如,即使战略游戏中用户的某个操作触发了寻路算法,你完全可以等待几帧(用户完全感知不到)再开始移动游戏角色。
另外,将任务拆分以优化性能,会带来显著的代码复杂度的增加,以及额外的开销。有时候,我觉得也许可以考虑优先砍一砍需求。

2. 样式

计算样式(computing styles)的第一部分是创建一组匹配选择器,以便浏览器计算出给指定元素应用哪些类、伪选择器和 ID。第二部分涉及从匹配选择器中获取所有样式规则,并计算出此元素的最终样式。
在当前的Chrome渲染引擎中,用于计算某元素计算样式的时间中大约有 50% 用来匹配选择器,而另一半时间则用于从匹配的规则中构建 RenderStyle。

降低选择器的复杂度:能写出高效率选择器的前端开发者本来就不多,又加上当前Less和Sass的普及,一些前端开发者对Less、Sass的滥用,导致编译后的css选择器有时候甚至能达到六七层嵌套,这大大增加了浏览器计算样式所消耗的总时间。
最理想的状态是每个元素都有一个唯一的id,这样选择器最简单也是最高效的,可是我们知道这是不现实的。但是,遵循一些指导原则依然能让我们写出较为高效的CSS选择器:Writing efficient CSS selectors

避免强制同步布局和布局抖动


浏览器每次进行布局计算时几乎总是会作用到整个DOM,如果有大量元素,那么将会需要很长时间才能计算出所有元素的位置与尺寸。

所以我们应当尽量避免在运行时动态地修改几何属性(宽度、高度等),因为这些改动都会导致浏览器重新进行布局计算。如果无法避免,那么要优先使用Flexbox,它会尽量减少布局所需的开销。

强制同步布局就是使用JavaScript强制浏览器提前执行布局。需要先明白一点,JavaScript运行时,来自上一帧的所有旧布局值都是已知的。

以下代码为例,它在每一帧的开头输出了元素的高度:

requestAnimationFrame(logBoxHeight);

function logBoxHeight() {
  console.log(box.offsetHeight);
}

但如果在请求高度之前,修改了其样式,就会出现问题,浏览器必须先应用样式,然后进行布局计算,之后才能返回正确的高度。这是不必要的且会产生非常大的开销。

function logBoxHeight() {
  box.classList.add('super-big');

  console.log(box.offsetHeight);
}

正确的做法,应该利用浏览器可以使用上一帧布局值的特性,然后再执行任何写操作:

function logBoxHeight() {
  console.log(box.offsetHeight);

  box.classList.add('super-big');
}

如果接二连三地发生强制同步布局,那么就会产生布局抖动。以下代码循环处理一组段落,并设置每个段落的宽度以匹配一个名为“box”的元素的宽度。

function resizeAllParagraphsToMatchBlockWidth() {
  for (var i = 0; i < paragraphs.length; i  ) {
    paragraphs[i].style.width = box.offsetWidth   'px';
  }
}

这段代码的问题在于每次迭代都会读取box.offsetWidth,然后立即使用此值来更新段落的宽度。在循环的下次迭代中,浏览器必须考虑样式更新这一事实(box.offsetWidth是在上一次迭代中请求的),因此它必须应用样式更改,然后执行布局。这会导致每次迭代都会产生强制同步布局,正确的做法应该先读取值,然后再写入值。

// Read.
var width = box.offsetWidth;

function resizeAllParagraphsToMatchBlockWidth() {
  for (var i = 0; i < paragraphs.length; i  ) {
    // Now write.
    paragraphs[i].style.width = width   'px';
  }
}

要想轻松地解决这个问题,可以使用FastDOM进行批量读取与写入,它可以防止强制布局同步与布局抖动。

小结

正文就到这里,最后我们来稍微总结一下,在大部分情况下,需要遵循的「最佳实践」。

  1. 将渲染阶段的开销转嫁到计算阶段之上。
  2. 使用多个分层的 Canvas 绘制复杂场景。
  3. 不要频繁设置绘图上下文的 font 属性。
  4. 不在动画中使用 putImageData 方法。
  5. 通过计算和判断,避免无谓的绘制操作。
  6. 将固定的内容预先绘制在离屏 Canvas 上以提高性能。
  7. 使用 Worker 和拆分任务的方法避免复杂算法阻塞动画运行。

    1 赞 5 收藏 评论

图片 16

3. 布局

使用不会触发布局与绘制的属性来实现动画


在像素管道一节中,我们发现有种属性修改后会跳过布局与绘制阶段,这显然会减少不少性能开销。目前只有两种属性符合这个条件:transformopacity

需要注意的是,使用transformopacity时,更改这些属性所在的元素应处于其自身的图层,所以我们需要将设置动画的元素单独新建一个图层(这样做的好处是该图层上的重绘可以在不影响其他图层上元素的情况下进行处理。如果你用过Photoshop,想必能够理解多图层工作的方便之处)。

创建新图层的最佳方式是使用will-change属性,该属性告知浏览器该元素会有哪些变化,这样浏览器可以在元素属性真正发生变化之前提前做好对应的优化准备工作。

.moving-element {
  will-change: transform;
}

// 对于不支持 will-change 但受益于层创建的浏览器,需要使用(滥用)3D 变形来强制创建一个新层
.moving-element {
  transform: translateZ(0);
}

但不要认为will-change可以提高性能就随便滥用,使用will-change进行预优化与创建图层都需要额外的内存和管理开销,随便滥用只会得不偿失。

尽可能避免布局操作

在修改CSS样式时,心里要清楚哪些属性会触发布局操作,能避免则避免。考虑到实际的开发情况,几乎避免不了啊~~如果无法避免,则要使用Timeline查看一下布局要花多长时间,并确定布局是否会造成性能瓶颈。如果布局消耗时间过多,则要从布局前面的JS和样式阶段查找一下原因,并做进一步的优化。
想知道哪些CSS属性会触发布局、绘制或合成?请查看CSS触发器

参考文献


  • Web | Google Developers

  • Using Web Workers - Web APIs | MDN

  • will-change - CSS | MDN

  • Quick start / Methodology / BEM

优先使用flexbox布局

如果用定位、浮动和flexbox都能达到相同的布局效果,在浏览器兼容的情况下,优先使用flexbox布局,不仅因为其功能强大,更是因为其性能在布局上更胜一筹。

避免强制同步布局

将一帧绘制到屏幕上会经历以下顺序:

首先执行JS,然后计算样式,然后布局。但是,某些JS有可能强制浏览器提前执行布局操作,变成 JS > Layout > Styles > Layout > Paint > Composite,这被称为强制同步布局(Forced Synchronous Layout)

用一个demo来说明一下FSL:

点击Trigger按钮,改变上面三个按钮的宽度,index.js内容如下:

1. var element1 = document.querySelector('.btn1');
2. var element2 = document.querySelector('.btn2');
3. var element3 = document.querySelector('.btn3');
4. var triggerBtn = document.querySelector('.trigger');
5. triggerBtn.addEventListener('click', function trigger(){
6.   // Read
7.   var h1 = element1.offsetWidth;
8.   // Write (invalidates layout)
9.   element1.style.width = (h1 * 2)   'px';
10.
11.   // Read (triggers layout)
12.   var h2 = element2.offsetWidth;
13.   // Write (invalidates layout)
14.   element2.style.width = (h2 * 2)   'px';
15.
16.   // Read (triggers layout)
17.   var h3 = element3.offsetWidth;
18.   // Write (invalidates layout)
19.   element3.style.width = (h3 * 2)   'px';
20. });

可以看到,读取offsetWidth属性会导致layout。但是,要注意的是,在 JS 运行时,来自上一帧的所有旧布局相关的值是已知的,并且可供查询。所以,在Timeline中看到第7行代码只是触发了Recalculate Style事件,并未触发Layout事件。当JS执行到第12行代码的时候,为了获取element2.offsetWidth,浏览器必须先执行计算样式(因为第9行代码改变了element1的width属性),然后执行布局,才能返回正确的宽度,第17行代码也是如此。这是不必要的,而且可能导致很大的时间开销。JS执行到第19行时,触发最终的Recalculate Style事件和Layout事件,渲染出新的一帧。

避免强制同步布局:先读取布局属性,然后批量处理样式更改。

...
6. // Read
7. var h1 = element1.clientHeight;
8. var h2 = element2.clientHeight;
9. var h3 = element3.clientHeight;
10.
11. // Write (invalidates layout)
12. element1.style.height = (h1 * 2)   'px';
13. element2.style.height = (h2 * 2)   'px';
14. element3.style.height = (h3 * 2)   'px';

// Document reflows at end of frame

可以看到,先读取布局属性,然后批量处理样式更改,只会导致最终的Layout,避免了FSL。

4. 绘制与合成

当在页面上进行交互时,想知道哪些区域被重新绘制了?打开DevTools的副面板,切换到Rendering,勾选“Paint Flashing”:

交互发生后,重新绘制的区域会闪烁绿色:

绘制并非总是绘制到内存中的单个图像上。实际上,如果必要,浏览器可以绘制到多个图像(层)上。这种方法的优点是,定期重绘的元素,或者通过动画变形在屏幕上移动的元素,可以在不影响其他元素的情况下进行处理。这和图像处理软件Photoshop、Sketch等层的概念是类似的,各个层可以在彼此的上面处理并合成,以创建最终图像。

创建新层的最佳方式是使用will-change CSS 属性,当其属性值为transform时,将会创建一个新的合成器层(compositor layer)

.moving-element {
  will-change: transform;
}

对于不支持will-change属性的浏览器,可以使用以下css做兼容处理:

.moving-element {
  transform: translateZ(0);
}

需要注意的是:不要创建太多层,因为每层都需要内存和管理开销。如果你已将一个元素提升到一个新层,最好使用 DevTools 确认一下这样做能带来性能优势。请勿在不分析的情况下提升元素

最后说一下如何使用Timeline了解网页中的层。

勾选Paint,然后录制Timeline,然后点击单个帧,这时详情选项里面多了个“layer”选项卡,切换到此选项卡。展开左侧#document,即可看到页面里面有多少个层(layer),单击每个层时,右侧还会显示这个层被创建的原因。
如果在性能关键操作期间(比如滚动或动画)花了很多时间在合成上(应当力争在4-5ms左右),则可以使用此处的信息来查看页面有多少层、创建层的原因,进一步去管理页面中的层数。


References

  • https://developers.google.com/web/fundamentals/performance/rendering/
  • https://cn.udacity.com/course/browser-rendering-optimization--ud860/
  • https://gist.github.com/paulirish/5d52fb081b3570c81e3a
  • http://wilsonpage.co.uk/preventing-layout-thrashing/
  • http://stackoverflow.com/questions/510213/when-does-reflow-happen-in-a-dom-environment

本文由星彩网app下载发布于前端技术,转载请注明出处:渲染品质,浏览器渲染

TAG标签:
Ctrl+D 将本页面保存为书签,全面了解最新资讯,方便快捷。