性能优化
# 性能优化的指标和工具
为什么要进行性能优化?
Amazon发现每100ms延迟导致1%的销量损失
# 性能指标和优化目标
性能优化-加载
- 网络加载瀑布图
- 基于HAR存储与重建性能信息
- 速度指数(Speed Index) 4s
重要测量指标
- Speed Index
- TTFB
- 页面加载时间
- 首次渲染时间
性能优化-响应
- 交互动作的反馈时间
- 帧率FPS
- 异步请求的完成时间
# 网络
以淘宝网站为例
瀑布
TTFB指标,请求发送出去到响应回来的时间
- 反映后台的处理能力
- 反映网络延时
保存这份网络测试报告,右键,然后选择以HAR格式保存所有内容
# lighthouse
浏览器自带有lighthouse
重点关注指标FCP 和 Speed Index
# FPS
点击之后就可以看到实时帧率变化
测试其他网站帧率(这个是有明显卡顿的)
# RAIL测量模型
# 什么是RAIL
- Response 响应 (用户交互后的响应体验)
- Animation 动画
- Idle 空闲
- Load 加载 (资源网络加载时间)
让良好的用户体验成为性能优化的目标
# RAIL评估标准
- Response 响应:处理事件应在50ms以内完成
- 理论100ms,但是要预留50ms给浏览器处理事件
- Animation 动画:每10ms产生一帧
- 1秒60帧,那么就是16.67ms一帧,留6ms给浏览器渲染
- Idle 空闲:尽可能增加空闲时间
- 处理时间不能超过50ms
- 利用空闲时间进行延迟加载(合理)
- 前端做一些业务逻辑运算(不合理)
- Load 加载:在5s内完成内容加载并可以交互
- 加载-解析-渲染
# 性能测量工具
- Chrome DevTools开发调试、性能评测
- Lighthouse 网站整体质量评估
- WebPageTest 多测试地点、全面性能报告
# WebPageTest
https://www.webpagetest.org/
测试报告
- waterfall chart请求瀑布图
- first view首次访问
- repeat view二次访问
# lighthouse
# Chrome DevTools
- Audit(Lighthouse)
- Throttling调整网络吞吐
- Performance性能分析
- Network 网络加载分析
# 常用的性能测量APIs
- 关键时间节点(Navigation Timing,Resource Timing)
- 网络状态(Network APIs)
- 客户端服务端协商(HTTP Client Hints)&网页显示状态(UI APIs)
DNS 解析耗时: domainLookupEnd - domainLookupStart
TCP 连接耗时: connectEnd - connectStart
SSL 安全连接耗时: connectEnd - secureConnectionStart
网络请求耗时 (TTFB): responseStart - requestStart
数据传输耗时: responseEnd - responseStart
DOM 解析耗时: domInteractive - responseEnd
资源加载耗时: loadEventStart - domContentLoadedEventEnd
First Byte时间: responseStart - domainLookupStart
白屏时间: responseEnd - fetchStart
首次可交互时间: domInteractive - fetchStart
DOM Ready 时间: domContentLoadEventEnd - fetchStart
页面完全加载时间: loadEventStart - fetchStart
http 头部大小: transferSize - encodedBodySize
重定向次数:performance.navigation.redirectCount
重定向耗时: redirectEnd - redirectStart
2
3
4
5
6
7
8
9
10
11
12
13
14
15
<script>
// load事件触发
window.addEventListener('load', function () {
// Time to Interactive 可交互时间
let timing = performance.getEntriesByType('navigation')[0]
// 计算tti domInteractive - fetchStart
let tti = timing.domInteractive - timing.fetchStart
console.log("tti: ", tti)
});
</script>
2
3
4
5
6
7
8
9
10
<script>
// 监听页面切换事件
let vEvent = 'visibilitychange'
if (document.webkitHidden !== undefined) {
// webkit事件
vEvent = 'webkitvisibilitychange'
}
function visibilityChanged() {
// 页面不可见
if(document.hidden || document.webkitHidden) {
console.log('web page is hidden')
} else {
// 页面可见
console.log('web page is visibile')
}
}
document.addEventListener(vEvent, visibilityChanged)
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
# 本地部署WebPageTest
# docker安装
- 访问Docker官网文档,按需下载对应版本安装
https://docs.docker.com/get-docker/
- 注册docker id
https://hub.docker.com/signup
- 安装后点击工具栏的Docker图标,使用注册的docker id登录
# WebPageTest本地部署说明
拉取镜像
docker pull webpagetest/server docker pull webpagetest/agent
1
2
3运行实例
docker run -d -p 4000:80 --rm webpagetest/server docker run -d -p 4001:80 --network="host" -e "SERVER_URL=http://localhost:4000/work/" -e "LOCATION=Test" webpagetest/agent
1
2
3
# mac 用户自定义镜像
创建server目录
mkdir wpt-mac-server cd wpt-mac-server
1
2创建Dockerfile,添加内容
vim Dockerfile FROM webpagetest/server ADD locations.ini /var/www/html/settings/
1
2
3
4创建locations.ini配置文件,添加内容
vim locations.ini [locations] 1=Test_loc [Test_loc] 1=Test label=Test Location group=Desktop [Test] browser=Chrome,Firefox label="Test Location" connectivity=LAN
1
2
3
4
5
6
7
8
9
10
11
12创建自定义server镜像
docker build -t wpt-mac-server .
1创建agent目录
mkdir wpt-mac-agent cd wpt-mac-agent
1
2创建Dockerfile,添加内容
vim Dockerfile FROM webpagetest/agent ADD script.sh / ENTRYPOINT /script.sh
1
2
3
4
5创建script.sh, 添加内容
vim script.sh #!/bin/bash set -e if [ -z "$SERVER_URL" ]; then echo >&2 'SERVER_URL not set' exit 1 fi if [ -z "$LOCATION" ]; then echo >&2 'LOCATION not set' exit 1 fi EXTRA_ARGS="" if [ -n "$NAME" ]; then EXTRA_ARGS="$EXTRA_ARGS --name $NAME" fi python /wptagent/wptagent.py --server $SERVER_URL --location $LOCATION $EXTRA_ARGS --xvfb --dockerized -vvvvv --shaper none
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17修改script.sh权限
chmod u+x script.sh
1创建自定义agent镜像
docker build -t wpt-mac-agent .
1用新镜像运行实例 (注意先停掉之前运行的containers)
docker run -d -p 4000:80 --rm wpt-mac-server
1
2
docker run -d -p 4001:80 --network="host" -e "SERVER_URL=http://localhost:4000/work/" -e "LOCATION=Test" wpt-mac-agent
# 渲染优化
# 浏览器渲染原理
参考这篇文章 (opens new window),很详细
# 回流和重绘
布局(回流/重排)与绘制(重绘)
- 渲染树只包含网页需要的节点
- 布局计算每个节点精确的位置和大小-“盒模型”
- 绘制是像素化每个节点的过程
影响**回流(重排/布局)**的操作
- 添加/删除元素
- 操作styles
- display: none
- offsetLeft, scrollTop, clientWidth
- 移动元素位置
- 修改浏览器大小,字体大小
# 测试布局变化
未修改图片尺寸前
<script>
// 测试回流(布局)变化
// 获取所有的图片
const imgs = document.getElementsByClassName('MuiCardMedia-root')
console.log(imgs)
const update = () => {
imgs[0].style.width = '800px'
}
window.addEventListener('load', update)
</script>
2
3
4
5
6
7
8
9
10
# 避免layout thrashing
避免布局抖动
- 避免回流
- 读写分突
- 批量读,批量写
# FastDom
使用FastDom (opens new window)批量对DOM的读写操作
# 复合线程(compositor thread)与图层(layers)
复合线程做什么
- 将页面拆分图层进行绘制再进行复合
- 利用DevTools了解网页的图层拆分情况
- 哪些样式仅影响复合
transform
- translate
- scale
- rotate
opacity
will-change创建新的图层
录制动画,并没有进行布局和重绘
# 减少重绘
多使用transform和opticity
# 高频事件防抖
高频事件:滚动,鼠标滚动(一帧触发多次)
一帧要做的事情
<script>
// 高频事件防抖
const imgs = document.getElementsByClassName('MuiCardMedia-root')
// 修改卡片宽度的方法
const changeWidth = (pos) => {
for(let i = 0; i < imgs.length; i++) {
imgs[i].style.width = ((Math.sin(imgs[i].offsetTop + pos / 1000) + 1) * 500) + 'px'
}
}
let ticking = false; // 防抖保证一帧只执行一次
window.addEventListener('pointermove', (e) => {
let pos = e.clientX;
if(ticking) return
window.requestAnimationFrame(() => {
changeWidth(pos)
ticking = false
})
})
</script>
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
# React时间调度
requestIdleCallback的问题
- 一帧16.67ms如果还有空闲时间,就会去执行
- 但是有兼容性问题,兼容性不好。不如requestAnimationFrame
可以利用requestAnimationFrame来模拟requestIdleCallback
requesetAnimation, 窗口隐藏是不会执行的,想执行的话,可以用setTimeout来实现。
# 代码优化
# JS开销和如何缩短解析时间
解决方案
- Code splitting 代码拆分,按需加载
- Tree shaking代码减重
减少主线程工作量
- 避免长任务
- 避免超过1kB的行间脚本
- 使用rAF和rIC进行时间调度
我们有这样的背景,接触到的是最先进的技术。但有一些用户并不能接触到这些,所以我们还要考虑这些。
# V8引擎
const {performance, PerformanceObserver} = require('perf_hooks');
const add = (a,b)=>a+b;
const num1 = 1;
const num2 = 2;
performance.mark('start');
for(let i = 0; i < 10000000; i++){
add (num1,num2);
}
add(num1,'s');
for(let i = 0; i < 10000000; i++){
add ( num1,num2);
}
performance.mark('end');
const observer = new PerformanceObserver((list) => {
console.log(list.getEntries()[0]);
})
observer.observe({entryTypes: ['measure']});
performance.measure('测量1', 'start' , 'end');
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
这一行代码导致反向优化add(num1,'s');
去掉这一行,运行时间减少为14ms
node --trace-opt --trace-deopt de-opt.js
上述命令可以查看做了哪些优化
node
: 运行 Node.js 程序的命令行工具。--trace-opt
: 启用 V8 引擎中的优化跟踪,这意味着当 V8 引擎尝试对 JavaScript 代码进行优化时,会输出相关信息,比如哪些函数被优化了、哪些函数没有被优化等。--trace-deopt
: 启用 V8 引擎中的去优化跟踪,这意味着当 V8 引擎检测到某些优化不适用于某些代码时,会输出相关信息,比如哪些函数被取消优化、为什么取消优化等。de-opt.js
: 这是要运行的 Node.js 脚本文件的名称。
# 抽象语法树
- 源码=>抽象语法树=>字节码Bytecode =>机器码
- 编译过程会进行优化
- 运行时可能发生反优化
# V8优化机制
- 脚本流
- 文件超30kb会单独开一个线程进行解析(边下载边解析)
- 字节码缓存
- 懒解析
- 用到函数再解析
# 函数优化
函数的解析方式
- lazy parsing 懒解析vs eager parsing饥饿解析
- 利用Optimize.js优化初次加载时间 (webpack4之后好像不需要了,这个主要帮助我们来找回括号的,默认压缩后会把我们饥饿解析的括号去掉)
export default () => {
// 默认会先进行懒解析,然后到使用的时候进行饥饿解析
// const add = (a, b) => a*b;
// 这样会直接进行饥饿解析(告诉v8引擎后面会使用)
const add = (a, b) => a*b;
const num1 = 1;
const num2 = 2;
add(num1, num2);
}
2
3
4
5
6
7
8
9
10
在App.jsx中引入,并调用。最后要在webpack.config.js
可以看到直接进行饥饿解析,有轻微的耗时变化。(要多测量几组数据)
# 对象优化
- 以相同顺序初始化对象成员,避免隐藏类的调整
- 实例化后避免添加新属性
- 尽量使用Array代替array-like 对象(类数组)
- 避免读取超过数组的长度
- 避免元素类型转换
21种隐藏类型
cass RectArea { // HCO
constructor(l, w){
this.l = l; // HC1
this.w = w; // HC2
}
}
const rect1 = new RectArea(3,4);
const rect2 = new RectArea(5,6);
// 反例
const car1 = {color: 'red'}; // HCO
car1.seats = 4; // HC1
const car2 = {seats: 2};// HC2
car2.color = 'blue'; // HC3
2
3
4
5
6
7
8
9
10
11
12
13
14
// 反例
const car1 = {color: 'red'}; // In-object属性
car1.seats = 4;// Normal/Fast 属性,存储property store里,需要通过描述数组间接查找
2
3
类数组没有优化(有length属性,索引属性),V8引擎会对数组进行优化
Array.prototype.forEach.call(arrObj,(value, index) =>{//不如在真实数组上效率
console. log(`$iindex} :${value}`);
})
// 推荐先转为数组,再循环遍历
const arr = Array.prototype.slice.call(arrobj, 0);//转换的代价比影响优化小
arr.forEach((value, index) =>{
console.log(`${index}: ${value}`);
})
2
3
4
5
6
7
8
数组不要越界查找
function foo(array ) {
for(let i = 0; i <= array.length; i++){ // 越界比较
if(array[i] > 1000){ // 1.造成undefined跟数字进行比较2.沿原型链的查找
console.log(array[i]);// 业务上无效、出错
}
}
// [10,100,1000]
2
3
4
5
6
7
避免元素类型转换
const array = [3,2,1];// PACKED_SMI_ELEMENTS
array.push(4.4);// PACKED_DOUBLE_ELEMENTS
2
# 资源优化
# 资源的压缩与合并
# 为什么要压缩&合并
- 减少http请求数量
- 减少请求资源的大小
# HTML压缩
- 使用在线工具进行压缩
- 使用html-minifier等npm工具
# CSS压缩
- 使用在线工具进行压缩
- 使用clean-css等npm工具
# JS压缩与混淆
- 使用在线工具进行压缩
- 使用Webpack对JS在构建时压缩
# CSS JS文件合并
- 若干小文件, maybe...
- 无冲突,服务相同的模块, ok.
- 优化加载, NO!
# 图片格式优化
JPEG/JPG
- 优点:高的压缩比,有损压缩,色彩还好。
- 使用场景:轮播图
- 缺点:纹理和边缘,不好。不适合小的图标logo之类的
- 工具:imagemin
png
- 优点:透明背景
- 使用场景:
- 缺点:体积大
- 工具:imagemin-pngquant
webp
- png同样的质量,但是体积更小
# 图片加载优化
# 图片的懒加载(lazy loading)
- 原生的图片懒加载方案
- 属性
loading="lazy"
- 属性
- 第三方图片懒加载方案
- verlok/lazyload
- yall.js
- Blazy
# 渐进式图片
渐进式图片的优点和不足
渐进式图片的解决方案
- progressive-image
- ImageMagick
- libjpeg
- jpegtran
- jpeg-recompress
- imagemin
# 响应式图片
- Srcset属性的使用
- sizes属性的使用
- picture的使用
# 字体优化
- 字体未下载完成时,浏览器隐藏或自动降级,导致字体闪烁
- Flash Of Invisible Text (FOIT)
- Flash Of Unstyled Text (FOUT)
font-display 控制字体加载行为
- auto
- block (3s内下载不完,看不到字体,然后切为系统默认字体,最后自定义字体加载完成,再切换)
- swap (先显示系统默认字体,自定义字体加载好,再切换回去)
- fallback (对block的优化,100ms内没下载完,就显示默认字体,然后切换)
- optional (根据网络情况,一旦使用默认字体,就不再使用自定义字体)
使用AJAX + Base64
- 解决兼容性问题
- 缺点:缓存问题。字体文件无法缓存
# 构建优化
# webpack优化配置
Tree-shaking
- 上下文未用到的代码(dead code)
- 基于ES6 import export
JS压缩
- Webpack 4后引入uglifyjs-webpack-plugin
- 支持ES6替换为terser-webpack-plugin
- 减小JS文件体积
作用域提升
- 代码体积减小
- 提高执行效率
- 同样注意Babel的modules配置
/****************** util.js ******************/
export default 'Hello,Webpack';
/**************** index.jsx ********************/
import str from './util';
console.log(str);
/***************** 没有 scope hoisting, webpack 打包后 *******************/
[
(function (module, __webpack_exports__, __webpack_require__) {
var __WEBPACK_IMPORTED_MODULE_0__util_js__ = __webpack_require__(1);
console.log(__WEBPACK_IMPORTED_MODULE_0__util_js__["a"]);
}),
(function (module, __webpack_exports__, __webpack_require__) {
__webpack_exports__["a"] = ('Hello,Webpack');
})
]
/************************************/
/***************** 有 scope hoisting, webpack 打包后 *******************/
[
(function (module, __webpack_exports__, __webpack_require__) {
var util = ('Hello,Webpack');
console.log(util);
})
]
/************************************/
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
Babel7优化配置
- 在需要的地方引入polyfill
- 辅助函数的按需引入
- 根据目标浏览器按需转换代码
# webpack依赖优化
- noParse (module里面配置noparse)
- 提高构建速度
- 直接通知webpack忽略较大的库
- 被忽略的库不能有import, require, define的引入方式
- DIlPlugin
- 避免打包时对不变的库重复构建
- 提高构建速度
# 基于webpack的代码拆分
代码拆分做什么
- 把单个bundle文件拆分成若干小bundles/chunks
- 缩短首屏加载时间
Webpack代码拆分的方法
手工定义入口
splitChunks提取公有代码,拆分业务代码与第三方库
动态加载
# webpack资源压缩
Minification
- Terser压缩JS
- mini-css-extract-plugin压缩CSS
- HtmlWebpackPlugin - minify压缩HTML
# 基于Webpack的持久化缓存
持久化缓存方案
- 每个打包的资源文件有唯一的hash值
- 修改后只有受影响的文件hash变化
- 充分利用浏览器缓存
chunkHash和ContentHash
# 基于webpack的应用大小检测与分析
监测与分析
- Stats 分析与可视化图
- webpack-bundle-analyzer进行体积分析
- speed-measure-webpack-plugin速度分析
# React按需加载
- React router基于webpack动态引入
- 使用Reloadable高级组件
# 传输加载优化
# gzip压缩
- 对传输资源进行体积压缩,可高达90%
- 如何配置Nginx启用Gzip
gzip on;
: 启用 Gzip 压缩功能。gzip_types
: 定义要进行 Gzip 压缩的 MIME 类型,可以根据实际需求添加或修改。上述配置中包括了常见的文本、样式表、JavaScript 和 JSON 格式。gzip_min_length
: 定义启用 Gzip 压缩的最小文件大小,例如在这里设置为 1000 字节,文件大小低于该值将不会被压缩。gzip_comp_level
: 定义 Gzip 压缩级别,范围从 1 到 9,值越大压缩率越高,但同时也会消耗更多的 CPU 资源。一般推荐使用 2 到 4 之间的值。gzip_proxied
: 指定压缩是否应该在代理服务器上进行,any
表示压缩会在任何情况下都被执行。gzip_vary
: 表示是否在响应头中添加 Vary 头信息,以通知缓存服务器基于 Accept-Encoding 头字段来识别压缩文件。gzip_disable
: 定义一组浏览器用户代理,不需要进行 Gzip 压缩。在这里,MSIE [1-6] 表示禁用对 IE6 及其以下版本的 Gzip 压缩。
# 启用Keep Alive
HTTP/1.1协议默认会开启keep-alive
如何查看是否启用了keep-alive
通过浏览器查看
通过命令来看发送请求的详细信息
curl -v http://127.0.0.1:8080
1
# HTTP资源缓存
官方文档 (opens new window),看英文的,中文的不太完善
提高重复访问时资源加载的速度
HTTP缓存
- Cache-Control/Expires
- Last-Modified + If-Modified-Since
- Etag + If-None-Match
HTML文件不缓存
其他文件设置7天过期时间(如果长时间不更新,也可以设置更久)
# Service workers技术
作用:
- 加速重复访问
- 离线支持
Service Workers注意
- 延长了首屏时间,但页面总加载时间减少
- 兼容性(IE, Opera不可以使用)
- 只能在localhost(开发环境)或https下使用
# HTTP/2的提升
- 二进制传输
- 请求响应多路复用
- Server push
HTTP/1.1发送请求的情况
nginx开启http2
http2实现多路复用
http资源请求有顺序,会引起阻塞问题。
Server push
搭建HTTP/2服务
- HTTPS (https情况下才能使用http2)
- 适合较高的请求量
# 服务端渲染
- 加速首屏加载
- 更好的SEO
是否使用SSR?
- 架构–大型,动态页面,面向公众用户
- 搜索引擎排名很重要
# 前言优化解决方案
# 图标SVG
从PNG到IconFont
- 多个图标——>一套字体,减少获取时的请求数量和体积
- 矢量图形,可伸缩
- 直接通过CSS修改样式(颜色,大小等)
https://icofont.com/
https://www.iconfont.cn/
从IconFont到SVG
- 保持了图片能力,支持多色彩
- 独立的矢量图形
- XML语法,搜索引擎SEO和无障碍读屏软件读取
{
test: /\.svg$/,
use: ['@svgr/webpack']
}
2
3
4
https://fontawesome.com/
# Flexbox优化布局
Flexbox的优势
- 更高性能的实现方案
- 容器有能力决定子元素的大小,顺序,对齐,间隔等
- 双向布局
10w个元素,使用float布局渲染耗时1800ms,使用flex布局渲染耗时1200ms
# 优化资源加载的顺序
资源优先级
- 浏览器默认安排资源加载优先级
- 使用preload, prefetch调整优先级
preload和prefetch适用场景
- Preload:提前加载较晚出现,但对当前页面非常重要的资源
- Prefetch:提前加载后继路由需要的资源,优先级低
# 预渲染页面
预渲染的作用
- 大型单页应用的性能瓶颈:JS下载+解析+执行
- SSR的主要问题:牺牲TTFB来补救**First Paint;**实现复杂
- Pre-rendering打包时提前渲染页面,没有服务端参与
使用React-Snap
- 配置postbuild
- 使用ReactDOM.hydrate()
- 内联样式,避免明显的FOUC(样式闪动)
# Windowing(窗口化)提高列表性能
windowing的作用
- 加载大列表、大表单的每一行严重影响性能
- Lazy loading仍然会让DOM变得过大
- windowing只渲染可见的行,渲染和滚动的性能都会提升
# 骨架屏
Skeleton/Placeholder的作用
- 占位
- 提升用户感知性能
# 性能优化问题面试指南
# web加载&渲染原理
浏览器进程:
UI线程
网络线程
渲染进程
# 首屏优化
- Web增量加载的特点决定了首屏性能不会完美
- 过长的白屏影响用户体验和留存
- 首屏(above the fold)→初次印象
首屏——用户加载体验的3个关键时刻
三个关键时刻:
资源体积太大?
- 资源压缩
- 传输压缩
- 代码拆分
- Tree shaking
- HTTP/2
- 缓存
首页内容太多?
- 路由/组件/内容lazy-loadihg,
- 预渲染/SSR,
- Inline CSs
加载顺序不合适?
- prefetch
- preload
# 内存管理
JS是怎样管理内存的?什么情况会造成内存泄漏?
- 内存泄漏严重影响性能
- 高级语言!=不需要管理内存
变量创建时自动分配内存,不使用时“自动”释放内存-GC
内存释放的主要问题是如何确定不再需要使用的内存 所有的GC都是近似实现,只能通过判断变量是否还能再次访问到
局部变量,函数执行完,没有闭包引用,就会被标记回收 全局变量,直至浏览器卸载页面时释放
# GC实现机制
引用计数——无法解决循环引用的问题
标记清除
标记所有的对象是否可以访问到,访问不到就清楚
# 代码层面避免内存泄漏
避免意外的全局变量产生
避免反复运行引发大量闭包
避免脱离的DOM元素
detachedDiv对Dom元素有引用,即使调用了deleteElement方法,也不会这个DOM元素也不会被回收,因为有引用
# 补充
测试网站:https://googlechrome.github.io/devtools-samples/jank/
调试测试页面:https://googlechrome.github.io/devtools-samples/debug-js/get-started
找到对应的按钮。然后选择事件监听器,点击到源码对应