前段时间, 我将精力专注在 web 性能领域; 在这个领域下有个重要的课题是如何让网页更丝滑(流畅).
想让网页变得丝滑, 首先, 我们需要一个标准来判断什么样的网页是丝滑的; 其次, 我们要准确的测量出网页的性能数据; 最后, 使用有效的方法让网页变得丝滑.
本篇文章将针对这三个方面进行详细的介绍.
1. RAIL
到底怎样的网页是丝滑的? 我们需要一个标准来辅助判断我们的网页是否丝滑.
Chrome 团队提出了一个以用户为中心的性能模型被称为 RAIL, 它为工程师提供一个目标, 只要达到目标的网页, 用户就会觉得很流畅; 它将用户体验拆解为一些关键操作, 例如: 点击, 加载等; 并给这些操作规定一个目标, 例如: 点击一个按钮后, 多长时间给反馈用户会觉得流畅.
RAIL 将影响性能的行为划分为四个方面, 分别是: Response(响应),Animation(动画),Idle(空闲) 与 Load(加载). 没错, RAIL 这个名字来自于这四个单词的首字母, 方便记忆.
1.1 响应 Response
研究表明, 100ms 内对用户的输入操作进行响应, 通常会被人类认为是立即响应. 时间再长, 操作与反应之间的连接就会中断, 人们就会觉得它的操作有延迟. 例如: 当用户点击一个按钮, 如果 100ms 内给出响应, 那么用户就会觉得响应很及时, 不会察觉到丝毫延迟感.
1.2 动画 Animation
现如今大多数设备的屏幕刷新频率是 60Hz, 也就是每秒钟屏幕刷新 60 次; 因此网页动画的运行速度只要达到 60FPS, 我们就会觉得动画很流畅.
FFrames PPer SSecond 指的画面每秒钟传输的帧数, 60FPS 指的是每秒钟 60 帧; 换算下来每一帧差不多是 16 毫秒.
(1 秒 = 1000 毫秒) / 60 帧 = 16.66 毫秒 / 帧
但通常浏览器需要花费一些时间将每一帧的内容绘制到屏幕上(包括样式计算, 布局, 绘制, 合成等工作), 所以通常我们只有 10 毫秒来执行 JS 代码.
1.3 空闲 Idle
为了更好的性能, 通常我们会充分利用浏览器空闲周期 (Idle Period) 做一些低优先级的事情. 例如: 在空闲周期预请求一些接下来可能会用到的数据或上报分析数据等.
RAIL 规定, 空闲周期内运行的任务不得超过 50ms, 当然不止 RAIL 规定, W3C 性能工作组的 Longtasks 标准也规定了超过 50 毫秒的任务属于长任务, 那么 50ms 这个数字是怎么得来的呢?
浏览器是单线程的, 这意味着同一时间主线程只能处理一个任务, 如果一个任务执行时间过长, 浏览器则无法执行其他任务, 用户会感觉到浏览器被卡死了, 因为他的输入得不到任何响应.
为了达到 100ms 内给出响应, 将空闲周期执行的任务限制为 50ms 意味着, 即使用户的输入行为发生在空闲任务刚开始执行, 浏览器仍有剩余的 50ms 时间用来响应用户输入, 而不会产生用户可察觉的延迟. 如图 1-1 所示:
图 1-1
事实上, 不论是空闲任务还是高优先级的其他任务, 执行时间都不得超过 50ms.
1.4 加载 Load
如果不能在 1 秒钟内加载网页并让用户看到内容, 用户的注意力就会分散. 用户会觉得他要做的事情被打断, 如果 10 秒钟还打不开网页, 用户会感到失望, 会放弃他们想做的事, 以后他们或许都不会再回来.
1.5 小结
通过 RAIL, 我们可以判断出我们的网页是否丝滑. RAIL 从用户感知角度出发规定了一些指标, 只要我们的网页符合标准, 则我们的网页是丝滑的, 用户会觉得我们的网页很流畅.
RAIL | 关键指标 | 用户操作 |
---|---|---|
响应(Response) | 小于 100ms | 点击按钮。 |
动画(Animation) | 小于 16ms | 滚动页面,拖动手指,播放动画等。 |
空闲(Idle) | 小于 50ms | 用户没有与页面交互,但应该保证主线程足够处理下一个用户输入。 |
加载(Load) | 1000ms | 用户加载页面并看到内容。 |
2. 像素管道
像素管道是制作丝滑网页的灵魂, 我们后面将要介绍的技术都与它有关.
上图就是像素管道, 通常我们会使用 JS 修改一些样式, 随后浏览器会进行样式计算, 然后进行布局, 绘制, 最后将各个图层合并在一起完成整个渲染的流程, 这期间的每一步都有可能导致页面卡顿.
注意, 并不是所有的样式改动都需要经历这五个步骤. 举例来说: 如果在 JS 中修改了元素的几何属性 (宽度, 高度等), 那么浏览器需要需要将这五个步骤都走一遍. 但如果您只是修改了文字的颜色, 则布局(Layout) 是可以跳过去的, 如下图所示:
除了最后的合成, 前面四个步骤在不同的场景下都可以被跳过. 例如: CSS 动画就可以跳过 JS 运算, 它不需要执行 JS.
CSS-triggers https://csstriggers.com/ 给出了不同的 CSS 属性被更改后会触发像素管道的哪些步骤.
简单来说, 像素管道经历的步骤越多, 渲染时间就越长, 单个步骤内可能也会因为某种原因而变得耗时很长; 所以不管是步骤多还是单个步骤耗费的时间长, 最终都会导致整体渲染时间变长. 整体时间越长就越有可能超出 RAIL 所规定的指标.
举个简单的例子: 网页动画的渲染若是达到 60FPS, 则动画不会丢帧. 假设渲染管道的布局与绘制耗费了 10ms, 那么加上样式计算与合成的时间, 则留给 JS 处理动画的时间就只有几毫秒, 如果 JS 的执行超过了几毫秒那么该动画每一帧所耗费的时间就会超过 16ms, 这时候动画一定会丢帧, 用户用肉眼就可以看到明显的卡顿.
当然, 即便能保证每一帧的总耗时小于 16ms, 依然无法保证不会丢帧. 关于这点后面我们会详细介绍.
3. 如何让动画更丝滑
动画需要达到 60FPS 才能变得丝滑, 本节我们介绍如何让动画在不丢帧的情况下稳定保持在 60FPS.
3.1 使用 Chrome 开发者工具测量动画性能
在评估动画性能时, 通常需要逐帧评估像素管道的开销; 使用 Chrome 开发者工具可以辅助我们进行精准的测量.
在 Chrome 开发者工具中, 点击 Performance 面板, 然后选中 Screenshots 复选框,. 如图 3-1 所示:
图 3-1Chrome 开发者工具 Performance 面板
然后点击录制按钮, 录制完毕后点击停止按钮就可以捕获当前页面的性能数据. 如图 3-2 所示:
图 3-2 捕获性能数据
捕获出的结果如图 3-3 所示:
图 3-3 捕获出的性能结果
我们可以放大主线程从而精准的看到每一帧浏览器都执行了哪些任务以及每个任务耗费了多长时间. 如图 3-4 所示:
图 3-4 性能面板最主要的部分
从上图可以看到, 浏览器每一帧渲染所执行的任务与前面我们介绍的像素管道是相同的. 上图中因为是 CSS 动画, 所以没有运行 JS, 但每一帧都需要计算样式, 布局, 绘制与合成.
3.2 如何让 JS 动画更丝滑
JS 动画是使用定时器不停的执行 JS, 通过在 JS 中修改样式完成网页动画; 若想保证动画流畅, 从 JS 的执行到最终浏览器显示出画面, 每一帧总耗时最多 16ms, 这样动画才能达到 60FPS.
如图 3-4 所示, 即便是在不执行 JS 的情况下, 浏览器计算样式, 布局, 绘制等工作也是需要时间的, 所以需要给浏览器预留出 充分的时间(6ms) 做这些事情, 现在留给 JS 的执行时间就只有 10ms.
图 3-5 每一帧总体耗时必须小于 16ms,JS 运行时间小于 10ms
一旦 JS 运行时间超过 10ms, 就很有可能导致这一帧的像素管道整体耗时超过 16ms, 从而无法达到 60FPS, 但你以为只要保证 JS 的运行时间小于 10ms 就一定能保证不丢帧? Naive~
3.2.1 使用
requestAnimationFrame
即便你能保证每一帧的总耗时都小于 16ms, 也无法保证一定不会出现丢帧的情况, 这取决于触发 JS 执行的方式.
假设使用 setTimeout 或 setInterval 来触发 JS 执行并修改样式从而导致视觉变化; 那么会有这样一种情况, 因为 setTimeout 或 setInterval 没有办法保证回调函数什么时候执行, 它可能在每一帧的中间执行, 也可能在每一帧的最后执行. 所以会导致即便我们能保障每一帧的总耗时小于 16ms, 但是执行的时机如果在每一帧的中间或最后, 最后的结果依然是没有办法每隔 16ms 让屏幕产生一次变化. 如图 3-6 所示:
图 3-6 使用定时器触发动画
也就是说, 即便我们能保证每一帧总体时间小于 16ms, 但如果使用定时器触发动画, 那么由于定时器的触发时机不确定, 所以还是会导致动画丢帧. 现在整个 Web 只有一个 API 可以解决这个问题, 那就是 requestAnimationFrame, 它可以保证回调函数稳定的在每一帧最开始触发. 如图 3-7 所示:
图 3-7 使用 requestAnimationFrame 触发动画
3.2.2 避免 FSL
FSL Forced Synchronous Layouts 被称为强制同步布局; 前面介绍像素管道时说过, 将一帧送到屏幕会通过如下顺序:
先执行 JS, 然后在 JS 中修改了样式从而导致样式计算, 然后样式的改动触发了布局, 绘制, 合成. 但 JavaScript 可以强制浏览器将布局提前执行, 这就叫 F (强制) S (同步) L (布局).
图 3-8 强制同步布局
通常我们一不小心就造成了 FSL, 请看下面代码:
- box.classList.add('big');
- const width = box.offsetWidth;
代码中通过新增 class 修改了元素的样式, 随后使用 offsetWidth 读取元素的宽度. 乍一看似乎没什么问题, 但这段代码会导致 FSL.
在 JavaScript 运行时, 上一帧已经渲染好的所有布局值都是已知的, 我们可以使用 offsetWidth 这样的语法获得值; 但这一帧刚修改完的样式浏览器还没渲染呢, 这时候使用 offsetWidth 这样的语法读取元素的宽度, 那么浏览器为了告诉我们宽度值, 它必须先计算该宽度, 这就需要布局. 如图 3-8 所示, 布局跑到了样式计算的前面.
所以正确的做法是先获取宽度, 然后再更改样式:
- const width = box.offsetWidth;
- box.classList.add('big');
看起来, 似乎即使触发了 FSL 也不过就是管道的顺序变了而已, 影响好像并没有那么大.
单个 FSL 对性能的影响确实不大, 但如果触发了布局抖动, 则影响会变得非常大. 看下面代码:
- const container = document.querySelector('.container');
- const boxes = document.querySelectorAll('p');
- for (var i = 0; i < boxes.length; i++) {
- // Read a layout property
- const newWidth = container.offsetWidth;
- // Then invalidate layouts with writes.
- boxes[i].style.width = newWidth + 'px';
- }
上面代码的作用是批量修改 N 个 P 元素的宽度; 在循环中我们先获取容器元素的宽度, 随后设置了 P 元素的样式. 这会导致浏览器去布局, 然后计算样式. 每次更改样式, 都会导致刚刚执行的布局失效, 因为我们又改了新的样式, 所以下一轮循环读取宽度时, 浏览器又要执行一次布局, 如此反复直到循环结束. 在循环期间, 浏览器不停地执行无效布局, 这被称为 布局抖动(Layout Thrashing); 这种错误导致的性能问题非常高.
如果我们不小心触发了 FSL,Chrome 开发者工具会给出红色的线提示, 如图 3-9 所示:
图 3-9 开发者工具提示 FSL
同时任务的右上角会有红色的三角形表示, 我们可以放大任务进一步查看, 如图 3-10 所示:
图 3-10 开发者工具提示 FSL 详情
若想看 Demo 可以点击我 http://demos.so/result/5c73acf736cf500e0578c49e , 在 Demo 中点击按钮可以让 P 标签的宽度变长.
为了避免布局抖动, 我们可以将读取元素宽度的代码放到循环的外面. 代码如下:
- const container = document.querySelector('.container');
- const boxes = document.querySelectorAll('p');
- // Read a layout property
- const newWidth = container.offsetWidth;
- for (var i = 0; i < boxes.length; i++) {
- // Then invalidate layouts with writes.
- boxes[i].style.width = newWidth + 'px';
- }
若想看 Demo 可以点击我 http://demos.so/result/5c73b72636cf500e0578c4a0 , 可以看到这个 Demo 与前一个 demo 一模一样, 甚至我们无法用肉眼分辨出哪个更快, 这是因为 DOM 元素少, 所以总体时间都比较少, 但我们可以通过 Chrome 开发者工具来捕获性能数据.
图 3-11 优化后的时间
图 3-11 可以看到, 优化后这一帧的总时间用了 4.7ms, 而优化前的是 101ms, 如图 3-12 所示:
图 3-12 优化前的时间
优化后比优化前, 每帧所耗费的时间快了 21.7 倍, 数字非常惊人.
3.3 如何让 CSS 动画更丝滑
CSS 动画通常使用 @keyframe 或 transition 结合样式的变动来实现视觉变化的效果. 我们同样可以通过减少像素管道的步骤和每个步骤所耗费的时间让 CSS 动画更流畅.
本节介绍的 CSS 动画的优化方式同样适用于 JS 动画, 但上一节介绍的 JS 动画优化方法不适用于 CSS 动画, 它们是包含关系.
绘制 (Paint) 通常需要花费很长时间, 我们可以通过 Chrome 开发者工具来观察正在绘制的区域. 打开开发者工具, 按下键盘上的 Esc 键. 在出现的面板中, 切换到 "rendering" 标签, 然后选中 "Paint flashing". 如图 3-13 所示:
图 3-13 开启绘制闪烁
开启绘制闪烁 (Paint flashing) 后, 每当页面发生绘制时, 我们都可以在屏幕上看到绘制发生区有绿色在闪烁. 如图 3-14 所示:
图 3-14 绘制区域闪烁
如图 3-14 所示, 当我们开启了绘制闪烁, 则会绘制区域出现了绿色的闪烁, 可以点击我查看 Demo http://demos.so/result/5c7a1c7b36cf500e0578c516 .
当我们看到我们认为不应该绘制的区域时, 我们应该进一步研究并取消绘制区域.
如何才能避免绘制的发生呢? 答案是: 图层.
事实上浏览器在渲染页面时, 可以将页面分为很多个图层, 有点类似于 Photoshop, 一张图片在 PotoShop 中是由多个图层组合而成, 而浏览器最终显示的页面实际也是由多个图层构成的. 如图 3-15 所示:
图 3-15 图层
将原本不断发生变化的元素提升到单独的图层中, 就不再需要绘制了, 浏览器只需要将两个图层合并在一起即可, 查看 Demo 请狠狠的点击我 http://demos.so/result/5c7a328f36cf500e0578c51b .
如果您点击了上面的 Demo 地址, 并开启了绘制闪烁, 您会发现没有任何闪烁发生, 因为浏览器没有进行绘制. 如果您查看 Layers 面板, 你会看到这样的场景, 如图 3-16:
3-16 图层
当我们使用 Performance 面板捕获性能数据时会发现绘制 (Paint) 已经不见了. 如图 3-17 所示:
图 3-17 捕获不到绘制
创建图层的最佳方式是使用 will-change, 但某些不支持这个属性的浏览器可以使用 3D 变形 (transform: translateZ(0)) 来强制创建一个新层.
在 Chrome 开发者工具 "rendering" 标签中, 选中 "Layer borders". 可以看到页面中有哪些合成层. 合成层会使用橘黄色的边框, 如图 3-18 所示:
图 3-18 显示合成层
为了减少绘制, 可以通过新增图层, 但是图层的管理也是需要成本的, 所以要避免滥用, 通常需要具体情况具体分析, 做出合适的选择.
前面我的 Demo 都是修改元素的 left 属性让方块移动, 这避免不了需要进行布局操作, 最佳的方法是使用 transform 属性, 这个属性是由合成器单独处理的, 所以使用这个属性可以避免布局与绘制.
总结
RAIL 可以帮助我们判断什么样的网页是丝滑的, 而开发者工具可以让我们进一步准确的捕获出网页的性能数据.
JS 动画要保证预留出 6ms 的时间给浏览器处理像素管道, 而自身执行时间应该小于 10ms 来保证整体运行速度小于 16ms. 但触发动画的时机也很重要, 定时器无法稳定的触发动画, 所以我们需要使用 requestAnimationFrame 触发 JS 动画. 同时我们应该避免一切 FSL, 它对性能的影响非常大.
CSS 动画我们可以通过降低绘制区域并且使 transform 属性来完成动画, 同时我们需要管理好图层, 因为绘制和图层管理都需要成本, 通常我们需要根据具体情况进行权衡并做出最好的选择.
若想了解更多请移步个人博客, 博客地址: https://github.com/berwin/blog/issues
来源: https://juejin.im/post/5c874d9ce51d4574bd6ad8ca