查看原文
其他

【第1339期】如何不择手段提升scroll事件的性能

escawn 前端早读课 2019-06-01

前言

不要被项目的类型所限制,不然只能定向了。今日早读文章由@escawn分享。

正文从这开始~

TL;DR

  1. chrome devtool 是诊断页面滚动性能的有效工具

  2. 提升滚动时性能,就是要达到fps高且稳。

  3. 具体可以从以下方面着手

  • 使用web worker分离无页面渲染无关的逻辑计算

  • 触发监听事件时使用函数节流与函数去抖

  • 使用requestAnimationFrame与requestIdleCallback代替定时器

  • 避免强制重排

  • 提升合成层

场景

滚动行为无时无刻不出现在我们浏览网页的行为中,在许多场景中,我们有有意识地、主动地去使用滚动操作,比如:

  • 懒加载

  • loadmore

  • affix

  • 回到顶部

以上场景伴随着滚动事件的监听操作,一不留神可能就让页面的滚动不再“如丝般顺滑”。

不择手段打造一个卡顿的scroll场景:


作为一名优秀的前端工程师(未来的),怎么能容许出现这种情况!不就性能优化吗,撩起袖子就是干!

原理

在一个流畅的页面变化效果中(动画或滚动),渲染帧,指的是浏览器从js执行到paint的一次绘制过程,帧与帧之间快速地切换,由于人眼的残像错觉,就形成了动画的效果。那么这个“快速”,要达到多少才合适呢?

我们都知道,下层建筑决定了上层建筑。受限于目前大多数屏幕的刷新频率——60次/s,浏览器的渲染更新的页面的标准帧率也为60次/s—60FPS(frames/per second)。

  • 高于这个数字,在一次屏幕刷新的时间间隔16.7ms(1/60)内,就算浏览器渲染了多次页面,屏幕也只刷新一次,这就造成了性能的浪费。

  • 低于这个数字,帧率下降,人眼就可能捕捉到两帧之间变化的滞涩与突兀,表现在屏幕上,就是页面的抖动,大家通常称之为卡顿

来个比喻。快递每天整理包裹,并一天一送。如果某天包裹太多,整理花费了太多时间,来不及当日(帧)送到收件人处,那就延期了(丢帧)。

那么在这16.7ms之内,浏览器都干了什么呢?

浏览器内心OS:不要老抱怨我延期(丢帧),我也很忙的好伐?

帧维度解释帧渲染过程

浏览器渲染页面的Renderer进程里,涉及到了两个线程,二者之间通过名为Commit的消息保持同步:

  • Main线程:浏览器渲染的主要执行步骤,包含从JS执行到Composite合成的一系列操作(下文会介绍)

  • Compositor线程:接收用户的一些交互操作(比如滚动) => 唤起Main线程进行操作 => 接收Main线程的操作结果 => commit给真正把页面draw到屏幕上的GPU进程

标准渲染帧:

在一个标准帧渲染时间16.7ms之内,浏览器需要完成Main线程的操作,并commit给Compositor进程

丢帧

主线程里操作太多,耗时长,commit的时间被推迟,浏览器来不及将页面draw到屏幕,这就丢失了一帧

那么Main线程里都有些什么操作会导致延时呢?

进一步解释浏览器主要执行步骤

  • JavaScript:包含与视觉变化效果相关的js操作。包括并不限于:dom更新、元素样式动态改变、jQuery的animate函数等。

  • Style:样式计算。这个过程,浏览器根据css选择器计算哪些元素应该应用哪些规则,然后将样式规则落实到每个元素上去,确定每个元素具体的样式。

  • Layout:布局。在知道对一个元素应用哪些规则之后,浏览器即可开始计算它要占据的空间大小及其在屏幕的位置。

  • Painting:绘制。绘制是填充像素的过程。它涉及绘出文本、颜色、图像、边框和阴影,基本上包括元素的每个可视部分。绘制一般是在多个表面(通常称为层)上完成的。(paint和draw的区别:paint是把内容填充到页面,而draw是把页面反映到屏幕上)

  • Composite:合成。由于页面的各部分可能被绘制到多层,由此它们需要按正确顺序绘制到屏幕上,以便正确渲染页面。对于与另一元素重叠的元素来说,这点特别重要,因为一个错误可能使一个元素错误地出现在另一个元素的上层。

理论上,每次标准的渲染,浏览器Main线程需要执行JavaScript => Style => Layout => Paint => Composite五个步骤,但是实际上,要分场景。

再进一步解释浏览器渲染流程

流程:

  1. Compositor线程接收一个vsync信号,表示这一帧开始

  2. Compositor线程接收用户的交互输入(比如touchmove、scroll、click等)。然后commit给Main线程,这里有两点规则需要注意:

  • 并不是所有event都会commit给Main线程,部分操作比如单纯的滚动事件,打字等输入,不需要执行JS,也没有需要重绘的场景,Compositor线程就自己处理了,无需请求Main线程

  • 同样的事件类型,不论一帧内被Compositor线程接收多少次,实际上commit给Main线程的,只会是一次,意味着也只会被执行一次。(HTML5标准里scroll事件是每帧触发一次)

  1. Main线程执行从JavaScript到Composite的过程,也有两点需要注意:

  • 注意红线,意思是可能会在JS里强制重排,当访问scrollWidth系列、clientHeight系列、offsetTop系列、ComputedStyle等属性时,会触发这个效果,导致Style和Layout前移到JS代码执行过程中。

  • 实际上图中省略了Renderer进程中的其他线程,比如当Main线程走到js执行这一步时,会调起单独的js线程来执行。另外还有如HTML解释线程等。

  1. 当Main线程完成最后合成之后,与Compositor线程使用commit进行通信,Compositor调起Compositor Tile Work(s)来辅助处理页面。Rasterize意为光栅化,想深入了解什么是光栅的小伙伴可以戳这里了解:浏览器渲染详细过程:重绘、重排和composite只是冰山一角

  2. 页面paint结束之后,这一帧就结束了。GPU进程里的GPU线程负责把Renderer进程操作好的页面,交由GPU,调用GPU内方法,由GPU把页面draw到屏幕上。

  3. 屏幕刷新,我们就在浏览器(屏幕)上看到了新页面。

接下来,简要介绍一下,如何使用chrome devtool分析页面性能。

示意图(chrome version: 61):

  • 帧率概览。看顶端绿色长条,越高代表帧率越高,高低起伏多代表帧率变化不稳定,越坑坑洼洼代表容易产生视觉上的卡顿。

  • 分析具体某一帧。如果发现,有哪一帧帧率特别低,可以在中间那一栏找到耗时长的那一帧,点击进行具体的活动分析。

分析个活动耗时。自由选择某一段或某一帧观察这段时间内各项活动的耗时来诊断页面。(注意颜色)

应该注意,我们可以看见,很少有帧的时间准确卡在了16.7s,实际上每帧达到60fps的帧率,只是一个理想化的数字,浏览器执行过程中可能受到各种情况的干扰。而我们人眼也没有那么灵敏,只要达到20帧以上,页面看起来就比较流畅了。尤其是结构复杂,数据较多的页面,盲目追求60fps只是钻牛角尖。所以,以我浅见,稳定的fps更能影响scroll效果。

解决方案

我们的目标很明确,就是拒绝卡顿!具体说来就是尽量赶在16.7ms之内让浏览器完成五项工作,压缩每个步骤时间。

使用web worker

当我们了解了浏览器渲染时执行的过程,并且清楚浏览器内核处理方式(处理js的线程与GUI页面渲染线程互斥)之后,我们很容易假想出这样一种状况:如果js大量的计算和逻辑操作霸占着浏览器,使页面渲染得不到处理,怎么办?

这种情况,很容易造成scroll的卡顿,甚至浏览器假死,就像alert()出现一样。

想象一下吧,本来大家好好地按照生理周期一个接一个上厕所,突然小j便秘了!你说排在他后面的小g急不急,可急死了!

web worker是什么?

Web Worker为Web内容在后台线程中运行脚本提供了一种简单的方法。线程可以执行任务而不干扰用户界面。

这就好像,给容易“便秘”的小j,单独搭了个简易厕所。

之所以说这是一个简易厕所,因为它有一些限制

  • 无法访问DOM节点

  • 无法访问全局变量或是全局函数

  • 无法调用alert()或者confirm之类的函数

  • 无法访问window、document之类的浏览器全局变量

主线程和 worker 线程之间通过这样的方式互相传输信息:两端都使用 postMessage() 方法来发送信息, 并且通过 onmessage 这个 event handler来接收信息。 (传递的信息包含在 Message 这个事件的数据属性内) 。数据的交互是通过传递副本,而不是直接共享数据。

使用案例 - 判断素数

案例来自Web Workers, for a responsive JavaScript application

素数,定义为在大于1的自然数中,除了1和它本身以外不再有其他因数。判断算法为,以2到它的平方根为界取整数做循环判断,用它和这个数字求余数,只要中间任意一次计算得到余数为零,则能够确认这个数字不是质数。

code:

// in html
<script type="text/javascript">
// we will use this function in-line in this page
function isPrime(number)
{
   
if (number === 0 || number === 1) {
       
return true;
   
}
   
var i;
   
for (i = 2; i <= Math.sqrt(number); i++) {
       
if (number % i === 0) {
           
return false;
       
}
   
}
   
return true;
}
// a large number, so that the computation time is sensible
var number = "1000001111111111";
// including the worker's code
var w = new Worker('webworkers.js');
// the callback for the worker to call
w
.onmessage = function(e) {
   
if (e.data) {
       alert
(number + ' is prime. Now I\'ll try calculating without a web worker.');
       
var result = isPrime(number);
       
if (result) {
           alert
('I am sure, it is prime. ');
       
}
   
} else {
       alert
(number + ' is not prime.');
   
}
};
// sending a message to the worker in order to start it
w
.postMessage(number);
</script>
<p style="height: 200px; width: 400px; overflow: scroll;">
Lorem ipsum dolor sit amet, consectetur adipiscing elit. Fusce blandit tristique risus, a rhoncus nisl posuere sed. Praesent vel risus turpis, et fermentum lectus. Ut lacinia nunc dui. Sed a velit orci. Maecenas quis diam neque. Vestibulum id arcu purus, quis cursus arcu. Etiam luctus, risus eu scelerisque scelerisque, sapien felis tincidunt ante, vel pellentesque eros nunc at magna. Nam tincidunt mattis velit ut condimentum. Vivamus ipsum ipsum, venenatis vitae placerat eu, convallis quis metus. Quisque tortor sapien, dapibus non vehicula quis, dapibus at purus. Nunc posuere, ligula sed facilisis sagittis, justo massa placerat nulla, nec pellentesque libero erat ut ligula. Aenean molestie, urna quis molestie auctor, lorem purus hendrerit nisi, vitae tincidunt metus massa et dolor. Sed leo velit, iaculis tristique elementum tincidunt, ornare et tellus. Quisque lacinia felis at est faucibus in facilisis dui consectetur. Phasellus sed ante id tortor pretium ornare. Aliquam ante justo, aliquam ut mollis semper, mattis sit amet urna. Pellentesque placerat, diam nec consectetur blandit, libero metus placerat massa, quis mattis metus metus nec lorem.
</p>
// in webworkers.js
function isPrime(number)
{
   
if (number === 0 || number === 1) {
       
return true;
   
}
   
var i;
   
for (i = 2; i <= Math.sqrt(number); i++) {
       
if (number % i === 0) {
           
return false;
       
}
   
}
   
return true;
}
// this is the point of entry for the workers
onmessage
= function(e) {
   
// you can support different messages by checking the e.data value
   number
= e.data;
   result
= isPrime(number);
   
// calling back the main thread
   postMessage
(result);
};

代码说明:

  • 使用web worker对一个较大数字(1000001111111111)进行素数判断

  • 得到结果之后alert(number + ‘ is prime. Now I’ll try calculating without a web worker.’)

  • 在不使用web worker的情况下,对相同数字进行素数判断,完成后alert(‘I am sure, it is prime. ‘)

  • 从页面标签里的内容的滚动情况判断两次计算对浏览器/页面造成的影响

现场还原:

案例总结:

从两次alert之后的段落滚动情况(第二次根本动不了),足以看出大量繁杂的js计算对页面的影响。恰当地使用web worker,能有效缓解页面scroll阻塞的情况。

而且它的支持率也良好~

在应用方面,Angular已经做了一些尝试。

解密Angular WebWorker Renderer (一):想办法打破web worker本身不能操作dom元素等限制,利用web worker执行渲染操作

函数节流与函数去抖

针对scroll事件中的回调,思路之一是对事件进行“稀释”,减少事件回调的执行次数。

这就涉及到两个概念:函数节流和函数去抖

  • 函数节流(throttle):让函数在指定的时间段内周期性地间断执行

  • 函数去抖(debounce):让函数只有在过完一段时间后并且该段时间内不被调用才会被执行

有人这样比喻:

就像一窝蜂的人去排队看演出,队伍很乱,看门的老大爷每隔1秒,让进一个人,这个叫throttle,如果来了这一窝蜂的人,老大爷一次演出只让进一个人,下次演出才让下一个人进,这个就叫debounce

OK, text is long, show you code.

以下code来自underscore.js(类似jQuery的库,封装了一些方法)

// Returns a function, that, when invoked, will only be triggered at most once
 
// during a given window of time. Normally, the throttled function will run
 
// as much as it can, without ever going more than once per `wait` duration;
 
// but if you'd like to disable the execution on the leading edge, pass
 
// `{leading: false}`. To disable execution on the trailing edge, ditto.
 _
.throttle = function(func, wait, options) {
   
var timeout, context, args, result;
   
// 标记时间戳
   
var previous = 0;
   
// options可选属性 leading: true/false 表示第一次事件马上触发回调/等待wait时间后触发
   
// options可选属性 trailing: true/false 表示最后一次回调触发/最后一次回调不触发
   
if (!options) options = {};
   
var later = function() {
     previous
= options.leading === false ? 0 : _.now();
     timeout
= null;
     result
= func.apply(context, args);
     
if (!timeout) context = args = null;
   
};
   
var throttled = function() {
     
// 记录当前时间戳
     
var now = _.now();
     
// 如果是第一次触发且选项设置不立即执行回调
     
if (!previous && options.leading === false)
     
// 将记录的上次执行的时间戳置为当前
     previous
= now;
     
// 距离下次触发回调还需等待的时间
     
var remaining = wait - (now - previous);
     context
= this;
     args
= arguments;
     
// 等待时间 <= 0或者不科学地 > wait(异常情况)
     
if (remaining <= 0 || remaining > wait) {
       
if (timeout) {
           
// 清除定时器
         clearTimeout
(timeout);
         
// 解除引用
         timeout
= null;
       
}
       
// 将记录的上次执行的时间戳置为当前
       previous
= now;
       
// 触发回调
       result
= func.apply(context, args);
       
if (!timeout) context = args = null;
     
}
     
// 在定时器不存在且选项设置最后一次触发需要执行回调的情况下
     
// 设置定时器,间隔remaining时间后执行later
     
else if (!timeout && options.trailing !== false)    {
       timeout
= setTimeout(later, remaining);
     
}
   
return result;
   
};
   throttled
.cancel = function() {
     clearTimeout
(timeout);
     previous
= 0;
     timeout
= context = args = null;
   
};
   
return throttled;
 
};
   
// Returns a function, that, as long as it continues to be invoked, will not
 
// be triggered. The function will be called after it stops being called for
 
// N milliseconds. If `immediate` is passed, trigger the function on the
 
// leading edge, instead of the trailing.
 _
.debounce = function(func, wait, immediate) {
   
var timeout, result;
   
// 定时器设置的回调,清除定时器,执行回调函数func
   
var later = function(context, args) {
     timeout
= null;
     
if (args) result = func.apply(context, args);
   
};
   
// restArgs函数将传入的func的参数改造成Rest Parameters —— 一个参数数组
   
var debounced = restArgs(function(args) {
     
if (timeout) clearTimeout(timeout);
     
if (immediate) {
       
// 立即触发的条件:immediate为true且timeout为空
       
var callNow = !timeout;
       timeout
= setTimeout(later, wait);
       
if (callNow) result = func.apply(this, args);
     
} else {
       
// _.delay方法实际上是setTimeout()包裹了一层参数处理的逻辑
       timeout
= _.delay(later, wait, this, args);
     
}
     
return result;
   
});
   debounced
.cancel = function() {
     clearTimeout
(timeout);
     timeout
= null;
   
};
   
return debounced;
 
};

对比以上代码,我们可以发现,两种方法应用的场景时有差别的

  • 函数节流:适用于多次提交(commit)的场景,如点击按钮提交发送请求的情况

  • 函数去抖:适用于scroll/resize等场景

相对于多次触发只执行一次的debounce,间隔地执行回调的throttle更能满足“稀释”scroll事件的需求。

至于wait的设定值,到底多久执行一次比较合适?很大部分还是取决于具体的场景&代码复杂度,但是这里有一个例子可以参考:Learning from Twitter

2011年Twitter出现过滚动性能差到严重影响用户体验的案例,原因是

It’s a very, very, bad idea to attach handlers to the window scroll event.

Always cache the selector queries that you’re re-using.

最后采用了函数节流的办法:

var outerPane = $details.find(".details-pane-outer"),
   didScroll
= false;
$
(window).scroll(function() {
   didScroll
= true;
});
setInterval
(function() {
   
if ( didScroll ) {
       didScroll
= false;
       
// Check your page position and then
       
// Load in more results
   
}
}, 250);

示例中给出的数字250,可以给大家参考一下~

去定时器

为什么定时器会引起掉帧?

如你所见,定时器导致掉帧的原因,就在于无法准确控制回调执行的时机。

即使给定时器设置延时时间wait恰好为16.7ms,也不行。

js的单线程限制了回调会在16.7ms之后加入任务队列,却不能保证一定在16.7ms之后触发。如果当下js正在进行耗时计算,回调就只能等着。所以实际上回调执行的时机,是定时器设置后 >= 16.7ms后。

那么去定时器是否意味着否定了之前说的函数去抖和函数节流操作?

NONONO,这两种提升scroll性能的操作应用于不同的场景:

  • scroll过程中伴随着不直接改变画面效果的计算操作,如懒加载、loadmore等,在这样的scroll场景里,我们要不断进行判断操作,大量的计算操作就可能阻塞scroll,所以要对操作进行“稀释”。

  • scroll过程中伴随着直接改变画面效果的操作,如动画、affix引起的scroll滚动等。

案例:在这个世界上,有一种经典的导航栏形式,那就是,affix。

这种导航栏在你scroll时会粘在你的窗口的固定位置(一般是top),并且在你点击导航栏时自动滚动到页面对应的target内容。

这是我自己做的一个小demo,利用了setInterval,每16.7ms设置scrollTop + 5px,达到“平滑”滚动的效果。

emmmm,看着不规则的锯齿,难受。

如果还不够明显,试试将wait设为50ms

看起来,要赶上每一个标准帧渲染的时机,不是那么容易,但是旁友,你听说过安利吗?哦走错片场了,是requestAnimationFrame()和requestIdleCallback().

requestAnimationFrame()

The window.requestAnimationFrame() method tells the browser that you wish to perform an animation and requests that the browser call a specified function to update an animation before the next repaint. The method takes a callback as an argument to be invoked before the repaint.

可以将它看做一个钩子,刚好卡在浏览器重绘前向我们的操作伸出橄榄枝。实际上它更像定时器,每秒60次执行回调——符合屏幕的刷新频率,遇到耗时长的操作,这个数字会降到30来保证稳定的帧数。

语法也很简单:window.requestAnimationFrame(callback)

更改后的代码:

const newScrollTop = this.getPosition(this.panes[index].$refs.content).top - this.distance
function scrollStep() {
   document
.documentElement.scrollTop += 5
   
if (document.documentElement.scrollTop < newScrollTop) {
       window
.requestAnimationFrame(scrollStep)
   
}
}
window
.requestAnimationFrame(scrollStep)

与定时器很相似,只是鉴于其一次执行只调用一次回调,所以需要以递归的方式书写。

测试一下:

可以说是很顺滑了~

兼容性呢?

requestIdleCallback()

The window.requestIdleCallback() method queues a function to be called during a browser’s idle periods. This enables developers to perform background and low priority work on the main event loop, without impacting latency-critical events such as animation and input response. Functions are generally called in first-in-first-out order; however, callbacks which have a timeout specified may be called out-of-order if necessary in order to run them before the timeout elapses.

意思是,它会在一帧末尾浏览器空闲时触发回调,否则,推迟到下一帧。

看定义,它适合应用于执行在后台运行或者优先度低的任务,但是鉴于我们的案例逻辑和计算都比较简单,应该能满足一帧末尾有空闲(毕竟标题是“不择手段”),have a try.

实际上,基础使用上requestIdleCallback()和requestAnimationFrame()语法相同,代码修改甚至也只替换了方法名。

应用情况呢?

也是如丝般顺滑~仔细看每一帧,我们会发现,Fire Idle Callback正如其定义,出现在每帧的最后。

但是兼容性看起来除了chrome和FireFox之外,就不是那么友好了:

总结

在追求高性能的渲染效果时,可以考虑用requestIdleCallback()和requestAnimationFrame()代替定时器。前者适合流畅的动画效果场景,后者适用于分离一些优先级低的操作逻辑,使用时需要考虑清楚。

避免强制重排

记忆力好的同学可能还记得,我们在之前描述浏览器渲染过程时,提到一个强制重排的概念,它的特点是,会插队!

注意红线,意思是可能会在JS里强制重排,当访问scrollWidth系列、clientHeight系列、offsetTop系列、ComputedStyle等属性时,会触发这个效果,导致Style和Layout前移到JS代码执行过程中
这个强制重排(force layout)听起来好像和重排很像啊,那么它和重排以及重绘是什么关系呢?

优秀的前端工程师对重绘和重绘的概念已经很熟悉了,我这里就不再赘述。浏览器有自己的优化机制,包括之前提到的每帧只响应同类别的事件一次,再比如这里的会把一帧里的多次重排、重绘汇总成一次进行处理。

flush队列是浏览器进行重排、重绘等操作的队列,所有会引起重排重绘的操作都包含在内,比如dom修改、样式修改等。如果每次js操作都去执行一次重排重绘,那么浏览器一定会卡卡卡卡卡,所以浏览器通常是在一定的时间间隔(一帧)内,批量处理队列里的操作。但是,对于有些操作,比如获取元素相对父级元素左边界的偏移值(Element.offsetLeft),但在此之前我们进行了样式或者dom修改,这个操作还攒在flush队列里没有执行,那么浏览器为了让我们获取正确的offsetLeft(虽然之前的操作可能不会影响offsetLeft的值),就会立即执行队列里的操作。

所以我们知道了,就是这个特殊操作会影响浏览器正常的执行和渲染,假设我们频繁执行这样的特殊操作,就会打断浏览器原来的节奏,增大开销。

而这个特殊操作,具体指的就是:

  • elem.offsetLeft, elem.offsetTop, elem.offsetWidth, elem.offsetHeight, elem.offsetParent

  • elem.clientLeft, elem.clientTop, elem.clientWidth, elem.clientHeight

  • elem.getClientRects(), elem.getBoundingClientRect()

  • elem.scrollWidth, elem.scrollHeight

  • elem.scrollLeft, elem.scrollTop

解决办法呢,有俩:

  • 基础版:使用前面提到过的requestAnimationFrame(),将以上特殊操作汇集并延迟入队

  • 进阶版:使用第三方FastDom帮助我们自动完成读写操作的批处理,实际上它也是建立在requestAnimationFrame()上构造的。官方提供的example看起来效果简直优秀

FastDom works as a regulatory layer between your app/library and the DOM. By batching DOM access we avoid unnecessary document reflows and dramatically speed up layout performance.
Each measure/mutate job is added to a corresponding measure/mutate queue. The queues are emptied (reads, then writes) at the turn of the next frame using window.requestAnimationFrame.
FastDom aims to behave like a singleton across all modules in your app. When any module requires ‘fastdom’ they get the same instance back, meaning FastDom can harmonize DOM access app-wide.
Potentially a third-party library could depend on FastDom, and better integrate within an app that itself uses it.

总结

谨慎使用以上特殊的读操作,要使用也尽量汇集、包裹(requestAnimationFrame()),避免单个裸奔。

提升合成层

不知道有没有人,曾经围坐在黑夜里的炉火旁边,听前端前辈们传递智慧的话语 —— 做位移效果时使用tranform代替top/left/bottom/right,尤其是移动端!

why?

因为top/left/bottom/right属性性能差呀 —— 这类属性会影响元素在文档中的布局,可能改变其他元素的位置,引起重排,造成性能开销

因为tranform属性性能好呀 —— 使用transform属性(3D/animation)将元素提升至合成层,省去布局和绘制环节,美滋滋~

说到这里,你可能还不是太清楚合成层的概念,其实看这篇就够了:无线性能优化:Composite

但是照顾一下有些“太长不看”猫病的旁友们,在这里做一些总结。

1.一些属性会让元素们创建出不同的渲染层

  • 有明确的定位属性(relative、fixed、sticky、absolute)

  • 透明的(opacity 小于 1)

  • 有 CSS 滤镜(fliter)

  • 有 CSS transform 属性(不为 none)

2.达成一些条件,渲染层会提升为合成层

  • 硬件加速的 iframe 元素(比如 iframe 嵌入的页面中有合成层)

  • 3D 或者 硬件加速的 2D Canvas 元素

  • video 元素

  • 有 3D transform

  • 对 opacity、transform、fliter、backdropfilter 应用了animation 或者 transition

  • will-change 设置为 opacity、transform、top、left、bottom、right(其中 top、left 等需要设置明确的定位属性,如 relative 等)

提升为合成层干什么呢?普通的渲染层普通地渲染,用普通的顺序普通地合成不好吗?非要搞啥特殊待遇!

浏览器就说了:我这也是为了大家共同进步(提升速度)!看那些搞特殊待遇的,都是一些拖我们队伍后腿的(性能开销大),分开处理,才能保证整个队伍稳定快速的进步!

特殊待遇:合成层的位图,会交由 GPU 合成,比 CPU 处理要快。当需要 repaint 时,只需要 repaint 本身,不会影响到其他的层。

对布局属性进行动画,浏览器需要为每一帧进行重绘并上传到 GPU 中

对合成属性进行动画,浏览器会为元素创建一个独立的复合层,当元素内容没有发生改变,该层就不会被重绘,浏览器会通过重新复合来创建动画帧
所以,从合成层出发,为了优化scroll性能,我们可以做这些:

will-change

提升合成层的有效方式,应用这个属性,实际上是提前通知浏览器,为接下来的动画效果操作做准备。值得注意的是

不要将 will-change 应用到太多元素上,增加渲染层意味着新的内存分配和更复杂的层的管理
有节制地使用。动态样式增加比一开始就写在样式表里更能减少不必要的开销。
示例:

will-change: scroll-position // 表示开发者希望在不久后改变滚动条的位置或者使之产生动画。

然后,国际惯例【并不,附上兼容性

除此之外

使用 transform 或者 opacity 来实现动画效果

对于较少可能变化的区域,防止页面其他部分重绘时影响这一片,考虑提升至合成层。

提升合成层的hack方法:translateZ(0)

总结

从合成层的角度作为性能提升的下手方向,是值得肯定的,但是具体采用什么样的方案,还是要先切实地分析页面的实际性能表现,根据不同的场景,综合考虑方案的得失,再总结出正确的优化途径。

what’s more

使用css属性代替js“模拟操作”

scroll-behavior

The scroll-behavior CSS property specifies the scrolling behavior for a scrolling box, when scrolling happens due to navigation or CSSOM scrolling APIs. Any other scrolls, e.g. those that are performed by the user, are not affected by this property. When this property is specified on the root element, it applies to the viewport instead.

可以借此实现affix,而不用使用定时器或requestAnimationFrame模拟平滑的scroll操作

但是目前仅是实验性的功能,残念

总结

页面渲染性能的优化涉及方方面面,这里只是以scroll事件为立足点分析列举了一些改善的方法,深入性和全面性都不足,但更多希望能起到一个引子的作用,给有心深入的同学一个概括性的印象。

参考链接

  • 渲染性能

  • 【前端性能】高性能滚动 scroll 及页面渲染优化

  • 浏览器渲染详细过程:重绘、重排和 composite 只是冰山一角

  • Web Workers, for a responsive JavaScript application

  • JavaScript 函数节流和函数去抖应用场景辨析


关于本文

作者:@escawn

原文:https://zhuanlan.zhihu.com/p/30078937

最后,为你推荐


【第1336期】Airbnb 中的 React Native:技术部分


【第1331期】初学者应该如何开启自己的编程生涯?

    您可能也对以下帖子感兴趣

    文章有问题?点此查看未经处理的缓存