前端项目优化 -Web 开发常用优化方案、Vue & React 项目优化
github
从输入URL到页面加载完成的整个过程
- 首先做 DNS 查询,如果这一步做了智能 DNS 解析的话,会提供访问速度最快的 IP 地址回来
- 接下来是 TCP 握手,应用层会下发数据给传输层,这里 TCP 协议会指明两端的端口号,然后下发给网络层。网络层中的 IP 协议会确定 IP 地址,并且指示了数据传输中如何跳转路由器。然后包会再被封装到数据链路层的数据帧结构中,最后就是物理层面的传输了
- TCP 握手结束后会进行 TLS 握手,然后就开始正式的传输数据(如果使用HTTPS)
- 数据在进入服务端之前,可能还会先经过负责负载均衡的服务器,它的作用就是将请求合理的分发到多台服务器上,这时假设服务端会响应一个 HTML 文件
- 首先浏览器会判断状态码是什么,如果是 200 那就继续解析,如果 400 或 500 的话就会报错,如果 300 的话会进行重定向,这里会有个重定向计数器,避免过多次的重定向,超过次数也会报错
- 浏览器开始解析文件,如果是 gzip 格式的话会先解压一下,然后通过文件的编码格式知道该如何去解码文件
- 文件解码成功后会正式开始渲染流程,先会根据 HTML 构建 DOM 树,有 CSS 的话会去构建 CSSOM 树。如果遇到
script
标签的话,会判断是否存在async
或者defer
,前者会并行进行下载并执行 JS,后者会先下载文件,然后等待 HTML 解析完成后顺序执行,如果以上都没有,就会阻塞住渲染流程直到 JS 执行完毕。遇到文件下载的会去下载文件,这里如果使用 HTTP 2.0 协议的话会极大的提高多图的下载效率。 - 初始的 HTML 被完全加载和解析后会触发
DOMContentLoaded
事件 - CSSOM 树和 DOM 树构建完成后会开始生成 Render 树,这一步就是确定页面元素的布局、样式等等诸多方面的东西
- 在生成 Render 树的过程中,浏览器就开始调用 GPU 绘制,合成图层,将内容显示在屏幕上了
- 没有要传输的文件了,断开TCP连接 4 次挥手
性能优化分析
根据上面的过程可以看到,页面的加载过程主要分为下载、解析、渲染三个步骤,整体可以从两个角度来考虑:
- 网页的资源请求与加载阶段
- 网页渲染阶段
网页的资源请求与加载阶段
我们可以打开 Chrome 的调试工具来分析此阶段的性能指标
在建立 TCP 连接的阶段(HTTP 协议是建立在 TCP 协议之上的)
- Queuing 和 Stalled 表示请求队列以及请求等待的时间
- DNS Lookup 表示执行 DNS 查询所用的时间。页面上的每一个新域都需要完整的往返才能执行DNS查询
- Initila connection 和 SSL 包括 TCP 握手重试和协商 SSL 以及 SSL 握手的时间。
在请求响应的阶段
- Request sent 是发出网络请求所用的时间,通常不会超过 1ms
- Watiting(TTFB) 是等待初始响应所用的时间,也称为等待返回首个字节的时间,该时间将捕捉到服务器往返的延迟时间,以及等待服务器传送响应所用的时间。
- Content Download 则是从服务器上接收数据的时间。
资源请求阶段优化方案
依据上面的指标给出以下几点优化方案(仅供参考)
1、划分子域
条件:拥有多个域名
Chrome 浏览器只允许每个源拥有 6 个 TCP 连接,因此可以通过划分子域的方式,将多个资源分布在不同子域上用来减少请求队列的等待时间。然而,划分子域并不是一劳永逸的方式,多个子域意味着更多的 DNS 查询时间。通常划分为 3 到 5 个比较合适。
对如何拆分资源有如下建议:
- 前端类:把项目业务本身的 html、css、js、图标等归为一类
- 静态类:CDN 资源
- 动态类:后端 API
2、DNS 预解析
DNS 解析也是需要时间的,可以通过预解析的方式来预先获得域名所对应的 IP,方法是在 head 标签里写上几个 link 标签
<link rel="dns-prefetch" href="https://www.google.com">
<link rel="dns-prefetch" href="https://www.google-analytics.com">
对以上几个网站提前解析,这个过程是并行的,不会阻塞页面渲染。
3、预加载
在开发中,可能会遇到这样的情况。有些资源不需要马上用到,但是希望尽早获取,这时候就可以使用预加载。
预加载其实是声明式的 fetch,强制浏览器请求资源,并且不会阻塞 onload 事件,可以使用以下代码开启预加载:
<link rel="preload" href="http://example.com">
预加载可以一定程度上降低首屏的加载时间,因为可以将一些不影响首屏但重要的文件延后加载,唯一缺点就是兼容性不好。
4、保持持久连接
HTTP 是一个无状态的面向连接的协议,即每个 HTTP 请求都是独立的。然而无状态并不代表 HTTP 不能保持 TCP 连接,Keep-Alive 正是 HTTP 协议中保持 TCP 连接非常重要的一个属性。 HTTP1.1 协议中,Keep-Alive 默认打开,使得通信双方在完成一次通信后仍然保持一定时长的连接,因此浏览器可以在一个单独的连接上进行多个请求,有效地降低建立 TCP 请求所消耗的时间。
5、CND 加速
使用 CND 加速可以减少客户端到服务器的网络距离。
- CDN 的意图就是尽可能地减少资源在转发、传输、链路抖动等情况下顺利保障信息的连贯性;
- CDN 系统能够实时地根据网络流量和各节点的连接、负载状况以及到用户的距离和响应时间等综合信息将用户的请求重新导向离用户最近的服务节点上
- CDN 采用各节点缓存的机制,当我们项目的静态资源修改后,如果 CDN 缓存没有做相应更新,则看到的还是旧的网页,解决的办法是刷新缓存,七牛云、腾讯云都可单独针对某个文件/目录进行刷新;
- CDN 缓存需要合理地使用:图片、常用 js 组件、css 重置样式等,即不常改动的文件可走 CDN,包括项目内的一些介绍页;
还有一种比较流行的做法是让一些项目依赖走 CDN,比如 vuex、vue-router 这些插件通过外链的形式来引入,因为它们都有自己免费的 CDN,这样可以减少打包后的文件体积。
6、设置缓存
缓存对于前端性能优化来说是个很重要的点,良好的缓存策略可以降低资源的重复加载提高网页的整体加载速度。
通常浏览器缓存策略分为两种:强缓存 和 协商缓存。
- 强缓存:实现强缓存可以通过两种响应头实现:
Expires
和Cache-Control
强缓存表示在缓存期间不需要向服务器发送请求 - 协商缓存:缓存过期了就是用协商缓存,其通过
Last-Modified/If-Modified-Since
和ETag/If-None-Match
实现
HTTP 头中与缓存相关的属性,主要有以下几个:
(1) Expires: 指定缓存过期的时间,是一个绝对时间,但受客户端和服务端时钟和时区差异的影响,是 HTTP/1.0 的产物
形如Expires: Wed, 22 Oct 2018 08:41:00 GMT
(2) Cache-Control:比 Expires 策略更详细,max-age 优先级比 Expires 高,其值可以是以下五种情况
- no-cache: 强制所有缓存了该响应的缓存用户,在使用已存储的缓存数据前,发送请求到原始服务器(进行过期认证),通常情况下,过期认证需要配合 etag 和 Last-Modified 进行一个比较
- no-store: 告诉客户端不要响应缓存(禁止使用缓存,每一次都重新请求数据)
- public: 缓存响应,并可以在多用户间共享(与中间代理服务器相关)
- private: 缓存响应,但不能在多用户间共享(与中间代理服务器相关)
- max-age: 缓存在指定时间(单位为秒)后过期
(3) Last-Modified / If-Modified-Since: Last-Modified
表示本地文件最后修改日期,If-Modified-Since
会将上次从服务器获取的Last-Modified
的值发送给服务器,询问服务器在该日期后资源是否有更新,有更新的话就会将新的资源发送回来。
但是如果(服务器)在本地打开缓存文件(或者删了个字符 a 后又填上去),就会造成Last-Modified
被修改,所以在 HTTP / 1.1 出现了ETag
。
(4) Etag / If-None-Match: ETag
类似于文件指纹,If-None-Match
会将当前ETag
发送给服务器,询问该资源ETag
是否变动,有变动的话就将新的资源发送回来。并且ETag
优先级比Last-Modified
高。
由于 etag 要使用少数的字符表示一个不定大小的文件(如 etag: "58c4e2a1-f7"),所以 etag 是有重合的风险的,如果网站的信息特别重要,连很小的概率如百万分之一都不允许,那么就不要使用 etag 了。使用 etag 的代价是增加了服务器的计算负担,特别是当文件比较大时。
选择合适的缓存策略
对于大部分的场景都可以使用强缓存配合协商缓存解决,但是在一些特殊的地方可能需要选择特殊的缓存策略
- 对于某些不需要缓存的资源,可以使用
Cache-control: no-store
,表示该资源不需要缓存 - 对于频繁变动的资源,可以使用
Cache-Control: no-cache
并配合ETag
使用,表示该资源已被缓存,但是每次都会发送请求询问资源是否更新。 - 对于代码文件来说,通常使用
Cache-Control: max-age=31536000
并配合策略缓存使用,然后对文件进行指纹处理,一旦文件名变动就会立刻下载新的文件。
7、使用 HTTP / 2.0
因为浏览器会有并发请求限制,在 HTTP / 1.1 时代,每个请求都需要建立和断开,消耗了好几个 RTT 时间,并且由于 TCP 慢启动的原因,加载体积大的文件会需要更多的时间。
在 HTTP / 2.0 中引入了多路复用,能够让多个请求使用同一个 TCP 链接,极大的加快了网页的加载速度。并且还支持 Header 压缩,进一步的减少了请求的数据大小。
8、图片和文件压缩
这又涉及到很多知识点了,简单来说,我们要尽可能地在保证我们的 App 能正常运行、图片尽可能保证高质量的前提下去压缩所有用到的文件的体积。比如图片格式的选择、去掉我们代码中的注释、空行、无关代码等。
图片相关优化
- 不用图片。很多时候会使用到很多修饰类图片,其实这类修饰图片完全可以用 CSS 去代替。
- 对于移动端来说,屏幕宽度就那么点,完全没有必要去加载原图浪费带宽。一般图片都用 CDN 加载,可以计算出适配屏幕的宽度,然后去请求相应裁剪好的图片。
- 小图使用 base64 格式
- 选择正确的图片格式:
- 对于能够显示 WebP 格式的浏览器尽量使用 WebP 格式。因为 WebP 格式具有更好的图像数据压缩算法,能带来更小的图片体积,而且拥有肉眼识别无差异的图像质量,缺点就是兼容性并不好
- 小图使用 PNG,其实对于大部分图标这类图片,完全可以使用 SVG 代替
- 照片使用 JPEG
构建工具的使用
- 对于 Webpack4,打包项目使用 production 模式,这样会自动开启代码压缩
- 使用 ES6 模块来开启 tree shaking,这个技术可以移除没有使用的代码
- 优化图片,对于小图可以使用 base64 的方式写入文件中
- 按照路由拆分代码,实现按需加载
- 给打包出来的文件名添加哈希,实现浏览器缓存文件(能及时更新)
- 启用 gzip 压缩(需要前后端支持)
- 各种 loader/plugin 的使用
压缩 HTML 文件
可以把 HTML 的注释去掉,把行前缩进删掉,这样处理的文件可以明显减少 HTML 的体积;这样做几乎是没有风险的,除了 pre 标签不能够去掉行首缩进之外,其他的都正常。
网页渲染阶段优化方案
1、<script>
标签位置
渲染线程和 JS 引擎线程是互斥的,如果你想首屏渲染的越快,就越不应该在首屏就加载 JS 文件,因此建议将 script 标签放在 body 标签底部的原因。或者使用给 script 标签添加 defer 或者 async 属性 。
- defer 表示该文件会并行下载,但是会放到 HTML 解析完成后再顺序执行;
- 对于没有任何依赖的 JS 文件可以加上 async 属性,表示加载和渲染后续文档元素的过程将和 JS 文件的加载与执行并行无序进行
2、Webworker 的使用
执行 JS 代码过长会卡住渲染,对于需要很多时间计算的代码可以考虑使用 Webworker。Webworker 可以让我们另开一个线程执行脚本(这并没有改变 JS 单线程的本质,因为新开的线程受控于主线程且不得操作 DOM)而不影响渲染。
3、懒加载
懒加载就是将不关键的资源延后加载。
懒加载的原理就是只加载自定义区域(通常是可视区域,但也可以是即将进入可视区域)内需要加载的东西。对于图片来说,先设置图片标签的src
属性为一张占位图,将真实的图片资源放入一个自定义属性中,当进入自定义区域时,就将自定义属性替换为src
属性,这样图片就会去下载资源,实现了图片懒加载。
懒加载不仅可以用于图片,也可以使用在别的资源上。比如进入可视区域才开始播放视频等等。
4、预加载
- 图片等静态资源在使用之前的提前请求
- 资源使用到时能从缓存中加载,提升用户体验
- 页面展示的依赖关系维护
使用场景比如抽奖动画展示过程中预先加载其他内容,或者电子书阅读章节的预加载可以使切换下一章节时更为流畅。
5、减少回流与重绘
执行 JavaScript 的解析和 UI 渲染的两个浏览器线程是互斥的,UI 渲染时 JS 代码解析终止,反之亦然。
当 页面布局和几何属性 改变时,就会触发 回流。
当 需要更新的只是元素的某些外观 时,就会触发 重绘。
- 用 translate 替代 top 属性:top 会触发 reflow,但 translate 不会
- 不要一条一条地修改 DOM 的样式,预先定义好 class,然后修改 DOM 的 className
- 把 DOM 离线后修改,比如:先把 DOM 给 display:none(有一次 reflow),然后你修改 100 次,然后再把它显示出来
- 不要把 DOM 节点的属性值放在一个循环里当成循环的变量
- offsetHeight、offsetWidth 每次都要刷新缓冲区,缓冲机制被破坏,先用变量存储下来
- 不要使用 table 布局,可能很小的一个小改动会造成整个 table 的重新布局
- 动画实现的速度的选择:选择合适的动画速度
- 启用 gpu 硬件加速(并行运算),gpu 加速意味着数据需要从 cpu 走总线到 gpu 传输,需要考虑传输损耗.
- transform:translateZ(0)
- transform:translate3D(0)
- 似乎现在浏览器能智能地分析 gpu 加速了?
6、编写高效率的 CSS
使用 CSS 预处理器时注意不要有过多的嵌套,嵌套层次过深会影响浏览器查找选择器的速度,且一定程度上会产生出很多冗余的字节。
7、减少 DOM 元素数量、减少 DOM 的操作
减少 DOM 元素数量,合理利用 :after、:before 等伪类,避免页面过深的层级嵌套;
优化 JavaScript 性能,减少 DOM 操作次数(或集中操作),能有效规避页面重绘/重排;
只能说尽可能去做优化,如数据分页、首屏直出、按需加载等
8、函数节流
为触发频率较高的函数使用函数节流
其他
SPA SEO SSR
SPA:单页面富应用
动态地重写页面的部分与用户交互而不是加载新的页面。
优点:① 前后端分离 ② 页面之间切换快 ③ 后端只需提供 API
缺点:① 首屏速度慢,因为用户首次加载 SPA 框架及应用程序的代码然后才渲染页面 ② 不利于 SEO
SEO(Search Engine Optimization):搜索引擎优化
常用技术:利用 <title> 标签和 <meta> 标签的 description
<html>
<head>
<title>标题内容</title>
<meta name="description" content="描述内容">
<meta name="keyword" content="关键字1,关键字2,—">
</head>
</html>
SPA 应用中,通常通过 AJAX 获取数据,而这里就难以保证我们的页面能被搜索引擎正常收录到。并且有一些搜索引擎不支持执行 JS 和通过 AJAX 获取数据,那就更不用提 SEO 了。
对于有些网站而言,SEO 显得至关重要,例如主要以内容输出为主的 Quora、stackoverflow、知乎和豆瓣等等,那如何才能正常使用 SPA 而又不影响 SEO 呢 ?所以有了 SSR
SSR(Server-Side Rendering):服务端渲染
以下内容部分参考《深入浅出 React 与 Redux》- 程墨
为了量化网页性能,我们定义两个指标:
- TTFP(Time To First Paint):指的是从网页 HTTP 请求发出,到用户可以看到第一个有意义的内容渲染出来的时间差
- TTI(Time To Interactive):指的是从网页 HTTP 请求发出,到用户可以对网页内容进行交互的时间
在一个 完全靠浏览器端渲染 的应用中,当用户在浏览器中打开一个页面的时候,最坏情况下没有任何缓存,需要等待三个 HTTP 请求才能到达 TTFP 的时间点:
- 向服务器获取 HTML,这个 HTML 只是一个无内容的空架子,但是皮之不存毛将焉附,这个 HTML 就是皮,在其中运行的 JavaScript 就是毛,所以这个请求时不可省略的
- 获取 JavaScript 文件,大部分情况下,如果这是浏览器第二次访问这个网站,就可以直接读取缓存,不会发出真正的 HTTP 请求
- 访问 API 服务器获取数据,得到的数据将由 JavaScript 加工之后用来填充 DOM 树,如果应用的是 React,那就是通过修改组件的状态或者属性来驱动渲染
而对于服务器端渲染,因为获取 HTTP 请求之后就会返回所有有内容的 HTML,所以在一个 HTTP 的周期之后就会提供给浏览器有意义的内容,所以首次渲染时间 TTFP 会优于完全依赖于浏览器端渲染的页面。
除了更短的 TTFP,服务器端渲染还有一个好处就是利于搜索引擎优化,虽然某些搜索引擎已经能够索引浏览器端渲染的网页,但是毕竟不是所有搜索引擎都能做到这一点,让搜索引擎能够索引到应用页面的最直接方法就是提供完整 HTML
上面的性能对比只是理论上的分析,实际上,采用服务器端渲染是否能获得更好的 TTFP 有多方面因素。
1、服务器端产生的 HTML 过大是否会影响性能?
因为服务器端渲染返回的是完整的 HTML,那么下载这个 HTML 的时间也会增长。
2、服务器端渲染的运算消耗是否是服务器能够承担得起的?
浏览器端渲染的方案下,服务器只提供静态资源,压力被分摊到了访问用户的浏览器中;如果使用服务器端渲染,每个页面请求都要产生 HTML 页面,这样服务器的压力就会很大。
React 并不是给服务器端渲染设计的,如果应用对 TTFP 要求不高,也不希望对 React 页面进行搜索引擎优化,那么没有必要使用“同构”来增加开发难度;如果希望应用的性能能更进一步,而且服务器运算资源充足,那么可以尝试。对 Vue 而言应该也是同样的道理。
最后我们来总结下服务端渲染理论上的优缺点:
优点:
- 更快的响应时间、首屏加载时间,可以将 SEO 的关键信息直接在后台渲染成 HTML,从而保证搜索引擎的爬虫都能爬到关键数据
- 更快的内容到达时间,特别是对于缓慢的网络情况或运行缓慢的设备
- 无需等待所有的 JavaScript 都完成下载并执行,才显示服务器渲染的标记,所以用户将会更快速地看到完整渲染的页面,通常可以产生更好的用户体验
- 资源文件从本地请求(各种 bundle 什么的),更快的下载速度
缺点:
- 占用服务器更多的 CPU 和内存资源
- 一些常用的浏览器 API 可能无法使用,如 window、document、alert 等,如果需要使用的话需要对运行的环境加以判断
- 开发难度加大
Vue 项目优化点
1、第三方库走 cdn
例如:
<script src="//cdn.bootcss.com/vue/2.2.5/vue.min.js"></script>
<script src="//cdn.bootcss.com/vue-router/2.3.0/vue-router.min.js"></script>
<script src="//cdn.bootcss.com/vuex/2.2.1/vuex.min.js"></script>
<script src="//cdn.bootcss.com/axios/0.15.3/axios.min.js"></script>
在 webpack 里有个 externals 选项,可以忽略不需要打包的库
https://webpack.js.org/configuration/externals/#root
const path = require('path')
module.exports = {
mode: 'production',
entry: './src/index.js',
externals: {
'vue': 'Vue',
'vue-router': 'VueRouter',
'vuex': 'Vuex',
'axios': 'axios'
},
output: {
...
}
}
2、路由懒加载
import Vue from 'vue'
import Router from 'vue-router'
Vue.use(Router)
export default new Router({
routes: [
{
path: '/ebook',
component: () => import('./views/ebook/index.vue'), // 路由懒加载,这里用的是ES6的语法 import()函数是动态加载 import 是静态加载
children: [ // 动态路由, 可以传递路径参数
{
path: ':fileName',
component: () => import('./components/ebook/EbookReader.vue')
}
]
},
{
path: '/store',
component: () => import('./views/store/index.vue'),
redirect: '/store/shelf', // #/store -> #/store/home
...
}
]
})
3、使用懒加载插件 Vue-Loader
具体的使用可以参考 这篇文章 或者去看官方文档
step1:cnpm install vue-lazyload --save
step2:main.js导入
import VueLazyLoad from 'vue-lazyload'
Vue.use(VueLazyload)
step3:<img class="item-pic" v-lazy="newItem.picUrl"/>
vue 文件中将需要懒加载的图片绑定v-bind:src
修改为v-lazy
这只是图片懒加载,还有很多其他可选配置
4、v-if
与v-show
的选择
一般来说,v-if
有更高的切换开销,而v-show
有更高的初始渲染开销。因此,如果需要非常频繁地切换,则使用v-show
较好;如果在运行时条件很少改变,则使用v-if
较好。
React 项目优化点
1、单个组件的优化:更改 shouldComponentUpdate 函数的默认实现,根据每个 React 组件的内在逻辑定制其行为,减少不必要的重新渲染
shouldComponentUpdate(nextProps, nextState) {
// 假设影响渲染内容的 prop 只有 completed 和 text,只需要确保
// 这两个 prop 没有变化,函数就可以返回 false
return (nextProps.completed !== this.props.completed) ||
(nextProps.text !== this.props.text)
}
2、使用 immutable.js 解决复杂数据 diff、clone 等问题。
immutable.js 实现原理:持久化数据结构,也就是使用旧数据创建新数据时,要保证旧数据同时可用且不变。同时为了避免 deepCopy 把所有节点都复制一遍带来的性能损耗,Immutable 使用了结构共享,即如果对象树中一个节点发生变化,只修改这个节点和受它影响的父节点,其它节点则进行共享。
3、在 constructor() 里做 this 绑定
当在 render() 里使用事件处理方法时,提前在构造函数里把 this 绑定上去(如果需要的话),因为在每次 render 过程中, 再调用 bind 都会新建一个新的函数,浪费资源.
// bad
class App extends React.Component {
onClickDiv() {
// do stuff
}
render() {
return <div onClick={this.onClickDiv.bind(this)} />;
}
}
// good
class App extends React.Component {
constructor(props) {
super(props);
this.onClickDiv = this.onClickDiv.bind(this);
}
onClickDiv() {
// do stuff
}
render() {
return <div onClick={this.onClickDiv} />;
}
}
4、基于路由的代码分割
使用React.lazy
和React Router
来配置基于路由的代码分割
import { BrowserRouter as Router, Route, Switch } from 'react-router-dom';
import React, { Suspense, lazy } from 'react';
const Home = lazy(() => import('./routes/Home'));
const About = lazy(() => import('./routes/About'));
const App = () => (
<Router>
<Suspense fallback={<div>Loading...</div>}>
<Switch>
<Route exact path="/" component={Home}/>
<Route path="/about" component={About}/>
</Switch>
</Suspense>
</Router>
);
参考资料
https://juejin.im/post/5c4a6fcd518825469414e062#heading-29
https://juejin.im/post/5cab64ce5188251b19486041#heading-6
https://juejin.im/post/5d548b83f265da03ab42471d
https://www.jianshu.com/p/333f390f2e84
https://yuchengkai.cn/docs/frontend/