前端性能优化总结

472 阅读11分钟

这是我参与更文挑战的第10天,活动详情查看: 更文挑战

从输入 URL 到页面加载完成,发生了什么?

可以回看我之前的文章:前端HTTP知识总结

性能优化大概都可以围绕以下4个过程进行:

  1. DNS 解析
  2. TCP 连接
  3. HTTP 请求响应
  4. 浏览器渲染

DNS解析TCP连接这两个步骤,我们前端可以做的努力非常有限,相比之下,HTTP请求浏览器渲染的过程优化才是前端性能优化的核心。

DNS 解析

DNS解析是需要花费时间的,应该尽量减少解析次数或者把解析前置,如采取浏览器DNS缓存和 DNS prefetch。

TCP 连接

长连接、预连接等可以解决每次都要三次握手TCP。

HTTP请求响应

我们最终写好的前端代码最终会打包成一些浏览器可识别的资源包,这个资源包的大小往往决定了HTTP请求次数和HTTP请求时间,或者我们可以将一些资源放到缓存里,不用每次都去请求。

gzip

在请求头中加上accept-encoding:gzip,服务器了解到Gzip压缩的需求,它会启动自己的 CPU 去完成压缩,可理解牺牲一些服务器时间开销和CPU开销,省下了一些HTTP传输过程中的时间开销。Webpack 中也有Gzip压缩操作,事实上也是为了在构建过程中去做一部分服务器的工作,为服务器分压。

webpack优化

  1. loader:比如babel-loader,最常见的优化方式是,用 include 或 exclude 来帮我们避免不必要的转译,还可以选择开启缓存将转译结果缓存至文件系统。
  2. 插件:由于第三方库的庞大,webpack构建速度依然会因此大打折扣。我们可以选择一些当依赖自身发生版本变化时才会重新打包、如DllPlugin。
  3. Tree-Shaking:基于import/export 语法,Tree-Shaking可以在编译的过程中获悉哪些模块并没有真正被使用,这些没用的代码,在最后打包的时候会被去除。
  4. 多进程:webpack是单线程的,多任务时只能一个接一个地等待处理。而我们的 CPU 是多核的,使用比如Happypack会充分释放 CPU 在多核并发方面的优势,帮我们把任务分解给多个子进程去并发执行,大大提升打包效率。

缓存

  1. HTTP Cache(重点) 分为强缓存协商缓存

强缓存:利用Expires和Cache-Control这两个字段控制缓存时间。浏览器根据expires和cache-control字段判断是否命中,命中则直接从缓存中获取资源并返回的HTTP状态码为200,不再发HTTP请求给后端。服务器返回响应在 Response Headers 中将过期时间戳写入 expires 字段,对比本地时间和 expires 的时间戳,如果本地时间小于 expires 设定的过期时间,那么就直接去缓存中取这个资源,如果直接手动去把客户端的时间改掉,那么 expires 将无法达到我们的预期,HTTP1.1 新增了 Cache-Control 字段来设置时间长度,在时间长度秒以内都是有效的,规避了时间戳带来的潜在问题。当 Cache-Control 与 expires 同时出现时,我们以 Cache-Control 为准。 Snipaste_2021-06-22_09-57-32.png

协商缓存:如果启用了协商缓存,它会在首次请求时随着响应头返回Last-Modified时间戳字段,随后我们每次请求时,会携带If-Modified-Since字段,值为上一次response返回给它的last-modified值,服务器接收到这个时间戳后,会比对该时间戳和资源在服务器上的最后修改时间是否一致,从而判断资源是否发生了变化,如果发生了变化,就会返回一个完整的响应内容,并在 Response Headers 中添加新的 Last-Modified值;否则,返回缓存资源未改动(304 Not Modified),资源会被重定向到浏览器缓存,Response Headers 不会再添加 Last-Modified 字段。当我们修改文件的速度过快时(比如花了 100ms 完成了改动),由于 If-Modified-Since 只能检查到以秒为最小计量单位的时间差,所以它是感知不到这个改动的——该重新请求的时候,反而没有重新请求了。Etag 是由服务器为每个资源生成的唯一的标识字符串,这个标识字符串是基于文件内容编码的,只要文件内容不同,它们对应的 Etag 就是不同的,反之亦然。因此 Etag 能够精准地感知文件的变化。那么下一次请求时,请求头里就会带上一个值相同的、名为 if-None-Match 的字符串供服务端比对了。当 Etag 和 Last-Modified 同时存在时,以 Etag 为准。

优先级较高的是强缓存,在命中强缓存失败的情况下,才会走协商缓存。

1.强缓存:不会向服务器发送请求,直接从缓存中读取资源,在chrome控制台的network选项中可以看到该请求返回200的状态码;

2.协商缓存:向服务器发送请求,服务器会根据这个请求的request header的一些参数来判断是否命中协商缓存,如果命中,则返回304状态码并带上新的response header通知浏览器从缓存中读取资源;

两者的共同点是,都是从客户端缓存中读取资源;区别是强缓存不会发请求,协商缓存会发请求。

  1. Memory Cache Memory Cache,是指存在内存中的缓存。从优先级上来说,它是浏览器最先尝试去命中的一种缓存。从效率上来说,它是响应速度最快的一种缓存,但关闭当前页面,内存里的数据也将不复存在。Base64格式的图片、体积不大的JS、CSS文件,都有可能被写到memory cache里。

  2. Service Worker Cache Service Worker 是一种独立于主线程之外的 Javascript 线程。它脱离于浏览器窗体,可以帮我们实现离线缓存、消息推送和网络代理等功能。

  3. Push Cache(HTTP2新特性) 只有在 Memory Cache、HTTP Cache 和 Service Worker Cache 均未命中的情况下才会去询问 Push Cache。

本地存储

  1. Cookie:HTTP 协议是一个无状态协议,服务器在请求完后不知道知道客户端,而Cookie是附着在HTTP请求上,使得服务器知道客户端的状态。体积小。

  2. Web Storage:弥补了Cookie的局限性,体积大,存储容量5-10M。分为 Local Storage 与 Session Storage。

    Local Storage:是持久化的本地存储,使其消失的唯一办法是手动删除 Session Storage:是临时性的本地存储,当会话结束(页面被关闭)时,存储内容也随之被释放。 Local Storage、Session Storage 和 Cookie 都遵循同源策略。但 Session Storage 特别的一点在于,即便是相同域名下的两个页面,只要它们不在同一个浏览器窗口中打开,那么它们的 Session Storage 内容便无法共享。

  3. IndexDB:是一个运行在浏览器上的非关系型数据库。体积很大。

CDN优化

指的是一组分布在各个地区的服务器,根据哪些服务器与用户距离最近,来满足数据的请求。把资源从根服务器复制一份到CDN服务器上这个过程称为缓存;CDN服务器没有这个资源,转而向根服务器去要这个资源的过程称为回源。在许多一线的互联网公司,“静态资源走CDN”并不是一个建议,而是一个规定。

内容展示

浏览器内核可以分成两部分:渲染引擎和JS引擎。随着 JS 引擎越来越独立,内核也成了渲染引擎的代称。常见的浏览器内核可以分为这四种:Trident(IE)、Gecko(火狐)、Blink(Chrome、Opera)、Webkit(Safari), Chrome 的内核就是以前是 Webkit,现在 Chrome 内核迭代到了Blink。

34.png 浏览器渲染过程:首先是HTML解释器解析HTML构建一个DOM树,浏览器CSS 解释器识别并加载所有的 CSS 样式信息生成CSSOM树,与 DOM 树合并,最终生成渲染树,页面中所有元素的相对位置信息,大小等信息计算,得到了基于渲染树的布局渲染树。最后浏览器以布局渲染树为蓝本,遍历渲染树,这个过程叫做绘制渲染树,最后,页面的初次渲染完成。

避免CSS阻塞

  1. CSS 放在 head 标签里
  2. 启用 CDN 实现静态资源加载速度的优化

避免JS阻塞

JS有三种加载方式:async/defer/默认。当与DOM元素和其它JS依赖不强时,选用 async;当依赖于DOM元素和其它JS时,我们会选用 defer。

避免回流和重绘

回流:当我们对 DOM 的修改引发了 DOM 几何尺寸的变化(比如修改元素的宽、高或隐藏元素等)时,就会触发回流。

重绘:当我们对 DOM 的修改导致了样式的变化、却并未影响其几何属性(比如修改了颜色或背景色)时,就会触发重绘。

异步更新策略

Event Loop完整过程:初始状态下调用栈为空,micro队列为空,macro队列里有且只有一个script脚本(整份代码),全局上下文被推入调用栈,同步代码执行。同步代码执行完了,script脚本会被移出 macro 队列,然后处理micro-task。但需要注意的是:当 macro-task 出队时,任务是一个一个执行的;而 micro-task 出队时,任务是一队一队执行的。因此,我们处理 micro 队列这一步,会逐个执行队列中的任务并把它出队,直到队列被清空。

40.png

根据上面的图,如果我想在异步任务里进行DOM更新,我该把它包装成 micro 还是 macro 呢?

假如异步任务里进行DOM更新是一个macro任务:现在 task 被推入的 macro 队列,但因为 script 脚本本身是一个 macro 任务,所以本次执行完 script 脚本之后,下一个步骤就要去处理 micro 队列了,再往下就去执行了一次 render,在本次(理解这个本次很重要)render中,目标异步任务其实并没有执行,想要修改的DOM也没有修改。

假如异步任务里进行DOM更新是一个micro任务:结束了对 script 脚本的执行,紧接着就去处理 micro-task 队列,micro-task 处理完,我们的异步任务DOM也修改好了,紧接着就可以走render流程了,直接为用户呈现最即时的更新结果。

因此,我们更新 DOM 的时间点,应该尽可能靠近渲染的时机,也就是micro。当我们使用 Vue 或 React 提供的接口去更新数据时,这个更新并不会立即生效,而是会被推入到一个微任务队列里。待到适当的时机,队列中的更新任务会被批量触发。这就是异步更新。

服务端渲染

服务端渲染解决了一个非常关键的性能问题----首屏加载速度过慢。在客户端渲染模式下,我们除了加载 HTML,还要等渲染所需的这部分 JS 加载完,之后还得把这部分 JS 在浏览器上再跑一遍。相比之下,服务端渲染模式下,服务器给到客户端的已经是一个直接可以拿来呈现给用户的网页,中间环节早在服务端就帮我们做掉了,服务端渲染将虚拟 DOM 转化为真实DOM相当于把 Vue、React 等框架代码先在 Node 上跑一遍。把这么多台浏览器的渲染压力集中起来,分散给相比之下数量并不多的服务器,服务器肯定是承受不住的,所以,服务端渲染最好是我们先把能用的低成本都用过了,性能表现还是不尽人意,这时候我们就可以再去考虑服务端渲染。

虽然本文不涉及代码,但前端性能问题不是一两行代码就能解决的,优化的结果没有更好,也没有最好,想要追求极致的优化,就必须你能在哪方面入手解决性能问题,还需要权衡各方面的因素,选择一个适合项目的优化方式。