引言
以前一直以为用Chrome Performance面板进行性能分析很难。实际做过以后方能认识到这是多么简单!
海量图片预警!
作者:hans774882968以及hans774882968以及hans774882968
本文52pojie:https://www.52pojie.cn/thread-1708906-1-1.html
重排分析
例1:入门
这一节主要是在复现参考链接1的内容。我们来写一个简单的demo:点击.div1
,该元素高度变大。
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>reflow</title>
<style>
.div1 {
width: 100px;
height: 100px;
background-color: deepskyblue;
}
.div2 {
width: 200px;
height: 200px;
background-color: pink;
}
</style>
</head>
<body>
<div class="div1"></div>
<div class="div2"></div>
<script>
function main() {
const div1 = document.querySelector('.div1')
function div1Click() {
div1.style.height = '200px'
}
div1.addEventListener('click', div1Click)
}
main()
</script>
</body>
</html>
我们打开Chrome Performance,点击箭头所示的按钮进行录制,并点击一下.div1
,生成性能分析结果。
Main
表示主线程,渲染和JS执行都是在主线程进行的。我们看到主线程有两个值得关注的Task,第一个Task主要依次由Schedule Style Recalculation
、点击事件、Recalculate Style
和Layout
组成,第二个Task主要依次由Paint
和Composite Layers
组成。这里一个Task
表示一个事件循环,Layout
事件表示重排,Paint
事件表示重绘,重排和重绘可能在同一个Task也可能不在同一个Task执行。
看第一个Task:
点击色块可以查看相关信息。
- 箭头所指为
Compile Code
,耗时37 μs。
div1Click
左侧有一个非常细的紫色块Schedule Style Recalculation
。
div1Click
耗时0.11 ms。
click
结束后,右侧有3个紫色块:第一个是Recalculate Style
,耗时0.12 ms。
- 第二个是
Layout
,耗时0.13 ms,Layout root
是#document
,First Layout Invalidation
是这行代码div1.style.height = '200px'
。如下图所示。
- 第三个是
Pre-Paint
,不关注。
第二个Task我们只看一下Paint
:
可以看到Layer Root
是#document
,并且重绘耗时仅53μs。
例2:重排和重绘各发生几次?
我们基于例1的代码,稍微改下div1Click
:
function div1Click() {
div1.style.height = '200px'
div2.style.height = '100px'
}
请问它引起了几次重排?我们看一下Performance面板:
可以看到只引发了一次重排(Layout
)和一次重绘(鼠标所指的Paint
),并且发生在不同的事件循环。这大概是因为浏览器的优化!
接下来试试参考链接1所说的“一次事件循环(即一个Task)当中触发多次重排”的情况。再改下代码:
function div1Click() {
div1.style.height = '200px'
console.log(div2.clientHeight)
div2.style.height = '100px'
console.log(div2.clientHeight)
}
看下Performance面板:
第一个Task的两个箭头是两次Layout
,它们左边相邻的紫色块分别都是Schedule Style Recalculation
(非常细)和Recalculate Style
。第二个Task鼠标指向的是Paint
。所以div1Click
在第一个Task触发了2次重排,但仅在第二个Task触发了1次重绘。
我们点击两个Recalculate Style
,发现两者都有一个之前没看到的属性Recalculation Forced
,相关的代码分别是两句console.log(div2.clientHeight)
。为了探究这个属性是否真的导致了强制重排,我们把代码改成:
function div1Click() {
div1.style.height = '200px'
console.log(div2.clientHeight)
div2.style.height = '100px'
}
则发现重排仍发生2次,但第一次Recalculate Style
有Recalculation Forced div1Click@reflow.html:29
(即console.log(div2.clientHeight)
),而第二次Recalculate Style
没有。对比这些结果我们猜测,Recalculation Forced
表示Performance面板分析出这次重排是被迫发生的,而发生的原因是我们读取了div2.clientHeight
。
接下来我们把修改div2
的代码包裹进宏任务。
function div1Click() {
div1.style.height = '200px'
console.log(div2.clientHeight)
setTimeout(() => {
div2.style.height = '100px'
console.log(div2.clientHeight)
})
}
看下Performance面板,下面是刷新多次的结果:
图1
图2
图3
观察到的一些现象:
- 重排肯定发生2次,但重绘可能发生1次也可能发生2次。如果重绘发生了2次,则
Timer Fired
的事件循环在重绘的事件循环之后。如果发生1次则Timer Fired
的Task与点击的Task相邻。图1到图3重绘分别发生了1次、1次、2次。图1、图2的重绘Task和Timer Fired
Task相隔长达几毫秒,所以截不进来。
Schedule Style Recalculation
是非常细的紫色块,既可能在函数执行的下面也可能在函数执行的左侧。图1到图3Schedule Style Recalculation
分别在匿名函数的左侧、左侧、下面。3个图Schedule Style Recalculation
都在div1Click
下面仅仅是巧合。
Install Timer
是非常细的黄色块,既可能在setTimeout
下面也可能在setTimeout
左侧。图1到图3Install Timer
分别在setTimeout
下面、左侧、左侧。
- 如果发生了重排,则
Schedule Style Recalculation
、Recalculate Style
和Layout
总是在同一个Task按时间顺序出现。
宏任务和微任务执行顺序分析
校招必考八股!来写一个简单的例子:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>task</title>
</head>
<body>
<script>
function main() {
console.log(1)
setTimeout(() => {
console.log(7)
})
console.log(2)
function pro1() {
function then1() {
console.log(5)
}
new Promise((resolve) => {
console.log(3)
resolve()
}).then(then1)
}
pro1()
function pro2() {
function then2() {
console.log(6)
}
new Promise((resolve) => {
setTimeout(() => console.log(8))
resolve()
}).then(then2)
}
pro2()
function pro3() {
new Promise((resolve) => {
setTimeout(() => {
console.log(9)
resolve()
})
}).then(() => {
console.log(10)
})
}
pro3()
setTimeout(() => {
console.log(11)
})
console.log(4)
}
main()
</script>
</body>
</html>
我们把Promise放进函数里,并写个then1
、then2
,是期望在Performance里能方便地找到它们。如果不这么做,Performance里就不好找到它们(找不到的话就多刷新几次,看RP的)。
上面代码的输出为1~11。我们来简单分析一下:注意Promise传入的函数体也是同步任务,在执行resolve()
的时候才会加入微任务队列。因此1~4执行后有2个微任务(5、6)和4个宏任务(7、8、9、11),先清空微任务再处理宏任务。执行9后将10加入微任务队列,因此10先于11执行。接下来看看Performance面板:
Task1:
点击查看的是then2
,4个红色箭头指出的是Install Timer
,绿色箭头指出的是pro2
和pro3
。
- 看到执行顺序是
timer1 -> promise1 -> promise2 -> timer2 -> promise3 -> timer3 -> timer4
,于是我们验证了:Promise
传入的函数体是同步任务。
- 图右侧有一个
Run Microtasks
表示两个微任务then1
、then2
执行了。1~6是在同一个事件循环中执行的。
后续Tasks:
可以看到,即使定时器设置的延迟时间为0,第一个事件循环和这4个事件循环也并没有挨在一起,而是由一个有重绘Paint
的事件循环隔开了。这里4个定时器分为4个事件循环,各有一个粉色的(anonymous)
块。其中,第三个宏任务在跑完同步任务(输出9)以后执行了一个微任务(输出10)。
综上:
- 不同的同步任务和不同的微任务可以在同一个事件循环中执行。
- 当前事件循环执行同步任务的过程中会加入一些宏任务和微任务。所有的微任务都会在当前事件循环的末尾执行完毕,而宏任务都要在后续的事件循环才能执行。
- 不同的宏任务在不同的事件循环中执行。
- 可以通过Performance面板来分析各种宏任务和微任务的真实执行顺序。
一个简单的寻找性能瓶颈的例子:Janky Animation
这一节主要是在复现参考链接2。案例:https://googlechrome.github.io/devtools-samples/jank/
为了方便,我们把代码copy到本地,再加一个显示当前图片总数的feature。完整代码就不贴出来了,太长了。
我们看到:
- 10个方块的时候,一个Task时间在3.5ms左右。
- 一个事件循环恰好有10个
Schedule Style Recalculation
、Recalculate Style
和Layout
。
- 和前面的案例不同,这里重排和重绘发生在同一个事件循环。
把方块个数增加到100:
我们看到一个Task时间增加到了23~25ms。再看方块个数增加到150和200的情况:
上图箭头指向的红条表示帧率过低。
上图绿色箭头指向的红色三角标签表示耗时超过50ms的long task。
对于左下角的饼图,Idle
的时间占多数才是正常的,比如10个方块的饼图:
对于200个方块的情况,我们查看某个方块的Layout
,可以看到Performance已经贴心地帮我们指出了“Forced reflow(强制重排)是可能的性能瓶颈”。
引起Recalculation Forced
(在Recalculate Style
事件)和Layout Forced
(在Layout
事件)的代码是同一行,点击查看:
于是我们找到了性能瓶颈:每个方块都要读取offsetTop
,导致每个方块都引发了强制重排。
点击一下Optimize
按钮,避免读取offsetTop
:
可以看到无论有多少方块,一个Task都只引发了一次重排,所以时间降为7ms左右,饼图也优化了许多:
但是查看代码可以看到,读写m.style
的代码成为了新的性能瓶颈。
这个需求仅仅是在展示运动的方块,因此我们可以用css3的transforms
等属性,来达到类似的效果。另外,我们可以使用will-change
属性,告知浏览器提前做好优化准备。
部分JS代码如下:
app.update = function (timestamp) {
for (var i = 0; i < app.count; i++) {
var m = movers[i];
if (app.optimize === 1) {
var pos = m.classList.contains('down') ?
m.offsetTop + distance : m.offsetTop - distance;
if (pos < 0) pos = 0;
if (pos > maxHeight) pos = maxHeight;
m.style.top = pos + 'px';
if (m.offsetTop === 0) {
m.classList.remove('up');
m.classList.add('down');
}
if (m.offsetTop === maxHeight) {
m.classList.remove('down');
m.classList.add('up');
}
} else if (app.optimize === 2) {
var pos = parseInt(m.style.top.slice(0, m.style.top.indexOf('px')));
m.classList.contains('down') ? pos += distance : pos -= distance;
if (pos < 0) pos = 0;
if (pos > maxHeight) pos = maxHeight;
m.style.top = pos + 'px';
if (pos === 0) {
m.classList.remove('up');
m.classList.add('down');
}
if (pos === maxHeight) {
m.classList.remove('down');
m.classList.add('up');
}
} else if (app.optimize === 3) {
var pos = parseInt(
m.style.transform.slice(
m.style.transform.indexOf('(') + 1,
m.style.transform.indexOf('px')
)
) || 0;
m.classList.contains('down') ? pos += distance : pos -= distance;
if (pos < 0) pos = 0;
if (pos > maxHeight) pos = maxHeight;
m.style.transform = `translateY(${pos}px)`;
if (pos === 0) {
m.classList.remove('up');
m.classList.add('down');
}
if (pos === maxHeight) {
m.classList.remove('down');
m.classList.add('up');
}
}
}
frame = window.requestAnimationFrame(app.update);
}
新增的CSS:
.proto.mover {
will-change: transform;
}
这个方法有个美中不足之处:无法去除m.style.top
(去除了优化就白做了),导致方块运动范围与前两种方案不一致。
此时我们看到只有Schedule Style Recalculation
、Recalculate Style
,没有Layout
事件,说明没有引起重排。一个Task的时间进一步降低为5ms左右。
再看看优化2的饼图,也比优化1更好:
另外,使用will-change
的元素会单独分出一个图层,我们可以用Layers面板查看。不使用will-change
:
使用will-change
:
web worker案例
这一节主要是在复现参考链接4。
我们准备一段会阻塞渲染的代码:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>web worker</title>
</head>
<body>
<p id="p1"></p>
<p id="p2"></p>
<script>
function main() {
let ans1 = 0n
for (let i = 1n; i <= 10000000n; i++) {
ans1 += i * i
}
const p1 = document.getElementById('p1'), p2 = document.getElementById('p2')
p1.innerText = ans1.toString()
let ans2 = 0n
for (let i = 1n; i <= 10000000n; i++) {
ans2 += i * i * i
}
p2.innerText = ans2.toString()
}
main()
</script>
</body>
</html>
Chrome打开无痕窗口,消除插件的影响。点击Performance面板的Start profiling and reload page
按钮,得下图:
LCP大约3s,说明耗时计算的确阻塞了渲染。再放大看看f1
和f2
的分界点:
看来f1
常数比f2
小些,是符合直觉的。当然更好的方式是直接在Sources
面板看哪些代码执行时间最长。
我们有可能优化这个页面的LCP嘛?如果计算任务可拆分,那么我们可以参考React Fiber的架构,把任务拆开,组织成链表。但是这两个简单任务不能再拆分。怎么办?在参考链接4了解到Worker
可以开启额外线程来运行耗时任务,达到不阻塞渲染任务的目的。于是我们可以接着写代码:
index.html
:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>web worker优化后</title>
</head>
<body>
<p id="p1">答案1</p>
<p id="p2">答案2</p>
<script>
function main() {
function runWorker(url, message) {
return new Promise((resolve, reject) => {
const worker = new Worker(url)
worker.postMessage(message)
worker.addEventListener('message', (e) => {
resolve(e.data)
})
worker.onerror = reject
})
}
const p1 = document.getElementById('p1'), p2 = document.getElementById('p2')
async function f1() {
const ans = await runWorker('./web_worker1.js', 10000000n)
p1.innerText = ans.toString()
}
async function f2() {
const ans = await runWorker('./web_worker2.js', 10000000n)
p2.innerText = ans.toString()
}
f1()
f2()
}
main()
</script>
</body>
</html>
web_worker1.js
:
addEventListener('message', (e) => {
let ans = 0n;
let num = e.data;
for (let i = 1n; i <= num; i++) {
ans += i * i
}
postMessage(ans);
});
web_worker2.js
:
addEventListener('message', (e) => {
let ans = 0n;
let num = e.data;
for (let i = 1n; i <= num; i++) {
ans += i * i * i
}
postMessage(ans);
});
注意点:
Worker
的第一个参数只能是url
,这逼迫我们把耗时任务放到单个文件中。
- 对于Chromium内核的浏览器,需要开启http服务器,再打开
index.html
才能运行。否则会报错Failed to construct 'Worker': Script at '' cannot be accessed from origin 'null'.
。
Performance如下:
我们看到LCP变成了60ms,耗时任务额外开了两个线程去运行,不再影响页面交互。它们的事件可以展开上图的两个Worker
来查看。
进度条组件性能瓶颈分析(React Hooks CDN)
这一节主要是在复现参考链接3。现在要求你用React写一个进度条:
- 支持播放、暂停、重播。
- 播放结束后,播放次数+1,并重新开始播放。
- 暂时不需要支持进度条拖拽等功能,纯展示。
实际上:不需要支持拖拽等功能时,用html5的progress
标签最好;需要支持拖拽等功能时,只有做法1可行。但我们先忽略这些吧!
用CDN运行React Hooks代码
先介绍一下怎么用CDN跑React Hooks。
1、我们需要导入react
、react-dom
和Babel
:
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/babel-standalone/7.0.0-beta.3/babel.min.js"></script>
2、我们需要一个挂载点div[id='app']
,对应的挂载语句:ReactDOM.render(React.createElement(App), document.querySelector('#app'))
。
3、我们在脚手架开发时使用的ES6 Module写法,需要改成const {useState} = window.React
。
4、script标签需要声明为<script type="text/babel">
。
做法1:setTimeout
按照常人思维,我们大概率会这么写:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>progress组件</title>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/babel-standalone/7.0.0-beta.3/babel.min.js"></script>
<style>
.container {
height: 10px;
border-radius: 5px;
border: 1px solid black;
}
.progress {
height: 100%;
width: 0;
border-radius: 5px; /* 与.container一致 */
background-color: red;
}
</style>
</head>
<body>
<div id="app"></div>
<script type="text/babel">
function main() {
const {useState} = window.React
let timer = null // 递增进度的定时器
let totalTime = 3000 // 假设视频播放为3s
function App() {
const [count, setCount] = useState(0)
const [progress, setProgress] = useState(0) // 进度
const [isPlay, setIsPlay] = useState(false) // 是否播放
const handlerProgress = pre => {
if (pre < 100) return pre + 1;
else {
// 使用setCount(count + 1)则无法及时更新
setCount(count => count + 1)
return 0 // 播放结束,重新开始播放
}
}
// 开始播放 && 暂停播放
const handleVideo = () => {
setIsPlay(!isPlay)
isPlay
? clearInterval(timer)
: timer = setInterval(() => setProgress(handlerProgress), totalTime / 100)
}
// 重播
const replay = () => {
setIsPlay(true)
if (timer) clearInterval(timer);
setProgress(0)
timer = setInterval(() => setProgress(handlerProgress), totalTime / 100)
}
return (
<div id="root">
<button onClick={handleVideo}>{isPlay ? '暂停' : '播放'}</button>
<button onClick={replay}>重播</button>
<span>{`播放次数为:${count}`}</span>
<div className="container">
<div className="progress" style={{width: `${progress}%`}}/>
</div>
</div>
)
}
ReactDOM.render(React.createElement(App), document.querySelector('#app'));
}
main()
</script>
</body>
</html>
Performance如下:
可以看到,每次更新进度条都需要一个宏任务,并且要触发一次重排。
做法2:构造@keyframes
切换
接下来看一种比较精妙的做法(来自参考链接3)。这种做法自己想不到的,学一下:
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>progress组件-v2</title>
<script crossorigin src="https://unpkg.com/react@18/umd/react.production.min.js"></script>
<script crossorigin src="https://unpkg.com/react-dom@18/umd/react-dom.production.min.js"></script>
<script src="https://cdn.bootcdn.net/ajax/libs/babel-standalone/7.0.0-beta.3/babel.min.js"></script>
<style>
.container {
height: 10px;
border-radius: 5px;
border: 1px solid black;
}
.progress {
height: 100%;
width: 0;
border-radius: 5px; /* 与.container一致 */
background-color: red;
animation-timing-function: linear;
}
.progress.play { /* 使animation动画启动 */
animation-play-state: running;
}
.progress.pause { /* 使animation动画暂停 */
animation-play-state: paused;
}
@keyframes animeSwitch0 {
to {
width: 100%;
}
}
@keyframes animeSwitch1 {
to {
width: 100%;
}
}
</style>
</head>
<body>
<div id="app"></div>
<script type="text/babel">
function main() {
const {useState} = window.React
let totalTime = 3000 // 假设视频播放为3s
function App() {
const [count, setCount] = useState(0)
const [animeSwitch, setAnimeSwitch] = useState(false)
const [isPlay, setIsPlay] = useState(false) // 是否播放
// 开始播放 && 暂停播放
const handleVideo = () => {
setIsPlay(!isPlay)
}
const playEnd = () => {
setCount(count + 1)
replay()
}
// 重播
const replay = () => {
setIsPlay(true)
setAnimeSwitch(!animeSwitch)
}
return (
<div id="root">
<button onClick={handleVideo}>{isPlay ? '暂停' : '播放'}</button>
<button onClick={replay}>重播</button>
<span>{`播放次数为:${count}`}</span>
<div className="container">
<div
className={`progress ${isPlay ? 'play' : 'pause'}`}
style={{
animationDuration: `${totalTime}ms`,
animationName: `animeSwitch${animeSwitch + 0}`
}}
onAnimationEnd={playEnd}
/>
</div>
</div>
)
}
ReactDOM.render(React.createElement(App), document.querySelector('#app'));
}
main()
</script>
</body>
</html>
因为这个进度条组件是纯展示组件,所以可以用@keyframes
来完成所有的要求。关键的思路是:我们有两份完全一样的@keyframes
,通过切换animation-name
来实现重播功能。播放结束时重新播放的要求也可以视为一次重播来实现,具体实现是监听了onAnimationEnd
事件。Performance如下:
这个做法依旧会频繁触发重排,因为@keyframes
改变了元素的宽度。这种做法会导致Performance面板无法找到重排发生的原因。另外,性能的瓶颈比较突出:playEnd()
。
做法2的优化:使用translateX + scaleX
来造成宽度改变的视觉效果
这个做法有一个更为精妙的优化,只需要修改做法2的CSS部分:
.container {
height: 10px;
border-radius: 5px;
border: 1px solid black;
}
.progress {
height: 100%;
width: 100%;
border-radius: 5px; /* 与.container一致 */
background-color: red;
animation-timing-function: linear;
will-change: transform;
}
.progress.play { /* 使animation动画启动 */
animation-play-state: running;
}
.progress.pause { /* 使animation动画暂停 */
animation-play-state: paused;
}
@keyframes animeSwitch0 {
0% {
transform: translateX(-50%) scaleX(0);
}
to {
transform: translateX(0) scaleX(1);
}
}
@keyframes animeSwitch1 {
0% {
transform: translateX(-50%) scaleX(0);
}
to {
transform: translateX(0) scaleX(1);
}
}
在做法2的基础上,因为只需要进行展示,所以我们用translateX + scaleX
代替了宽度的变化(视觉效果一样即可)。设当前缩放属性为scaleX(x)
,那么我们需要右移:- (100% - x) / 2
,才能让起点始终左对齐。代入0
和100%
,分别得-50%, 0
。这就确定了@keyframes
的写法。显然这种写法不会引起重排。另外,因为这个方案只有视觉效果,所以我们不妨用will-change: transform
把进度条提升到一个单独的图层。
做法3的Performance图懒得截了,可以看下文“性能评估”一节。做法2和做法3的图层对比:
做法3:
进度条的确提升到了单独的图层。
性能评估
注:空闲时间的评估结论看上去不太对劲,仅供参考。
我们分两种情况:进度跨过了100%和进度没跨过100%,只录制2s左右。选择在第4次播放时开始录制。
进度没跨过100%:做法2的空闲时间 = 2004/2084
,做法1的空闲时间 = 2045/2144
,差为0.008,单位ms。差不多。
进度跨过100%:令我感到意外,做法2比做法1的性能差。不过做法3性能最好,还是符合直觉的。
做法1:
做法2:
做法3:
上图验证了我们的说法,做法3用translateX + scaleX
代替做法2宽度的变化后,不会引起重排了。
总结
Chrome Devtools 的 Performance 工具是网页性能分析的利器,它可以记录一段时间内的代码执行情况,比如 Main 线程的 Event Loop、每个 Event loop 的 Task,每个 Task 的调用栈,每个函数的耗时等,还可以定位到 Sources 中的源码位置。
性能优化的目标就是找到 Task 中的 long task,然后消除它。因为网页的渲染是一个宏任务,和 JS 的宏任务在同一个 Event Loop 中,是相互阻塞的。
参考资料
- https://www.bilibili.com/video/BV1Pr4y1N7QZ
- 你不知道的chrome performance调试技巧:https://juejin.cn/post/6977637532494200863
- 我优化了进度条,页面性能竟提高了70%:https://juejin.cn/post/69768100169300050294. Chrome Performance + web worker:https://juejin.cn/post/7046805217668497445