前言
在上一篇 前端性能优化:高在性能,大在范围,必要前置知识一网打尽!(上) 一文中介绍了和前端性能优化相关的一些前置知识,那么本篇就针对优化方案进行总结,核心的方向还是上篇文章中提到的内容:
保证资源更快的 加载速度 :达到越快渲染越快,视图展现就越快
保证视图更快的 渲染速度/交互速度 :用户与页面交互,前提是页面要渲染出来,其次是页面需要尽早反馈,目的就是保证用户良好的体验性
其实将上述两点再进行翻译,那么其实指的就是 网络层面的优化 和 浏览器层面的优化,这样看来其实前端性能优化方向还是很明确的,只不过明确的方向中还是会涉及不同方面的具体优化手段。
还是不得不回顾 从输入 URL 到页面加载完成 的核心过程:
进行 DNS 解析
建立 TCP 连接
客户端发送 HTTP 请求
服务端响应 HTTP 资源
浏览器获取响应内容,进行解析和渲染
从上述的内容来看,不难发现每一步都是需要消耗一定的时间,那么优化的方向就可以围绕着这些内容来考虑。
长文预警❗️❗️❗️
如何保证资源更快的加载速度?
下面内容主要针对 DNS 解析、 TCP 连接、 HTTP 请求/响应 等阶段来谈的优化,核心优化核心其实就是 网络层面。
使用 dns-prefetch 减少 DNS 的查询时间
dns-prefetch 能够 提前解析 后续可能会用到的 不同域的域名,使解析结果 缓存到系统缓存 中,缩短 DNS 解析时间以提高网站的访问速度。
比如在掘金中的体现如下:
【扩展】 DNS 解析的核心过程
当浏览器访问一个域名时需解析一次 DNS,以获得对应域名的 IP 地址:
浏览器 会从自身的 缓存中 查询是否存在对应域名的 IP 地址,若存在返回,若不存在进入下一步
客户机查询操作系统中的 /etc/hosts 文件中查询是否有对应域名的 IP 地址,若存在则返回,若不存在进入下一步
客户机请求 本地域名服务器(LDNS) 进行解析,本地域名服务器收到客户机的请求后,先查询自己的缓存信息是否有对应域名的 IP 地址,若存在返回结果,没有则进行下一步
本地域名服务器请求 根域名服务器 解析该域名,根域名告诉本地域名服务器去找对应的 一级域名服务器
本地域名服务器请求一级域名服务器解析这个域名,一级域名服务器告诉它去找对应的 二级域名服务器
本地域名服务器请求二级域名服务器解析这个域名,二级域名服务器告诉它去找对应的 子域名服务器
本地域名服务器请求子域名服务器解析这个域名,子域名服务器返回对应的 IP 地址
本地域名服务器将 IP 地址记录到缓存中,并返回给客户机(会缓存起来),客户机根据收到的 IP 地址访问该网站
使用 preconnect 提前建立连接
preconnect 的作用是提前和第三方资源建立连接,设置了它浏览器就会做好早期的连接工作,但这个连接通常只会维持 10 s。
比如在当前域请求一个资源前,可能会涉及 DNS 寻址、TLS 握手、TCP 握手、重定向等,这过程也会花费一定的时间。
比如在掘金中的体现如下:
使用 preload / prefetch 预先加载资源
preload
preload 的作用是提前加载页面对应的 关键资源 加快页面的渲染,preload 的优先级顺序和 as 属性相关。
【注意】as 属性一定要设置,除了上面提到的设置优先级外,还涉及到浏览识别的问题:如果没有设置 as 属性,后续遇到该请求就会被作为一个 XHR 请求,把意味着资源预加载的功能可能会失效,因为可能会每次都发起新的请求获取
比如在掘金中的体现如下:
比如在 vue-cli 的默认 webpack 配置,如下:
prefetch
preload 是对资源的预加载,它虽提前加载但只在需要执行时执行,即这个资源一定是当前页面所需要的资源,如果是需要为下一个页面提前加载资源,那么应该使用 prefetch,它会在 浏览器空闲时 下载资源。
比如在 vue-cli 的默认 webpack 配置,如下:
压缩资源体积
资源是需要通过 http 数据包的方式在网络中进行传输的,那么只要能减少传输数据包的体积,也是能够使得资源更快到达客户端,这也是压缩资源体积的核心目的。
HTTP 压缩
HTTP 压缩中一个典型代表就是 gzip,它是一种优秀的压缩算法,可对 http 请求中的一些文件资源进行压缩处理,一般来讲是要在服务端处理的,可通过在响应头中设置 Content-encoding: gzip 表示当前资源使用的压缩方式(如:gzip、deflate、br 等),便于客户端使用正确的方式解压。
【注意】gzip 并不是万能的,它不能保证针对每个文件的压缩都能使其体积变小,关于 Content-Encoding 的内容可参考 content-encoding 除了gzip之外,你还知道哪些?
比如在京东中的体现:
比如在掘金中的体现:
Webpack 压缩
有 HTTP 压缩 不就够了吗?为什么还需要 Webpack 压缩?
首先必须要明确的是压缩的过程本身就是会消耗时间的,如果所有资源都等到被访问的时候再由服务端进行压缩,在压缩完成之前客户端还是得处于等待状态,即仍 不能保证资源以最快的速度到达客户端。
那么优化方案就是将压缩资源的时间放到打包构建中,毕竟只有真正需要发布线上生产环境时才需要执行一系列的打包优化的操作,而这相比于 http 的 请求/响应 速度,稍微延长产物打包时间没有什么大问题。
下面会列举一些 Webpack 插件,但并不会去讲其中的具体用法,因为这些只是达到目的的不同方案而已,每个方案要是细讲都可以独占一篇文章,在这是没有必要的,具体用法可自行查阅。
使用 CompressionPlugin 压缩文件
webpack 文档 提供插件合集中就包含了该插件,它的作用就是: Prepare compressed versions of assets to serve them with Content-Encoding.
使用 HtmlWebpackPlugin 压缩 HTML 文件
通常我们需要 HtmlWebpackPlugin 插件来生成对应 HTML 或 对已有的 HTML 模板自动注入 webpack bundles 资源,除此之外,它还可配置 minify 选项实现压缩模板的目的。
可以在 vue 项目下执行 vue inspect --mode production > webpack.config.js来查看脚手架的默认 webpack 配置内容,比如:
使用 SplitChunksPlugin 自定义分包策略
Webpack 默认会将尽可能多的模块代码打包在一起,这种默认规则的带来的优点和缺点都很明显:
优点:能减少最终页面的 HTTP 请求数
缺点:
页面初始代码包过大,影响首屏渲染性能
无法有效应用浏览器缓存
SplitChunksPlugin 是 Webpack 4 之后内置实现的最新分包方案,与 Webpack 3 中的 CommonsChunkPlugin 相比,它能够基于一些更灵活、合理的启发式规则将 Module 编排进不同的 Chunk,最终构建出 性能更佳、缓存更友好 的应用产物。
比如在 vue-cli 的默认 webpack 配置,如下:
使用 MiniCssExtractPlugin 抽离和压缩 CSS
MiniCssExtractPlugin 会将 CSS 提取到单独的文件中,为每个包含 CSS 的 JS 文件创建一个 CSS 文件,并且支持 CSS 和 SourceMaps 的 按需加载 。
比如在 vue-cli 的默认 webpack 配置,如下:
使用 ImageMinimizerWebpackPlugin 压缩图片资源
图片仍是一个 Web 应用中的必不可少的资源,而图片资源的体积也是首屏页面加载的瓶颈之一,因此,压缩图片也是性能优化需要考虑的内容。
ImageMinimizerWebpackPlugin 可用于使用 优化/压缩 所有图像,它可以支持 无损(不损失质量)、有损(质量下降) 两种模式的压缩方式。
通过 Tree Shaking 移除无用代码
Tree Shaking 依赖于 ES6 模块语法的 静态结构 特性(如: import 和 export),当 webpack 的模式 mode 为 "production" 时,就可以启用 更多优化项 ,包括 压缩代码与 Tree Shaking。
但同时我们就必须保证:
尽量使用 ES6 模块语法,即 import 和 export
保证没有 编译器(如:babel)将对应的 ES6 模块语法转换为 CommonJS 的语法(如:@babel/preset-env 的默认行为)
可在项目的 package.json 文件中添加 "sideEffects" 属性,标识当前内容是否存在副作用操作
可在通过 /*#__PURE__*/ 注释,将函数调用标记为无副作用
减少 http 请求数量
不同协议 下 请求数量 仍然可能成为 请求/响应 慢的原因:
合并公共资源,如 雪碧图 等
内置模块资源,如 生成 base64 图片、通过 symbol 引用 svg 等
合并代码块,如构建工具分包策略配合 公共组件封装、组件复用逻辑抽离 等
按需加载资源,如 路由懒加载、图片懒加载、上拉加载、分页加载 等
减少不必要的 cookie
不必要的 cookie 来回传输会造成带宽浪费:
减少 cookie 存储的内容
对于静态资源采用 CDN 托管(即非同域),不同域名默认不携带 cookie
CDN 托管静态资源 + HTTP 缓存
CDN 加速的本质是缓存加速,将服务器上存储的静资源容缓存在 CDN 节点上,当后续访问这些静态内容时,无需访问服务器源站,选择就近访问 CDN 节点即可,从而达到加速的效果,同时减轻服务器源站的压力。
在掘金中体现如下:
协议升级为 Http2.0
http1.x 存在的问题: HTTP 的底层协议是 TCP,而 TCP 是面向连接即需要 三次握手才能建立连接,其中:
http1.0 中使用的是 短连接,即 一次请求/响应 结束后就会断开连接,这个过程比较耗时
http1.1中使用的是长连接,在请求/响应头中设置Connection: keep-alive即可开启,优点是长连接允许多个请求共用一个TCP连接,缺点是带来了队头阻塞:
每个 TCP 连接中的多个请求,需要进行排队,只有队头的请求被响应,才能继续处理下一个请求
其中一个缓解方案就是如果当前 TCP 连接中发生 队头阻塞,那就将部分请求放到其他 TCP 连接中
浏览器一般会限制同一个域名建立 6-8 个 TCP 链接,这也就是为什么需要为应用划分子域名、静态资源托管 CDN 的原因之一
http1.x 中 header 部分的内容可能会很大,而且每一个请求可能都需要携带大量 重复header 的 文本内容,而这些也是导致 请求/响应 慢的原因之一
以上问题 http2.0 都能够解决:
针对 TCP 连接数 被限制的问题,http2.0 采用 多路复用 一个域名只对应一个 TCP 连接
针对 http 队头阻塞 问题,http2.0 中通过二进制分帧层为每个 请求/响应 添加 stream id 保证 请求/响应 一一对应,即不必等待前面的请求处理完成,并且还可以为每个请求添加 优先级
针对header数据大的问题,http2.0中传输的header帧经过处理后会用二进制的方式表示,替换了原本的文本格式 ,并使用HPACK算法进行压缩
接收/发送 两端会维护一个 索引表,通过下标来标识 header,针对后续重复的 header 信息就可以用对应的索引来代替
针对传统的 请求 —> 响应 模式,http2.0 中提供了 服务端推送 的能力,让服务端能够主动向客户端推送关键资源,加快资源加载
比如在掘金中的体现:
如何保证视图更快的渲染和交互?
保证资源快速到达客户端后,接下来就需要针对 浏览器的解析和渲染 进行优化,当然还包括后续的页面交互的优化,这其实就是 浏览器层面 的优化。
浏览器渲染 HTML 文件的核心过程:
HTML 解释器:将 HTML 文档经过词法分析输出 DOM Tree
CSS 解释器:解析 CSS 文档,生成样式规则 CSSOM
样式计算:将 DOM Tree 和 CSSOM 合并生成 Render Tree
布局计算:计算 Render Tree 节点在页面中的坐标位置,创建 Layout Tree
划分图层:页面中有很多复杂的效果,如一些复杂的 3D 变换、页面滚动,或使用 z-index 做 z 轴排序等,为了更加方便地实现这些效果,渲染引擎还需要为特定的节点生成专用的图层,生成对应的图层树 Layer Tree
图层绘制:
染引擎实现图层的绘制时,会把一个图层的绘制拆分成很多小的 绘制指令,并将这些指令按照顺序组成一个 待绘制列表
当图层的绘制列表准备好后,主线程 会把 待绘制列表 提交(commit)给 合成线程
栅格化 raster:
由于视口有限,用户只能看到页面的很小一部分,没必要绘制出所有图层内容,因此 合成线程 会将 图层(layer) 划分为 图块(tile)
渲染进程 把 生成图块的指令 发送给 GPU 并执行生成图块的 位图
合成和显示:
一旦所有图块都被光栅化,合成线程 就会生成一个绘制图块的命令 —— DrawQuad,然后将该命令提交给 浏览器进程
浏览器进程 里面有一个 viz 组件,会根据 DrawQuad 命令,将其页面内容绘制到内存中,最后再将内存显示在屏幕上
渲染层面
减少阻塞渲染的因素
真正渲染视图之前,必然要生成 DOM Tree 和 CSSOM,因此必须保证 HTML 解释器 和 CSS 解释器 都尽早处理完成,同时 JavaScript 的加载和执行可能会阻塞这个过程:
HTML 文档中首次渲染的节点数量要尽量少,避免深层次的嵌套结构,避免大量使用慢标签(如:iframe)等
CSS 资源放文档头部,降低 CSS 复杂度,比如 合理使用 CSS 选择器
JavaScript 资源放文档底部,合理使用 defer、async 的加载方式
懒加载
懒加载主要是针对数量大、资源加载慢的情况,比如图片资源、大量列表数据展示等:
图片资源:优先加载在可视区范围内的图片,可视区外的图片 延后加载 ,或者说当移入的可视区时再加载
列表数据:列表数据通常数据里量大,不可能一次渲染完所有数据,一般通过 分页加载、上拉加载 等方式分批次渲染
白屏优化
白屏是由于 SPA 应用需要等待 JavaScript 加载并执行完成后才会生成具体的页面结构内容导致的,即初始化模板中没有任何有意义需要被渲染的 HTML 结构:
添加 白屏 loading ,可在模板中添加默认的 loading 效果,等到真正页面内容被渲染就可以替换 loading 内容
添加 骨架屏,和上述方案一致,在真正页面内容展示出来之前,先展示默认的视图内容,避免白屏
服务端渲染(server-side rendering)
现代框架默认是属于客户端应用框架,即组件的代码会在浏览器中运行,然后向页面输出 DOM 元素,也叫 客户端渲染(client-side rendering,CSR):
优点
用户体验更好 ,基于 前端路由 的方式并不会真正进行 页面跳转,即不会使页面重新刷新、加载,带来更高的流畅度
占用服务端资源少 ,CSR 渲染 是交由客户端进行处理,服务端不需要关心渲染计算的过程,减轻了服务端的压力
缺点
"白屏" 时间较长 ,主要是因为 CSR 渲染需要 *.js 的支持,而 *.js 又必须保证 *.html 被接收和解析, *.html 又强依赖于当前的 网络环境,因此,在差网环境下回导致 白屏时间过长,特别是在移动网络环境下
对 SEO 的支持不友好 ,因为 白屏时间较长 导致在一段时间内没有重要的内容能够交由 搜索引擎 进行分析、分类、打标签等,并且 搜索引擎 并不会等待页面渲染完成,因此对 SEO 优化并不友好
服务端渲染(server-side rendering,SSR) 可将相同组件在服务渲染成相应的 HTML 字符串,并发送给浏览器进行渲染,即客户端不需要等待所有的 JavaScript 都被下载并执行之后才显示,所以用户可以更快看到完整的渲染好的内容。
预渲染(prerender)
上述 服务端渲染(server-side rendering,SSR) 虽然能够解决一些客户端存在的问题,但它也带来了别的问题:
需要保证开发一致性 ,比如 服务端 和 客户端 能够执行的组件生命周期钩子不同,一些外部库在 服务端渲染 应用中可能需要经过特殊处理
需要更多的构建设定和部署要求 ,一个完全静态的 SPA 可以部署在任意的静态文件服务器,但服务端渲染应用需要一个能够运行 Node.js 服务器的环境
更多的服务端负载 ,在 Node.js 中渲染一个完整的应用,会比仅供应静态文件产生更密集的 CPU 运算,并且需要考虑访问流量过大的情况等
因此,并不是所有应用都合适 服务端渲染,如果只是希望通过 SSR 来改善一些 推广页面 (如 /、/about、/contact 等) 的 SEO,那么应该优先考虑 预渲染 的方式:
预渲染 是在打包构建过程中(离屏状态),针对对应的 routes 路由预先生成对应的页面内容
预渲染 需要和 打包构建工具(webpack、rollup 等) 进行配合,如 webpack ,就可通过 prerender-spa-plugin 来支持 预渲染
交互层面
减少回流/重绘
重绘:页面中元素样式的改变并不影响它在文档流中的位置时(如:color、background-color、visibility 等),浏览器会将新样式赋予给元素并 重新绘制
回流:当 Render Tree 中部分或全部元素的 尺寸、结构、某些属性 发生改变时,浏览器 重新渲染 部分或全部文档
减少对 DOM 进行频繁操作
使经常变动的元素脱离文档流,如具有持续性的动画效果,会一直触发回流和重绘
避免访问或减少访问会导致浏览器强制刷新队列的属性,如:offsetTop、offsetLeft、offsetWidth等
【扩展】浏览器的渲染队列机制会通过 队列 将会触发 回流或重绘 的操作进行存储,等到一定的时间或一定的数量时再执行这些操作
避免对 css 进行单个修改,如在 JavaScript 修改多个样式时,尽量使用 css 选择器实现样式的集中变更
使用 will-change 开启 GPU 加速,will-change 指定的属性使得浏览器可在元素属性真正发生变化之前提前做好对应的优化
预先设定图片尺寸,避免图片资源加载完成后引发回流
防抖/节流
防抖:多次频繁触发执行操作,以 最后一次 为准,忽略中间过程
节流:在指定的时间间隔内,只允许 执行一次对应的操作
合理使用 防抖/节流 优化应用中的操作,比如 节流 可用于优化 滚动事件、模糊搜索等,防抖 可用于优化一些按钮点击操作等。
Web Worker
JavaScript 是单线程的,如果存在需要大量计算的场景(如视频解码),UI 线程就会被阻塞,甚至浏览器直接卡死。
Web Worker 可以使脚本运行在新的线程中,它们独立于主线程,可以进行大量的计算活动,而不会影响主线程的 UI 渲染,但不能滥用 Web Worker 。
虚拟列表
最常用的还是 分页加载 的方式:
基于 table 表格的渲染,只会渲染固定数量的 DOM
基于 上拉加载 列表的渲染,随着加载数据的增多,对应的 DOM 节点也会增多,达到某个限制页面一定会发生卡顿
虚拟列表 核心就是固定渲染的 DOM 数,通过动态切换数据内容实现视图的更新,并保证文档中真实 DOM 的数量不随着数据量增大而增大(其实和 table 分页很像,但它支持滚动)。
大文件分片上传
大部分的项目总少不了文件上传功能,但对大文件的上传还是有必要进行优化,所谓的 断点续传、秒传 都要基于 分片上传 这个核心功能。
Excel 导入/导出
针对 Excel 导入/导出 的功能相信很多人第一印象是后端的活,但大多数情况下,后端接口的处理速度会受各种影响,导致速度方面不是很理想,有时候也是需要前端来进行优化处理的,比如导入时前端不发送文件只发送解析后的 JSON 数据,导出时不需要单独发送额外接口,直接使用当前展示数据实现导出等。
Vue 项目的优化
这部分内容相信大家都不陌生,下面就简单列举一些内容(包括但不限于):
减少响应式数据的生成,对于纯展示、又需要使用在 template 模板中使用的数据,可使用 Object.freeze() 进行冻结,避免被转为 不必要的响应式数据
Vue 组件初始化是比较损耗性能的,使用 函数式组件 减少组件初始化的过程,适用于实现没有业务逻辑只展示内容的简单组件
合理使用 v-show 和 v-if、为 v-for 组件设定唯一 key(非 index)、v-for 和 v-if 不要一起使用等
使用 KeepAlive 复用组件,避免组件重复的创建、销毁带来的性能损耗
使用 () => import(xxx) 方式实现路由懒加载
使用 ESM 的方式封装自定义工具库等
针对第三方库做到按需引入
合理使用闭包,避免造成内存泄漏
及时清除组件中的副作用,比如 setTimeout、setInterval、addEventListener 等
总结
以上优化方案对应到 上一篇 中提到的性能指标,如下:
首字节达到时间(Time to First Byte,TTFB)
首次绘制(First Paint,FP)
首次内容绘制(First Contentful Paint,FCP)
首屏时间 / 最大内容绘制(Largest Contentful Paint, LCP)
累积布局偏移(Cumulative Layout Shift, CLS)
首次输入延迟(First Input Delay, FID)
关键资源越早到达客户端,证明 TTFB 时间越短,而这也能间接的减少 FP 和 FCP 的时间;对资源进行了压缩处理意味着能够尽可能提升 LCP 的时间;减少了页面的 回流/重绘 就能使得 CLS 的数值越小,视图越趋于稳定;FID 是一个用于跟踪浏览器对用户输入做出反应之前的延迟时间的指标,包括点击和敲击,保证资源的快速加载和页面尽早渲染,其对应的数值就越小,视图响应就越快。
最后
前端性能优化 的范围实在太大,以上列举的优化主要围绕着 资源加载、页面渲染/交互 两个大的方向,而具体的优化方案其实有很多(包括但不限于上述内容),很多内容随着关注的方向不同而不同。