渲染性能
重绘重排
以下三种情况会导致网页重新渲染:
- 修改 DOM
- 修改样式表
- 用户事件(比如鼠标悬停、页面滚动、输入框键入文字、改变窗口大小等等)
需要注意的是,”重绘”不一定需要”重排”,比如改变某个网页元素的颜色,就只会触发”重绘”,不会触发”重排”,因为布局没有改变。但是,”重排”必然导致”重绘”,比如改变一个网页元素的位置,就会同时触发”重排”和”重绘”,因为布局改变了。像重绘一般很难避免,所以这里不讨论。
减少重排方案
1.分离读写操作DOM
的多个读操作(或多个写操作),应该放在一起。不要两个读操作之间,加入一个写操作。
2.如果某个样式是通过重排得到的,那么最好缓存结果。避免下一次用到的时候,浏览器又要重排。
3.不要一条条地改变样式,而要通过改变 class
,或者 csstext
属性,一次性地改变样式。
4.尽量使用离线 DOM
,而不是真实的网面 DOM
,来改变元素样式。比如,操作 Document Fragment
对象,完成后再把这个对象加入 DOM
。再比如,使用 cloneNode()
方法,在克隆的节点上进行操作,然后再用克隆的节点替换原始节点。
5.先将元素设为 display: none
(需要 1 次重排和重绘),然后对这个节点进行 100 次操作,最后再恢复显示(需要 1 次重排和重绘)。这样一来,你就用两次重新渲染,取代了可能高达 100 次的重新渲染。
6.position
属性为 absolute
或 fixed
的元素,重排的开销会比较小,因为不用考虑它对其他元素的影响。原理就是使得元素不再同一层,基于这种想法还可以使用 transform:translateZ(0);
或者 will-change:transform
来创建新层,同样能减少重排,一般是对有动画的元素,因为重排很会频繁。
7.只在必要的时候,才将元素的 display
属性为可见,因为不可见的元素不影响重排和重绘。另外,visibility : hidden
的元素只对重绘有影响,不影响重排。先把元素设置为 display:none
最后再 display:block
就只会触发两次次重绘重排,一次消失,一次出现。
8.使用 createDocumentFragment
来创建 DocumentFragment
(不属于文档树),把需要插入的节点放到其中,最后再把 DocumentFragment
插入到文档流。当请求把一个 DocumentFragment
节点插入文档树时,插入的不是 DocumentFragment
自身,而是它的所有子孙节点。这使得 DocumentFragment
成了有用的占位符,暂时存放那些一次插入文档的节点。它还有利于实现文档的剪切、复制和粘贴操作。
9.使用 cloneNode(true or false)
和 replaceChild
技术,引发一次回流和重绘;
10.使用虚拟 DOM
的脚本库,比如 React
等。
11.使用 window.requestAnimationFrame()
、window.requestIdleCallback()
这两个方法调节重新渲染(详见后文)。
刷新率
网页动画的每一帧(frame
)都是一次重新渲染。每秒低于 24 帧的动画,人眼就能感受到停顿。一般的网页动画,需要达到每秒 30 帧到 60 帧的频率,才能比较流畅。如果能达到每秒 70 帧甚至 80 帧,就会极其流畅。
如果想达到 60 帧的刷新率,就意味着 JavaScript
线程每个任务的耗时,必须少于 16 毫秒。一个解决办法是使用 Web Worker
,主线程只用于 UI
渲染,然后跟 UI
渲染不相干的任务,都放在 Worker
线程。
开发者工具 Timeline
Chrome
或 Safari
之类的浏览器在开发者模式下有个 Timeline
的选项卡,可以录制一段时间内浏览器性能的问题,有帧模式和事件模式,可以互相切换。横条的是”事件模式”(Event Mode
),显示重新渲染的各种事件所耗费的时间;竖条的是”帧模式”(Frame Mode
),显示每一帧的时间耗费在哪里。
帧模式有两条水平线,下面的一条是 60FPS
,低于这条线,可以达到每秒 60 帧;上面的一条是 30FPS
,低于这条线,可以达到每秒 30 次渲染。如果色柱都超过 30FPS
,这个网页就有性能问题了。
PerformanceObserver
这是个性能监测的 API
,可以观察不同性能类型。
Google
提出了以用户为中心的四个衡量指标:
- Is it happening? First Paint (
FP
,首次渲染,背景颜色之类的) / First Contentful Paint (FCP
,首次内容渲染,有DOM
出现) - Is it useful? First Meaningful Paint (
FMP
,首次有意义渲染) / Hero Element Timing - Is it usable? Time to Interactive (
TTI
,可以交互时间) - Is it delightful? Long Tasks(长任务)
跟踪
FP/FCP
,监听paint
事件。1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16const observer = new PerformanceObserver(list => {
for (const entry of list.getEntries()) {
// `name` will be either 'first-paint' or 'first-contentful-paint'.
const metricName = entry.name
const time = Math.round(entry.startTime + entry.duration)
//发送到 Google Analytics
ga('send', 'event', {
eventCategory: 'Performance Metrics',
eventAction: metricName,
eventValue: time,
nonInteraction: true
})
}
})
observer.observe({ entryTypes: ['paint'] })同理监听
longtask
事件,可以发现阻塞主进程的长任务进而进行优化。FMP
关于页面有效内容,或者“Hero element”,由于依赖具体实现,并没有给出通用方法。 具体可以使用 performance api 度量指标。TTI
对于TTI
可以使用 tti-polyfill 的垫片来完成对TTI
的监控:1
2
3
4
5
6
7
8
9
10
11import ttiPolyfill from './path/to/tti-polyfill.js'
ttiPolyfill.getFirstConsistentlyInteractive().then(tti => {
//发送到 Google Analytics
ga('send', 'event', {
eventCategory: 'Performance Metrics',
eventAction: 'TTI',
eventValue: tti,
nonInteraction: true
})
})input latency(输入延迟)
有时候鼠标事件可能会超过很长一段时间才响应,这也是需要我们发现并解决的,可以用当前时间与事件事件比较得出结果:1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16const subscribeBtn = document.querySelector('#subscribe');
subscribeBtn.addEventListener('click', (event) => {
// Event listener logic goes here...
const lag = performance.now() - event.timeStamp;
if (lag > 100) {
ga('send', 'event', {
eventCategory: 'Performance Metric'
eventAction: 'input-latency',
eventLabel: '#subscribe:click',
eventValue: Math.round(lag),
nonInteraction: true,
});
}
});在某一个根元素上绑定对应监测事件,获得时间差,大于 100ms 则发送报告。
window.requestAnimationFrame
window.requestAnimationFrame(callback)
方法可以将某些代码放到下一次重新渲染时执行。
页面滚动事件(scroll
)的监听函数,就很适合用 window.requestAnimationFrame()
,推迟到下一次重新渲染。
1 | $(window).on('scroll', function() { |
但是得注意一个问题,像滚动或者触屏事件可能频繁触发,导致在一帧内多次渲染,造成不必要的计算与绘制,解决方法一个是使用节流函数,另一个比较完美的方法就是通过 requestAnimationFrame
来管理队列,其思路就是保证 requestAnimationFrame
的队列里,同样的回调函数只有一个。示意代码如下:
1 | let scheduledAnimationFrame = false |
当然,最适用的场合还是网页动画。下面是一个旋转动画的例子,元素每一帧旋转 1 度。
1 | var rAF = window.requestAnimationFrame |
window.requestIdleCallback()
另一个比较冷门的就是 window.requestIdleCallback(callback [,timeout])
了,这个函数指定只有当一帧的末尾有空闲时间,才会执行回调函数。也就是说只有当前帧的运行时间小于 16.66ms 时,函数 callback
才会执行。否则,就推迟到下一帧,如果下一帧也没有空闲时间,就推迟到下下一帧,以此类推。
第二个参数代表过了规定时间后如果还没出发就强制执行,怕浏览器一直很忙而来不及渲染。
callback
可以接收 deadline
作为参数,deadline
对象有一个方法和一个属性:timeRemaining()
和 didTimeout
。
timeRemaining()
方法返回当前帧还剩余的毫秒。这个方法只能读,不能写,而且会动态更新。因此可以不断检查这个属性,如果还有剩余时间的话,就不断执行某些任务。一旦这个属性等于 0,就把任务分配到下一轮 requestIdleCallback
。didTimeout
属性会返回一个布尔值,表示指定的时间是否过期。
1 | function myNonEssentialWork(deadline) { |
上面代码确保了,doWorkIfNeeded
函数一定会在将来某个比较空闲的时间(或者在指定时间过期后)得到反复执行。
使输入处理程序去除抖动
输入处理程序可能是应用出现性能问题的原因,因为它们可能阻止帧完成,并且可能导致额外(且不必要)的布局工作。
解决方案如下:
- 避免长时间运行输入处理程序;它们可能阻止滚动。
- 不要在输入处理程序中进行样式更改。
- 使处理程序去除抖动;存储事件值并在下一个
requestAnimationFrame
回调中处理样式更改(注意更改样式放到最后,读取操作放在更改样式之前,以免发生强制同步布局)。
去抖动:
1 | function onScroll(evt) { |
可以发现是先存储了 window.scrollY
,然后在 requestAnimationFram
的回调函数中再去获取这个值,不用担心触发强制同步布局;使用 scheduledAnimationFrame
来防止抖动;这样做还有一个好处是使输入处理程序轻量化,效果非常好,因为现在您不用去阻止计算开销很大的代码的操作,处理逻辑都放到了 requestAnimationFrame
的回调函数中执行了!
worker
todo
加载性能
压缩资源
源码压缩:预处理和环境特定优化
压缩冗余或不必要数据的最佳方法是将其全部消除。我们不能只是删除任意数据,但在某些环境中,我们可能对数据格式及其属性有内容特定了解,往往可以在不影响其实际含义的情况下显著减小负载的大小。
比如消除CSS
,JS
的注释,空格等等。通过
GZIP
压缩文本GZIP
对基于文本的资产的压缩效果最好:CSS
、JavaScript
和HTML
。
所有现代浏览器都支持GZIP
压缩,并且会自动请求该压缩。
您的服务器必须配置为启用GZIP
压缩。
某些CDN
需要特别注意以确保GZIP
已启用。
图像优化
- 消除多余的图像资源
- 尽可能利用
CSS3
效果,因为CSS3
可能会启动GPU
加速,这样就在单独的线程中去完成动画了,而不需要在主线程中,参考 动画与性能 - 使用网页字体取代在图像中进行文本编码
- 使用矢量图(SVG),还可以同时使用
GZIP
压缩 - 将图片格式转为
WebP
来压缩图片(有些浏览器不支持需要注意) - 一些
CDN
也提供图片的优化 - 懒加载(将页面里所有
img
属性src
属性用data-xx
代替,当页面滚动直至此图片出现在可视区域时,用js
取到该图片的data-xx
的值赋给src
,onscroll
监听每一个li
的scrollTop
,或者对于css
属性的图片可以动态添加visible
的class
来完成,比如初始化的时候找一张holder
的图片,等到滚动到可视区域后加上visible
的class
来替换成真实的图片)。这里可以了解一下IntersectionObserver API
来检测对象是否在用户可视区。有时候为了节约渲染性能会使用和图片相同大小的占位符 - 大的
GIF
可以转化为视频,减少加载时间 - 使用
Progressive JPEG
(这种加载时从低分辨率到高分辨率,从模糊到清晰) 代替传统的JPEG
(这种是 Baseline 的,从上加载到下,需要等待加载完才知道图片是啥),更加具体的细节可以查看谷歌文档 automating-image-optimization - 视频使用
preload="none"
来阻止预加载视频,有时可以使用GIF
替换视频
JS 优化
- 只发送用户需要的,可使用代码分割技术,例如
webpack
中的code-spliting
。 - 缩小,
UglifyJS
缩小ES5
的代码,使用babel-minify
来缩小ES6
及以上代码。 - 压缩,
GZIP
。 - 使用
HTTP
缓存。 - 加载第三方脚本可以使用
async
或者defer
属性。 - 移除未引用的代码,
tree-shaking
。
这里可以看到 async
与 defer
的区别,async
是使得脚本的下载和 DOM
的解析同时进行,当脚本下载好的时候立即停止 DOM
解析然后执行脚本;而 defer
虽然也是同时下载和解析,但是就算下载完成了也是需要等待 DOM
解析完成了才可以执行。
字体优化
- 在构建渲染树之前会延迟字体请求,这可能会导致文本渲染延迟,
CSS
已经下载完并与DOM
共同构建渲染树,这个时候如果需要请求字体可能会阻塞渲染,产生了“空白文本问题”,出现该问题时,浏览器会在渲染网页布局时遗漏所有文本。 - 可以通过
Font Loading API
实现自定义字体加载和渲染策略,以替换默认延迟加载字体加载。 - 可以通过字体内联替换较旧浏览器中的默认延迟加载字体加载。
通过 Font Loading API 优化字体渲染
Font Loading API 提供了一种脚本编程接口来定义和操纵 CSS
字体,追踪其下载进度,以及替换其默认延迟下载行为。例如,如果您确定将需要特定字体变体,您可以定义它并指示浏览器启动对字体资源的立即获取:
1 | var font = new FontFace('Awesome Font', 'url(/fonts/awesome.woff2)', { |
通过内联优化字体渲染
使用 Font Loading API
消除“空白文本问题”的简单替代策略是将字体内容内联到 CSS
样式表内:
- 浏览器会使用高优先级自动下载具有匹配媒体查询的
CSS
样式表,因为需要使用它们来构建CSSOM
。 - 将字体数据内联到
CSS
样式表中会强制浏览器使用高优先级下载字体,而不等待渲染树。即它起到的是手动替换默认延迟加载行为的作用。
离线
一些需要离线的资源(下次也能用上,如购物车)也可以考虑放到本地存储里,如 localStorage
、sessionStorage
等等。
离线的图片可以使用 Cache API
来完成,详情查看 Using the Cache API。
当数据量较大的时候,就可能用到 IndexedDB
来存储了。注意使用的时候需要注意并不是所有的类型都能写到 IndexedDB
中的,IOS
上的 Safari
是不能存储 Blob
类型的数据的,但是 ArrayBuffer
类型就是比较通用的了。写入可能失败,开发者需要意识到这一点,添加错误的监听函数。
使用 CDN 加速
一般网站还会使用 CDN
来加速,使用离用户最近的节点给用户提供资源。详情查看 CDN 技术详解。
使用 HTTP 缓存
详情查看另一篇博客 前端基础之网络–强缓存与协商缓存。
关键路径渲染优化
详情查看另一篇博客 前端基础之关键路径渲染优化。
PRPL 模式
PRPL
是一种用于结构化和提供 Progressive Web App
(PWA
) 的模式,该模式强调应用交付和启动的性能。 它代表:
- 推送 - 为初始网址路由推送关键资源。(Push critical resources for the initial route.)
- 渲染 - 渲染初始路由。(Render initial route.)
- 预缓存 - 预缓存剩余路由。(Pre-cache remaining routes.)
- 延迟加载 - 延迟加载并按需创建剩余路由。(Lazy-load and create remaining routes on demand.)
像下面这种应用结构就很适合用 RPRL
模式:
- 应用的主进入点从每个有效的路由提供。 此文件应非常小,它从不同网址提供,因此会被缓存多次。 进入点的所有资源网址都需要是绝对网址,因为它可以从非顶级网址提供。
Shell
或App Shell
,包含顶级应用逻辑、路由器,等等。- 延迟加载的应用 _片段_ 。片段可以表示特定视图的代码,或可延迟加载的其他代码(例如,首次绘制不需要的部分主应用,如用户与应用交互前未显示的菜单)。
Shell
负责在需要时动态导入片段。
在此图表中,实线表示静态依赖项:使用 <link>
和 <script>
标记在文件中标识的外部资源。 虚线表示动态或按需加载的依赖项:根据 Shell
所需加载的文件。
构建过程会构建一个包含所有这些依赖项的图表,服务器会使用此信息高效地提供文件。 还会为不支持 HTTP/2
的浏览器构建一组硬化捆绑包。
资源优先级
不是每个资源的都是同等重要的,浏览器加载资源有一定的优先级(例如 CSS
的加载优先级就比脚本和图片要高)。
浏览器默认优先级
一般来说 HTML
和 CSS
有同样高的优先级(Highest
),而在 head
标签中的 script
标签的优先级就是 High
,在 body
里最后时是 Medium
,但是如加上了 async
属性那么优先级就会变成 Low
,等等。具体的读者可以打开 Chrome
的开发者工具中的 Network
右键表头显示 Priority
查看网站加载资源优先级的详情,如图:
那么当你发现资源的优先级和你预想的不一样该怎么办?这里提供三种解决方案,都是和新的 <link>
类型相关的。一方面,如果发现资源对用户是关键的,但是加载优先级却特别低,你可以使用 preload
或者 preconnect
来解决;另一方面,如果想要当其他所有资源都已经处理完毕再让浏览器去获取某些资源,可以使用 prefetch
。
Preload
<link rel="preload">
告诉浏览器这个资源是当前页面所需要的,需要尽快获取。可以这么使用:
1 | <link rel="preload" as="script" href="super-important.js"> |
as
属性是用来告诉浏览器资源的类型(如果类型没有设置那么浏览器是不会拿这个资源来用的)。大部分基于标签的资源会被浏览器内部的预加载器(preloader)提早发现,但并非所有资源都是基于标签的。有些资源是隐藏在 CSS
和 JavaScript
中的,浏览器不知道页面即将需要这些资源,而等到发现它们时已经为时已晚。所以在有些情况,这些资源延缓了首屏渲染,或是延缓了页面关键部分的加载。而 preload
就告诉浏览器当前页面一定会用到这个资源的,赶紧去获取。
注意
preload
不会阻塞window.onload
事件,除非该资源是被一个阻塞该事件的资源请求的。
加载该资源后,如果 3s 内还没有被当前页面使用,那么控制台会抛出一个警告,故而需要注意!!!
使用场景:
较早加载字体
一种流行的“较晚发现关键资源”的代表是Web
字体。一方面,它对页面渲染字体很关键(除非你在使用最新的font-display
)。另一方面,它们被埋在CSS
很深的地方,很难发现。所以对一定需要的字体可以使用preload
:1
<link rel="preload" as="font" crossorigin="crossorigin" type="font/woff2" href="myfont.woff2">
有一点需要指明,获取字体时必须加上
crossorigin
属性,就如使用CORS
的匿名模式获取一样,即使你的字体与页面同域(否则会被浏览器忽略)。加载关键路径的
CSS
和JavaScript
关键路径资源是初始加载所必需的,虽然可以使用内联来达到及时加载的目的,但是却失去了缓存(HTML
是不缓存的,文件太大的话缓存的作用就不明显了)的优势和版本控制(修改关键路径的任何资源都导致整个页面更新,而如果是分开的资源则只需更新部分资源)的优势。
Preconnect
<link rel="preconnect">
告诉浏览器你的页面将要与另一个域建立连接,并且想要这个过程尽快开始。
建立连接在慢网络中通常需要较多的时间来建立,尤其是安全连接时,包括了 DNS
查找、重定向、若干循环才找到能处理用户请求的服务器,不仅完成 DNS
预解析,同时还将进行 TCP
握手和建立传输层协议,而将这些操作提前能提升网页的性能和用户体验。
1 | <link rel="preconnect" href="http://example.com"> |
尽量使用
preload
,因为它是更为全面的性能提升。
dns-prefetch
的浏览器支持度会好点,但是这个只是提前做了DNS
查找,并不进行TCP
握手和传输层协议的建立。
使用场景:
- 知道当前需要获取的资源在哪却不知道具体是什么资源
- 流媒体
Prefetch
不像 preload
和 preconnect
使用关键资源更早被获取或连接,prefetch
使得那些非关键的资源被下载(如果可能的话)。这个是优先级最低的,在 Chrome
中能看到是 Lowest
。
一般预测用户下一步要干什么并提前准备好,比如加载某个列表的第一项、加载下一页(小说里比较常见).
1 | <link rel="prefetch" href="page-2.html"> |
注意
prefetch
不能覆盖,如果同时有一个正常请求的资源和一个prefetch
的相同资源,那么这个资源会被加载两次,一个以高优先级下载,一个以低优先级下载。
预获取的资源没有同源限制!
subresources
是另一个预获取资源的方式,只不过优先级更高,在所有的prefetch
之前进行。<link rel="subresource" href="styles.css">
。
Webpack 优化加载性能
1.有效利用浏览器缓存:code split
,如第三方库、polyfill
单独打包,分离公共库;css
单独提取出一个文件,ExtractTextPlugin
。
2.懒加载:动态引入,import
,注意可能需要使用 babel
的 dynamic-webpack-import
插件,不然编译会报错。
3.减少代码体积:Minification
,使用 UglifyJsPlugin
来 minify
代码,生产环境相要对应上源代码需要同时设置 devtool
的值和 UglifyJsPlugin
的 sourceMap
为 true
;使用 babel-preset-env
的 useBuiltIns
和 target
来共同控制需要 shim
的 polyfill
代码,尽量少加载垫片,比原来直接 import 'babel-polyfill'
要少一部分的垫片;tree-shaking
来去除无用的代码。
4.图片压缩:使用 image-webpack-loader
来压缩图片,注意 webp
支持度较低,不建议使用。
5.分析包结构:使用 BundleAnalyzerPlugin
来分析打包后的包结构以及大小,便于后续的优化。