前端交互动画优化

2,349 阅读12分钟
原文链接: bluest.me

前端优化是个很广泛的命题,铺开去得出本书了(事实上我也没那本事),实际上市面上也有很多相关的书籍。动画与交互上的性能问题最容易被察觉,特别是在机能较低的移动端。由于自己有过一段移动开发的经历,较为关注这块且作为一个爱拾人牙慧的切图狗,现将一些他人成熟的优化方法总结如下:

当然,所有的优化都是有场景,请根据实际的场景去选择最优的方案使用。

DOM 相关

DOM 天生就慢,如下比喻就很形象的解释了这样的关系。

把 DOM 和 js(ECMAScript)各自想象为一座岛屿,它们之间用收费桥进行连接。ECMAScript 每次访问 DOM,都要途径这座桥,并交纳“过桥费”。访问 DOM 的次数越多,费用也就越高。

最基本的优化思路就是优化 DOM 的读写操作。

减少对 DOM 元素读操作:

缓存 DOM 引用

获取 DOM 之后请将引用缓存,不要重复获取。很多人在使用 jQuery 的时候没有培养良好的习惯,链式调用用起来方便,但有时候会让人调入忽视缓存 DOM 的陷阱,因为获取太便捷就不去珍惜了,果然被偏爱的就会有恃无恐。

var render = (function() {
    // get DOM 
    var domCache = document.querySelector("dom");
    
    return function() {
        // do something...
        domCache.style.width =  '100px';
        domCache.style.height = '100px';
        // ....
    }
})();

缓存 DOM 的属性

思路同上,在获取初始值后并且已知变化量,直接通过计算得知元素变化后的值并缓存在内存中,避免将结果值使用 DOM 属性进行存储。可以减少很多不必要的 DOM 读取操作,特别是某些属性还会引发浏览器回流(这些属性下文会提及)。这在用 JavaScript 控制一些物体位置变化的时候比较容易忽略。jQuery 时代,人们习惯于将数据保存在 DOM 元素上,殊不知这将引发性能问题,我曾今就犯过类似的错误,导致一个移动端上的赛车游戏性能低下。

// bad
var dom = document.querySelector("dom");
var step = 1;

var timer = window.setInterval(function () {
    var left = parseInt(dom.style.left);
    if (left >= 200) {
        clearInterval(timer);
    }
    dom.style.left = (left +1) + 'px';
}, 1000 / 60);

// good
var dom = document.querySelector("dom");
var step = 1;
var left = parseInt(dom.style.left);

var timer = window.setInterval(function () {
    if (left >= 200) {
        clearInterval(timer);
    }
    left++;
    dom.style.left = left + 'px';
}, 1000 / 60);  

还有常见的就是缓存 HTMLCollection 的 length,HTMLCollection 还有一个很重要的特性就是它是根据页面的情况动态更新的,如果你更新的页面那么它的内容也会发生变化,下面的代码会是无限循环。

var divs = document.getElementsByTagName("div") ;
for(var i = 0 ; i < divs.length ; i ++){
    document.body.appendChild(document.createElement("div")) ;
}

减少 DOM 的写操作

记录上次结果与现有结果 Diff, 如有变化才进行写操作,去除不必要的写操作。

var dom = document.querySelector('#dom');
var lastVal =  null;
var currVal = null;

if (lastVal !== currVal) {
    dom.style.someAttr = currVal;
}

避免循环操作 DOM 元素

循环中操作 DOM,每次循环都会产生一次读操作与写操作,所以我们的优化思路是将循环结果缓存起来,循环结束后统一操作能节省很多读写操作。

合并多次写操作

// bad
for (var i = 0; length < 100; i++) {
    // 一次 get,一次 set
    document.getElementById('text').innerHTML += `text${i}`
}

// better
var html = '';

for (var i = 0; length < 100; i++) {
    html += `text${i}`
}

document.getElementById('text').innerHTML = html;

使用 documentFragment

另外 documentFragment 也可达到这样的目的,因为文档片段存在于内存中,并不在 DOM 树中,所以将子元素插入到文档片段时不会引起页面回流。因此,使用文档片段 document fragments 通常会起到优化性能的作用。

var fragment = document.createDocumentFragment();

for (var i = 0; length < 100; i++) {
    var div = document.createElement('div');
    div.innerHTML = i;
    fragment.appendChild(div);
}

document.body.appendChild(fragment)

至于上文中 innerHTMLfragment 谁更快,请看这里,有此文还引申出新的优化规则:优先使用 innerHTML(甚至是更好地 insertAdjacentHTML) 与 fragment

回流(reflow)与重绘(repaint)

如果了解过浏览器的渲染原理,我们知道,重绘和回流的性能消耗是非常严重的,破坏用户体验,造成UI卡顿。回流也叫重排,回流一定会引起重绘,重绘不一定会触发回流。触发浏览器回流与重绘的条件有:

  • 添加或者删除可见的DOM元素
  • 元素位置改变
  • 元素尺寸改变
  • 元素内容改变
  • 页面渲染初始化
  • 浏览器窗口尺寸改变,字体大小改变,页面滚动

我们的优化思路是减少甚至避免触发浏览器产生回流与重绘。

避免一些引起浏览器回流的属性

当获取一些属性值时,浏览器为取得正确的值也会发生重排,这些属性包括:

  • Element:
    • offsetTopoffsetLeftoffsetWidthoffsetHeight
    • scrollTopscrollLeftscrollWidthscrollHeight
    • clientTopclientLeftclientWidthclientHeight
  • Frame, HTMLImageElement:
    • heightwidth
  • Range:
    • getBoundingClientRect(),
    • getClientRects()
  • SVGLocatable:

    • computeCTM()
    • getBBox()
  • SVGTextContent:

    • getCharNumAtPosition()
    • getComputedTextLength()
    • getEndPositionOfChar()
    • getExtentOfChar()
    • getNumberOfChars()
    • getRotationOfChar()
    • getStartPositionOfChar()
    • getSubStringLength()
    • selectSubString()
  • SVGUse:

    • instanceRoot
  • window:

    • getComputedStyle()
    • scrollBy()scrollTo()scrollXscrollY
    • webkitConvertPointFromNodeToPage()webkitConvertPointFromPageToNode()

更全面的属性请访问这个Gist

display:none的元素上进行操作

如果 DOM 元素上需要进行很多操作,可以让该 DOM 元素从 DOM 树中"离线"——display:none,等操作完毕后再”上线“取消display:none。这样能去除在操作期间引发的回流与重绘。

操作 cloneNode

也可以将当前节点克隆一份,操作克隆节点,操作完毕之后再替换原节点。

浏览器优化

重排和重绘很容易被引起,而且重排的花销也不小,如果每句 JavaScript 操作都去重排重绘的话,浏览器可能就会受不了。所以很多浏览器都会优化这些操作,浏览器会维护一个队列,把所有会引起重排、重绘的操作放入这个队列,等队列中的操作到了一定的数量或者到了一定的时间间隔,浏览器就会 flush 队列,进行一个批处理。这样就会让多次的重排、重绘变成一次重排重绘。

var dom = document.querySelector("#dom");

// 触发两次 layout
var newWidth = dom.offsetWidth + 10;   // Read  
aDiv.style.width = newWidth + 'px';     // Write  
var newHeight = dom.offsetHeight + 10; // Read  
aDiv.style.height = newHeight + 'px';   // Write

// 只触发一次 layout
var newWidth = dom.offsetWidth + 10;   // Read  
var newHeight = dom.offsetHeight + 10; // Read  
aDiv.style.width = newWidth + 'px';     // Write  
aDiv.style.height = newHeight + 'px';   // Write  

一次性修改元素

每次修改 DOM 元素,都可能引起浏览器的回流与重绘,尽可能去较少改变次数,这与上文优化 DOM 读写思路重合不再赘述。

通过样式去改变元素样式
// bad
var dom = document.getElementById('dom');
dom.style.color = '#FFF';
dom.style.fontSize = '12px';
dom.style.width = '200px';

上述例子每次修改 style 属性后都会触发元素的重绘,如果修改了的属性涉及大小和位置,将会导致回流。所以我们应当尽量避免多次为一个元素设置 style 属性,应当通过给其添加新的 CSS 类,来修改其样式。

<!--better-->
<style>
.my-style {
    color: #FFF;
    font-size: 12px;
    width: 200px;
}
</style>

<script>
    var dom = document.getElementById('dom');
    dom.classList.add('my-style');
</script>
cssText

同上文优化思路,用cssText也可达到类似目的。

 var dom = document.getElementById('dom');
 dom.style.cssText = 'color: #FFF;font-size: 12px;width: 200px;'

简化 DOM 结构

首先每个 DOM 对象的都会占据浏览器资源,占据的资源与数量成正相关。另外,DOM 结构越深,最里面 DOM 元素的变化可能引发的祖先 DOM 数量就越多。

使用场景例如大量数据表格的展示,几万个 DOM 就能把浏览器卡得不要不要的甚至直接奔溃。我曾经遇到这样真实的案例,后在保持后端接口不变的情况下,采用前端假分页解决。

DOM 事件优化

使用事件委托或事件代理

使用事件代理与每个元素都绑定事件相比,能够节省更多的内存。当然还有另外的好处,就是新增加假的 DOM 元素也无需绑定事件了,这里不详述。

截流函数

首先这样场景下,在页面滚动的时候需根据页面滚动位置做一些操作,但是 scroll 事件触发过于频繁,导致绑定的事件执行频率太高开销太大。我们就需要采取一些措施来降低事件被执行的频率。

节流实际上就降低函数触发的频率。

let throttle = (func, wait) => {
    let context, args;
    let previous = 0;

    return function () {
        var now = +new Date();
        context = this;
        args = arguments;
        if (now - previous > wait) {
            func.apply(context, args);
            previous = now;
        }
    };
};

防抖函数

说道节流,不得不提防抖,相交于节流的降低触发的频率,防抖函数实际上是延后函数执行的时机,一般情况下,防抖比截流更节省性能。

let debounce = (func, wait) => {
    let timeout;
    return function () {
        let context = this;
        let args = arguments;
        clearTimeout(timeout);
        timeout = setTimeout(function () {
            func.apply(context, args)
        }, wait);
    };
};

使用场景例如一个输入框的实时搜索,对用户而言其实想要输入的关键词是输入完成的最终结果,而程序需要实时针对用户输入的无效关键词进行响应,这无疑是种浪费。我们需要

CSS

文档流中元素样式改变可能触发浏览器回流,被影响的 DOM 树越大,需要重绘的时间就越长,也就可能导致性能问题。CSS Triggers 就列举了会引发浏览器,回流与重绘的属性。

使用定位让元素脱离文档流

使用定位让元素脱离文档流,引发回流重绘的 DOM 树范围被大大缩小。

.selector {
    position: fixed;
    // or
    position: absolute; 
}

使用 transform 与 opacity

transform 和 opacity 保证了元素属性的变化不影响文档流、也不受文档流影响,并且不会造成重绘。

FLTP

FLIP 来源于 First,Last,Invert,Play。FLIP 是将一些开销高昂的动画,如针对 widthheightlefttop 的动画,映射为 transform 动画。通过记录元素的两个快照,一个是元素的初始位置(First – F),另一个是元素的最终位置(Last – L),然后对元素使用一个 transform 变换来反转(Invert – I),让元素看起来还在初始位置,最后移除元素上的 transform 使元素由初始位置运动(Play – P)到最终位置。

触发 GPU 加速

使用 GPU 硬件加速可以使得浏览器动画更加流畅,不过切勿贪杯, GPU 加速是损耗硬件资源为代价的,会导致移动端设备续航能力的降低。

.selectror {
    webkit-transform: translateZ(0);
    -moz-transform: translateZ(0);
    -ms-transform: translateZ(0);
    -o-transform: translateZ(0);
    transform: translateZ(0);
}
// 或者
.selector {
    webkit-transform: translate3d(0,0,0);
    -moz-transform: translate3d(0,0,0);
    -ms-transform: translate3d(0,0,0);
    -o-transform: translate3d(0,0,0);
    transform: translate3d(0,0,0);
}

transform 在浏览器中可能有一些非预期内的表现,比如闪烁等,可以使用如下代码 hack:

.selector {
    -webkit-backface-visibility: hidden;
    -moz-backface-visibility: hidden;
    -ms-backface-visibility: hidden;
    backface-visibility: hidden;

    -webkit-perspective: 1000;
    -moz-perspective: 1000;
    -ms-perspective: 1000;
    perspective: 1000;
}

will-change

上一种方式其实是欺骗浏览器,达到浏览器“误以为”需要 GPU 渲染加速,而 will-change 则是很礼貌的告知浏览器“这里会变化,请先做好准备”。不过切勿贪杯,适度使用。

.selector {
    will-change: auto
    will-change: scroll-position
    will-change: contents
    will-change: transform        // Example of <custom-ident> 
    will-change: opacity          // Example of <custom-ident>
    will-change: left, top        // Example of two <animateable-feature>
    
    will-change: unset
    will-change: initial
    will-change: inherit
}

避免复杂的 CSS 选择器以及 calc

复杂的 CSS 选择器会导致浏览器作大量的计算,我们应当避免

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

避免动画中使用使用高开销的CSS属性

CSS 有些属性存性能问题,使用它们会导致浏览器进行大量计算,特别是在 animation 中,我们应该谨慎使用,

  • box-shaow
  • background-image:
  • filter
  • border-radius
  • transforms
  • filters

使用 flexbox 布局替代 浮动布局

新版 flexbox 一般比旧版 flexbox 或基于浮动的布局模型更快

Canvas

对于渲染频率不一致的场景,采用分屏绘制

有些动画场景比如游戏中,背景一般变化较游戏物体运动较少,我们就可以把这些跟新频率较低的物体分离出形成一个更新频率更低的 Canvas 层。

帧率与帧生成

帧率或帧率是用于测量显示帧数的量度。测量单位为“每秒显示帧数”(Frame per Second,FPS)或“赫兹”,一般来说 FPS 用于描述视频、电子绘图或游戏每秒播放多少幀。
via Wikipedia

上文说了那么多,其实都是在为人眼的感受服务。一般来说电影帧率每秒 24 帧,对一般人而言已算可接受了。但是游戏与页面动效追求 60 帧乃至更高,因为电影画面是预先处理过的,运动画面中包含了画面运动信息 —— 也就是我们人眼看快速运动的物体产生的模糊感,人脑会根据这些模糊感去脑补画面的运动感。而游戏或者交互动画很多是实时绘制出来的,并不包含模糊人脑自然也无法脑补了,所以对帧率更加苛刻,这也是为什么有些游戏会有动态模糊弥补游戏帧率不足来改善游戏观感这个选项了。

使用微任务分解大量计算

除了人们关注的帧率,帧生成时间也很重要。假使帧率过关但是生成时间不够恒定,就容易产生跳帧感,就好比一锅粥里的老鼠屎。解决方法就是分解高计算量的操作,维护成任务列表平均分布到刷新间隔中去执行。谢谢聂俊在讲解游戏刷新率的启发,玩游戏也能学知识!哎呀,串场了这是机核的口号~~

使用 requestAnimationFrame

相比 setTimeOut,setInterval 恒定间隔刷新方案,requestAnimationFrame 能充分利用显示器的刷新机制,与浏览器的刷新频率保持同步,带来更加流畅的动画。

另外使用 requestAnimationFrame 页面处于非激活状态,动画也会停止执行,这样更加节省机器能耗。

Web Worker

JavaScript 是单线程的,大量的计算会阻塞线程,导致浏览器丢帧。Web Worker 给 JavaScript 开辟新的线程的能力,我们可以将纯计算在 Web Worker 中处理。

参考

  1. developers.google.com/web/fundame…
  2. bubkoo.com/2016/03/31/…