ReactSchedule时间分片,并对比对浏览器js执行线程,GUI线程的的真实影响。

648 阅读11分钟

一、浏览器

首先我们知道浏览器是多进程的,有一个主控进程,每个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值,通过重新绘制让其动起来。之所以看着有跳帧的感觉是因为我们设置的时间间隔太长,导致动画不连贯。 普通效果.gif 我们都知道chrome浏览器的是每秒60帧,也就是16ms重绘一次,那我们这时尝试,将间隔改为16ms,看看效果如何: 接近浏览器帧率.gif 这时我们可以看到,滑块滚动顺滑多了,但是仍然可以看到偶尔滑块跳动了一小下。why?

image.png 如图我们可以看到,有的时候浏览器的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效果不好,我会在最后贴上全部代码,大家可以自行尝试):

requestApi实现主线程.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的上树操作。 我们看一下会发生什么 浏览器js动画批量更新.gif 可以很清晰看到,滑块动画在2秒后就卡住不动了,根本没办法继续滚动下去,过了一会儿,又继续滚动,过了还不到半秒,就又停止了下来。这也可以解释为由于js是单线程,主线程被我们其他的js脚本阻塞了,不仅如此,就连定时器的间隔也不准了,因为定时器可不管你主线程有没有被阻塞,反正我就是2秒,执行一下上树。

image.png

我们可以看到从1000ms-3500ms每一帧几乎全部被js线程占据,只有下边8us的时间去解析dom

1624802338(1).png

从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

补充了这个基础我们来看代码实现

  1. 首先需要明确几个概念,先要声明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
  }
}
  1. 然后当任务每次开始执行时,就开始初始化时间,这个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
    }
  1. 这个函数是关键,首先判断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)
          }
        }
      }
    }
  1. 我们来看下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玩法: 浏览器js动画批量更新.gif react 16玩法:

// 浏览器渲染小红块移动 当作主进程 
    simulateMainThread()

    setInterval(() => {
      // 模拟react不卡顿主进程的方式  
      performWorkUntilDeadline()
    }, 2000)

浏览器js动画schedule调度.gif 可以看到,经过时间分片,滑块左移的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就完成了动画。 image.png

接下来我们再来对比下新老react玩法,在这个时候还是否有区别(直接看效果,不详述)

  • react 15: 浏览器动画批量更新.gif
  • react 16: 浏览器动画schedule时间分片实现 效果.gif

我们可以发现批量插入大量的虚拟dom上树,依然会阻塞css3动画的渲染,这也印证了我上边说的js引擎线程和GUI线程本身是互斥的,css3动画确实比js动画效率高,但是本身如果js执行耗时过高,依然会阻塞css3动画。

  • react15 很糟糕,一开始浏览器每一帧全部被js占据,完全卡死,随后的几秒又完全被GUI占据。 1624853662(1).png image.png
  • react16 图中清晰可见,插入虚拟dom的js和GUI线程在每一帧,分配的很好,不会互相阻塞。 image.png

七、最后补充下为什么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()

image.png 无需多言了吧

  • requestAnimationFrame 原因1:这个api只在浏览器更新页面时候才会触发回调,如果浏览器连续100ms没有触发更新,这100ms就浪费了,时间不可控。MessageChannel可以主动触发。 原因2: 而且非requestAnimationFrame触发的func中再次调用requestAnimationFrame会被立即执行,不会放到浏览器下一帧,这在做第一个performWorkUntilDeadline的时候就会执行两次performWorkUntilDeadline

终于讲完了,最后附上我的代码实现:传送门