# 前端性能优化
"春江水暖鸭先知,产品好坏用户知",产品的好坏决定着用户体验,产品的好坏有很多因素,其中性能是决定因素,怎么优化才能让产品的性能更加优良?我们需要了解浏览器工作过程以及性能调试工具。
# 从浏览器里输入URL后发生了什么?
- 浏览器开启一个线程来处理这个请求,对URL判断如果是http协议就按照web方式处理。
- 浏览器先查看浏览器缓存-系统缓存-路由器缓存,如果缓存中有,会直接在屏幕中显示页面内容(此时没有向服务端发请求)。若没有,则进行下一步操作(后面需要向服务端发送请求)。
- 通过DNS解析获取网址的IP地址。
- 向真实IP地址服务器发起tcp连接,与浏览器建立tcp三次握手。
- 握手成功后,进行HTTP协议会话,浏览器发送报头(请求报头)。
- 进入到web服务器上的 Web Server,如 Apache、Tomcat、Node 等服务器。
- 进入部署好的后端应用,如 Go、Java、Node、Python 等,找到对应的请求处理。
- 处理结束回馈报头,将数据返回至浏览器。
- 浏览器开始下载html文档(响应报头,状态码200),同时设置缓存。
- 浏览器对整个 HTML 结构进行解析,形成 DOM 树;与此同时,它还需要对相应的 CSS 文件进行解析,形成 CSS 树(CSSOM);DOM + CSSOM,形成一个绘制树(Render Tree)。
- 浏览器根据渲染树来布局,以计算每个节点的几何信息,将各个节点绘制到屏幕上,组合形成一个页面。
其实这个问题的回答可以非常细致,能从信号与系统、计算机原理、操作系统到网络通信、浏览器内核,再到 DNS 解析、负载均衡、页面渲染等等,但我们主要关注前端方面的内容,将整个过程划分为以下几个阶段。
# 1. 开启线程
# 浏览器中的进程
1.** 浏览器进程****(Browser进程):浏览器的主进程(负责协调,主控)****,只有一个**
1)负责浏览器的界面界面显示,与用户交互,网址栏输入、前进、后退等
2)负责管理各个页面,创建和销毁进程
3)将页面内容(位图)写入到浏览器内存中,最后将图像显示在屏幕上
4)文件存储等功能
2.** 渲染进程****(浏览器内核, Renderer 进程,内部是多线程的)**:默认一个tab页面一个渲染进程,主要的作用为页面渲染,脚本执行,事件处理等
3.GPU** 进程**:用于3D绘制等,将开启了3D绘制的元素的渲染由CPU转向GPU,也就是开启GPU加速。最多一个
4.** 网络进程**:主要负责页面的网络资源加载,之前是作为一个模块运行在浏览器进程里面,现在独立开来,成为一个单独的进程
5.** 插件进程**:每种类型的插件对应一个进程,仅当使用该插件时才创建
6.** 音频进程**:浏览器音频管理
# 浏览器的线程(浏览器内核包含以下线程)
1.GUI** 渲染线程**
1)负责渲染浏览器界面,解析HTML,CSS,构建DOM树和RenderObject树,布局和绘制
2)当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行
3)与JS引擎互斥,当执行JS引擎线程时,GUI会pending,当任务队列空闲时,才会继续执行GUI
2.JS** 引擎线程**
1)也称为JS内核,负责处理javascript脚本程序
2)JS引擎线程负责解析Javascript脚本,运行代码
3)JS引擎一直等待任务队列中任务的到来,然后加以处理,浏览器无论什么时候都只有一个JS线程在运行JS程序
4)同样注意,GUI渲染线程与JS引擎线程时互斥的,所以如果JS执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染加载阻塞。
3.** 事件触发线程**
1)事件触发线程归属于浏览器而不是JS引擎(辅助JS引擎),用来控制事件循环(存在一个事件队列)
2)当JS引擎执行代码块如setTimeOut时(也可来自浏览器内核的其他线程,如鼠标点击,Ajax异步请求等),会将对应的任务添加到事件线程中
3)当对应的事件符合触发条件被触发时,该线程会把事件添加到待处理队列的队尾,等待JS引擎的处理
4)注意,由于JS的单线程关系,所以这些待处理队列的事件都得排队等待JS引擎的处理(当JS引擎空闲时才会去执行)
4.** 定时触发器线程**
1)setInterval、setTimeOut所在线程
2)浏览器定时计数器并不是由JavaScript引擎计数的,(因为JavaScript引擎时单线程的,如果处于阻塞线程状态就会影响计时的准确)
3)因此通过单独线程来计时并触发(计时完毕后,添加到事件队列中,等待JS引擎空闲后执行)
4)注意,W3C在HTML标准中规定要求setTimeOut中低于4ms的时间间隔为4ms
5.** 异步 HTTP 请求线程****(IO线程)**
1)在XMLHttpRequest在连接后是通过浏览器新开一个线程请求
2)将检测到状态变更时,如果设置有回调函数,异步线程就产生状态变更事件,将这个回调再放入事件队列中(放入事件触发线程中)。再由JavaScript引擎执行。
总结:
- 浏览器是多进程的。
- js执行的主线程为JS引擎,并且无论何时都只有一个JS线程在运行,所以是单线程执行。
- GUI渲染线程和JS引擎线程是互斥的,并且JS会阻塞页面的加载和渲染。
- 定时器(setInterval,setTimeout)会在定时器触发器线程中进行计时。
- 定时触发器线程计时结束后需要执行的事件和异步HTTP请求线程的回调事件都会进入到事件触发线程的任务队列中等待JS引擎的执行。
# 2. DNS域名解析
浏览器缓存 ->** 系统缓存 **** -> hosts -> **** 本地域名服务器 **** -> **** 根域名服务器 **** -> **** 顶级域名和权限域名服务器**
最终目的是把目标服务器IP地址返回本地主机
# 3. 建立TCP连接
- 第一次握手:客户端生成一个随机数seq,假设其值为t,并将标志位SYN设为1,将这些数据打包发给服务器端后,客户端进入等待服务器端确认的状态。(客户端发送SYN报文,并设置发送序号为t)
- 第二次握手:服务器端收到客户端发来的SYN=1的数据包后,知道这是在请求建立连接,于是服务器端将SYN与ACK都设置为1,并将请求包中客户端发来的随机数t加上1后赋值给ack,然后生成一个服务器端的随机数seq=k,完成这些操作后,服务器端将这些数据打包再发回给客户端,作为对客户建立连接请求的确认应答。(服务器发送SYN+ACK报文,并设置发送序号为k)
- 第三次握手:客户端收到服务器端的确认应答后,检查数据包中的ack的字段是否为t + 1,ACK是否等于1,若都正确就将服务器端发来的随机数加1(ack = k + 1),将ACK = 1的数据包再发送给服务器端以确认服务器端的应答,服务器端收到应答包后通过检查ack是否等于k + 1来确认连接是否建立成功。(客户端发送ACK报文)
# 4. 发送HTTP请求
建立起安全的加密信道后,浏览器开始发送 HTTP 请求,一个请求报文由请求行、请求头、空行、实体(Get 请求没有)组成。请求头由通用首部、请求首部、实体首部、扩展首部组成。其中,通用首部表示无论是请求报文还是响应报文都可以使用,比如 Date;请求首部表示只有在请求报文中才有意义,分为 Accept 首部、条件请求首部、安全请求首部和代理请求首部这四类;实体首部作用于实体内容,分为内容首部和缓存首部这两类;扩展首部表示用户自定义的首部,通过 X-前缀来添加。另外需要注意的是,HTTP 请求头是不区分大小写的,它基于 ASCII 进行编码,而实体可以基于其它编码方式,由 Content-Type决定。
# 5. 返回HTTP请求
服务器接受并处理完请求,返回 HTTP 响应,一个响应报文格式基本等同于请求报文,由响应行、响应头、空行、实体组成。区别于请求头,响应头有自己的响应首部集,比如 Vary、Set-Cookie,其它的通用首部、实体首部、扩展首部则共用。此外,浏览器和服务器必须保证 HTTP 的传输顺序,各自维护的队列中请求/响应顺序必须一一对应,否则会出现乱序而出错的情况。
# 6. 关闭TCP连接
- 第一次挥手:由客户端先向服务器端发送FIN=M指令,随后进入完成等待状态FIN_WAIT_!,表明客户端已经没有再向服务器端发送的数据,但若服务器端此时还有未完成的数据传递,可继续传递数据
- 第二次挥手:当服务器端收到客户端的FIN报文后,会先发送ack=M+1的确认,告知客户端关闭请求已收到,但可能由于服务器端还有未完成的数据窜提,所以请客户端继续等待
- 第三次挥手:当服务器端确认已完成所有的数据传递后,便发送带有FIN=N的报文给客户端,准备关闭连接
- 第四次挥手:客户端收到FIN=N的报文后可进行关闭操作,但为保证数据正确性,会回传给服务器端一个确认报文ack=N+1,同时服务器端也在等待客户端的最终确认,如果服务器端没有收到报文则会进行重传,只有收到报文后才会真正断开连接。而客户端在发送了确认报文一段时间后,没有收到服务器端任何信息则会任务服务器端连接已关闭,也可关闭客户端信息。
# 7. 浏览器解析HTML
1. 解码(** encoding **)
传输回来的其实都是一些二进制字节数据,浏览器需要根据文件指定编码(例如UTF-8)转换成字符串,也就是HTML 代码。
2. 预解析(** pre-parsing **)
预解析做的事情是提前加载资源,减少处理时间,它会识别一些会请求资源的属性,比如 img标签的 src属性,并将这个请求加到请求队列中。
3. 符号化(** Tokenization **)
符号化是词法分析的过程,将输入解析成符号,HTML 符号包括,开始标签、结束标签、属性名和属性值。它通过一个状态机去识别符号的状态,比如遇到 <, >状态都会产生变化。
4. 构建树(** tree construction **)
在上一步符号化中,解析器获得这些标记,然后以合适的方法创建 DOM对象并把这些符号插入到 DOM对象中。
# 8. 浏览器布局渲染
- 处理HTML标记并构建DOM树
- 处理CSS标记并构建CSSOM树
- 将DOM与CSSOM合并成一个render tree
- 根据渲染树来布局,以计算每个节点的几何信息
- 将各个节点绘制到屏幕上
注意 :
重绘** (repaint)**:
元素视觉表现属性被改变即触发重绘,如改变visibility,color等,不会影响到dom结构
重排** (reflow)**:
与repaint区别就是:所有影响dom的元素布局的事件都会触发重排,同时也会触发repaint。这种开销是非常昂贵的,导致性能下降是必然的,页面元素越多效果越明显。
reflow常见情况:
- 增删改DOM节点
- 移动DOM的位置或是动画显示(所以尽量用canvas来做动画)
- 修改width、display等CSS样式
- resize窗口或是滚动的时候
- 修改网页默认字体(不建议)
- display:none会触发reflow和repaint,而visibility:hidden只会产生repaint
# 调试工具
# 1. Network
上面这张图可以看到资源加载详情,初步评估影响性能的因素。页面底部是当前加载资源的一个概览,主要关注DOMContentLoaded:DOM渲染完成时间,Load:所有资源加载完成时间。
# Waterfall时间线
- Queueing浏览器将资源放入队列时间
- Stalled因放入队列时间而发生的停滞时间
- DNS Lookup DNS解析时间
- Initial connection建立HTTP连接的时间
- SSL浏览器与服务器建立安全性连接的时间
- Waiting等待服务端返回数据的时间
- Content Download浏览器下载资源的时间
# Performance monitor性能监控
# 2. Lighthouse
Performance - 性能检测,如网页的加载速度、响时间等(重点)
Accessibility - 铺助检测,如网页的可访问性问题,HTML代码标签之类的优化等
Best Practices - 实践性检测,如网页安全性,如是否开启HTTPS、网页存在的漏洞等
SEO - Search Engine Optimisation搜索引擎优化检测,如网页title是否符合搜索引擎的优化标准等
PWA- 检测对Progressive Web App的性能影响
首次内容绘制(First Contentful Paint,FCP)。即浏览器首次将任意内容(如文字、图像、canvas 等)绘制到屏幕上的时间点。
最大的内容绘制(Largest Contentful Paint,LCP)加载页面中元素到屏幕上的最长时间点。
可交互时间(Time to Interactive,TTI。指的是所有的页面内容都已经成功加载,且能够快速地对用户的操作做出反应的时间点。
速度指标(Speed Index,SI)。衡量了首屏可见内容绘制在屏幕上的速度。在首次加载页面的过程中尽量展现更多的内容,往往能给用户带来更好的体验,所以速度指标的值约小越好。
总阻塞时间(Total Blocking Time,TBT)网页被阻塞与用户交互的时间(用户无法进行输入)
累积布局偏移(Cumulative Layout Shift,CLS)是指一个页面的布局在加载时的偏移程度。布局转移的发生是因为浏览器倾向于异步加载页面元素,浏览器在完成加载之前不知道各个元素会占用多少空间,因此布局会发生剧烈变化。
# 3. Performance
关键时间节点数据:
- DCL、L
DCL(DOMContentLoaded) 表示HTML加载完成事件, L(onLoad) 表示页面所有资源加载完成事件
- FP、FCP
FP(First Paint): 页面在导航后首次呈现出不同于导航前内容的时间点。(首个像素开始绘制到屏幕上的时机,例如一个页面的背景色)
FCP(First Contentful Paint): 首次绘制任何文本,图像,非空白canvas或SVG的时间点。
- LCP
LCP: 可视区域"内容"最大的可见元素开始出现在页面上的时间点。
# 资源打包分析
打包分析配置
- nuxtjs
// nuxt.config.js
module.exports = {
build: {
analyze: true
}
}
// package.json
"analyze": "nuxt build --analyze"
- webpack
npm install --save-dev webpack-bundle-analyzer
// webpack.config.js 文件
const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin
module.exports={
plugins: [
new BundleAnalyzerPlugin({
analyzerMode: 'server',
analyzerHost: '127.0.0.1',
analyzerPort: 8889,
reportFilename: 'report.html',
defaultSizes: 'parsed',
openAnalyzer: true,
generateStatsFile: false,
statsFilename: 'stats.json',
statsOptions: null,
logLevel: 'info'
}),
]
}
// package.json
"analyz": "NODE_ENV=production npm_config_report=true npm run build"
也可以通过打包产物分析
# 性能优化
# 1. 服务端渲染
客户端渲染 :获取 HTML 文件,根据需要下载 JavaScript 文件,运行文件,生成 DOM,再渲染。
服务端渲染 :服务端返回 HTML 文件,客户端只需解析 HTML。
- 优点:首屏渲染快,SEO 好。
- 缺点:配置麻烦,增加了服务器的计算压力。
静态化站点 :配置 Nuxt.js 应用生成静态站点
- 优化支持。
- 服务端渲染。
- 快速的开发和运行时。
- 定义良好的项目结构。
- 支持无服务器静态站点生成。
- 自动代码拆分。
- 但动态路由配置麻烦。
# 2. 静态资源使用CDN
内容分发网络(CDN)是一组分布在多个不同地理位置的 Web 服务器。我们都知道,当服务器离用户越远时,延迟越高。CDN 就是为了解决这一问题,在多个位置部署服务器,让用户离服务器更近,从而缩短请求时间。
# 3. 将 CSS 放在文件头部,JavaScript 放在底部
如果这些 CSS、JS 标签放在 HEAD 标签里,JS 加载和执行会阻塞 HTML 解析,阻止 CSSOM 构建,并且需要加载和解析很久的话,那么页面就空白了。所以 JS 文件要放在底部(不阻止 DOM 解析,但会阻塞渲染),等 HTML 解析完了再加载 JS 文件,尽早向用户呈现页面的内容。
那为什么 CSS 文件还要放在头部呢?因为先加载 HTML 再加载 CSS,会让用户第一时间看到的页面是没有样式的,为了避免这种情况发生,就要将 CSS 文件放在头部了。
# 4. 缓存
- http缓存
- Service Worker
# 5. 压缩
- 开启gzip/br
- js、css、html压缩
- 图片压缩
# 6. 预加载&懒加载
- 配置reload和prefetch
<link rel="preload" href="xxx"/>
<link rel="prefetch" href="xxx" />
- 图片懒加载
- 路由、组件和文件懒加载
Selector: () => import(/* webpackChunkName: 'Selector' */'@uikit/xx/selector')
- 多语言懒加载
nuxtjs配置:
module.exports = {
strategy: 'no_prefix',
locales: [
{ code: 'en-US', name: 'English', des: '英语', iso: 'en-US', file: 'en-US.json' },
{ code: 'zh-CN', name: 'Chinese', des: '中文', iso: 'zh-CN', file: 'zh-CN.json' },
{ code: 'ja', name: 'Japanese', des: '日语', iso: 'ja', file: 'ja.json' },
{ code: 'th', name: 'Thai', des: '泰语', iso: 'th', file: 'th.json' },
{ code: 'ar-SA', name: 'Arabic', des: '阿拉伯语', iso: 'ar-SA', file: 'ar-SA.json' },
],
defaultLocale: 'en-US',
vueI18nLoader: true,
lazy: true,
langDir: 'i18n/langs/',
vueI18n: {
fallbackLocale: 'en-US',
}
}
vue-i18n配置:
export const i18n = new VueI18n({
locale: 'en',
messages,
})
function setI18nLanguage(lang){
i18n.locale = lang
return lang
}
export function loadLanguageAsync (lang) {
return import(/* webpackChunkName: "lang-[request]" */`@/i18n/${lang}`).then(langfile => {
let langFile = langfile.default
i18n.setLocaleMessage(lang, langFile)
return setI18nLanguage(lang)
})
}
# 7. webpack优化
splitChunks拆包:11M -> 3M -> 108kB
splitChunks: {
minSize: 51200, // 超过50K分包
maxSize: 819200, // 分包最大800K超出再分包
minChunks: 1, // 至少被minChunks个块(chunk)使用的模块,才会被提取
maxAsyncRequests: 4, // 按需加载时的最大并行请求数
maxInitialRequests: 3, // 入口点的最大并行请求数
automaticNameDelimiter: '.', // 指定用于生成名称的连接符
chunks: 'all',
cacheGroups: {
vendors: {
name: 'vendors',
test: /[\\/]node_modules[\\/]/,// 用于控制哪些模块被这个缓存组匹配到。原封不动传递出去的话,它默认会选择所有的模块。可以传递的值类型:RegExp、String和Function;
reuseExistingChunk: true, // 表示是否使用已有的 chunk,如果为 true 则表示如果当前的 chunk 包含的模块已经被抽取出去了,那么将不会重新生成新的
enforce: true,
chunks: 'all', // initial、async和all
priority: -10, // 表示抽取权重,数字越大表示优先级越高
},
common: {
name: 'common',
chunks: 'all',
reuseExistingChunk: true,
enforce: true,
priority: -10,
},
kso: {
name: 'kso',
test: /[\\/]node_modules[\\/](@oem)[\\/]/,
reuseExistingChunk: true,
enforce: true,
chunks: 'all',
priority: 10,
},
ksoUi: {
name: 'ksoUi',
test: /[\\/]node_modules[\\/](@oem)[\\/]ui[\\/]lib[\\/]/,
reuseExistingChunk: true,
chunks: 'all',
enforce: true,
priority: 11,
},
ksoUiLocale: { // 语言包 /lib/config/locale/
name: 'ksoUiLocale',
test: /[\\/]node_modules[\\/](@oem)[\\/]ui[\\/]lib[\\/]config[\\/]locale/,
chunks: 'all',
priority: 20,
},
uikit: {
name: 'uikit',
test: /[\\/]node_modules[\\/](@uikit)[\\/]/,
chunks: 'all',
priority: 20,
},
meta: {
name: 'meta',
test: /[\\/]node_modules[\\/](@meta)[\\/]/,
reuseExistingChunk: true,
enforce: true,
chunks: 'all',
priority: 10,
},
icf: {
name: 'icf',
test: /[\\/]node_modules[\\/](icf.js)[\\/]/,
chunks: 'all',
priority: 30,
},
elementui: {
name: 'elementui',
test: /[\\/]node_modules[\\/]element-ui[\\/]/,
chunks: 'all',
priority: 20,
},
}
}
# 8. 骨架屏
用css提前占好位置,当资源加载完成即可填充,减少页面的回流与重绘,同时还能给用户最直接的反馈。
# 9. 减少重绘和重排
- 用 JavaScript 修改样式时,最好不要直接写样式,而是替换 class 来改变样式。
- 如果要对 DOM 元素执行一系列操作,可以将 DOM 元素脱离文档流,修改完成后,再将它带回文档。推荐使用隐藏元素(display:none)或文档碎片(DocumentFragement),都能很好的实现这个方案。
const oFragmeng = document.createDocumentFragment(); // 先创建文档碎片
for (let i = 0; i < 10000; i++) {
let op = document.createElement('span');
let oText = document.createTextNode(i);
op.appendChild(oText);
oFragmeng.appendChild(op); // 先附加在文档碎片中
}
document.body.appendChild(oFragmeng); // 最后一次性添加到document中