前言
之前的项目在部署之后首屏加载过慢了,因此最近有想进行简单的优化一下,顺便就想以一个初学者的角度将项目优化的思路有条理的梳理一下,因为水平原因,很多方法可能只能写下思路,没办法应用在自己的项目上,而且可能很多的优化方案已经略有过时。主要还是想做一下有关优化知识的梳理吧,毕竟优化是一个永恒不变的话题。项目是基于vue框架开发的,但优化方法的思路是不拘泥于框架的。
代码层面优化
代码层面的优化这一部分其实比较杂乱,浅显的意思是要怎么去编写高性能点的代码?emm主要还是讲一下从一个新手的角度,避免出现一些影响性能的操作吧。
路由、模块懒加载
很常用的懒加载代码, 不用一次加载所有的路由或者模块,到需要引用时再进行加载,用函数来代替对象进行引入模块与路由,属于用vue框架时的基本操作吧。
1 | //路由懒加载 |
图片懒加载
图片懒加载其实通常更多地应用于图片较多的网站,我自己的项目上由于图片比较少,就没有用到复杂的长屏懒加载。只是在图片或模块的ajax请求没有返回时,使用一个loading的特效来代替图片、组件进行填充,简单地实现了基础的懒加载?思路很简单:大概就是预设一个div的z-index,让其覆盖图片、组件的上面,默认为show。同时在ajax请求的异步回调上修改其CSS,变为hidden。elementUI等开源组件库上应该有类似的loading组件。
对于含有多图片的长页面,在你没有滚动到图片所在位置的页面中时,是用空的div来填充代替图片位置的。一旦我们通过滚动使得这个 div 出现在了可见范围内,那么 div 元素的内容就会发生变化,呈现其中的内容,这就是图片的懒加载。
下面我们简单实现下懒加载:其实该功能的关键在于获取两个值:1、当前可视区域的高度,通常用window.innerHeight 属性获取。
2、元素距离可视区域顶部的高度,我们这里选用 getBoundingClientRect() 方法来获取返回元素的大小及其相对于视口的位置。该方法的返回值是一个DOMRect。DOMRect 对象包含了一组用于描述边框的只读属性——left、top、right 和 bottom,单位为像素。除了 width 和 height 外的属性都是相对于视口的左上角位置而言的。其中top 属性代表了元素距离可视区域顶部的高度,正好用来实现功能。
1 |
|
ajax请求
现在项目中通常都不会去手写原生的ajax,毕竟因为异步的回调地狱嘛。我自己的项目用的是axios,定义如下:axios
是一个轻量的 HTTP客户端
,它基于 XMLHttpRequest
服务来执行 HTTP 请求,支持丰富的配置,支持 Promise
,支持浏览器端和 Node.js
端。但往往我们需要封装一下axios,毕竟如果每发起一次HTTP请求,就要把这些比如设置超时时间、设置请求头、根据项目环境判断使用哪个请求地址、错误处理等等操作都重写一遍就太麻烦了。这里贴一下我自己很简单的axios封装。
1 | import Vue from 'vue' |
当然,这里ajax的优化其实并不是指简单的axios封装,毕竟这个属于常用操作。这个优化问题也是出自项目的主页,由于某些问题,主页中同一时间进行地ajax请求过多,一次跑过多的异步任务会导致页面的卡顿。开始时,我用的便是上述封装后的axios请求,为解决卡顿问题,开始时我希望能够使用fetch()来代替ajax请求,希望能达到目的;fetch()定义如下:
Fetch API是新的ajax解决方案 Fetch会返回Promise, fetch不是ajax的进一步封装,而是原生js,没有使用XMLHttpRequest对象。fetch(url, options).then()。
其实我感觉优点就三个:1、使用promise,这样也支持了async,编写异步时更加方便;2、可自定义是否携带cookie;3、fetch在ServiceWorker中使用。但实际项目中,ajax往往都被封装好了,例如上面的axios,这样前两项其实并没有所谓。但关键就在于第三项了。service work是基于web worker而来。
众所周知,javaScript 是单线程的,随着web业务的复杂化,开发者逐渐在js中做了许多耗费资源的运算过程,这使得单线程的弊端更加凹显。web worker正是基于此被创造出来,它是脱离在主线程之外的,我们可以将复杂耗费时间的事情交给web worker来做。但是web worker作为一个独立的线程,他的功能应当不仅于此。service work便是在web worker的基础上增加了离线缓存的能力。
特点:1、必须是https环境,本地调试localhost或者127.0.0.1环境也是可以的,2、依赖于cache api进行实现的3、依赖于h5的fetch Api;4、依赖于promise进行实现。但这里我自己并没有用这么复杂的优化方案,就不赘述了。
我自己运用基本的处理有:1、使用了axios对多并发请求的处理方案,当页面某个数据来源于多个互不关联的请求时,需要统一处理然后呈现。即使用axios.all(iterable),参数:请求数组;axios.spread(callback),参数: 对应请求返回值。API的应用实例如下:
1 | methods: { |
2、尽量复用ajax请求,当不同模块间可以公用同一接口的同一信息时,不要在两个模块中分别请求两次,而是尽量利用组件间通信来实现信息的共享;
2、设置HTTP缓存。HTTP 缓存是我们日常开发中最为熟悉的一种缓存机制。它又分为强缓存和协商缓存。
强缓存
优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存。强缓存是利用 http 头中的 Expires 和 Cache-Control 两个字段来控制的。强缓存中,当请求再次发出时,浏览器会根据其中的 expires 和 cache-control 判断目标资源是否“命中”强缓存,若命中则直接从缓存中获取资源,不会再与服务端发生通信。
当服务器返回响应时,在 Response Headers 中将过期时间写入 expires 字段。接下来如果我们试图再次向服务器请求资源,浏览器就会先对比本地时间和 expires 的时间戳,如果本地时间小于 expires 设定的过期时间,那么就直接去缓存中取这个资源。expires写的是一个绝对的时间戳,例如:xxx年x月x日。而在 Cache-Control 中,我们通过 max-age
字段 来控制资源的有效期。max-age 不是一个时间戳,而是一个时间长度。max-age 是一个相对时间,这就意味着它有能力规避掉 expires 可能会带来的时差问题。同样,因此cache-control的优先级比expires更高。Cache-Control 中还有更高优先级的s-maxage:用于表示 cache 服务器上(比如 cache CDN)的缓存的有效时间的,并只对 public 缓存有效。(public 与 private 是针对资源是否能够被代理服务缓存而存在的一组对立概念。)
协商缓存
协商缓存依赖于服务端与浏览器之间的通信。在协商缓存机制下,浏览器需要向服务器去询问缓存的相关信息,进而判断是重新发起请求、下载完整的响应,还是从本地获取缓存的资源。在该服务端提示缓存资源未改动(Not Modified),资源会被重定向到浏览器缓存,这种情况下网络请求对应的状态码是 304)。
实现:Last-Modified 到 Etag。Last-Modified 是一个时间戳,如果我们启用了协商缓存,它会在首次请求时随着 Response Headers 返回。随后我们每次请求时,会带上一个叫 If-Modified-Since 的时间戳字段,它的值正是上一次 response 返回给它的 last-modified 值。服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化。如果发生了变化,就会返回一个完整的响应内容,并在 Response Headers 中添加新的 Last-Modified 值;否则,返回如上图的 304 响应,Response Headers 不会再添加 Last-Modified 字段。
但是可能会有一个bug:我们编辑文件,但没有修改,服务器可能会以为我们修改了;修改文件的时间过快,服务器可能会感知不到。即:服务器并没有正确感知文件的变化。这样就引出了Etag,Etag 是由服务器为每个资源生成的唯一的标识字符串,这个标识字符串是基于文件内容编码的,只要文件内容不同,它们对应的 Etag 就是不同的。Etag 的生成过程需要服务器额外付出开销,会影响服务端的性能,这是它的弊端。同样,优先级方面,Etag高于Last-Modefied。
HTTP缓存决策流程:当我们的资源内容不可复用时,直接为 Cache-Control 设置 no-store,拒绝一切形式的缓存;否则考虑是否每次都需要向服务器进行缓存有效确认,如果需要,那么设 Cache-Control 的值为 no-cache;否则考虑该资源是否可以被代理服务器缓存,根据其结果决定是设置为 private 还是 public;然后考虑该资源的过期时间,设置对应的 max-age 和 s-maxage 值;最后,配置协商缓存需要用到的 Etag、Last-Modified 等参数。
组件库按需引入
这一点其实好理解,例如当你使用elementUI或者echarts这些组件库时,通常并没有用到其提供的全部组件,因此在import的时候,不需要全部引入整体,只需要引入你所用到的部分即可。
适用于V8引擎的JS代码
毫无疑问,就又是一个大坑了,关于对这个的理解我也是由他人的博客所看来的,不保证结论的正确性,只是记录下自己的了解。首先我们需要了解以下V8引擎底层的两个特征。
隐藏类
在V8引擎中采用了和动态查找完全不同的技术来实现属性的访问:动态地为对象创建隐藏类。每当一个新的属性被添加到对象中时,对象所对应的隐藏类会随之改变。乍一看似乎每次添加一个属性都创建一个新的隐藏类非常低效。实际上,利用类转移信息时,隐藏类可以被重用。即下次创建一个 Point 对象的时候,就可以直接共享由最初那个 Point 对象所创建出来的隐藏类。
这样的话,相当于一个构造函数中的所有属性都由一个隐藏类的链将他串联在了一起,由该构造函数新建的对象就可以直接共享该隐藏类链。主要的优点有:1、属性访问时不再需要从动态字典中进行查找了;2、为V8使用经典的基于类的优化和内联缓存技术提供了条件。
内联缓存技术:在第一次执行到访问某个对象的属性的代码时,V8会找出该对象的隐藏类;同时,V8会假设在相同的代码片段中其他所有的对象的属性访问都通过这一隐藏类来实现。只有在预测失败时,V8才会修改内联代码并移除刚才加入的内联优化。当有许多对象共享同一个隐藏类的时候,这样的实现方式下属性的访问速度可以接近大多数动态语言。使用内联缓存代码和隐藏类实现属性访问的方式和动态代码生成和优化的方式结合起来,即:你基于一个构造函数,构建多个实例时,用隐藏类的方法可以加快属性访问速度。
由隐藏得来的V8代码编写教训:1、在构造函数里初始化所有对象的成员(所以这些实例之后不会改变其隐藏类);2、总是以相同的次序初始化对象成员;//可以更好利用隐藏类 3、永远不要delete对象的某个属性;4、方法:重复执行相同方法的代码将比仅执行一次的多个不同方法(由于内联缓存)的代码运行得更快。5、数组:避免稀疏数组
两次编译
V8有两个不同的运行时编译器:1、“完全”编译器(unoptimized)。一开始,所有的V8代码都运行在unoptimized状态。它的好处是编译速度非常快,它使代码初次执行速度非常快。2、“优化”编译器(optimized)。当V8发现某段代码执行非常热时,它会根据通常的执行路径进行代码优化,生成optimized代码。优化代码的执行速度非常快。
编译器有可能从“优化”状态退回到“完全”状态, 这就是deoptimized。这是很不幸的过程,优化后的代码没法正确执行,不得不退回到unoptimized版本。当然最不幸的是代码不停地被optimized,然后又被deoptimized, 这会带来很大的性能损耗,例如:for…in遍历对象的属性和try…catch中的代码会让编译器无法到达optimized状态。
使用教训:1、把for…in 内部的代码单独提出来作为函数,这样V8引擎就能对其进行优化;2、谨慎使用try..catch
闭包
闭包会使程序逻辑变复杂,有时会看不清楚是否对象内存被释放,因此要注意释放闭包中的大对象, 否则会引起内存泄露。谨慎使用闭包,有时候不当的闭包使用会造成大量的内存占用。
存储层面的优化
其实关于缓存方面上面的ajax请求里已经写了好多了,嗯,感觉布局有点问题,不过并不打算改了。这里就主要说说webpack打包方面的修改吧,毕竟算存储内容的优化?不过我是用vuecli构建的项目,其实该有的优化都已经默认配好了?就像tree-shaking?
在你使用vue-cli构建项目时,webpack的配置会被隐藏在vuecli的框架下面,不过想要自己进行特别的webpack配置也比较容易,根据vuecli官方网站的说明:调整 webpack 配置最简单的方式就是在 vue.config.js
中的 configureWebpack
选项提供一个对象:该对象将会被 webpack-merge 合并入最终的 webpack 配置。如果你需要基于环境有条件地配置行为,或者想要直接修改配置,那就换成一个函数 (该函数会在环境变量被设置之后懒执行)。该方法的第一个参数会收到已经解析好的配置。在函数内,你可以直接修改配置,或者返回一个将会被合并的对象。具体的配置方案因为我自己也不太懂,就不赘述了。
webpack-bundle-analyzer
如果你是使用vue-cli3构建的项目,则直接vue-cli-service build –report就会生成一个report.html,打开这个html就能看到webpack打包之后的模块与依赖的加载状态。如果不是由vuecli构建的项目,也很简单,直接npm install 安装webpack-bundle-analyzer模块,版本号过高的话可能有意外的错误,推荐安装5.0.0。之后在vue配置中引入该包,并自定义运行命令即可,具体的可参照官网。之后你就可以看到自己项目的打包分析了,针对那些用的比较少的模块,把全局引入修改成针对性引入、使用更轻量级的组件库。总之根据该打包分析图,尽量减少项目的体积即可。
gzip压缩
gzip压缩可以说是为了优化首屏加载速度最常用的方法之一了。Gzip 压缩背后的原理,是在一个文本文件中找出一些重复出现的字符串、临时替换它们,从而使整个文件变小。根据这个原理,文件中代码的重复率越高,那么压缩的效率就越高,使用 Gzip 的收益也就越大。反之亦然。主要的实现方法有两个:
1、项目正常打包部署,直接在服务端对nginx配置进行修改。这样设置时,当你请求时,服务端就会先将对应的文件压缩成.gz格式再发送给你,客户端接收到了.gz文件的格式之后再解压并执行后续操作。相当于用压缩的时间,换取了文件传输的时间,通常都会是正优化,除非项目体积过小。
1 | http { |
2、项目打包时对webpack进行特殊设置,安装插件(compression-webpack-plugin);打包同时生成成两份文件,第一份为正常的文件,另一个为gz压缩后的文件,部署时将其全部部署至服务端。下面是vuecli构建项目的webpack配置参考,不用vuecli构建的,直接修改webpack配置即可。
1 | const CompressionPlugin = require('compression-webpack-plugin'); |
之后在nginx配置中使用:gzip_static on,该属性能够静态加载本地的gz文件,这样就完成了gzip。向较于上一种方案,这种方法虽然上传项目文件体积更大,但免去了服务端实时的压缩过程,速度会更快。
CDN缓存优化
定义:CDN (Content Delivery Network,即内容分发网络)指的是一组分布在各个地区的服务器。这些服务器存储着数据的副本,因此服务器可以根据哪些服务器与用户距离最近,来满足数据的请求。 CDN 提供快速服务,较少受高流量影响。相较于其他的缓存是为了优化网页流畅程度,CDN缓存更多的是为了优化首屏加载速度。
CDN 的核心点有两个,一个是缓存,一个是回源。这两个概念都非常好理解。“缓存”就是说我们把资源 copy 一份到 CDN 服务器上这个过程,“回源”就是说 CDN 发现自己没有这个资源(一般是缓存的数据过期了),转头向根服务器(或者它的上层服务器)去要这个资源的过程。
CDN 往往被用来存放静态资源。上文中我们举例所提到的“根服务器”本质上是业务服务器,它的核心任务在于生成动态页面或返回非纯静态页面,这两种过程都是需要计算的。业务服务器仿佛一个车间,车间里运转的机器轰鸣着为我们产出所需的资源;相比之下,CDN 服务器则像一个仓库,它只充当资源的“栖息地”和“搬运工”。
所谓“静态资源”,就是像 JS、CSS、图片等不需要业务服务器进行计算即得的资源。而“动态资源”,顾名思义是需要后端实时动态生成的资源,较为常见的就是 JSP、ASP 或者依赖服务端渲染得到的 HTML 页面。什么是“非纯静态资源”呢?它是指需要服务器在页面之外作额外计算的 HTML 页面。具体来说,当我打开某一网站之前,该网站需要通过权限认证等一系列手段确认我的身份、进而决定是否要把 HTML 页面呈现给我。这种情况下 HTML 确实是静态的,但它和业务服务器的操作耦合,我们把它丢到CDN 上显然是不合适的。
所以简单总结一下:静态资源走CDN便可以实现对静态资源加载的优化。同时静态资源往往并不需要 Cookie 携带什么认证信息,因此把静态资源和主页面置于不同的域名下,完美地避免了不必要的 Cookie 的出现。
理论的介绍大概就这么多了,在我自己的项目实践中,其实并没有把静态资源均部署在CDN上,毕竟技术力有限。只是将一些引入的公共框架代码,利用了BootCDN提供的免费资源进行取代。以本项目为例,我将vue、vuex、axios、echarts、elementUI均修改为CDN引入。主要的好处有两个:1、分离了公共库后,项目打包体积小了,打包速度提升了;2、使用CDN加载更加快速,且减轻了服务器压力。
具体实施步骤如下:1、在index.html中,添加CDN代码
1 | ... |
2.在vue.config.js中加入webpack配置代码,关于webpack配置中的externals,请参考地址
渲染层面的优化
服务端渲染技术
提到渲染层面的优化就不得不说现在特别火的SSR技术了,其实它是一个相对的概念,其对立面是客户端渲染。客户端渲染就是常用的正常情况,服务端会把渲染需要的静态文件发送给客户端,客户端加载过来之后,自己在浏览器里跑一遍 JS,根据 JS 的运行结果,生成相应的 DOM。这种特性使得客户端渲染的源代码总是特别简洁。页面上呈现的内容,你在 html 源文件里里找不到——这正是它的特点。
服务端渲染的模式下,当用户第一次请求页面时,由服务器把需要的组件或页面渲染成 HTML 字符串,然后把它返回给客户端。客户端拿到手的,是可以直接渲染然后呈现给用户的 HTML 内容,不需要为了生成 DOM 内容自己再去跑一遍 JS 代码。使用服务端渲染的网站,可以说是“所见即所得”,页面上呈现的内容,我们在 html 源文件里也能找到。关于服务端渲染的实践方式,已经有nust.js这样的框架可以使用了,不过由于我自己的技术原因,并没有去实践一下这个新潮的技术。这里我就只说一下SSR的优缺点了,很多地方也都会提到这个。
优点:1、主要是出于效益的原因,因为SSR之后,搜索引擎以及各种爬虫才能够爬取网站的内容,这样才便于网站的推广。
2、服务端渲染解决了一个非常关键的性能问题——首屏加载速度过慢。在客户端渲染模式下,我们除了加载 HTML,还要等渲染所需的这部分 JS 加载完,之后还得把这部分 JS 在浏览器上再跑一遍。这一切都是发生在用户点击了我们的链接之后的事情,在这个过程结束之前,用户始终见不到我们网页的庐山真面目,也就是说用户一直在等!相比之下,服务端渲染模式下,服务器给到客户端的已经是一个直接可以拿来呈现给用户的网页,中间环节早在服务端就帮我们做掉了。
缺点:服务端渲染本质上是本该浏览器做的事情,分担给服务器去做。这样当资源抵达浏览器时,它呈现的速度就快了。乍一看好像很合理,但其实这样会成倍地增加服务端的压力,造成大量的成本,很有可能得不偿失。
CSS选择器优化
CSS 引擎查找样式表,对每条规则都按从右到左的顺序去匹配,与我们正常人的书写习惯刚好相反,因此在使用选择器时如果没有意识到这一点,就写出一些高性能消耗的选择器。例如: #mylist li {}。如果像这样写的话,浏览器必须遍历页面上每个 li 元素,并且每次都要去确认这个 li 元素的父元素 id 是不是 myList,这样会消耗大量性能。可以修改为:.myList_li {}同样,CSS中的通配符#会匹配所有元素,这样你使用时会让浏览器去遍历每一个元素。
以下为CSS书写时的性能提升方案:1、避免使用通配符,只对需要使用到的元素进行选择;2、关注可以通过继承实现的属性,避免重复匹配、重复定义;3、少使用标签选择器,尽量多使用类选择器。4、不要画蛇添足,id 和 class 选择器不应该被多余的标签选择器拖后腿。5、减少嵌套。后代选择器的开销是最高的,因此我们应该尽量将选择器的深度降到最低(最高不要超过三层),尽可能使用类来关联每一个标签元素。
DOM优化
减少回流与重绘
重绘不一定导致回流,回流一定会导致重绘。硬要比较的话,回流比重绘做的事情更多,带来的开销也更大。定义如下:
回流:当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,浏览器需要重新计算元素的几何属性(其他元素的几何属性和位置也会因此受到影响),然后再将计算的结果绘制出来。这个过程就是回流(也叫重排)。
重绘:当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,浏览器不需重新计算元素的几何属性、直接为该元素绘制新的样式(跳过了上图所示的回流环节)。这个过程叫做重绘。
1、尽量多使用变量来进行缓存跟DOM相关的数据,避免引起DOM变化;
2、避免逐条改变样式,使用类名去合并样式;
3、将 DOM “离线”:当我们给元素设置 display: none,将其从页面上“拿掉”,那么我们的后续操作,将无法触发回流与重绘——这个将元素“拿掉”的操作,就叫做 DOM 离线化。拿掉一个元素,再将他放回去,虽然会触发一次回流,但在这期间对其做的任何操作,都不会太大影响性能。
减少获取DOM次数
在你需要多次操作并修改某个DOM时,只执行一次获取DOM的操作并将其存在变量中,这样就能节省获取DOM的性能消耗。
减少修改DOM的次数
对 DOM 的修改会引发渲染树的改变、进而去走一个(可能的)回流或重绘的过程。由于JS 的运行速度,比 DOM 快得多这个特性。我们减少 DOM 操作的核心思路,就是让 JS 去给 DOM 分压。这其实就是DOM Fragment](https://developer.mozilla.org/zh-CN/docs/Web/API/DocumentFragment) 的思路。
DocumentFragment 接口表示一个没有父级文件的最小文档对象。它被当做一个轻量版的 Document 使用,用于存储已排好版的或尚未打理好格式的XML片段。因为 DocumentFragment 不是真实 DOM 树的一部分,它的变化不会引起 DOM 树的重新渲染的操作(reflow),且不会导致性能等问题。
1 | let container = document.getElementById('container') |
DOM Fragment 对象允许我们像操作真实 DOM 一样去调用各种各样的 DOM API,我们的代码质量因此得到了保证。并且它的身份也非常纯粹:当我们试图将其 append 进真实 DOM 时,它会在乖乖交出自身缓存的所有后代节点后全身而退,完美地完成一个容器的使命,而不会出现在真实的 DOM 结构中。这种结构化、干净利落的特性,使得 DOM Fragment 作为经典的性能优化手段大受欢迎,这一点在 jQuery、Vue 等优秀前端框架的源码中均有体现。使用微任务队列,实现异步更新来避免过度渲染,就是用JS给DOM分压。