渲染优化
如果把浏览器呈现页面的整个过程一分为二,前面所讲的主要是浏览器为呈现页面请求所需资源的部分;本章将主要关注浏览器获取到资源后,进行渲染部分的相关优化内容;
- 浏览器从获取 HTML 到最终在屏幕上显示内容需要完成以下步骤
- 处理 HTML 标记并构建 DOM树;
- 处理 CSS 标记并构建 CSSOM树;
- 将 DOM 与 CSSOM 合并成一个 render tree;
- 根据渲染树来布局,以计算每个节点的几何信息;
- 将各个节点绘制到屏幕上;
- 经过以上整个流程我们才能看见屏幕上出现渲染的内容,优化关键渲染路径就是指最大限度缩短执行上述 第1步至第5步耗费的总时间,让用户最快的看到首次渲染的内容
- 不但网页页面要快连加载出来,而且运行过程也应更顺畅,在响应用户操作时也要更加及时,比如我们通常使用手机浏览网上商城时,指尖滑动屏幕与页面滚动应很流畅,拒绝卡顿;那么要达到怎样的性能指标,才能满足用户流畅的使用体验呢?
- 目前大部分设备的屏幕分辦率都在 60fps 左右,也就是每秒屏幕会刷新 60次,所以要满足用户的体验期望,就需要浏览器在渲染页面动画或响应用户操作时,每一帧的生成速率尽量接近屏幕的刷新率;若按照 6ofps来算,则留给每一帧画面的时间不到17ms,再除去浏览器对资源的一些整理工作,一帧画面的渲染应尽量在10ms内完成,如果达不到要求而导致帧率下降,则屏幕上的内容会发生松动或卡顿;
了解渲染过程
为了使每一帧页面渲染的开销都能在期望的时问范围内完成,就需要开发者了解渲染过程的每个阶段,以及各阶段中有哪些优化空间是我们力所能及的
-
经过分析根据开发者对优化渲染过程的控制力度,可以大体将其划分为五个部分:JavaScript处理、计算样式、页面布局、绘制与合成,下面先简要介绍各部分的功能与作用:
- JavaScript 处理:前端项目中经常会需要响应用户保作,通过 JavaScript 对数据集进行计算、操作 DOM 元素,并展示动画等视觉效果,当然对于动画的实现,除了 JavaScript,也可以考虑使用如 CSS Animations. Transitions 等技术;
- 计算样式:在解析 CSS 文件后,浏览器需要根据各种选择器去匹配所要应用 CSS 规则的元素节点,然后计算出每个元素的最终样式;
- 页面布局:指的是浏览器在计算完成样式后,会对每个元素尺寸大小和屏幕位置进行计算;由于每个元素都可能会受到其他元素的影响,并且位于 DOM 树形结构中的子节点元素,总会受到父级元素修改的影响,所以页面布局的计算会经常发生;
- 绘制:在页面布局确定后,接下来便可以绘制元素的可视内容,包括颜色、边框、阴影及文本和图像;
- 合成:通常由于页面中的不同部分可能被绘制在多个图层上,所以在绘制完成后需要将多个图层按照正确的顺序在屏幕上合成,以便最终正确地渲染出来;
-
这个过程中的每一介段都有可能产生卡顿,后续内容将会对各阶段所涉及的性能优化进行详细介绍,这里值得说明的是,并非对于每一帧画面都会经历这五个部分,比如仅修改与绘制相关的属性(文字颜色、背景图片或边缘阴影等),而未对页面布局产生任何修改,那么在计算样式阶段完成后,便会跳过页面布局直接执行绘制;
关键路径渲染优化
关键路径渲染
-
浏览器从获取 HTML 到最终在屏常上显示内容需要完成以下步骤:
- 处理 HTML 标记并构建 DOM 树;
- 处理 CSS 标记并构建 CSSOM 树;
- 将 DOM 与 CSSOM 合井成一个 render tree;
- 根据渲染树来布局,以计算每个节点的几何信息;
- 将各个节点绘制到屏幕上;
-
为尽快完成首次渲染,需要最大限度减小以下三种可变因素:
- 关键资源的数量;
- 关键路径长度;
- 关键字节的数量;
-
关键资源是可能阻止网页首次渲染的资源,例如 JavaScript、css 都是可以阻塞关键渲染路径的资源,这些资源越少,浏览器的工作量就越小,对 CPU 以及其他资源的占用也就越少;
-
同样,关键路经长度受所有关健资源与其字节大小之间依赖关系图的影响:某些资源只能在上一资源处理完毕之后才能开始下载,并且资源越大,下载所需的往返次数就越多;
-
最后,浏览器需要下载的关键字节越少,处理内容并让其出现在屏幕上的速度就越快:要减少字节数,可以减少资源数(将它们删除或设为非关键资源),此外还要压缩和优化各项资源,确保最大限度减小传送大小;
优化 DOM
-
在关键渲染路经中,构建渲染树 (Render Tree)的常一步是构建 DOM,所以先讨论如何让构建 DOM 的速度变得更快;
-
HTML 文件的尺寸应该尽可能的小,目的是为了让客户端尽可能早的接收到完整的 HTML:
- 通常 HTML 中有很多冗余的字符,例如:JS注释、CSS 注释、HTML 注释、空格、换行;
- 更烤糕的情況是很多生产环境中的 HTML 里面包含了很多废弃代码,这可能是因为随着时间的推移,项目越来越大,由于种种原因从历史遗留下来的问题,不过不管怎么说,这都是很糟糕的,对于生产环境的HTML来说,应该删除一切无用的代码,尽可能保证 HTML 文件精简;
-
总结起来有三种方式可以优化 HTML:
- 缩小文件的尺寸(Minify):Minify 会册除注释、空格与换行等无用的文本;
- 使用gzip压缩 (Compress);
- 使用缓存(HTTP Cache);
-
本质上,优化 DOM 其实是在尽可能的减小关键路径的长度与关键字节的数量;
优化 CSSOM
阻塞渲染的 CSS
-
除了上面提到的代化策略,CSS 还有一个可以影响性能的因素是:CSS 会阻塞关键渲染路径
- 示例代码
HTMLJS
<!-- index.html --> <!DOCTYPE html> <html> <head> <meta name="viewport" content="width=device-width,initial-scale=1"> <link href="style.css" rel="stylesheet"> <title>Critical Path</title> </head> <body> </body> </html>
// index.js const express = require('express') const fs = require('fs') const app = express() const port = 3000 app.get('/', (req, res) => { const data = fs.readFileSync('./index.html') res.end(data) }) app.get('/style.css', (req, res) => { setTimeout(() => { const data = fs.readFileSync('./style.css') res.end(data) }, 3000) }) app.listen(port, () => { console.log(`Example app listening at http://localhost:${port}`) })
- 效果展示:(延迟 3s 返回style.css,3s 后样式生效)
- 代码附件下载
- 示例代码
-
CSS是关健资源,它会阻塞关键渲染路径也并不奇怪,但通常并不是所有的 CSS 资源都那么的『关健』,举个例子:
- 一些响应式 CSS 只在屏幕宽度符合条件时才会生效;
- 还有一些 CSS只在打印页面时才生效;
-
这些 CSS 在不符合条件时,是不会生效的,所以为什么要让浏览器等待我们并不需要的 CSS资源呢?
-
针对这种情况,应该让这些非关键的 CSS资源不阻塞渲染;
<!-- 阻塞渲染,适用于所有情况 --> <link href="style.css" rel="stylesheet"> <!-- 只在打印网页时应用,因此网页首次在浏览器中加载时,它不会阻塞渲染 --> <link href="print.css" rel="stylesheet" media="print"> <!-- 提供由浏览器执行的“媒体查询":符合条件时,浏览器将阻塞渲染,直至样式表下载并处理完毕 --> <link href="other.css" rel="stylesheet" media="(min-width: 40em)"> <!-- 具有动态媒体查询,将在网页加载对计算,根据网页加载时没备的方向,portrait.css 可能阻塞渲染,也可能不阻塞渲染 --> <link href="portrait.css" rel="stylesheet" media="orientation:portrait">
-
最后,请注意"阻塞渲染”仅是指浏览器是否需要暂停网页的首次渲染,直至该资源准备就绪,无论哪一种情况,浏览器仍会下载 css 资源,只不过不阻塞渲染的资源优先级较低罢了;
-
为获得最佳性能,可能会考虑将关键 CSS 直接内联到 HTML 文档内,这样做不会增加关键路径中的往返次数,并且如果实现得当,在只有 HTML 是阻塞渲染的资源时,可实现“一次往返”关键路径长度;
避免在 CSS 中使用 @import
-
这段代码使用 link 标签加载了两个 CSS 资源:
- 示例代码
<!doctype html> <html> <head> <meta charset="UTF-8"> <title>Demos</title> <!-- 这两个 CSS 资源是并行下载的 --> <link href="style.css" rel="stylesheet"> <link rel="stylesheet" href="main.css"> </head> <body> <div class="cm-alert">Default alert</div> </body> </html>
- 效果展示
- 示例代码
-
这段代码使用 @impprt 加载资源,代码如下:
- 示例代码
<!doctype html> <html> <head> <meta charset="UTF-8"> <title>Demos</title> <!-- 代码中使用 link 标签下载一个 CSS,然后在 CSS 文件中使用 @import 加载另一个 CSS --> <link href="style.css" rel="stylesheet"> </head> <body> <div class="cm-alert">Default alert</div> </body> </html>
- 效果展示
- 示例代码
-
所以避免使用 @import 是为了降低关键路径的长度
优化 JavaScript 的使用
与 CSS 资源相似,JavaScript 资源也是关键资源,JavaScript 资源会阻塞 DOM 构建;并且 JavaScript 会被 CSS 文件所阻塞;
- 当浏览器加载 HTML 时遇到 script 标签,浏览器就不能继续构建 DOM,它必须立刻执行此脚本;
- 对于外部脚本 script 也是一样的,浏览器必须等脚本下载完,并执行结束,之后才能继续处理剩余的页面;
- 这会导致两个重要的问题:
- 脚本不能访问到位于它们下面的 DOM 元素,因此,脚本操作 DOM;
- 如果页面顶部有一个笨重的脚本,它会“阻塞页面”,在该脚本下载并执行结束前,用户都不能看到页面内容;
- 这里有一些解决办法,例如:
- 可以把脚本放在页面底部,此时,它可以访问并且不会阻塞页面显示内容;
- 但是这种解决方案远非完美,浏览器只有在下载了完整的 HTML 文档之后才会注意到该脚本(并且可以开始下载它),对于长的 HTML 文档来说,这样可能会造成明显的延迟;
- 这对于使用高速连接的人来说,这不值一提,他们不会感受到这种延迟,但是这个世界上仍然有很多地区的人们所使用的网络速度很慢,并且使用的是远非完美的移动互联网连接;
使用 defer 延迟加载 JavaScript
-
defer 告诉浏览器不要等待脚本,浏览器会继续处理 HTML,构建 DOM,腳本会“在后台”下载,然后等 DOM构建完成后,脚本才会执行;
-
换句话说:
- 具有 defer 特性的脚本不会阻塞页面;
- 具有 defer 特性的脚本总是要等到 DOM 解折完毕,但在 DOMContentLoaded 事件之前执行;
- 具有 defer 特性的脚本保持其相对顺序,就像常规脚本一样;
使用 async 延迟加载 JavaScript
-
async是异步的,无序的(谁先获取到先执行谁);
-
如果在 HTML 解析过程中返回js脚本,会立即执行js脚本,阻塞HTML的解析;
-
如果在 HTML 解析结束后返回js脚本,如果脚本中有修改dom节点的地方,就有可能报错,则 async 适合加载第三方脚本;
使用 preload & prefetch 预加载 JavaScript
-
preload 会提升资源的优先级,因为它标明这个资源是本页肯定会用到 —— 本页优先;
-
preload 会提升资源的优先级,因为它标明这个资源是本页肯定会用到 —— 本页优先;
-
preload 的功能听起来很像被 defer 的脚本,但是:
- defer 无法控制脚本执行的时机,是在 DOMContentLoaded 执行前触发;
- defer 会阻塞 DOMContentLoaded 事件;
- defer 会阻塞 onload 事件,preload 不会阻塞 onload 事件;
- defer 的脚本下载的优先级是 low,preload 的脚本优先级是 high;
避免运行时间过长的 JavaScript
总结
-
关键渲染路径是浏览器将 HTML、 CSS,、JavaScript 转换为屏幕上所呈现的实际像素的具体步骤,而优化关键渲染路径可以提高网页的呈现速度;
-
介绍的内容都是如何优化 DOM、CSSOM 以及 JavaScript,因为通常在关键渲染路径中,这些步骤的性能最差,这些步骤是导致首屏渲染速度慢的主要原因;
参考链接
javaScript 执行优化
实现动画效果
-
前端实现动画效果的方法有很多,比如:
- 在 CSS 中可以通过 transition 和 animation 来实现;
- 在 HTML 中可以通过 canvas 来实现;
- 在 JavaScript 中通常最容易想到的方式是利用定时器 setTimeout 或 setInterval 来实现,即通过设置一个间隔时间来不断地改变目标图像的位置来达到视觉变化的效果;
-
实践经验告诉我们,使用定时器实现的动画会在一些低端机器上出现抖动或者卡顿的现象:
- 这主要是因为浏览器无法确定定时器的回调函数的执行时机,以 setInterval 为例,其创建后回调任务会被放入异步队列,只有当主线程上的任务执行完成后,浏览器才会去检查队列中是否有等待需要执行的任务,如果有就从任务队列中取出执行,这样会使任务的实际执行时机比所设定的延迟时间要晚一些;
- 其次屏幕分辦率和尺寸也会影响刷新频率,不同设备的屏幕绘制频率可能会有所不同,而 setInterval 只能设置某个固定的时间间隔,这个间隔时间不一定与所有屏幕的刷新时间同步,那么导致动画出现随机丢帧也在所难免,如图所示:
-
为了避免这种动画实现方案中因丢顿而造成的卡顿现象,推荐使用 window.requestAnimationFrame 方法,与 setInterval 方法相比,其最大的优势是将回调函数的执行时机交由系统来决定,即如果屏幕刷新频率是 60Hz,则它的回调函数大约会每 16.7ms 执行一次,如果屏幕的刷新频率是 75Hz,则它回调函数大约会每 13.3ms 执行一次,就是说 requestAnimationFram 方法的执行时机会与系统的刷新频率同步;
- 这样就能保证回调函数在屏幕的每次刷新间隔中只被执行一次,从而避免因随机丢帧而造成的卡顿现象;
- 其使用方法也十分简单,仅接受一个回调函数作为入参,即下次重绘之前更新动画顾所调用的函数;返回值为一个 Long 型整数,作为回调任务队列中的唯一标识,可将该值传给 window.cancelAnimationFrame 来取消回调,以某个目标元素的平移动画为例:
<!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Document</title> <style> .box { width: 100px; height: 100px; position: absolute; background-color: skyblue; } </style> </head> <body> <div class="box"></div> <script> const element = document.querySelector('.box') let start function step(timestamp) { if (!start) { start = timestamp } const progress = timestamp - start // 在这里使用 Math.min() 确保元素刚好停在 200px 的位置 element.style.left = `${Math.min(progress / 10, 200)}px` // 在两秒后停止动画 if (progress < 2000) { window.requestAnimationFrame(step) } } window.requestAnimationFrame(step) </script> </body> </html>
- 使用这个 API 还需要浏览器兼容性的问题:
恰当使用 Web Worker
-
众所周知 Javasoript 是单线程执行的,所有任务放在一个线程上执行,只有当前一个任务执行完才能处理后一个任务,不然后面的任务只能等待,这就限制了多核计算机充发挥它的汁算能力,同时在浏览器上,JavaScript 的执行通常位于主线程,这恰好与样式计算、页正布局及绘制一起,如果 javaScript 运行时间过长,必然就会导致其他工作任务的阻塞而造成丢帧;
-
为此可将一些纯计算的工作迁移到 Web Workar 上处理,它为 Javascript 的执行提供了多线程环境,主线程通过创建出 Worker 子线程,可以分担一部分自己的任务执行压力,在 Worker 子线程上执行的任务不会干扰主线程,待其上的任务执行完成后,会把结果返回给主线程,这样的好处是让主线程可以更专注地处理 UI 交互,保证页面的使用体验流程;
-
需要注意的是,Worker 子线程一旦创建成功就会始终次行,不会被主线程上的事件所打断,这就意味着 Worker 会比较耗费资源,所以不应当过度使用,一旦任务执行完毕就应及时关闭;
-
除此之外,在使用中还有以下几点应当注意:
- DOM 限制:Worker 无法读取主线程所处理网页的 DOM 对象,也就无法使用 document、 window 和 parent 等对象,只能访问 navigator 和 location 对象;
- 文件读取限制:Worker 子线程无法访问本地文件系统,这就要求所如载的脚本来自网络;
- 通信限制:主线程和 Worker 子线程不在同一个上下文内,所以它们无法直接进行通信,只能通过消息来完成;
- 脚本执行限制:虽然 Worker 可以通过 XMLHTTPRequest 对象发起 ajax 请求,但不能使用 alert()方法和 confirm() 方法在页面弹出提示;
- 同源限制:Worker 子线程执行的代码文件需要与主线程的代码文件同源;
-
Web Worker 的使用方法非常简单,在主线程中通过 new Worker() 方法来创建一个 Worker 子线程,构造函数的入参是子线程执行的脚本路径,由于代码文件必须来自网络,所以如果代码文件没能下载成功,Worker 就会失败。代码示例如下:
HTMLJS<!-- index.html --> <!DOCTYPE html> <html lang="en"> <head> <meta charset="UTF-8"> <meta http-equiv="X-UA-Compatible" content="IE=edge"> <meta name="viewport" content="width=device-width, initial-scale=1.0"> <title>Web Worker</title> </head> <body> <input type="number" id="num1" value="1">+ <input type="number" id="num2" value="2"> <button id="btn">=</button> <strong id="result">0</strong> <script> const worker = new Worker('worker.js') const num1 = document.querySelector('#num1') const num2 = document.querySelector('#num2') const result = document.querySelector('#result') const btn = document.querySelector('#btn') btn.addEventListener('click', () => { worker.postMessage({ type: 'add', data: { num1: num1.value - 0, num2: num2.value - 0 } }) }) // 监听来自子线程的消息事件 worker.addEventListener('message', e => { const { type, data } = e.data if (type === 'add') { result.textContent = data } }) </script> </body> </html>
// worker.js // 监听来自主线程的消息事件 onmessage = function (e) { const { type, data } = e.data if (type === 'add') { const ret = data.num1 + data.num2 // 给主线程发布事件 postMessage({ type: 'add', data: ret }) // 关闭线程自己 self.close() } }
-
在子线程处理完相关任务后,需要及时关闭 Worker 子线程以节省系统资源,关闭的方式有两种:
- 在主线程中通过调用 worker.terminate()方法来关闭;
- 在子线程中通过调用自身全局对象中的 self.close()方法来关闭;
-
考虑到上述关于 Web Worker 使用中的限制,井非所有任务都适合采用这种方式来提升性能,如果所要处理的任务必须要放在主线程上完成,则应当考虑将一个大型任务拆分为多个微任务、每个微任务处理的耗时最好在几亳秒之内,能在每帧的 requestAnimaticnFrame 更新方法口处理完成,代码示例如下:
// 将一个大型任务拆分为多个微任务 const taskList = splitTask(BigTask) // 微任务处理逻辑,入参为每次任务起始时间戳 function processTaskList(taskStartTime) { let taskFinishTime do { // 从任务栈中推出要处理的下一个任务 const nextTask = taskList.pop() // 处理下一个任务 processTask(nextTask) // 执行任务完成的时间,如果时间够 3 毫秒就继续执行 taskFinishTime = window.performance.now() } while (taskFinishTime - taskStartTime < 3) // 如果任务堆栈不为空则继续 if (taskList.length > 0) { requestAnimationFrame(processTaskList) } } requestAnimationFrame(processTaskList)
事件节流和事件防抖
-
当用户在与 Web 应用发生交互的过程中,势必有一些操作会被频繁触发,如:
- 滚动页正触发的 scroll 事件;
- 页面缩放触发的 resize 事件;
- 鼠标涉及的 mousemove、 mouseover等事件;
- 以及键盘涉及的 keyup、keydown 等事件;
-
频繁地触发这些事件会导致相应回调函数的大量计算,进而引发页面科动甚至卡顿,为了控制相关事件的触发频率,就有了接下来要介绍的事件节流与事件防抖操作;
计算样式优化
减少要计算样式的元素数量
-
首先需要知道与计算样式相关的一条重要机制:CSS 引擎在查找样式表时,对每条规则的匹配顺序是从右向左的,举个例子,如下 CSS 规则:
.product-list li {}
-
CSS 选择器的匹配规则实际上是从右向左的,这样再回看上面的规则匹配,其实开销相当高,因为 CSS 引擎需要首先遍历页面上的所有 li 标签元素,然后确认每个 li 标签有包含类名为 product-list 的父元素才是目标元素,所以为了提高页面的渲染性能,计算样式阶段应当尽量减少参与样式计算的元素数量,在这里总结了如下几点实战建议:
- 使用类选择器替代标签选择器,对于上面 li 标签的错误示范,如果想对类名为 product-list 下的 li标签添加样式规则,可直接为相应的 li 标签定义名为 product-list_li的类选择器规则,这样做的好处是在计算样式时,减少了从整个页面中查找标签元素的范围,毕竟在 CSS 选择器中,标签选择器的区会度是最低的;
- 避免使用通配符做选择器,对于刚入门前端的小伙伴,通常在编写 CSS样式之前都会有使用通配符去清除默认样式的习惯,如下所示:
* { margin : 0; padding: 0; }
降低选择器的复杂性
-
随着项目不断迭代,复杂性会越来越高,或许刚开始仅有一个名为 content 的类选择元素,但慢慢地单个元素可能会并列出列表,列表又会包裹在某个容器元素下,甚至该列表中的部分元素的样式又与其他兄弟元素有所差异,这样原本的一个类选择器就会被扩展成如下形式:
.container:nth-last-child(-n+1) .content { /* 样式规则 */ }
-
浏览器在计算上述样式时,首先就需要查询有哪些应用了 content 类的元素,井且其父元素恰好带有 container 类的倒数第n+1个元素,这个计算过程可能就会花费许多时间,如果仅对确定的元素使用单一的类名选择器,那么浏览器的计算开销就会大幅度降低;
-
比如使用名为 final-container-content 的类选择替代上述的复杂样式计算,直接添加到目标元素上,而且复杂的匹配规则可能也会存在考虑不周从而导致画🐍添足的情况,例如,通过 id 选择器已经可以唯一确定目标元素了,就无须再附加其他多余的选择器:
❌ .content #my-content {} ✅ #my-content {}
使用 BEM 规范
-
概念:BEM 是一种 CSS的书写规范,它的名称是由三个单词的首字母组成的,分别是块(Block)、元素(Element)和修饰符(Moditier);理论上它希望每行 CSS 代码只有一个选择器,这就是为了降低选择器的复杂性,对选择器的命名要求通过以下三个符号的组合来实现:
- 中画线(-):仅作为连字符使用,表示某个块或子元素的多个单词之间的连接符;
- 单下画线(_):作为描述一个块或其子元泰的一种状态;
- 双下画线 (__):作为连接块与块的子元素;
-
块:通常来说,凡是独立的页面元素,无论简单或是复杂都可以被视作一个块,在 HTML 文档中会用一个唯一的类名来表示这个块;具体的命名规则包括三个:
- 只能使用类选择器,而不使用 ID 选择器;
- 每个块应定义一个前缀用来表示命名空问;
- 每条样式规则必须属于一个块,比如一个自定义列表就可视作为一个块,其类名匹配规则可写为:
.mylist {}
-
元素:元素即指块的子元素,且子元素也被视作块的直接子元素,其类名需要使用块的名称作为前级;
- 以上面自定义列表中的子元素类名写法为例,与常规写法对比如下:
/* 常规写法 */ .mylist {} .mylist .item {} /* BEM 写法 */ .mylist {} .mylist__item {}
- 以上面自定义列表中的子元素类名写法为例,与常规写法对比如下:
-
修饰符
- 修饰符可以看作是块或元素的某个特定状态,以按钮为例,它可能包含大、中、小三种默认尺寸及自定义尺寸,对此可使用 small、normal、 big 或 size-N 来修饰具体按钮的选择器类名,示例如下:
/* 自定义列表下子元素大、中、小三种尺寸的类选择器 */ .mylist__item_big {} .mylist__item_normal {} .mylist__item_small {} /* 带自定义尺寸修饰符的类选择器 */ .mylist__item_size-10 {}
- BEM 样式编码规范建议所有元素都被单一的类选择器修饰,从 CSS 代码结构角度来说这样不但更加清晰,而且由于样式查找得到了简化,渲染阶段的样式计算性能也会得到提升;
- 修饰符可以看作是块或元素的某个特定状态,以按钮为例,它可能包含大、中、小三种默认尺寸及自定义尺寸,对此可使用 small、normal、 big 或 size-N 来修饰具体按钮的选择器类名,示例如下:
页面布局与重绘优化
概念
-
页面布局也叫作重排和回流,指的是浏览器对页面元素的几何属性进行计算并将最终结果绘制出来的过程,凡是元素的宽高尺寸、在页面中的位置及隐藏或显示等信息发生改变时,都会触发页面的重新布局;
-
如果仅修改了 DOM 元素的样式,而未影响其几何属性时,则浏览器会跳过页面布局的计算环节,直接进入重绘阶段;
-
通常页面布局的作用范围会涉及整个文档,所以这个环节会带来大量的性能开销,在开发过程中,应当从代码层面出发,尽量避免页面布局或最小化其处理次数;
触发页面布局与重绘的操作
-
要想避免或減少页面布局与重绘的发生,首先就是需要知道有哪些操作能够触发浏览器的页面布局与重绘的操作,然后在开发过程中尽量去避免;
-
这些操作大致可以分为三类:
- 首先就是对 DOM 元素几何属性的修改,这些属性包括 width、 height、 padding、 margin、 left、 top 等,某元素的这些属生发生变化时,便会波及与它相关的所有节点元素进行几何属性的重新计算,这会带来巨大的计算量;
- 其次是更改 DOM 树的结构,浏览器进行页面布局时的计算顺序,可类比树的前序遍历,即从上向下、从左向右,这里对 DOM 树节点的增、删、移动等操作,只会影响当前节点后的所有节点元素,而不会再次影响前面已经遍历过的元素;
- 最后一类是获取某些特定的属性值操作,比如:页面可见区域宽高 offsetwidth、 offsetHeight;页面视窗中元素与视窗边界的距离 offsetTop、offsetLeft;类似的属性值还有 scrollTop、 scrollLeft、 scrollWidth、 scrollHeight、 clientTop、clientwidth、clientHeight 及调用 window.getComputedStyle 方法;
-
这些属性和方法有一个共性,就是需要通过即时计算得到,所以浏览器就需要重新进行页面布局计算;
避免对样式的频繁改动
-
使用类名对样式逐条修改
- 在 JavaScript 代码中逐行执行对元素样式的修改,是一种糟糕的编码方式,对未形成編码规范的前端初学者来说经常会出现这类的问题,错误代码示范如下:
const div = document.getElementById('mydiv') div.style.height = '100px' div.style.width = '100px' div.style.border = '1px solid blue'
- 上述代码对样式逐行修改,每行都会触发一次对渲染树的更改,于是会导致页面布局重新计算而带来巨大的性能开销;合理的做法是,将多行的样式修改合并到一个类名中,仅在JavaScript脚本中添加或更改类名即可,CSS类名可预先定义:
.my-div { height: 100px; width : 100px; border: 1px solid blue; }
- 然后统一在 JavaScript 中通过给指定元素添加类的方式一次完成,这样便可避免触发多次对页面布局的重新计算
const div = document.getElementById('mydiv') div.classList.add('mydiv')
- 在 JavaScript 代码中逐行执行对元素样式的修改,是一种糟糕的编码方式,对未形成編码规范的前端初学者来说经常会出现这类的问题,错误代码示范如下:
-
缓存对敏感属性值的计算
- 有些场景想要通过多次计算来获得某个元素在页面中的布局位置,比如:
const list = document.getElementById('list') for (let i = 0; i < 10; i++) { list.style.top = `${list.offsetTop + 10}px` list.style.left = `${list.offsetLeft + 10}px` }
- 这不但在赋值环节会触发页面布局的重新计算,而且取值涉及即时敏感属性的获取,如 offsetTop 和 offsetLeft,也会触发中页面布局的重新计算,这样的性能是非常糟糕的,作为优化可以将敏感属性通过变量的形式緩存起来,等计算完成后再统一进行赋值触发布局重排;
const list = document.getElementById('list') let { offsetTop, offsetLeft } = list for (let i = 0; i < 10; i++) { offsetTop += 10 offsetLeft += 10 } // 计算完成后统一赋值触发重排 list.style.left = offsetLeft list.style.top = offsetTop
- 有些场景想要通过多次计算来获得某个元素在页面中的布局位置,比如:
-
使用 requestAnimationFrame 方法控制渲染帧
- 前面讲 JavaSeript 动画时,提到了 requestAnimationFrame 方法可以控制回调在两个渲染帧之间仅触发一次,如果在其回调函数中一开始就取值即敏感属性,其实获取的是上一帧旧布局的值,井不会触发页面布局的重新计算:
requestAnimationFrame(queryDivHeight) function queryDivHeight () { const div = document.getElementById('div') console.log(div.offsetHeight) }
- 如果在请求此元素高度之前更改其样式,浏览器就无法直接使用上一帧的旧属性值,而需要先应用更改的样式,再运行页面布局计算后,才能返回所需的正确高度值;这祥多余的开销显然是没有必要的,因此考虑到性能因素,在 requestAnimaticnFrame 方法的回调函数中,应始终优先样式的读取,然后再执行相应的写操作:
requestAnimationFrame(queryDivHeight) function queryDivHeight () { const div = document.getElementById('div') console.log(div.offsetHeight) // 样式的写操作应该放在读操作后进行 div.classList.add('my-div') }
- 前面讲 JavaSeript 动画时,提到了 requestAnimationFrame 方法可以控制回调在两个渲染帧之间仅触发一次,如果在其回调函数中一开始就取值即敏感属性,其实获取的是上一帧旧布局的值,井不会触发页面布局的重新计算:
通过工具对绘制进行评估
-
监控渲染信息
- 打开 Chrome 的开发者工具,可以在“设置”一“更多工具"中,发现许多很实用的性能辅助小工具,比如监控渲染的 Rendering 工具,如图所示:
- 打开 Rendering 的工具面板后,会发现许多功能开关与选择器,下面举例介绍其中若干常用功能项:首先是 Paint flashing,当开启该功能后,操作页面发生重新渲染,Chrome 会让重绘区或进行一次绿色闪动;这栏就可以通过观察闪动区域来判断是否存在多余的绘制开销,比如若仅单击 Select 组件弹出下拉列表框,却发现整个屏幕区域都发生了闪动,或与此操作无关区城发生了闪动,这都意味着有多余的绘制开销存在,需要进一步研究和优化;
- 打开 Chrome 的开发者工具,可以在“设置”一“更多工具"中,发现许多很实用的性能辅助小工具,比如监控渲染的 Rendering 工具,如图所示:
-
降低绘制复杂度
- 当使用之前介绍过的渲染性能分析工具,发现了有明显性能瓶颈需要优化时,需要确认是否存在高复杂度的绘制内容,可以使用其他实现方式来替换以降低绘制的复杂度,比如:位图的阴影效果,可以考虑使用 Photoshop 等图像处理工具直接为图片本身添加阴影效果,而非全交给 CSS 样式去处理;
- 除此之外,还要注意对绘制区域的控制,对不需要重新绘制的区域应尽呈避免重绘,例如:页面的顶部有一个固定区域的 header 标头,若它与页面其他位置的某个区域位于同一图层,当后者发生重绘时,就有可能触发包括固定标头区域在内的整个页面的重绘;对于固定不变不期望发生重绘的区域,建议可将其提升为独立的绘图层,避免被其他区域的重绘连带着触发重绘;
请求和响应优化
上一篇