前言
这篇文章的主要目的在于整理一下当前常用的一些性能优化技术,更多的是偏向理论层面,一些知识点可能只会是停留在表层的介绍,例如 service worker ,indexedDB 等,只是给出了介绍和简单的示例代码。整篇文章更偏向于面试所用,希望看完这篇文章可以对前端性能优化方面有一个大概的认识。
性能优化的目的
降低首屏加载时间(减少白屏时间) 首次可交互时间 首次有意义内容渲染时间 等性能优化的操作
总体来说,可以分为三大方面:
网络层面:主要是针对 DNS,TCP,HTTP 请求的一些优化,包括资源压缩,图片格式等缓存层面:主要包括一些浏览器缓存机制以及本地存储方案和CDN渲染层面:包括了服务端渲染,浏览器渲染,DOM 层面的优化最后还总结了一些常见的操作例如防抖节流,并发控制等。
网络层面
资源之前解析域名。这可能是后面要加载的文件,也可能是用户尝试打开的链接目标。写法:
浏览器从第三方服务器请求资源的水后,需要先将跨域的域名解析为 IP 地址,这个过程叫 DNS 解析,但是 DNS 解析可以导致请求增加明显的延迟,所以在 head 里面提前对一些域名进行预解析,这样就不必等到真正请求的时候再去解析域名了。
2. DNS 缓存
分钟,在这个期限内不会重新请求DNS。
DNS查询大概有以下几个步骤:
浏览器缓存:查看浏览器 DNS 缓存是否命中(chrome 对 DNS 的缓存大概在一分钟) OS(操作系统)缓存:浏览器缓存没有命中就查找 OS cache 访问 ISP DNS 服务器:递归查找对应的 IP参考链接:
What really happens when you navigate to a URL3. preload 内容预加载
preload 可以让浏览器提前加载指定资源(但是加载后不会执行),这样做可以不阻塞渲染和 document.onload 事件,同时也可以提前加载指定资源,例如字体文件等。当加载完成后可以通过 js 将 rel 属性的值换成对应的类型来执行,比如对于 css 文件,可以将 rel 换成 stylesheet,这样就会执行这个 css 文件。
注意如果预加载跨域请求,需要加上 crossorigin 属性。
使用 preload
注意需要通过 as 属性来指定被预加载的资源的类型,一些常用的 as 属性的值如下:
audio: 音频文件。 document: 一个将要被嵌入到<frame>或<iframe>内部的HTML文档。 font: 字体文件。 image: 图片文件。 script: JavaScript文件。 style: 样式表。 video: 视频文件。对于字体文件以及一些跨域资源,需要加上 crossorigin 属性
如果你已经有了一个可以正确工作的CORS设置,那么你也可以同样成功的预加载那些跨域资源,只需要你在<link>元素中设置好crossorigin属性即可。
参考链接:
通过rel=”preload”进行内容预加载4. preconnect 预连接
preconnect 会告诉浏览器提前去与外部的地址建立连接,例如 DNS 解析、TCP 握手等。但是尽量不要预连接所有的外部地址,因为对于浏览器来说存在一个同时连接数量的上限,所以一般只需要预连接最重要的一些地址即可。
参考链接:
什么是 Preload、Prefetch 和 Preconnect? WHAT IS PRECONNECT AND WHY YOU SHOULD IMPLEMENT IT ON YOUR WEBSITE5. 缩减资源体积(资源压缩与合并)
使用 webpack 对资源进行打包压缩webpack4 已经内置了
2. Gzip
开启 Gzip,request header 加上 accept-encoding: gzip 即可。
Gzip 效率很高,通常可以减少 70% 的大小(1.2 M => 300k)
3. 图片的优化
针对不同屏幕分辨率考虑使用不同分辨率的图片考虑不同分辨率下适用不同像素的图片,例如在移动端可以使用宽为 375 的图片,而没有必要使用 1920 * 1080 的图片
雪碧图将小图标和背景图像合并到一张图中,然后通过使用 CSS 的定位来确定使用的部分
图像精灵(sprite,意为精灵),被运用于众多使用大量小图标的网页应用之上。它可取图像的一部分来使用,使得使用一个图像文件替代多个小文件成为可能。相较于一个小图标一个图像文件,单独一张图片所需的 HTTP 请求更少,对内存和带宽更加友好。 base64 编码:Base64 是一种用于传输 8Bit 字节码的编码方式,通过对图片进行 Base64 编码,我们可以直接将编码结果写入 HTML 或者写入 CSS,从而减少 HTTP 请求的次数。
base64编码原理:一份简明的Base64原理解析
比如说,对于下面这个图片链接:
浏览器可以将这个 base64 字符串直接解析为一个图片,而不需要再去进行 http 请求了。
但是 base64 也有一个问题,就是编码后的大小会比原来大很多(这是由于它的编码原理决定的),因此,对于一些体积比较大的图片,就不适合进行转码,否则膨胀过大的体积带来的劣势,与它省略的 http 请求开销相比就差不多了。
因此,对于一些 icon 或者 logo 也就是体积较小的图片可以应用 base64 ,可以有效的减少 http 请求开销
Webp 与 PNG 相比,WebP 无损图像的尺寸缩小了 26%。在等效的 SSIM 质量指数下,WebP 有损图像比同类 JPEG 图像小 25-34%。 Webp 也支持透明(png),动态图片(gif),在图片压缩方面也比 jpeg 更优越。在质量相同的情况下,WebP格式图像的体积要比JPEG格式图像小40%。 根据Google较早的测试,WebP的无损压缩比网络上找到的PNG文件少了45%的文件大小,即使这些PNG文件在使用pngcrush和PNGOUT处理过,WebP还是可以减少28%的文件大小但是 webp 在浏览器的支持情况不是很完美,如图所示,比如说在 Safari 中支持情况就很一般
所以一般情况下需要对浏览器是否支持 webp 进行检测,具体可以参考以下代码:
参考链接:
浅谈Webp缓存层面
对于任意一个网站,打开控制台,打开 NetWork 面板,查看 Size 栏,可以看到诸如此类的东西:
这些就是下面要说到的浏览器缓存的几种方式
浏览器缓存机制 – HTTP 缓存
http 缓存接触的相对来说会多一点,整体分为强缓存和协商缓存。
强缓存(优先级较高)
返回状态码 200。
expires 字段
expires 字段是一个时间戳,它标识了资源的过期时间,是一个绝对的时间,例如:
这个字段是服务端在返回响应的时候会加载 Response Headers 里面的,也就是说,这个时间其实是服务端的时间,这就导致了 expires 的一个问题,利用客户端的时间去和服务端的时间做对比,就需要要求客户端的时间与服务端的时间必须一致。否则资源可能提前过期,甚至永远不会过期。
cache-control 字段
cache-control 可以解决 expires 的这个问题,它会有一个 max-age 字段,这个字段返回的是 一个 number ,是一个相对值,如果最新一次请求时间还小于资源第一次请求时间加上这个 number,那么就不需要去服务端请求,直接从缓存中拿就可以了。可以通过设置 max-age 为 0 表示立马过期来向服务器请求资源,也就是走协商缓存
除了 max-age,cache-control 还有其他字段:
s-maxage:s-maxage 优先级高于 max-age,设置了 s-maxage 同时也表示资源是 public 的。 public:默认情况下资源都是 private 的,表示只允许客户端进行缓存,不允许服务端进行缓存 private:public 表示该资源可以被所有客户端和代理服务器缓存 no-cache:不使用强缓存,直接验证协商缓存 no-store:不使用任何缓存,直接向服务端重新请求资源协商缓存
如果强缓存没有命中,或者 max-age
也就是说,协商缓存是需要向服务端发起请求的,强缓存则不需要。
协商缓存也主要有两个字段:last-modified 和 etag
Last-Modified 字段
last-modified 也是一个时间戳,表示的是资源上次修改的时间,第一次请求后会由服务端将其塞到 Response Heades 中,以后客户端每次请求,会带一个 if-modified-since 字段,这个字段的值就是上次返回的 last-modified 的值。
服务端收到后,会比对两个值是否有区别,没有的话就返回 304,否则就重新拿去资源并返回,同时 last-modified 的值也会变成最新的。
last-modified 也有一些问题,比如说在一个文件中加了一个空格,然后又把它删了,这样实际上文件内容是没有改变的,但是此时 last-modified 的值也会变。或者在 1s 内对文件完成了修改,这样 last-modified 是不会变的。因为它的精确度只到秒。所以就有了 etag。
Etag 字段
etag 的值是服务端为每一个文件生成的唯一的字符串,是根据文件内容进行编码的,所以只要实际上文件内容不变,这个值就不变。同样在第一次请求服务端也会把这个字段放到 Response Headers 里面。
客户端在请求的时候,会在 if-none-match 字段中带上这个值,服务端去比对两个值,如果一样就返回 304,否则重新编码资源,重新返回完整的响应
但是服务端对文件内容进行编码生成 etag 的时候会有额外的损耗,所以 etag 也并不能完全替代 last-modified,最多是作为一个补强的存在,当 etag 和 last-modified 都存在的时候,优先 etag
参考链接:
强缓存和协商缓存(简书) 强缓存和协商缓存(腾讯云)浏览器缓存机制 – 内存缓存(Memory Cache)
但是内存缓存存在时间较短,当标签页关闭的时候,内存会被释放,其中的内容也不复存在。
在查看了一些 NetWork 面板的内存缓存的数据,大部分内存缓存都是存的图片,例如 jpg,png,base64 等格式的图片或者一些小体积的 JS ,CSS 文件,因为这些文件相对来说体积较小,对于一些体积较大的比如说 JS 和 CSS 文件,一般都是会放到硬盘缓存中
浏览器缓存机制 – 硬盘缓存(Disk Cache)
如果没有命中内存缓存,浏览器就会查看硬盘中是否有对应的资源,读取硬盘中的资源要进行 I/O 操作,并需要重新解析该资源,读取速度相对内存缓存较慢(可以从第一张图看出来),对于一些大体积的文件例如一些大体积的 JS / CSS 文件,通常会被丢进硬盘缓存
浏览器缓存机制 – Service Worker 缓存
Service Worker 是一种独立于主线程之外的线程。它脱离于浏览器窗体,无法直接访问 DOM。它可以实现离线缓存、消息推送和网络代理等功能。借助 Service worker 实现的离线缓存就称为 Service Worker Cache。
在使用 service worker 前,需要先做一些准备工作,因为 service worker 只能运行在 https 或者 localhost 下,所以如果用 vscode 编写 service worker 文件,需要以 open with live server 来打开 html 文件,同时,对于不同浏览器,需要做下面的操作:
Firefox Nightly: 访问 about:config 并设置 dom.serviceWorkers.enabled 的值为 true; 重启浏览器; Chrome Canary: 访问 chrome://flags 并开启 experimental-web-platform-features; 重启浏览器 (注意:有些特性在 Chrome 中没有默认开放支持); Opera: 访问 opera://flags 并开启 ServiceWorker 的支持; 重启浏览器。下图(图片来自 MDN)展示了 service worker 支持的所有事件(会在后面介绍到)
Service Worker 是存在生命周期的,主要包括:
Registration(注册) Installation(安装)=> { installing, installed } Activation(激活)Registration
一个 service worker 就是一个 js 文件,和普通的 js 文件的区别就是它是脱离于浏览器主线程,运行在后台中的,在真正使用 service worker前,需要先对其进行注册:
通过 register() 函数来注册 serviceWorker ,该函数返回一个 promise 。
Installation
一个 service worker 被注册不代表它已经被安装好了(installed),当注册成功后,浏览器会尝试去安装 service-worker 对应的 script ,只有在这两种情况下才会安装成功:
这个 service worker 之前没有被注册过 修改了 service worker 的文件注册成功后,会触发 install 事件,可以对这个事件做一个监听,在 install 后,就可以添加缓存了,添加缓存用到了 service worker 提供的一个存储 API – cache
cache:一个 service worker 上的全局对象,它使我们可以存储网络响应发来的资源,并且根据它们的请求来生成key
通过 Cache API 提供的 open 方法,传入了一个缓存的名字(这里面是 v1,如果没有这个缓存就会新建一个,有的话就会直接打开)。函数返回一个 promise,当 promise resolve 以后,意味着缓存创建成功,这时可以把需要缓存的资源通过 addAll 这个 API 放到 v1 这个缓存中,addAll 接受一个待缓存资源列表数组。
由于 open() 这个方法是异步的,所以需要将它包在 event.waitUntil 里面,这会确保 Service Worker 不会在 waitUntil() 里面的代码执行完毕之前安装完成。如果 promise 失败了(reject),那么安装也会失败。
当安装成功完成之后, service worker 就会激活。在第一次 service worker 注册/激活时,并不会有什么不同。但是当 service worker 更新的时候 ,就不一样了。
Activation
安装成功后,会进入 installed 阶段(还没激活(active)),一个 service worker 要被激活,只有以下三种场景:
当前没有其他的 service worker 被激活(同一个scope 下) 用户刷新页面 self.skipWaiting() 在 install 事件中被触发如果你的 service worker 已经被安装,但是刷新页面时有一个新版本的可用,新版的 service worker 会在后台安装,但是还没激活。当不再有任何已加载的页面在使用旧版的 service worker 的时候,新版本才会激活。一旦再也没有更多的这样已加载的页面,新的 service worker 就会被激活。
激活后会触发 activate 事件,同样可以监听这个事件,比如说做一些清除缓存的操作:
上面的代码就是把所有不属于 v1 版本的缓存全部清除掉。
一旦 service worker 被激活以后,它就拥有了当前页面的完整控制权,此时就可以监听一些事件例如:fetch,push ,sync。
监听 fetch API
每次任何被 service worker 控制的资源被请求到时,都会触发 fetch 事件,这些资源包括了指定的 scope 内的文档,和这些文档内引用的其他任何资源(比如 index.html 发起了一个跨域的请求来嵌入一个图片,这个也会通过 service worker 。)
你可以给 service worker 添加一个 fetch 的事件监听器,接着调用 event 上的 respondWith() 方法来劫持我们的 HTTP 响应,然后你用可以用自己的方法来更新他们(类似于 middleware)。
(关于 caches.match() API 更详细的解释可以在这边看到:CacheStorage.match())
打开 Chrome 浏览器,输入:chrome://serviceworker-internals/ ,可以查看所有的 ServiceWorker 运行情况
参考链接:
Service Worker 从入门到出门 使用 Service Workers Cache Demystifying The Service Worker Lifecycle本地存储 – cookie
由于 http 是无状态协议,每一次的请求响应结束以后就 over 了,服务端不会记录客户端的任何东西,那么为了“维持状态”,就有了 cookie。
Cookie 打开 Chrome Application 面板可以看到,本质就是一个存储在浏览器里的文本文件,大小上限为 4KB,这决定了 cookie 不能存储很多的东西,而且对于同一个域名下的请求,浏览器都会自动带上 cookie。这样当在请求一些与用户状态无关的资源的时候(例如一些公用的图片啥的),每次都带上不必要的 cookie,当然会有一定的浪费,而且因为本身存储体积小,也并不能满足日益增长的缓存需要,所以就有了Web Storage。
本地存储 – Web Storage
Web Storage 比较熟悉了,主要就是分 localStorage 和 sessionStorage ,因为它俩的 API 都差不多,所以就不分开写了。
Web Storage 的存储空间相对于 cookie 要大很多,一般是在 5 – 10M,而且是存储于浏览器端,不会与服务端发生通信。
localStorage 和 sessionStorage 主要区别有两点
生命周期不同:localStorage 一旦存储不会过期,除非手动删除或者调用 API 清除,而 sessionStoarge 只要关闭当前标签页,里面的数据就没了。 作用域不同:localStorage 和 sessionStorage 都是遵循同源策略,但 sessionStorage 特别的一点在于,即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 sessionStorage 内容便无法共享。localStorage 和 sessionStorage 都是只能存储字符串,它们的核心 API 都是一样的
删除对应 key 的数据 clear:清空数据由于这二者只能存储字符串,所以在存一些对象格式的数据时候需要进行转译,或者将其封装在一个对象中,给出一个参考:
参考链接:
localStorage vs. sessionStorage – Explained本地存储 – IndexedDB
IndexedDB 是一种在用户浏览器中持久存储数据的方法。它允许您不考虑网络可用性,创建具有丰富查询能力的可离线 Web 应用程序。IndexedDB 对于存储大量数据的应用程序(例如借阅库中的 DVD 目录)和不需要持久 Internet 连接的应用程序(例如邮件客户端、待办事项列表或记事本)很有用。简单来说,它就是运行在浏览器中的非关系型数据库(key-value 型),理论上来说存储空间没有上限,它不仅可以存储字符串,还可以存储二进制数据。
示例代码:
更多关于 indexedDB 的使用的情况,可以查看 MDN 的文档:
IndexedDB – 基本概念 使用 indexedDBCDN
内容分发网络(Content Delivery Network,CDN)是在现有 Internet 中增加的一层新的网络架构,由遍布全国的高性能加速节点构成。这些高性能的服务节点都会按照一定的缓存策略存储您的业务内容,当您的用户向您的某一业务内容发起请求时,请求会被调度至最接近用户的服务节点,直接由服务节点快速响应,有效降低用户访问延迟,提升可用性。 ——————出自腾讯云简单来说,CDN 就是分布在各个地区的服务器,这些服务器存储着源站的数据的副本,这样当用户发起请求的时候,服务器可以根据哪些服务器离用户最近,从而从最近的服务器返回用户数据,从而提高响应速率,较少高流量影响。
前面所介绍的缓存技术,都是在第一次请求以后,浏览器会将数据缓存起来,这样在后续的请求过程中就会降低响应时间,但是在第一次请求的时候,是没有这些缓存的,所以要提升响应的能力,就需要利用 CDN。
说的再明白点,CDN 就类似天猫超市,离哪下单近从哪给你提货。
CDN有两个概念需要理解,一个是刷新,一个是预热。
CDN刷新
CDN 刷新其实就是当源站的数据文件更新后,CD
CDN预热
CDN 预热是指的在不通过用户访问的情况下,提前把数据分发到全国各地的 CDN 节点上缓存起来,这样用户在请求的时候就可以直接从 CDN 拿取数据了。
对于一些瞬间大流量的访问,例如抖音春晚,如果有大量的用户都去请求一张图片,而 CDN 没有做预热的话,那么所有请求就都会打到源站,就有可能把源站打崩的危险,所以预热是很有必要的。
除了这两个概念以外,一般情况下 CDN 节点的域名和源站的域名是不一样的,这样做又一个好处:对于业务 cookie 来说,在请求 CDN 的资源的时候没有必要带着 cookie 到处跑,可以减轻一些网络负担。
参考链接:
CDN系列学习文章(一)——CDN介绍篇 CDN系列学习文章(二)——DNS调度 CDN系列学习文章(都在这里面了)总结
到这里,缓存层面的优化技术就大概整理完了,主要包括了浏览器缓存机制,本地存储的相关技术以及 CDN 的理论向简单科普,下一节会介绍渲染层面的优化,主要包括例如服务端渲染,浏览器渲染机制,事件循环,回流重绘等。
然后最后一节会放一些代码,包括如何利用 Promise 做到并发控制,如何测量一个函数的运行时间,节流与防抖,还有一些在代码层面可以做的简单的小优化等。
渲染层面
渲染层面一节主要会讲到服务端渲染,浏览器渲染机制,以及事件循环和 DOM 操作的优化等
服务端渲染
首先引用下 Vue 官方对服务端渲染(SSR)的解释(下面基本都用 Vue 来举例):
Vue.js 是构建客户端应用程序的框架。默认情况下,可以在浏览器中输出 Vue 组件,进行生成 DOM 和操作 DOM。然而,也可以将同一个组件渲染为服务器端的 HTML 字符串,将它们直接发送到浏览器,最后将这些静态标记”激活”为客户端上完全可交互的应用程序。这段介绍比较官方,先从和服务端渲染对应的客户端渲染来说起,可以更好的理解什么是服务端渲染。
客户端渲染
使用 Vue 的时候,它用到的模板 index.html,只有短短的几行代码:
只是查看 index.html 的话,啥也看不出来,所有的逻辑都被放在了外部引入的 JS 文件中,浏览器需要将 JS 跑一遍以后,再生成相应的 DOM,这样一来,看到的跟节点(#app)下其实啥也没有。
以上差不多就是客户端渲染的主要特点,在源 html 文件里面找不到页面实际呈现的内容。
根据客户端渲染的特点,可以大致得出它的一些缺点:
由于页面的渲染,JS的逻辑以及处理一些第三方模块的逻辑都在 JS 文件中,导致首屏加载的时间会较慢,出现长时间的白屏情况。 对 SEO 不友好,因为大多数搜索引擎采用的都是爬取页面的代码然后去分析关键词,但是对于客户端渲染,给他的 html 就那么一丁点,它搜索引擎能爬到个鬼咯所以接下来就引出服务端渲染。
服务端渲染
塞到 DOM 里面,最终返回客户端一个含有完整 DOM 树以及交互逻辑的 html 文件,浏览器所需要做的工作就大大减少了。
服务端渲染的一个特点也很清晰,就是页面上呈现的内容,都已经被塞的源 html 文件里面了,比如,查看知乎的 NetWork,就是很清晰的服务端渲染,对于这种东西,浏览器就轻松好多。
(注意那个超短的进度条)
和客户端的缺点相比,就得出了服务端渲染的优点:
首屏加载时间快:因为服务端返回的已经是一个完整的可以直接渲染的 HTML 文件了,所以浏览器即拿即用,相对于客户端的还要解析运行 JS,显然是快不少的,可以很好的降低白屏时间 对 SEO 友好:这个更多可能是效益方面的优点,因为对于搜索引擎来说,如果这个网站用了服务端渲染,那显然它可以爬到更多的关键字,用户搜索的时候搜索到对应关键字就会有更大概率找出来,提高曝光,岂不美哉下面以 Vue 为例(官方例子),大概实现下在 Vue 中如何实现服务端渲染(更多的解释可以在Vue SSR 指南中找到)
当然,服务端渲染也有缺点:由于渲染任务都交由服务端进行,在高并发的情况下,对于服务端负载压力大,而且因为返回的是整个页面,所以复用情况较差,对于每个路由都需要重新刷新,而且就开发形式上来说,前端的开发是很依赖服务端的开发的。
通过了解浏览器渲染机制来引出优化点
首先来了解下浏览器的渲染机制。
浏览器内核简介以及渲染流程概括
在浏览器中,有一部分软件的功能是计算如何根据拿到的文件来展示对应的内容,这部分叫做浏览器引擎(或者叫浏览器内核),对于不同的浏览器,内核也都不太一样,常见的浏览器内核有这几种:
Firefox:Gecko Chrome:Blink(也是 Webkit 的一个升级) Safari:Webkit IE:Trident从读取 HTML,CSS,JS文件到最终渲染到页面上,大概是分以下的过程:
解析 HTML 变为 DOM 树 解析 CSS 变为 CSSOM(CSS Object Model) 组合 DOM 和 CSSOM 为渲染树(render tree) 绘制和渲染需要注意一点是在组合 DOM 和 CSSOM 的过程中,设置了 display: none 的节点不会被挂到 render tree 上面。
在上面整个流程中,会有一些资源的解析会阻塞这个过程,由于对于一个页面来说,最重要的就是能尽快将 HTML 和 CSS 展示给客户端,也就是说 DOM 和 CSSOM 在第一帧绘制前必须被组合起来,也就是说,其实 HTML 和 CSS 都是会阻塞渲染的资源。
CSS的优化手段
根据上面的分析,首先可以得出的就是针对渲染流程中处理 CSS 的过程,有什么手段可以优化:
由于在解析 CSS 选择器的时候是从右往左解析的,所以这种解析顺序决定了我们在写选择器的时候就可以有一些优化操作,例如:
根据上面的代码,可以总结以下几点
尽量避免使用通配符 *,尽可能的只对用到的元素去匹配 少使用标签选择器,如果可以的话可以利用类来代替,例如上面的 1 和 2 的写法 对于嵌套较深的元素,在利用 CSS 匹配的时候尽可能的少嵌套,因为后代选择器的消耗是很高的。 对于 p.name 这种写法,如果只用一个 .name 就可以的话,那么就没必要写前面的 p,这样也可以减轻检索的消耗说完了 CSS 的一些简单的优化手段,接下来看一下他们对渲染阻塞情况的优化。
前面说过,HTML 和 CSS 其实都是阻塞渲染的资源,不仅如此,其实 JS 也是有着阻塞渲染的特性。下面大概解释一下:
对于 HTML 来说,如果没有对其解析完毕,就没有 DOM,那么就不会有后面的所有步骤了。
对于 CSS 来说,前面也提到,最终的 render tree 是由 DOM 和 CSSOM 一起组成的,只有他们俩都构建好了,才有可能进行下一步渲染的操作,所以浏览器在构建 CSSOM 的过程中,不会渲染任何已处理的内容。而对于 CSS 的解析,又是浏览器遇到 link 或者 style 标签的时候才进行的(不考虑内联样式)。所以,针对 CSS,需要尽早将其解析完毕,这也就是为什么要尽可能的将 link 或者 style 标签放在 head 里面了。(关于 CSS 解析更详细的解释,我会在节末贴出链接,有兴趣的可以自己研究下)
JS 的优化手段
终于到了分析 JS 的时候了,浏览器在遇到 script 标签的时候,会停止对 HTML 和 CSS 的解析,这是因为我们可以用 JS 修改 DOM,也可以修改样式,对于浏览器来说,它并不知道我们在 JS 里面做了什么操作,所以干脆就停止解析 HTML和 CSS,直接去下载执行 JS,省的会造成一些错误。
为了验证这个猜测,看下面代码:
三段 script 的输出是:
在浏览器遇到第一段 script 的时候,此时拿不到 div ,正是因为渲染 HTML 停止了,证明了 JS 可以阻塞 HTML 的解析
浏览器遇到第二段 script 的时候,此时已经解析完了 body 部分的 HTML 代码,所以可以拿到 div,但是因为 style 是在这个 script 后面执行的,所以此时拿到的 color 是默认颜色 rgb(0, 0, 0) ,也就是黑色。所以证明了 JS 可以阻塞 CSS 的解析。
最后一段,CSS 解析完了以后,就拿到了设置的颜色,红色,没有任何问题。
所以基于此,就可以得出一些优化 JS 的建议:
老生常谈,尽量把操作 DOM 相关的 JS 文件放到 body 后面,这样不会阻塞 HTML 和 CSS 的解析,毕竟,最快解析出来 HTML 和 CSS,呈现出一个页面给用户,是最主要的,就算此时只是一个静态的页面。 使用 async 模式加载 JS 外部文件,使用 async 加载的 JS 文件,浏览器遇到后,会先下载它,但是不会执行,她的加载是异步的,只有等到它加载结束以后,才会立即执行。可以看一段代码:还是刚才的代码,只不过将第一段 script 通过 async 的方式加载,而且用了外部的文件,最终输出结果:
注意 1 这段输出,它输出在了最后面,而且也拿到了 DOM,不是 null 了。
需要注意的是,如果有多个添加了 async 的外部 JS 文件,那么他们执行的顺序很可能不是按照写的顺序来的,所以要注意这些 JS 文件之间最好不要有依赖。
使用 defer。defer 和 async 差不多,唯一的区别就是它在加载完 JS 文件之后不会立即执行,而是要等到所有元素解析完成以后,在 DOMContentLoaded 事件之前执行。参考链接:
How browser rendering works — behind the scenes(这篇讲的非常好) 一文看懂Chrome浏览器运行机制(这篇稍微有些难懂)网页性能管理详解(阮一峰) 探究 CSS 解析原理 defer 和 async 的区别DOM操作的优化点
首先要了解的是,为什么 DOM 操作这么慢。
DOM 本质就是一个对象,利用 JS 操作 DOM 也就是操作对象,都知道单纯的 JS 层面的计算是非常快的。但真正消耗性能的,是 JS 操作 DOM 以后引发的回流与重绘。(这一小节先不会对回流和重绘做出详细的解释,主要是根据这个引出几个优化点)。回流和重绘本质上是因为对 DOM 的修改改变了 render tree 所导致的,所以根据此可以引出以下几个个优化点:
前面提到过,对于 display: none 的元素,是不会挂到 render tree 上面的,所以如果需要对某个节点进行多次操作(例如 1000 次),可以先把它变成 display: none,然后再去修改 1000 次,最后再恢复显示,这样以来,总共就触发了两次回流和重绘(设置 display: none 会触发回流和重绘),而不是可能的 1000 次回流和重绘。 批量操作尽可能一次性做完,最终再交给 DOM 进行一次或者少量的操作,例如:对于这段代码,在 Chrome 里面衡量执行时间是 979ms 左右,下面我们来优化一下:
对于这段代码,执行实现仅在 1.45ms。
通过这两段代码的对比,第一段代码中,1一并赋给 div.innerHTML,这就是上面说的,批量操作尽可能一次进行完毕,这样仅触发一次回流重绘,而不是触发 1000 次回流重绘。
离线 DOM – DOM Fragment
上面的思想其实也就是 DocumentFragment 的思想,我们也可以用这个东西来代替上面的操作,先来看下它是什么:
DocumentFragment,文档片段接口,一个没有父对象的最小文档对象。它被作为一个轻量版的Document使用,就像标准的document一样,存储由节点(nodes)组成的文档结构。与document相比,最大的区别是DocumentFragment 不是真实 DOM 树的一部分,它的变化不会触发 DOM 树的重新渲染,且不会导致性能等问题。上面的简介中可以得出几个简单的结论
DocumentFragment 类似标准的 DOM,只不过更轻量 DocumentFragment 的变化不会触发回流和重绘DocumentFragment 的 API 其实是和 DOM 基本一样的,用 DocumentFragment 来修改上面的例子:
这段代码用时在 4.39ms 左右,相比上面的优化后的代码会稍微慢一点,但是它多了可以操作的 DOM API ,更加灵活,也更加实用业务场景。
相关参考链接都会放在回流和重绘小节结尾的部分。
回流和重绘(reflow and repaint)
回流:回流又叫重排,当对一个节点的修改导致了布局或者几何尺寸的变化(例如改变宽高,设置显隐,设置 position 等),浏览器会重新计算该节点的相关属性,不仅如此,该节点所有的子节点,所有的父节点,还有相邻节点,都会收到影响,可想而知这是一个庞大的计算过程。
重绘:当修改了节点的一些样式,例如背景色,颜色,outline 这些,元素的几何尺寸没有变化,所以浏览器会直接给这个元素绘制新的样式,也不会影响周围的节点。
回流一定触发重绘,重绘不一定触发回流。
那么都有哪些操作可能会导致回流和重绘呢?
回流(太多了没必要记,看一看就行):
改变 window 的尺寸 激活 CSS 伪类(例如 :hover) 盒模型的相关操作,例如 box-sizing 定位相关操作,例如 position: absolute 浮动相关操作,例如 float: left 改变节点位置,或文本内容(例如在 input 里面输入文字) JS 对 DOM 的一些操作,例如: offsetTop/offsetLeft/offsetWidth/offsetHeight scrollTop/scrollLeft/scrollWidth/scrollHeight clientTop/clientLeft/clientWidth/clientHeight getComputedStyle() JS 对节点的删除,修改,增加(和上面区分一下,后面会用到) css的一些属性(修改 visibility 属性不会引发回流) width height padding border margin position top left bottom right float clear text-align vertical-align line-height font-weight font-size font-family overflow white-space display:none重绘:
color border-style border-radius text-decoration box-shadow outline background visibility至于可以引发回流和重绘的操作太多,不可能一一列举对应的解决办法(有的也必须用啊),针对一些常用的,在此总结下:
可以用一用 position: fixed/absolute,虽然说使用这俩会导致回流,但是因为他们是脱离文档流的,所以对他们进行重排的时候,开销会小一点,因为不会影响周围的元素。 如果要动态的修改一个 DOM 的多个样式(尤其是可以触发回流的样式),最好把这些样式放到一个 class 里面然后操作 class ,而不是通过 JS 一行一行的改变样式上面两段代码,第一段用时 33ms 左右,而第二段仅用时 1.375ms。(当然例子只是例子,很简单,只是体现这个理念而已)。
2. 前面提到过可以先将要修改的 DOM 设置 display: none ,然后再操作,这里再提一下,防止忘了
3. 使用 window.requestAnimationFrame()、window.requestIdleCallback() 这两个方法调节重新渲染
说了这么多优化的操作,难道浏览器自己本身没有啥优化的操作吗。当然有,它又不是傻子。看下面一段代码:
从理论上讲,操作了 1000 次,应该是会触发 1000 次回流,1001 次重绘(包括一次改变颜色),但是查看了 performance 面板,只找到了一次,Layout 和 Paint(代表重排和重绘·)。
先抱着疑问再看一个例子:
从这次的 performance 来看,触发了非常多次的重排重绘(那些细的柱子每一个都是触发的重排重绘,就不放大看了)
根据这两个例子,探究浏览器到底做了些什么:
浏览器自己缓存了一个 flush 队列,把回流与重绘任务都塞进去,待到队列里的任务多起来、或者达到了一定的时间间隔后,再一口气出队全部执行一次,但是对于一些需要实时结果的属性的操作(就是上面提到的会触发回流的操作中的 JS 对 DOM 的操作),会每一次执行都将 flush 里面的任务出一次队。所以对于第一个例子,只触发了一次回流和重绘,但是对于第二个例子,却触发了多次。
参考链接:
网页性能管理详解(阮一峰) REFLOWS & REPAINTS: CSS PERFORMANCE MAKING YOUR JAVASCRIPT SLOW? Repaint and reflow 讲清楚重排或回流、重绘 DocumentFragment事件循环在性能优化上可以做的操作
事件循环简述
事件循环是什么?这个问题网上已经有了太多相关的文章。关于事件循环是啥,具体的就不再描述了,主要提一下里面涉及到的几个知识点:
JS 是单线程的,所有同步任务都在主线程上执行,利用到一个执行栈 除了主线程,还有一个 task queue,用来存储异步任务的回调函数(异步任务有了结果或者计时到了以后) 主线程任务执行完以后就会去查看 task queue,有的话将异步事件的回调放回到主线程一一执行。 在任务队列(task queue)里面,所有的任务被分为了两类,宏任务 macro task 和微任务 micro task,script 本身是一个默认的宏任务(就是 JS 文件) 浏览器在执行任务队列里面的任务时,会先执行宏任务,执行完一个宏任务后,会查看微任务队列里面有没有任务,如果有的话就清空微任务队列,二者大概的执行顺序可以参考下面的代码:了解完事件循环,下面从渲染层面再考虑一下。
由于每次循环一次(一个宏任务 + 一队微任务)都会重新渲染一次,那么,对于如果想在异步任务中更新 DOM,是包装在宏任务里面好还是微任务里面好?
因为上面提到, script 本身也是一个宏任务,所以一开始宏任务队列里面会默认存在 script ,如果将异步更新 DOM 的操作也放在宏任务里面,那么在第一次执行 script 这个宏任务的时候,是不会执行到这个更新 DOM 的任务的,这样就浪费了一次渲染,因为每一次宏任务执行完,都要渲染一次。而如果将异步更新 DOM 的任务包装在微任务里面,那么在第一次执行完 script 的时候,就会执行微任务队列,就会在渲染前更新 DOM 了。为了方便理解上面的话,做了张图:
(为了形象化图片有可能会有一些问题,但描述我的意思是够了)可以看,如果是封装在宏任务中,在第一次渲染的时候,根本没有执行到这个任务,而如果在微任务中,第一次渲染之前就会执行到了。所以当需要在异步任务中实现 DOM 修改时,把它包装成微任务会好一点。Vue 也是这么做的,他用到了nextTick 来实现异步更新 DOM。
下面是对 Vue 中实现异步更新 DOM 的简单分析,会涉及到源码,觉得已经了解的可以跳过这节。
Vue中实现异步更新的原理 – nextTick
我们都知道在 Vue 中修改数据会导致 DOM 的修改,这其中其实 Vue 做了很多事情,核心的就是依赖收集和派发更新。在初始化的过程中,Vue 会将 data 上的每一个属性(包括深层次的属性),都变成响应式的,具体操作就是重写每一个属性的 getter 和 setter,同时,会为每一个属性都创建一个叫 Dep 的一个实例,不同的属性的 dep 通过 id 来区分。每个属性的 dep 里面有一个 subs ,这个 subs 是一个数组,里面存了所有该属性依赖的 Watcher。当触发属性修改的时候,实际上就是遍历这个属性的 subs,然后去执行里面的每一个 Watcher。
其实上面这个遍历 Watcher,并执行,这一步就是异步的,Vue 会把这个步骤放在 nextTick 的时候执行。
在了解了 Vue 异步更新的原理后,再来看一下它内部的 nextTick 的实现:
简单来分析,nextTick 函数做了这么几步操作:
向 callbacks 里面推入一个含有错误处理的包装函数,函数内部执行传入的 cb 判断上一个异步队列是否执行完毕,如果是 false (上一个异步任务队列执行完毕),则判断是否是使用宏任务,根据 useMarcoTask 来判断去调用 marcoTimerFunc 还是 microTimerFunc 最后是对 cb 没有传入的情况下做的一些处理对于 useMacroTask 和 useMicroTask,是这么处理的:
首先是处理宏任务 macroTimerFunc。
对于宏任务,会首先检测是否支持原生 setImmediate(涉事一个高版本 IE 和 Edge 才支持的特性),否则的话会降级为检测是否支持 MessageChannel,如果也不支持的话,就会使用 setTimeout 来包装 marcoTimerFunc,最终 marcoTimerFunc 其实就是一个异步执行 flushCallbacks 的宏任务。
对于微任务,首先会检测是否支持原生 Promise,支持的话就将 microTimerFunc 包装成 Promise,否则就降级为宏任务,最终 microTimerFunc 也是一个异步执行 flushCallbacks 的微任务。
下面再来看下 flushCallbacks 的实现:
flushCallbacks 很简单,就是把在调用 nextTick 的时候推入 callbacks 里面的回调函数拿出来一个一个执行一下,并同时清空 callbacks`。
如果对解析 nextTick 的时候 pending 的作用还不是很明白的话,看到这段代码应该就懂了。在每一次执行 flushCallbacks 的时候,都会清空 callbacks,同时会把 pending 变为 false。也就是说,pending 就是一个用于判断当前的 callbacks 是否执行完了的标志,当它是 false 的时候,表示任务队列为空,为 true 的时候表示还没执行完。
最后再总结一下,flushCallbacks 因为是封装在 setImmediate 或者 MessageChannel 或者 setTimeout 或者 Promise 里面执行的,所以它的调用也是异步的,这样保证了在同一个 tick 内多次执行 nextTick,不会开启多个异步任务,而把这些异步任务都压成一个同步任务,在下一个 tick 执行完毕。同时我们也可以看到,最开始 useMacroTask 这个变量的值是 false,这也对应了前面的分析,即对 DOM 的操作可以优先放到微任务队列中,这样可以保证在当次渲染前执行完更新 DOM 的操作。对于 Vue 来说,除非把函数通过 withMacroTask 传入,否则都是用微任务的形式去包装,调用的。
最后来一段伪代码扩展一下:
参考链接:
Philip Roberts 关于什么是事件循环的讲解(视频英文版) Vue.js技术揭秘 – nextTick其他方面(大杂烩)
这一节就是记录下看到的一些其他优化操作以及常见的并发场景的简单实现。
利用 Promise 实现并发控制
利用 Promise 实现并发控制,想写这个主要是因为自己在面试的时候被考到,觉得实现起来很有意思,就贴出代码来,当然这也是一种常见的场景。下面的代码以加载多张图片为例,规定任务队列里面最多只能有 limit 个任务在执行,每当有一个任务完成以后马上出队,同时下一个任务马上进来,始终保持队列充满,直到全部加载完毕。
如何去分析一段函数的执行时间
对于一段很长的 JS 文件,如何去测量这个文件中哪一段代码(或者哪一个函数)的使用时间最多,如何去写这个代码?
可以用 ts 的装饰器来实现
懒加载
懒加载或者按需加载,是一种很好的优化网页或应用的方式。这种方式实际上是先把你的代码在一些逻辑断点处分离开,然后在一些代码块中完成某些操作后,立即引用或即将引用另外一些新的代码块。这样加快了应用的初始加载速度,减轻了它的总体体积,因为某些代码块可能永远不会被加载。 –引自 webpack 官方文档对懒加载的解释懒加载的概念应该都不会陌生了,懒加载也叫按需加载,中心思想就是在模块被需要的时候才加载进来,而不是一次性的全部加载进来。懒加载常见的一些应用有针对图片的懒加载以及像 Vue 配合 ESModule 的动态加载的 API 和 Webpack 实现的异步组件等,下面就分别针对两种情况探究一下:
图片懒加载
当前处于可视窗口之外的图片,没必要加载进来,只有当用户往下滑动的时候,露出来了再加载也不迟。根据这个思想,先来实现一个简单的图片懒加载。
需要用到的一些前置知识:
通过 window.innerHeight 的位置,放一张MDN 的图接下来就来实现一下:
这样向下滑动,就可以实现图片一张一张加载(可以把浏览器缩小查看效果)。
异步组件/模块
关于异步组件,这里主要还是用 Vue 来实现,其实实现起来很简单,基本就是一行代码:
如果要配合 vue-router 使用的话,其实也很简单:
再来说一下 webpack 中使用懒加载的方式。
webpack 中,懒加载的前提是代码分割 code-splitting,有三种常用的代码分离方法,这里只说一下动态导入的方法,更多的可以参考下面给出的 webpack 代码分割链接。
动态导入主要使用了 import()语法,如果用上面图片加载的例子来说的话,就是可以理解为当 实际触发的时候,再去动态导入图片组件,下面看一个例子:
更多关于懒加载相关,可以参考下面给出的链接,尤其是可以看一下 webpack 是怎么实现这个的。
参考链接:
vue – 动态组件 & 异步组件 vue-router – 路由懒加载 webpack – 代码分割 webpack – 懒加载节流与防抖
节流(throttle)
概念:节流的概念,就是说在一定的时间内,如果多次触发回调函数,只会执行一次。它其实是以第一个触发回调函数的时间开始算的,比如说设定时间为一分钟,在 15:00:00 的时候触发了一次回调函数,那么在 15:00:00 – 15:01:00 这一段时间内,再触发的话也不会执行,而是只会执行 15:00:00 时候触发的第一次的回调函数。一分钟过了以后,会清空计时器,再重新开始,此时还是以第一次触发回调时候的时间开始算起,一分钟内,再次触发不会执行。
常用场景:resize,scroll,mousemove。
实现一个简单的节流函数
从代码上来看,throttle 的实现其实就是一个包装函数,它会返回一个函数,返回的函数才是真正要执行的函数。
上面这个写法是利用了时间戳,根据当前时间和上一次触发的时间相减来判断是否需要触发下一次的回调。对于时间戳的写法来说,如果是第一次触发回调,那么会立即执行,因为第一次触发的时候,last 是 0,now – last 肯定大于 interval。
节流还有一种写法,就是利用定时器 setTimeout。
这段代码也很好理解,一开始定时器 timer 是不存在的,第一次触发回调函数的时候就会创建一个定时器,并传入 interval,如果在 interval 时间以内再次触发的话,因为此时 timer 还存在,所以不会执行,当 setTimeout 计时到了以后,就会去执行回调函数 cb,同时把 timer 设成 null,这样子以来,在下次触发的时候,就可以再去创建定时器,计时了。
定时器写法和时间戳写法的区别在于,对于第一次触发的回调,时间戳写法会立马执行,而定时器写法是会延迟 interval 时间以后再执行(这个也很好理解因为是包在了 setTimeout 里面嘛)。
如果再仔细考虑上面两段代码的话,会发现一个问题:
对于第一段代码,假如用户在触发 scroll 事件,一共触发了 105 次,假设每一次触发所需要的时间是 100ms,也就是说,对于 1000ms 的计时器,会触发 10 次以后再执行一次回调,当触发了100 次以后,此时已经执行了 10 次回调。触发第 101 次的时候,会重新设置 last,再触发 5 次以后,用了 50ms,就不再触发了。也就导致最后一次(第 105 次)触发的时间减去第 101 次触发的时间是不会大于 interval 的,也就是说,在 101 – 105 这五次触发,不会执行回调函数。
对于第二段代码,就是之前说的第一次触发会延迟 interval 以后才会执行。
那么是否可以将两种写法结合,使得最后一次也可以触发同时第一次不会延迟触发呢,答案是可以的,这就需要结合两种写法了。
这段代码结合了第一段和第二段的特点,做到了第一次立即执行,而且最后几次的触发就算总的时间间隔小于计时器时间,也会触发一次。大概的逻辑如下:
检查从上一次触发到这一次触发以后,计时器还剩下多少时间,比如说 interval 设定 1000ms ,第一次和第二次触发时间间隔是 200ms,那么计时器就还剩下 800ms 如果 remain 小于等于 0 ,表示距离上一次执行已经过去了规定的时间,则再去执行一次。 如果大于 0 ,就维护一个定时器,这个定时器每次触发都会被清空,所以它的作用就是在最后几次执行以后,如果整体间隔小于 interval,也会在剩余时间 remain 结束后再执行一次。防抖(debounce)
概念:和节流不一样的事,节流认的是第一次,而防抖认的是最后一次。它的思想在于,如果在某一段时间内,又再次触发回调,那么我就清空计时器重新开始计时,直到在整个计时的时间内没有再触发了,才会去执行回调。同样,假如设定了一分钟计时器,在 15:00:00 时刻触发一次回调,此时计时器开始计时,但是在 15:00:05 的时候又触发了一次,此时计时器就会重新开始计算,假如一直到 15:01:05 这段时间内一直没有触发新的回调,最后计时器清零,才会去执行回调函数。
常用场景:input 输入,对于一些输入框来说,可能会根据当前输入的内容同步进行一些相关检索(比如百度搜索框在下面展示的下拉列表),这种情况下,如果每输入一个字符就检索一下比较浪费,所以可以使用防抖,也即,在输入结束以后(比如说结束了 1000 ms),再去检索。
防抖的代码实现:
debounce 实现看起来也很简单。巴特仔细思考防抖的原理,会发现一个问题:如果用户一直触发回调函数,那么就一直不会执行,直到最后一次触发后,再过了计时器时间后才会触发,这时候用户搞不得会得出这个页面卡死了的结论,所以上面的 debounce 还可以再优化一下,也就是说,在 interval 时间内,触发可以重新生成定时器,但是过了这个时间的话,就给用户一个返回,省的用户干等。
可以看到其实这一版的 debounce 其实和上面第三版的 throttle 是一样的思路。
其他性能优化的点
对于一些不互相依赖的请求,可以使用 promise.all 一次性处理完。 骨架图总结
整篇文章写下来,参考了很多网上的资料,大部分的链接都贴在了文章中,参考的最多的就是前端性能优化原理与实践这本小册,有兴趣的读者也可以购买来自己看看。我写这篇文章主要是为了让自己能在性能优化这部分形成一个大概的体系,起码在面试的时候不虚就行了,也希望这篇文章能帮助到更多的人,如果对文章有什么问题,请尽管提出,我会尽量修改。