一、浏览器
首先我们知道浏览器是多进程的,有一个主控进程,每个tab会单开一个进程。如果多个空白tab页,可能会合并成一个进程管理。浏览器进程主要可以分为以下几种:
- Browser进程:浏览器的主进程(主控),只有一个
- GPU进程:图形处理器,用于3D绘制,电脑的硬件配置,借助会做一些3D图形处理。
- 浏览器渲染进程(内核):默认每个Tab页面一个进程,互不影响,控制页面渲染,脚本执行,事件处理等,GUI处理
- 第三方插件进程:每种类型的插件对应一个进程,仅当使用该插件时才创建 而我们本篇文章将着重谈一下本人对浏览器渲染进程的理解,以及我们的代码效率,会对浏览器渲染造成的影响。
二、浏览器渲染进程**
它有以下几大类子线程:
-
GUI线程: 主要过程是是解析dom树,解析css树,再结合两棵树生成渲染树,然后layout布局,paint绘制。
-
JS引擎线程:也就是我们常说的js是单线程的,过于繁重的js计算可能会造成浏览器的卡顿。(本文分析下啥时候会卡)
-
事件触发线程: event
-
定时器线程: timer
-
网络请求线程: URL解析,DNS,握手,TCP/IP,http报文、post的header和body实体等。
-
...
三、js引擎线程
首先我们先来模拟js的主线程,在浏览器渲染过程中,每一帧的表现。 我们先来渲染一个红色的小滑块,通过改变他的left值,让其在屏幕中左右移动。 //old.html
const mainEl = document.querySelector("#box")
let temp
function mainWork () {
const style = mainEl.getBoundingClientRect()
if (style.left <= 0) {
temp = 2
} else if (style.left >= 500) {
temp = -2
}
mainEl.style.left = style.left + temp + 'px'
// requestAnimationFrame(mainWork)
}
//顺滑版本
// function simulateMainThread () {
// mainWork()
// }
// 非丝滑版本 跳跳块
function simulateMainThread () {
setInterval(() => {
mainWork()
}, 16)
}
window.simulateMainThread = simulateMainThread
// 在script标签中执行
// 浏览器渲染小红块移动 当作主进程 便于观察主进程有没有被卡住
simulateMainThread()
此时我们可以看到一个小滑块每50毫秒像左移动2px距离。
换言之,浏览器每50毫秒就要执行js改变滑块的left值,通过重新绘制让其动起来。之所以看着有跳帧的感觉是因为我们设置的时间间隔太长,导致动画不连贯。
我们都知道chrome浏览器的是每秒60帧,也就是16ms重绘一次,那我们这时尝试,将间隔改为16ms,看看效果如何:
这时我们可以看到,滑块滚动顺滑多了,但是仍然可以看到偶尔滑块跳动了一小下。why?
如图我们可以看到,有的时候浏览器的timeFired都已经和painting重合到一起,有的时候则分开。这是因为虽然我们将时间间隔调整为浏览器的16ms,但是由于定时器执行次数越多,触发时机就更加不准,大约16ms定时器会自动执行下一次simulateMainThread方法,此时GUI线程有可能正在进行painting,或者layout,这个时候就尴尬了,因为我们知道GUI线程和js引擎线程是互斥的,且GUI会发生在每帧js执行完之后,这也就是我们看到的
半丝滑状态的主要原因。
这个时候怎么办呢,我们先补充一个读懂这篇必备的知识点。
这就引出了一个浏览器每帧动画的requestAnimationFrame,这不是本文的重点,稍带说一下,后续的内容我们就都采用这个api。这个api会接收一个回调函数,会在每帧浏览器空闲的时间调用回调函数,也就是说我们的小滑块js动画,可以放到这个api中会更加丝滑。值得一提的是,在requestAnimationFrame回调函数中,再次调用requestAnimationFrame,会自动放到下一帧执行。所以改写模拟主线程代码:
function mainWork () {
const style = mainEl.getBoundingClientRect()
if (style.left <= 0) {
temp = 1
} else if (style.left >= 500) {
temp = -1
}
mainEl.style.left = style.left + temp + 'px'
requestAnimationFrame(mainWork)
}
// 顺滑版本
function simulateMainThread () {
mainWork()
}
看看效果(由于gif效果不好,我会在最后贴上全部代码,大家可以自行尝试):
ok,到此,我们的主线程小滑块,已经可以完美的在浏览器的每一帧流畅的滑动起来了,这时如果我们浏览器突然迎来了耗时很长js代码,会怎么样呢?
四、阻塞浏览器js线程。
我们在script标签中插入一段IO代码,
<script type='text/javascript'>
// 浏览器渲染小红块移动 当作主进程 便于观察主进程有没有被卡住
simulateMainThread()
const obj = {
tagName: 'div',
innerHTML: 'hello worldhello worldhello worldhello worldhello worldhello world'
}
const arr =[]
let num = 1
while(num < 50000) {
arr.push({...obj})
num++
if (num === 4999) {
console.log(num)
}
}
setInterval(() => {
arr.forEach(item => {
const el = document.createElement(item.tagName)
el.innerHTML = item.innerHTML
el.style.textAlign = "right"
document.body.appendChild(el)
})
}, 2000)
</script>
我们声明50000个虚拟dom数组,然后开启定时器,在主线程执行每2秒后,开始进行虚拟dom --> 真实dom的上树操作。
我们看一下会发生什么
可以很清晰看到,滑块动画在2秒后就卡住不动了,根本没办法继续滚动下去,过了一会儿,又继续滚动,过了还不到半秒,就又停止了下来。这也可以解释为由于js是单线程,主线程被我们其他的js脚本阻塞了,不仅如此,就连定时器的间隔也不准了,因为定时器可不管你主线程有没有被阻塞,反正我就是2秒,执行一下上树。
我们可以看到从1000ms-3500ms每一帧几乎全部被js线程占据,只有下边8us的时间去解析dom
从3500ms-5500ms的时间又都在painting,根本没有时间执行主线程。也就是这种批量遍历dom的方式根本带来了非常严重的问题。
五、ReactSchedule简介
Schedule就是调度的意思。主要功能是时间分片,每隔一段时间就把主线程还给浏览器,避免长时间占用主线程。
-
核心的思想:
- React 组件状态更新,向 Scheduler 中存入一个任务,该任务为 React 更新算法。
- Scheduler 调度该任务,执行 React 更新算法。
- React 在调和阶段更新一个 Fiber 之后,会询问 Scheduler 是否需要暂停。
- 如果不需要暂停,则重复上一步,继续更新下一个 Fiber。
- 如果 Scheduler 表示需要暂停,则 React 将返回一个函数,该函数用于告诉 Scheduler 任务还没有完成。Scheduler 将在未来某时刻调度该任务。
-
schedule采用浏览器native类,MessageChannel来实现。原理是:我们给channnel的port1绑定了message事件,我们在另一个port2,调用postMessage,这时候port1接收到消息,就会开启下一个宏任务来执行performWorkUntilDeadline,以此来通过浏览器的eventLoop给浏览器js引擎线程让出线程。
const channel = new MessageChannel()
const port = channel.port2
channel.port1.onmessage = performWorkUntilDeadline
补充了这个基础我们来看代码实现
- 首先需要明确几个概念,先要声明yieldInterval,这个停止的间隔就是每一帧我们留给react的玩的时间。currentTime + yieldInterval 就是deadline。
// 定义浏览器帧
function forceFrameRate(fps) {
if (fps < 0 || fps > 125) {
return console.error("支持 0- 125帧,超过就扯淡了,太牛的不支持,react没工夫干活了")
}
if (fps > 0) {
yieldInterval = Math.floor(1000 / fps);
} else {
// reset the framerate
yieldInterval = 6
}
}
- 然后当任务每次开始执行时,就开始初始化时间,这个getCurrentTime方法会在每一帧调用workLoop的时候调用。
let getCurrentTime
const hasPerformanceNow = typeof performance === 'object' && typeof performance.now === 'function'
// 这行目的就是计算程序脚本执行到此的一个时间和调用getCurrentTime的时间差
if (hasPerformanceNow) {
const localPerformance = performance
getCurrentTime = () => localPerformance.now()
} else {
const localDate = Date
const initialTime = localDate.now()
getCurrentTime = () => localDate.now() - initialTime
}
- 这个函数是关键,首先判断workLoop工作循环中是否有任务-->如果有这时getCurrentTime获取当前时间,通过上述声明的yieldInterval,算出本次react时间片的deadline,然后默认去尝试执行workLoop(这块源码中是scheduledHostCallback),其实scheduledHostCallback和flushWork和workLoop可以理解为一回事。 一旦workLoop中做任务到了currentTime >= deadline,就挂起workLoop,记录当前任务。执行finally,然后通过postMessage开启下一个宏任务队列,这时将线程还给浏览器js引擎线程。
function performWorkUntilDeadline () {
if (workLoop !== null) {
const currentTime = getCurrentTime();
// 计算这次时间间隔还能干多久的活
deadline = currentTime + yieldInterval
// 假设还有时间 先干活试试的
const hasTimeRemaining = true
// 默认都有活做, 还有一个目的就是
// 当try中的workLoop中存在耗时操作
// 会默认当作这次操作超出了这次deadline
// 也会直接让出主进程,开启下一个宏任务再去做。
let hasMoreWork = true
try {
// 源码是这个 其实scheduledHostCallback就是workLoop (有兴趣可以去看 看看我说的对不对)
// hasMoreWork = scheduledHostCallback(hasTimeRemaining, currentTime)
hasMoreWork = workLoop(hasTimeRemaining, currentTime)
} finally {
if (hasMoreWork) {
console.log()
port.postMessage(null)
}
}
}
}
- 我们来看下workLoop中代码 (这块我进行了简化,可以看我的注释),我只在每一次开始做任务的时候判断是否还有时间,如果没时间了就break,跳出workLoop,并记录是否还有任务,然后执行finally。其实源码中还在每次做完事情做了一次时间记录,和判断,由于reactFiber每个任务的优先级和预留时间有差异,这样防止前一个任务做完,到做下一个任务时间并不够,这样更加精确。
// 干活中
function workLoop (hasTimeRemaining, initialTime) {
// 由于是链表,停了之后可以找到父节点 兄弟节点 子节点 我们不这么实现 用数组
// while (currentTask !==null) {
while (taskQueue.length > 0) {
// 如果真的该停了就不要继续了 哈哈哈
if (!hasTimeRemaining || shouldYieldToHost()) {
break
}
// 这次可以踏踏实实的干活了
const task = pop(taskQueue)
// 模拟干活
simulateWork(task)
}
if (taskQueue.length > 0) {// 由于是链表,停了之后可以找到父节点 兄弟节点 子节点
return true
} else {
return false
}
}
ok,以上代码我们就实现了一个简易的ReactSchedule任务调度。我们对比之前的批量做完所有事情来看下瞬间操作50000个虚拟dom的效果:
react 15玩法:
react 16玩法:
// 浏览器渲染小红块移动 当作主进程
simulateMainThread()
setInterval(() => {
// 模拟react不卡顿主进程的方式
performWorkUntilDeadline()
}, 2000)
可以看到,经过时间分片,滑块左移的js线程被阻塞的程度大大降低了。当然我这里是用的requestAniamtionFrame测试的效果,这留给Schedule的时间本来就不多,如果使用setTimeout定个100ms的时间间隔来模拟mainThread,就几乎看不出来卡顿
六、css动画
我们都知道,js动画本身就会占用js引擎线程,大大降低效率。如果我们通过css3动画模拟浏览器GUI线程,两种玩法又会是什么效果呐。
- 我们将代码修改一下
<!-- <script src='./js/mainThread.js'></script> -->
<script type='text/javascript'>
// 浏览器渲染小红块移动 当作主进程
// simulateMainThread()
css :
animation: mymove 5s linear infinite normal;
@keyframes mymove
{
0% {left:0px;}
50% {left:400px;}
100% {left:0px;}
}
performance: 很清晰浏览器每帧渲染,少了原有的Timer中的js执行,只是通过GUI的解析css,painting和layout就完成了动画。
接下来我们再来对比下新老react玩法,在这个时候还是否有区别(直接看效果,不详述)
- react 15:
- react 16:
我们可以发现批量插入大量的虚拟dom上树,依然会阻塞css3动画的渲染,这也印证了我上边说的js引擎线程和GUI线程本身是互斥的,css3动画确实比js动画效率高,但是本身如果js执行耗时过高,依然会阻塞css3动画。
- react15
很糟糕,一开始浏览器每一帧全部被js占据,完全卡死,随后的几秒又完全被GUI占据。
- react16
图中清晰可见,插入虚拟dom的js和GUI线程在每一帧,分配的很好,不会互相阻塞。
七、最后补充下为什么Schedule选择MessageChannel,而不用setTimeout(()=>{}, 0)或者requestAnimationFrame呢?
- setTimeout不靠谱,时间不精确
let count = 0
const start = new Date().getTime()
function run () {
setTimeout(() => {
console.log('setTimeou执行了第', count++ , "次", new Date().getTime() - start)
if (count === 50) {
return
}
run()
}, 0)
}
run()
无需多言了吧
- requestAnimationFrame 原因1:这个api只在浏览器更新页面时候才会触发回调,如果浏览器连续100ms没有触发更新,这100ms就浪费了,时间不可控。MessageChannel可以主动触发。 原因2: 而且非requestAnimationFrame触发的func中再次调用requestAnimationFrame会被立即执行,不会放到浏览器下一帧,这在做第一个performWorkUntilDeadline的时候就会执行两次performWorkUntilDeadline