前端的性能优化可分为加载时优化和运行时优化。加载时的性能优化主要针对资源体积、网络请求、渲染和用户体验上的优化;运行时的优化主要是针对大量元素渲染,复杂计算场景及减少脚本执行和重新渲染等方面的优化。
资源体积优化
文件压缩
减少文件体积首先就是各种压缩。如 js 混淆压缩,html 压缩。
通过 cssnano 不仅可以对 css 进行压缩,还可以去掉 css 中的无效规则。
最后是图片压缩,可用tinypng网站对 jpg 或 png 格式的图片进行压缩。
Gzip
在经过上面提到的文件压缩的基础上,Gzip 能再压缩50%左右。但是需要服务端和浏览器都支持 Gzip。通过 HTTP 响应头的 content-encoding 字段可以判断该资源是否开启了 Gzip 压缩。
content-encoding: gzip
图片格式优化
图片格式
-
png
png 是⼀种⽆损压缩的位图图形格式,支持索引、灰度、RGB 三种颜⾊⽅案以及 Alpha 通道等特性。png 支持透明度,在网页中有广泛的应用。常用在logo,质量要求高但颜色简单的图片及雪碧图等地方。
-
jpg/jpeg
jpg/jpeg 是一种有损压缩格式,可在不影响用户分辨图片质量的前提下,尽可能地压缩图片大小。常用在banner大图,背景图片等地方。
-
gif
gif 是⼀种基于 LZW 算法的连续⾊调的⽆损压缩格式。它的压缩率⼀般在50%左右。gif 可使用简单的动画,如常见的表情包。常用在加载的loading,以及一些简单的动画。
-
svg
svg 是⼀种基于 XML 语法的图像格式,全称是可缩放⽮量图。svg 体积小,可压缩性强,具有缩放而不失真的特点,常用来绘制地图,股票K线图以及一些小图标。但 svg 是一种基于文档标记语法的格式,需要被浏览器解析,会带来一些性能损耗。
-
webp
webp格式是⾕歌开发的⼀种旨在加快图⽚加载速度的图⽚格式。具有无损压缩和有损压缩的能力,且比 png 及 jpg 拥有更多的压缩率。未来是一种全能的解决方案,但当下对浏览器的兼容很差,仅 Chrome 对其有较好的支持。
-
base64
base64 就是⼀种基于64个可打印字符来表示⼆进制数据的⽅法。可理解为将图片转换成字符串。但转换后体积会变大,适合一些小图标,将图片转成 base64 打包进脚本文件,可以减少 http 请求数量,可替代雪碧图。
在不同的场景使用不同的图片格式可以有效优化图片体积,提高用户体验。
图标优化
网页上经常通过许多小图标来提高用户体验。如果将这些图标当成普通图片加载,将会有大量的网络请求,一般通过以下的方案进行优化:
-
CSS Sprites
把 icon 合并成一张图片,从而减少网络请求。使用的时候,将合成的图片作为背景图片,通过
background-position,background-size等属性定位到要使用的 icon。这种技术称为雪碧图(CSS Sprites)。现在已经有很多工具可以帮助我们生成雪碧图,以及定位图片,但是维护起来仍是十分繁琐,而且一出问题可能就会导致大面积的图片错位。比如现在在 retina 屏幕上使用 icon 的二倍图,如果两张一样的图片比例不是二倍的关系,就会出现上面所说的那个问题。
-
Base64
通过 webpack 的
url-loader,我们可以把一些大小不超过某个范围的图片转化为 base64,放在 js 文件或者 css 文件中,从而减少网络请求。其问题在于图片转化为base64后体积会增大。 -
字体图标库
将图标转换为字体,然后用使用字体的方式使用图标。因此一张icon图片都不用加载,而是加载字体文件。常见的字体图标有 Font Awesome 和阿里的 iconfont。
-
SVG Sprite
SVG Sprites 是字体图标库的一种实现方式。类似于 CSS Sprites,SVG Sprites 也是将许多小图标合并成一张图片。但组成小图标的单元变成了svg。这使得这个操作更加简便。这得益于 svg 的 symbol 元素和 use 元素。symbol 元素可以定义一些“元件”,这些“元件”可以重复使用,且需要使用 use 元素才能使用这些“元件”。use 元素的特点是可以重复调用,可跨 SVG 调用。跨 SVG 调用是 SVG Sprites 的核心。
按需引入
为了使用的方便,很多时候会全量引入UI组件库或一些工具库(如 undescore ),那些没有使用但却一起打包的代码无疑会增大资源体积,影响加载速度。可以通过 Tree Shaking 等方法进行按需引入,移除不需要的代码。
请求优化
非阻塞
JS 代码执行会阻塞 DOM 的构建和 CSS 解析,即 JS 会阻塞渲染。因为 JS 代码中可能会修改 DOM 或 CSS 样式,如果 JS 执行时进行渲染,会导致数据不一致。render 树的生成需要 CSSOM 树是完备的,如果 CSS 解析被 JS 阻塞时,会导致页面处于白屏状态。
所以,一般把 script 标签放在body底部,避免 JS 的加载和执行阻塞页面渲染。而把 style 标签放到 head,以避免页面无样式内容闪烁(FOUC)。
另一种方法是通过 script 标签的 async 属性或 defer 属性。这两个属性都能使 JS 文件异步加载,区别在于 async 不会按照 script 的顺序执行,而是先加载完成的脚本先执行;defer 则会按照 script 的顺序执行。defer 较为常用,而 async 一般用于与其他脚本没有依赖关系的脚本加载。
还有一种异步加载 JS 文件的方法是通过脚本插入 script 标签。
var oS = document.createElement('script')
var oH = document.getElementsByTagName('head')[0]
oS.src = '//xxx.com/index.js'
oH.appendChild(oS)
http 缓存
http 缓存有两种,分别是强缓存和协商缓存。
强缓存
强缓存即满足一定条件时,直接使用缓存数据而不会发起新的请求。
强缓存通过这 expires 和 Cache-Control 两个字段实现。
-
expires
expires 是 http1.0 的字段。首次请求时服务端会在 http 响应头上加上 expires 字段,并设置一个过期时间。如
Expires: Thu, 10 Sep 2020 09:30:06 GMT。在这个时间之前我们去请求资源,浏览器就不会发起新的请求,直接使⽤本地已经缓存好的资源。expires 是最开始的强缓存方案。存在的问题是,⽤本地时间和 expires 设置的时间进⾏⽐较。如果服务端的时间和我们本地的时间存在误差,那么缓存这个时候很容易就失去了效果。
-
Cache-Control
Cache-Control 是 http1.1 的字段,其功能更为强大。Cache-Control 设置的是⼀个相对时间,可以更加精准地控制资源缓存,如
Cache-Control: max-age=315360000,单位是秒。解决了服务端和本地时间不统⼀造成的缓存问题。除此之外,Cache-Control 还有其他的字段:
- public:资源可以被任何对象(包括:发送请求的客户端、代理服务器等等)缓存
- private:资源只能被⽤户浏览器缓存,不允许任何代理服务器缓存
- no-cache:先和服务端确认返回的资源是否发⽣了变化,如果资源未发⽣变化,则直接使⽤缓存好的资源
- no-store:禁⽌任何缓存,每次都会向服务端发起新的请求,拉取最新的资源
- max-age:设置缓存的最⼤有效期,单位为秒
- s-maxage:适用于CDN等代理缓存,于 max-age 作用一致,优先级大于 max-age
- max-stale:在设置的时间内,即使缓存过期,也是用该缓存
- min-fresh:在设置的时间内,请求最新的资源
Cache-Control 的优先级大于 expires
协商缓存
协商缓存即当强缓存失效时,浏览器请求服务器,判断是否使用缓存的过程。
当强缓存失效时,浏览器携带缓存标识向服务器发起请求,如果资源无更新,则服务器返回304,浏览器依旧使用缓存;如果资源更新,则服务器返回200,同时返回新的资源。
协商缓存同样可通过两种 http 头部字段实现。
-
Last-Modified 和 If-Modified-Since
- 浏览器在第一次访问资源时,服务器返回资源的同时,在响应头中添加 Last-Modified 字段,其值是这个资源在服务器上的最后修改时间。
- 浏览器下一次请求这个资源时,就会携带 If-Modified-Since 这个字段,其值就是 Last-Modified 中的值。
- 服务器再次收到这个资源请求,会根据 If-Modified-Since 中的值与服务器中这个资源的最后修改时间对比。如果没有变化,则返回304,浏览器直接从缓存读取资源;如果 If-Modified-Since 的时间小于服务器中这个资源的最后修改时间,说明文件有更新,则返回200和新的资源文件。
其缺点在于
- 服务端对 Last-Modified 标注的最后修改时间只能精确到秒级,如果某些⽂件在1秒钟以内被修改多次的话,这个时候服务端⽆法准确标注⽂件的修改时间。
- 服务端上某些操作(打开但不编辑或重新生成)导致文件内容没有修改,但更新了 Last-Modified ,导致缓存失效。
-
ETag 和 If-None-Match
ETag 和 If-None-Match 是 http1.1 新增的字段。与上述的 Last-Modified 和 If-Modified-Since 类似,只是用文件的唯一标识代替了最后修改时间。
- 浏览器在第一次访问资源时,服务器返回资源的同时,在响应头中添加 ETag 字段,其值是服务器计算该资源的内容得出的 hash。当资源变化时,这个值就会重新生成。
- 浏览器下一次请求这个资源时,就会携带 If-None-Match 这个字段,其值就是 ETag 中的值。
- 服务器再次收到这个资源请求,会根据 If-None-Match 中的值与服务器中这个资源的 ETag 对比。如果没有变化,则返回304,浏览器直接从缓存读取资源;如果不一致,说明文件有更新,则返回200和新的资源文件。
ETag 的优先级高于 Last-Modified,且精度高于 Last-Modified。唯一的缺点是计算文件 hash 导致其性能比 Last-Modified 略差。
懒加载
懒加载即延迟加载,上篇文章中也说到,对于不是首屏渲染必要的图片、组件等,可以延迟加载。懒加载有以下优点:
- 加快首屏加载速度
- 减轻服务器压力
- 节约流量
图片懒加载
图片懒加载是一个常见优化点。其原理是
- 对图片的 src 赋值一张1*1的gif(体积最小)或其他简单的占位图片,而将真正的图片地址用其他属性(如
data-original)保存。 - 页面加载完成时,获取页面所有图片标签,判断其是否在页面内,是的话将
data-original的地址赋值给图片的src属性,浏览器开始下载图片。 - 监听滚动事件,判断图片是否进入页面内,是的话用第2步中的方法加载图片。
组件懒加载
SPA 首屏速度一直是一个硬伤,组件懒加载可以有效地优化 SPA 的首屏速度。
以 Vue 为例,对于其他页面组件,在第一次访问时并不需要加载,通过 webpack 提供的 import() 方法,将其打包成异步模块,当访问对应路由时再进行加载。
{
path: '/home',
name: 'Home',
component: () => import(/* webpackChunkName: "Home" */ '@/views/home')
}
预加载
预加载与懒加载恰好相反。预加载会预先加载用户即将访问的内容,当用户访问时直接读取缓存,节省了加载时间,提高用户体验。如小说网站,可以预先加载下面几章的内容,让用户可以流畅观看。
预加载的优点是提高了用户体验,减少了加载、白屏的时间,但同时需要以增加服务器压力为代价。
懒加载和预加载并不矛盾。懒加载目的在于提高首屏的加载速度,而预加载在于提高后续的用户体验。预加载一般在浏览器空闲的时候进行。
对于一些图片或弹窗组件,我们可以在首屏加载的时候对其进行懒加载,当首屏加载完成,浏览器空闲时再对其进行预加载。这样即保持了首屏加载速度,又可以提高了用户体验。
图片预加载
let image = new Image()
image.src = '//xxx.com/images/1.jpg'
除了用js进行图片预加载之外,还可以用PreloadJS之类的库。
组件预加载
在页面加载完成后,手动插入 script 标签可以实现模块的预加载。即异步加载 js。
在webpack中,使用魔法注释可以对异步组件模块进行预拉取和预加载
/* webpackPrefetch: true */
/* webpackPreload: true */
dns-prefetch
dns-prefetch可以提前去对域名进行dns解析,只需要在head中加入这样一样代码
<link rel="dns-prefetch" href="//xxx.com">
除此之外,还有预链接,预加载,预渲染。webpack中的魔法注释的原理就是这个。
<link rel="preconnect" href="//xxx.com" crossorigin>
<link rel="prefetch" href="//xxx.com/index.js" as="script">
<link rel="prerender" href="//xxx.com/index.html">
CDN 缓存
CDN (Content Delivery Network,即内容分发网络)指的是一组分布在各个地区的服务器。这些服务器存储着数据的副本,因此服务器可以根据哪些服务器与用户距离最近,来满足数据的请求。 CDN 提供快速服务,较少受高流量影响。
CDN 的核心功能是缓存和回源。缓存,即将一份资源的副本复制到 CDN 服务器上。回流则是当 CDN 发现没有这个资源时(一般是缓存过期),向源服务器请求这个资源的过程。
CDN 往往被用来存放静态资源,如图片,js,css等。这些资源不需要服务端进行计算,且具有访问频率高,承载流量大的特点。
CDN 服务器的域名在设置上也有需要注意的地方,即一般不与业务服务器的域名相同。因为同域名下的 cookie 会被自动发送,如果使用相同的域名会增加 CDN 服务器的压力。
请求合并
与上述的雪碧图都是一种合并的思想。通过将多个请求合并成一个以减少请求数量。打包合并 js 和 css 文件同时需要注意作用域污染的问题。
js文件可通过模块化解决。
css 的隔离方案:如 vue 的 scoped,qiankun 微前端框架的 shadow-dom。
在打包的同时还需要考虑拆分。因为单个大文件会导致加载时间长的问题。而浏览器可同时发起的http请求数量有限,有效的拆分可以充分地利用浏览器资源。
服务端渲染
SPA 的首屏一直是一个被诟病的点。因为其首屏的 html 中只有一个挂载元素,没有其他 DOM 结构,对 SEO 非常不友好。除此之外,页面内容需要等 JS 加载解析完成后才开始渲染,延长了首屏时间。优化 SPA 首屏,除了使用之前提到的组件懒加载外,还有另一个方案,就是服务端渲染。
服务端渲染是将 html 的内容在服务端拼接,直接返回给浏览器。这并不是一个新概念,传统的 JSP,PHP 也可看做是服务端渲染。相较于现在用 node 进行服务端渲染,结合现在的 Vue,React 等框架,用 node 进行服务端渲染可以达到“一次编写,前后端共用”的目的,即“同构直出”。
http2
http2的请求头部和数据都是二进制,其使用专用算法压缩头部,减少数据传输量;其具有多路复用的优点,即同一个TCP连接里面,客户端和服务器可以同时发送多个请求和多个响应,并且不用按照顺序来。由于服务器不用按顺序来处理响应,所以避免了队头阻塞的问题。
渲染优化
渲染优化与运行时的优化有部分重叠。渲染优化需要从浏览器的渲染过程开始分析。
浏览器接收到 html 文档到页面显示,一般会经过下面几步:
- 解析 html 文档,构建 DOM 树
- 解析 CSS,构建 CSSOM 规则树
- 通过 DOM 树和 CSSOM 规则树,生成 render 树
- render 树布局(Layout)
- render 树绘制(paint)
- 最后将像素发送给 GPU,显示在页面上(包括图层合并等操作)(Display)
在上述过程中,DOM 树构建过程中会被 CSS 和 JS 文件的加载阻塞。DOM 的构建是可以和 CSS 解析同时进行的。但是 JS 代码的执行会阻塞 DOM 的构建和 CSS 的解析。
-
回流(reflow / layout)
因为部分元素的尺寸,位置,布局等属性的改变,render 树需要重新构建,称之为回流(也叫重排)。即上述Layout,Paint和Display三步。
常见的会引起回流的操作:
- 页面首次渲染
- DOM 树的改变(增删节点)
- render 树的改变(元素尺寸,位置改变)
- 浏览器窗口 resize
- 获取元素某些属性,如offsetLeft, offsetTop等。浏览器为了获得正确的值会提前触发回流。可参考:What forces layout
-
重绘(repaint)
当改变元素的背景颜色,文案颜色等属性时,不会引起其周围元素及其内部的布局变化,不需要重新计算元素的尺寸和位置,称之为重绘。即上述的Paint和Display两步。
引起重绘的操作:背景、字体颜色、边框颜色的改变
可以看出,回流必定会导致重绘。回流的成本比重绘高得多。由于回流成本太高,浏览器会将回流操作用队列存储,当过一段时间或达到某个阈值时清空队列,执行一次回流。但是,当你获取有些值时,浏览器会提前触发回流,以确保获取的值是正确的(触发回流的操作第5点)。
因为回流和重绘代价昂贵,所以需要减少回流和重绘。一般有以下几种方法:
- 批量操作 DOM (innerHTML,documentFragment, cloneNode)
- 批量修改 CSS (el.style.cssText)
- 复杂动画脱离文档流(float,position: fixed / absolute)
- 使用
requestAnimationFrame代替定时器实现动画 - CSS3 硬件加速(transform、opacity)
用户体验优化
用户体验优化是通过一些手段,减少用户等待加载时的焦虑。如:
- 加载动画(菊花图)
- 骨架屏
- 离线缓存
运行时优化
虚拟列表
实际业务中,当列表无限滚动加载出大量列表项时,大量的 DOM 节点会耗尽浏览器的资源,导致页面变得很卡。在 Web 端上可以用分页进行优化,移动端上则可以使用虚拟列表。
虚拟列表的原理很简单。既然 DOM 节点太多的话,那就减少 DOM 的数量就可以了。长列表中的大多数列表项是不显示的,展示在可视界面中的只是很少的一部分,所以我们只需要渲染这一部分即可。通常的做法是监听浏览器的滚动事件,监听滚动的列表高度,结合屏幕的高度和列表项的高度,计算出展示的列表项的范围,并只对这些列表项进行渲染。
防抖和节流
对于 scroll,mousemove 这些事件,其回调函数触发的十分频繁,一般我们会用防抖或节流的方法去限制其触发的频率。
防抖即一个事件短时间内被触发多次,函数只会执行一次。以滚动事件为例,只有当滚动停下来时才执行函数,即是防抖。
节流是当一个事件被不断触发,在规定时间内,函数只执行一次。以滚动为例,假设节流事件为500ms,则在滚动的时候,函数每500ms执行一次。
复杂计算的场景
复杂的计算如文件 md5 计算,虚拟 DOM 树的比对和更新,如果在主线程中执行会导致页面卡顿,无法与用户交互。一般有以下的方法优化:
-
Web Worker
Web Worker 的作用在于创造多个线程,在 Worker 线程完成计算任务,再把结果返回给主线程。Worker 线程运行时不会受主线程的影响,两个线程之间通过消息进行通信。
-
时间分片
将一个复杂任务分成多个子任务,分别在浏览器空闲的时段执行,避免影响主线程。React 的 fiber 即是使用这种原理。浏览器原生的 api
requestIdleCallback即可实现,但其兼容性较差;fiber 架构中则通过MessageChannel实现了一个 polyfill。 -
WASM
WASM 即 WebAssembly,其是一种新的编码格式并且可以在浏览器中运行,WASM 可以与 JavaScript 并存,更类似一种低级的汇编语言。WASM 通过 C++,Rust,GO 等其他语言编译而成,执行速度比 JavaScript 更快,更安全。
-
缓存
缓存计算结果可以有效优化存在大量重复计算的场景。例如 Vue 的 computed。
运行时的渲染优化
除上述的减少回流和重绘外,针对目前流行的 Vue,React 等框架,核心在于减少重复渲染,并利用框架本身的特点来优化渲染的效率。
以 Vue 为例:
- 设置
v-for的key值,以便 Vue 内部查找节点,优化节点的复用。最好使用记录的主键,而不是循环的index。 - 不要在模板中使用太多表达式,因为每次渲染都会重新执行。
- 路由懒加载。
- 使用
keep-alive缓存组件。 - 与渲染无关的数据不放在
data中,因为初始化时会将这些数据转换为响应式数据,造成损耗。 - 不需要更改的数据(如静态表格)使用
Object.freeze。这样做之后,Vue 将不会对这些数据进行响应式监听。 - 切分成更小的组件。减小 Vue 响应式更新的范围。
- 正确区分
v-if和v-show的使用场景。 - 正确区分
computed和watch的使用场景 - 在 Vue 生命周期钩子中移除事件的监听。否则切换路由时这些事件依旧存在。
雅虎军规
最后是雅虎军规,可以看出很多点都与上面提及的内容重复,有些则已经被现代浏览器优化。
- 尽量减少HTTP请求数
- 减少DNS查找
- 避免重定向
- 让Ajax可缓存
- 延迟加载组件
- 预加载组件
- 减少DOM元素的数量
- 跨域分离组件
- 尽量少用iframe
- 杜绝404
- 避免使用CSS表达式
- 选择
<link>舍弃@import - 避免使用滤镜
- 把样式表放在顶部
- 去除重复脚本
- 尽量减少DOM访问
- 用智能的事件处理器
- 把脚本放在底部
- 把JavaScript和CSS放到外面
- 压缩JavaScript和CSS
- 优化图片
- 优化CSS Sprite
- 不要用HTML缩放图片
- 用小的可缓存的favicon.ico
- 给Cookie减肥
- 把组件放在不含cookie的域下
- 保证所有组件都小于25K
- 把组件打包到一个复合文档里
- Gzip组件
- 避免图片src属性为空
- 配置ETags
- 对Ajax用GET请求
- 尽早清空缓冲区
- 使用CDN(内容分发网络)
- 添上Expires或者Cache-Control HTTP头
总结
前端性能优化的知识点很多,但是核心思路是不变的。就是资源加载,运行等各个环节的优化,因此熟悉 Web 系统的加载过程,代码的解析和执行原理是性能优化的必要条件。