性能优化的考虑点
提到性能优化,我们就要知道为什么需要性能优化,哪里需要进行性能优化,以及怎么优化这三个问题。对于第一个问题,为什么要性能优化,答案很简单,对于一个中大型web应用,其并发访问量是非常大的,如果性能不堪重负的话,该应用将给用户带来非常糟糕的使用体验。因此,我们在中大型项目中必须考虑性能优化问题。至于小型web应用,使用优化过的开发方案依然会带给用户良好的用户体验。除去用户体验之外,性能提升所带来的降低服务器负担、减少网络请求数也是非常有价值的。对于第二个问题,哪里需要进行性能优化,或者说我们该如何考虑优化点,如何理清优化的目标,这是非常关键的步骤。这里,我引用修言小册中的例子进行说明。考虑经典的面试题:输入url后发生了什么?这有以下步骤:
- DNS解析
- 建立TCP链接
- 发送HTTP请求
- 服务器端处理请求,发出HTTP响应
- 浏览器获得响应结果,解析渲染页面
根据Web服务的这五个过程,我们可以针对性地选择优化方法,这五个方面就是我们在进行性能优化时的考虑点。具体地说,DNS解析所花费的时间怎么减少?TCP链接的三次握手非常耗时,如何提高速度?HTTP请求和响应如何提高性能?浏览器端如何进行性能优化?概括地说,优化涉及网络层面,渲染层面,具体应用(懒加载,节流,防抖)以及监测工具。
性能优化的具体方法
网络方面的性能优化
上一节我们提到,前四个过程属于网络方面的问题,本节主要讨论网络方面的性能提升方案。对于DNS解析和TCP连接的建立,我们能做的其实非常有限,DNS解析可以使用浏览器 DNS 缓存和 DNS prefetch,TCP连接的话可以使用长连接,预连接等。这些其实已经在基础的网络环境中配置好了,我们可以暂且略过。网络方面我们可以真正发挥实力的地方在于HTTP的请求和响应。我们务必要遵循以下两点:
- 减少HTTP请求数
- 减少单次请求所花费的时间 如果说请求和响应的资源都比较小的话,性能必然提升,那么如何缩小资源呢?对了,我们广泛使用的webpack就是一个优化点,因为webpack打包的时间太长,打包后的资源依然很大。如何进行提升呢?
webpack优化
- 不要让babel-loader做太多的事,可以用 include 或 exclude 来避免不必要的转译。另外,开启缓存将转译结果缓存至文件系统,大大提升loader的效率。
- 第三方库的处理,选择DLLPlugin,整个插件可以打包第三方库到一个单独的依赖文件中,这个依赖库文件只有在依赖自身发生版本迭代时才会重新打包
- HappyPack将单线程的webpack转变为多进程,大大提升打包效率
- 按需加载
- 删除冗余代码,典型应用Tree-Shaking
Gzip对HTTP的压缩
在请求头中加入gzip,可以实现对HTTP的压缩,这里引用修言小册中的定义:HTTP压缩就是以缩小体积为目的,对 HTTP 内容进行重新编码的过程。大型项目、项目中有重复的语句使用GZIP对于性能的提升显而易见,而迷你的小项目则意义不大。Gzip压缩在服务器端进行,所以本质上是以服务器端压缩的时间成本和CPU计算成本换取HTTP传输过程中的时间成本。所以开发者需要对此做出一定的权衡。
图片优化
目前的web世界里,图片、音视频渐渐成为数据的主流形式。其中,图片的占比最大,并且每张图片都占据不俗的存储空间,所以图片的优化对于HTTP请求响应过程中的性能提升十分关键。图片的优化,最简单的就是弄清楚不同格式图片的使用场景。
- JPG图片:有损压缩,轻量化色彩丰富,不支持透明,适合首页大图。
- PNG图片:无损压缩,质量高,支持透明,缺点就是体积大。分为PNG-8和PNG-24,- 8和-24指二进制位数,-8表示最多支持256种颜色,-24表示最多支持1024种颜色。 能用-8尽量用-8。小的LOGO图最好使用PNG格式获得更好的色彩表现。
- WEBP图片:谷歌最新推出的号称解决所有格式痛点的图片格式,表现最好,但是兼容性非常差,只有chrome支持。
- SVG图片:可缩放矢量图片,基于XML的文本文件,体积小,不失真,兼容性好。SVG图片是一种文本格式图片,不是基于像素点的,无论如何缩放不会失真。
- CSS雪碧图:将多个小图标和背景图像合成为一张图片。使得一个图片文件替换掉多个小图标文件。
- Base64:作为雪碧图的补充而存在的。直接对图片进行编码,所以可以直接将编码结果写入HTML或者写入CSS,减少了HTTP请求的次数和时间。
浏览器缓存机制
使用缓存的目的就是减少HTTP请求数,从而提高性能。浏览器缓存机制包括了四个方面,按照优先级的不同,可以分为:
- Memory Cache
- Service Worker Cache
- HTTP Cache (最关键,最常用)
- Push Cache(HTTP2.0的新特性)
HTTP Cache
HTTP缓存都是设置在服务端的。当客户端第一次发出请求后,服务端根据设置在响应头中返回对应的参数,客户端根据响应头的数据进行缓存 HTTP缓存可以分为强缓存和协商缓存。强缓存比协商缓存的优先级更高,会被优先考虑。强缓存使用http头部中的Expires和Cache-Control进行控制,命中强缓存,返回状态码200,直接从缓存中获得资源,不会再和服务器通信。 Expires是HTTP1.0的产物,它使用的是时间戳,也就是说当服务器在第一次请求的响应头中将过期时间写入expires,如果本地时间小于这个过期时间,就直接从缓存中获取资源,反之则需要重新访问服务器端。这样做的缺点就是本地时间的不确定性,本地时间和服务器端时间可能不一致,本地时间也可能被修改过,这都会导致强缓存的失效。在HTTP1.1中,Cache-Control完美解决了这个问题,它是expires的完美替代,使用max-age字段来控制资源的有效期,max-age不再是时间戳而是一个时间段,其实也就是把绝对时间换成了相对时间,它的优先级更高,优先使用。
基本概念的区分:public和private,这两个修饰词限制了资源能否被代理服务器获得(CDN)。no-store和no-cache,no-store直接访问服务器端询问是否资源过期,不会询问浏览器,而no-cache则直接不缓存,只允许向服务端发送请求并获得响应。
协商缓存,顾名思义需要客户端和服务端进行“协商”,也就是通信。浏览器需要询问服务端,如果资源未被修改,则返回状态码304,重定向到浏览器缓存。反之,则需要向服务器发出请求,并等待响应结果。协商缓存依靠Last-Modified/If-Modified-Since和ETag/If-None-Match,其中Last-Modified是一个时间戳,它会随着响应头返回到客户端,之后客户端的每次请求都会带上If-Modified-Since字段,它的值就是第一次请求时返回的last-modified值。服务器端收到之后的请求都会比对这个时间戳和资源的最后修改时间,如果没有变化,则返回304,使浏览器重定向到缓存中,反之发送最新的资源作为响应。这个时间戳存在一些问题,比如说编辑了文件但没有修改任何内容,这样的话会被误认为资源发生了变化从而无法使用缓存。也就是说服务器无法正确感知文件的变化。为了解决这个问题,ETag登场了。这是一个由服务器生成的唯一的字符串ID,这个字符串ID是根据资源内容编码的,如果内容发生了变化,ETag自然会变化。它也是在第一次响应的头部中被返回给客户端,在之后的请求中,请求头中都会附加一个叫做If-None-Match的字段,该字段的内容就是第一次返回的ETag值,服务器根据请求头中的这个字段与资源的ETag进行对比,如果一致则返回304,否则发送更新后的资源。ETag的优先级更高也更准确,但是它的缺点是会影响服务器性能。
MemoryCache
内存缓存顾名思义就是存在内存中的缓存,它的优先级最高,访问速度最快,但是也是生命周期最为短暂的,因为这种类型的缓存和渲染进程共生,当渲染进程结束时,这部分缓存也就在内存中被删除了。内存空间有限,非常宝贵,只有小文件、小图片可以被放进去作为缓存。
Service Worker Cache
Service Worker是一种独立于JS主线程之外的线程,它的独立性使得其无法操作DOM,也就不会影响网页性能。基于Service Worker的离线缓存也是浏览器缓存的一种。注意,Service worker是基于HTTPS协议的。
Push Cache
Push Cache是HTTP2中提出的一种缓存方式,也是浏览器缓存的最低优先级,其仅存在于当前会话中,会话终止缓存也就移除。
本地存储
本地存储和浏览器缓存一样,同样可以减少HTTP请求数,从而提升性能。本地存储分为Cookie,web storage以及IndexedDB,先别着急,这里我们一个一个讨论。
Cookie
Cookie是一种用于维持状态的小文件,其存储在浏览器中,由服务器在客户端第一次访问时生成并附着在响应头中返回,并在接下来的对于该服务器的每次请求附上这个cookie。众所周知,HTTP2.0之前HTTP是无状态的协议,Cookie机制解决的就是HTTP这个痛点,cookie采用的是在客户端保持状态的方案,需要用户打开cookie支持,如果用户禁用cookie,那么cookie也就失效了。其实本质上看,cookie只是保存状态信息,与之对应的应该是session机制。
Cookie的内容主要包括:名字,值,过期时间,路径和域。名字就是该cookie的识别信息,路径和域一起构成了cookie的作用范围。若不设置过期时间,则表示这个cookie的生命周期仅仅为会话期间,一旦会话被关闭,则cookie就会消失,会话cookie不会保存在硬盘中,而是保存在内存中。如果设置了过期时间,那么cookie会被浏览器保存在硬盘中,存在硬盘中的cookie可以在不同的浏览器进程间共享使用。
Cookie的缺点是体积小,只有4KB。另外,过量的cookie会带来非常大的性能损失,这是因为cookie紧跟域名,同一域名下的所有请求都会携带cookie,这就造成了cookie携带不必要的信息在大量HTTP请求中占用资源,为了解决这个问题,web storage诞生了。
web storage
Web storage是HTML5提出的专门用于浏览器存储的机制。它有local storage和session storage两种类型,其中local storage是永久性的本地存储,只要用户不删除即可一直存在,而session storage则仅存在于会话期间。 Web storage存储容量大,不会与服务器端通信。web storage可以看作cookie的补充,但是也只适合简单的数据结构,因为它也是依赖键值对的。
IndexedDB
坦率地说,笔者对于这一块内容目前还非常陌生,没有接触过,这里仅仅记录一下当前所了解到的。IndexedDB 是一个运行在浏览器上的非关系型数据库。 数据库允许我们操作复杂的数据结构和大量的数据,因此indexDB可以看作local storage的强力升级版。
CDN缓存
CDN(Content Delivery Network)中文翻译为内容分发网络,即一组分布在各个地区的服务器,这些服务器存放着数据的副本,因此服务器可以根据服务器离用户的距离远近分配服务器,从而提升响应速度。CDN本身也是前端面试的一个经典考题。CDN的核心有两点:缓存和回源。所谓缓存,指的是我们把主服务器中的资源复制一份到CDN服务器中存储以备后续的用户使用。回源也很好理解,就是如果CDN服务器没有该资源,则服务器会向主服务器请求资源。
CDN主要用于存放静态资源,而根服务器用于动态操作页面。静态资源指的是不会变动的图片、音视频、JS/CSS等文件。CDN的域名务必和主域名不同,这样就会砍掉cookie的不必要出现。
渲染层优化(浏览器端优化)
服务端渲染
服务端渲染(SSR)是相对于客户端渲染而言的,我们对客户端渲染更加熟悉,但是服务端渲染近几年发展迅速,广受欢迎。对于我们熟悉的客户端渲染,服务端会把渲染需要的静态文件(比如HTML/CSS/JS等)发送给客户端,客户端根据JS代码更新相应的DOM,从而渲染出结果呈现出来。它的一大特点是:页面上呈现的内容,在 HTML 源文件里里找不到。 而服务端渲染的话,服务端会预先把页面渲染成HTML然后返回给客户端,客户端拿到结果后可以直接呈现出来,所以它正好与客户端渲染相反,特点是页面上呈现的内容,在 HTML 源文件里里找不到。 服务端渲染主要解决了两个问题,一个是使得搜索引擎易于搜索到网页内容,二是使得用户无需等待资源的加载和JS运行,大大提升用户体验。但是服务端渲染也不是万能的,因为这样服务端的压力将会非常大,现实中也没有太多实践案例,毕竟用户的浏览器的数量要远远多于服务器数量的。
CSS优化
CSS样式表在渲染样式的时候需要构建CSSOM树,这需要耗费一定的时间,因此这一部分的时间节省也会带来性能上的提升。 这里非常容易误解的一个地方是CSS样式表的解析是从右往左进行的,所以一定要慎用通配符。我们应该只对需要选择的元素进行选择,另外就是尽量使用类或者ID选择符,而不是使用元素选择符。如果能使用继承的话,优先使用继承,避免重复定义。最后就是减少嵌套,过多的嵌套严重影响CSS解析速度。以上罗列的这些举措都可以加快CSS解析的速度。
避免阻塞
众所周知,CSS和JS都会阻塞渲染过程,只有CSS解析完毕生成CSSDOM树后,DOM和CSSDOM合作才会开始生成渲染树,因此不管DOM是否已经生成完毕,都要等待CSSDOM也构建完成才可以开始渲染,这就是CSS的阻塞。开始解析CSS文件始于style标签,所以为了尽快完成CSS解析,我们应该把style放在HTML文件的前面,所以总结一下就是CSS是阻塞渲染的资源,我们应该尽可能早地对其进行加载,尽可能快地下载到客户端。为了尽早地加载,我们可以把CSS文件放在head头里加载,为了尽快地下载到客户端,我们可以使用CDN内容分发网络存放CSS等静态资源。
JS的主要功能就是使得静态网页实现动态交互功能,首次渲染时不是必须资源,因为静态网页也是可以呈现给用户的,JS在实现动态交互的时候,难免操作DOM,因此JS的执行会阻塞DOM。JS阻塞其实本质上说是JS引擎在遇到JS文件时接管了渲染引擎的工作,使得渲染被阻塞了,之所以JS会阻塞渲染,主要是因为JS会修改DOM。如果不想JS阻塞渲染,可以使用async和defer。Async模式: JS的加载是异步的,当加载完成后会立即执行;defer模式:JS的加载是异步的,执行是被推迟的。脚本和其他脚本或者DOM的依赖性不强时建议选择使用async模式,依赖性很强时选择defer模式。合理选择模式就会使得JS加载模式从而避免JS阻塞的发生。
DOM优化
DOM操作非常费时,这是因为DOM操作涉及到JS引擎和渲染引擎(浏览器内核)的交互,交互需要接口,而接口容量必定是有限的,大量的DOM操作带来大量的接口占用,所以我们必须减少DOM操作。
减少DOM操作
本质上说,减少DOM操作就是使用JS协助DOM,JS的运行速度非常快,因此可以在JS中处理任务,最后一次性操作DOM。
- 缓存变量
- 针对性更改
- DOM Fragment
异步更新
当我们使用 Vue.js 提供的接口去更新我们的数据时,这个更新并不会立即生效,而是会被推入到一个队列里。等到某个时机批量触发,这就是异步更新。异步更新可以帮助我们避免过度渲染,使得我们只关注结果,不关注过程。
事件循环过程是:先执行同步任务,再执行异步队列中的任务,异步队列又可以分为宏任务和微任务,由于script标签属于宏任务,所以先执行宏任务,宏任务的执行是一个接一个的,当一个宏任务执行完毕后,该宏任务出队,接着执行微任务,微任务执行是一队一队的,一队微任务被执行完毕后,JS引擎交出控制权,渲染引擎开始工作,进行渲染并绘制页面,最后还会检查是否有web worker任务,有的话则进行相关处理。
从上面的事件循环可以得出,如果我们想在异步任务中更新DOM,最好的方式是将它包裹在micro中,因为micro执行完毕后直接开始渲染。如果放在macro中,则执行完script后会去执行一队微任务再渲染,而此时的渲染并没有改变我们想要改变的DOM,因为我们的宏任务还没有被执行。
避免回流和重绘
首先我们要知道什么是回流,什么是重绘。
回流: DOM的变动影响了DOM几何尺寸,因此浏览器需要重新计算各个元素的位置信息和尺寸信息,然后再将结果绘制出来,这个过程叫做回流(reflow)。
**重绘:**重绘指的是浏览器不需要重新计算DOM的几何信息,DOM元素只是颜色等样式发生了变化,因此直接重新绘制即可。
从回流和重绘的定义可以看出,回流一定会导致重绘,重绘却不一定会导致回流。回流比重绘更加影响性能,因此更需要关注。
为了提高性能,我们应该尽量避免回流和重绘。改变DOM几何属性的操作非常严重性能,DOM树的节点增删操作,还有一些隐藏回流的即时性操作:offsetTop, scrollTop等也会导致回流。具体方法可以概括为以下几条:
- 将DOM操作缓存到JS变量上,然后JS方法快速操作,最后一次性修改DOM,从而达到避免频繁改动的效果
- 避免逐条改变样式,使用类名去合并样式,这个应该是CSS3的保准写法
- 将DOM离线,即使用display:none将节点拿出来,操作完再放上去display:block