• 深入理解浏览器执行原理
  • 发布于 1个月前
  • 65 热度
    0 评论
前言
众所周知,JavaScript是一门单线程语言,即同一时间点,只能在做某一件事情。这也是js最大的特点,这与js的用途有关。正如我们所了解的,js是前端的逻辑层,主要用来处理逻辑、与用户互动以及操作DOM。因此决定了js必然是个单线程语言,不然会带来很复杂的同步问题。但是为了利用多核CPU的计算能力,HTML5提出了Web Worker(工作线程)标准。允许js脚本创建多个线程,但是子线程完全受控于主线程,且不得操作DOM。因此这个标准并没有改变js单线程的本质,而是进行了改善。

渲染进程(浏览器内核)
GUI渲染线程: 负责渲染浏览器界面,解析 HTML,CSS,构建 DOM 树和 RenderObject 树,布局和绘制。当界面需要重绘(Repaint)或由于某种操作引发回流(reflow)时,该线程就会执行。
JS引擎线程:即JS 内核,负责处理 Javascript 脚本程序。
还有其他线程:事件触发线程、定时器线程、网络请求线程等。

注意:GUI 渲染线程与 JS 引擎线程是互斥的,所以如果 JS 执行的时间过长,这样就会造成页面的渲染不连贯,导致页面渲染阻塞。这也是我们通常会把script脚本放在body底部、添加defer属性的原因。


而小程序则进行了双线程的改善:即渲染层和逻辑层的线程不互相阻塞,并且渲染层有多个webview线程。渲染层和逻辑层并行运行,这也是小程序性能优于web的原因之一。因此小程序中也无法操作dom,通过setData以数据驱动视图的方式来控制页面的渲染。通过createSelectorQuery来获取实例对象,仅可获取节点的信息。


Event Loop

js单线程的特性,因此也意味着所有的任务得按照先后顺序排队执行,前面的任务执行完了,才轮到后面的任务执行。任务分为同步任务和异步任务。

同步任务是指:在主线程上排队顺序执行的任务,只有等前一个任务执行完了才能执行后一个任务;

异步任务是指:任务不进入主线程,而是进入任务队列(Task Quene)中,只有等到主线程同步任务执行完毕的时候,该异步任务才会进入到主线程中执行。


所有的同步任务都会在主线程上顺序地执行,形成了一个执行栈。当遇到异步任务时,不会一直等待其结果返回,而是将其挂起,继续执行执行栈中其他的任务。当异步任务返回结果后,js会将其推入到任务队列中,此时并不会立即执行其回调函数。而是当当前执行栈中的任务都执行完毕后,主线程会去读取任务队列中的任务(主线程会检查执行事件,如定时器只有到了规定时间才能进入到主线程中),取出排在第一位的任务,将其回调函数放入执行栈中,执行里面的同步代码。不断重复上述的过程,于是就形成了事件循环(Event Loop),流程图大致如下:

宏任务(Macro Task)与微任务(Micro Task
异步任务中,执行的优先级也有区别,分为:宏任务与微任务。当每个宏任务执行完毕后,都会先执行完当前任务队列里面所有的微任务,才会执行下一个宏任务。
宏任务主要包括:script(整体脚本代码)、setTimeout、setInterval、I/O、UI交互事件、setImmediate(Node.js中)。
微任务主要包括:Promise、MutaionObserver、process.nextTick(Node.js中)。

帧(frame)和 FPS
正如我们知道的,页面的内容都是一帧一帧绘制出来的,浏览器刷新率代表浏览器一秒绘制多少帧,也就是每秒传输帧数(Frames Per Second)。原则上说 1s 内绘制的帧数也多,画面表现就也细腻。目前浏览器大多是 60Hz(60帧/s),每一帧耗时也就是在 16.6ms 左右。那么在这一帧的(16.6ms) 过程中浏览器又干了些什么呢?

.事件处理
.宏任务
.微任务
.requestAnimationFrame
.CSS 计算、页面布局
.UI绘制
.requestIdleCallback
注:requestIdleCallback不一定会执行,只有在当前帧有空闲时间才会执行,下面我们会细谈。

60Hz只是理想状态下的,帧率不是一直不变的,会随着页面的内容变化的丰富程度而改变。

异步任务执行顺序
Promise,setTimeout,requestIdleCallback,requestAnimationFrame都是异步任务,那么他们的执行顺序应该如何呢?先来看看以下代码:
setTimeout(() => console.log('setTimeout'), 0);
Promise.resolve().then(() => console.log('promise'));
requestAnimationFrame(() => console.log('animation'));
requestIdleCallback(() => console.log('idle'));
分析:
微任务队列会在 JS 运行栈为空的时候立即执行
requestAnimationFrame 队列会在页面渲染前执行
一般情况下会比 animation 队列优先级低,但不是绝对
idle 队列优先级最低,当浏览器有空闲时间的时候才会执行。

输出结果如下:

setTimeout
setTimeout(code/function, milliseconds, param1, param2, ...)

众所周知,setTiemout用于在指定的毫秒数后调用函数或计算表达式,返回值为整型数值id,是setTimeout的唯一标识符,可用于取消setTimeout设置的函数clearTimeout(id)。

早期我们经常用setTimeout来做动画,大致代码如下:
(function fn() {
  // ur code
  setTimeout(fn, 1000/16)
})();
注意,这是MDN原话:As specified in the HTML standard, browsers will enforce a minimum timeout of 4 milliseconds once a nested call to setTimeout has been scheduled 5 times.

也就是说,当setTimeout被递归调用超过五次,浏览器将强制最小执行间隔为4毫秒。我们在此可以做个测试,代码如下:
(function fn (count) {
    console.log(count, Date.now());
    count < 7 && setTimeout(() => fn(count + 1), 0);
 })(0);
输出结果如下:

setInterval
语法与setTimeout一致。setInterval方法可按照指定的周期(以毫秒计)来调用函数或计算表达式。setInterval方法会不停地调用函数,直到 clearInterval被调用或窗口被关闭。由 setInterval返回的 ID 值可用作 clearInterval 方法的参数。确保执行时间短于定时器时间间隔:setInterval其实就是定时向宏任务队列推入回调函数 ,如果你的代码执行时间大于设定的间隔,或者被其他优先级更高的任务阻塞了,则可能会造成当前回调函数不会执行。

定时器的代码执行部分不断的被调入任务队列中,如果定时器的执行时间比间隔时间长,定时器代码会在代码再次被添加到队列之前还没有完成执行,结果导致定时器代码连续运行好几次。
js 引擎为了解决这个问题,采用的方式是若任务队列中存在这个定时器代码,则不会将新的定时器代码放入任务队列,这样做的弊端是可能导致某些间隔被跳过。

此时,建议使用递归调用了 setTimeout的具名函数,虽然不能保证以固定的时间间隔执行,但它保证了上一次定时任务在递归前已经完成。

requestAnimationFrame
window.requestAnimationFrame(callback)告诉浏览器:你希望执行一个动画,并且要求浏览器在下次重绘之前调用指定的回调函数更新动画。该方法需要传入一个回调函数作为参数,该回调函数会在浏览器下一次重绘之前执行。

返回一个 long 整数,请求 ID ,是回调列表中唯一的标识。是个非零值,没别的意义。你可以传这个值给 window.cancelAnimationFrame() 以取消回调函数。在requestAnimationFrame问世之前,我们通常用setTimeout做动画,但是因为setTimeout的执行时间总是会大于所设定的间隔,执行的时间是不确定的,且设定的时间间隔很难与屏幕刷新率相同,所以会造成掉帧、抖动的现象。

requestAnimationFrame具有以下特点:
1.按帧对网页进行重绘。
2.由系统来决定回调函数的执行时机,浏览器会自动优化方法的调用。

requestAnimationFrame的执行频率可与浏览器的刷新率保持一致,例如假设浏览器的刷新率为:60Hz,也就是回调函数每(1000ms / 60 ≈ )16.7ms执行一次。
注意:若你想在浏览器下次重绘之前继续更新下一帧动画,那么回调函数自身必须再次调用 window.requestAnimationFrame,这也是我们做动画的基本思路。且看下方代码:
 const runAnimation = () => {
    translateRef.current += 1;
    console.log(translateRef.current, Date.now());
    if (translateRef.current <= 325) {
      setTranslateValue(translateRef.current);
      requestAnimationFrame(runAnimation);
    }
  };
输出结果如下:

分析:通过打开谷歌浏览器的 "show frames per second FPS meter" 查看浏览器的帧率,为118.8,因此每一帧的执行时间为:(1000 / 118.8) = 8.4ms。
requestAnimationFrame的优点如下:
能保证回调函数在屏幕每一次刷新间隔中只被执行一次,与屏幕刷新频率保持同步,这样就不会引起丢帧,动画也就不会卡顿。
CPU节能:在页面隐藏的时候,setTimeout在后台依然会被执行,其实这是毫无意的;而此时该页面的屏幕绘制任务会被浏览器暂停,requestAnimationFrame也会停止调用;当页面重新激活时,requestAnimationFrame也会继续上次的动画继续调用。

requestIdleCallback
window.requestIdleCallback(callback[, options])
requestIdleCallback方法插入一个函数,这个函数将在浏览器空闲时期被调用。因此我们能够在主事件循环上执行后台和低优先级工作,而不会影响延迟关键事件,如输入响应、日志上传等。
返回一个 ID,可以把它传入 window.cancelIdleCallback() 方法来结束回调。
 window.requestIdleCallback((deadline) => {
    if (deadline.timeRemaining() > 0 || deadline.didTimeout) {
       console.log('Run idle');
    }
 }, { timeout: 2000 });
requestIdleCallback接收2个参数:
callback:一个在事件循环空闲时即将被调用的函数的引用。函数会接收到一个名为 IdleDeadline 的参数,这个参数可以获取当前空闲时间以及回调是否在超时时间前已经执行的状态
options(可选):如果指定了timeout,并且有一个正值,而回调在 timeout 毫秒过后还没有被调用,那么回调任务将放入事件循环中排队,即使这样做有可能对性能产生负面影响。
由于requestIdleCallback还比较新,有些浏览器还未支持,而你的代码可能需要在那些不仍不支持此 API 的浏览器上运行,你可以这么做:
window.requestIdleCallback = 
window.requestIdleCallback || function(handler) {
  let startTime = Date.now();

  return setTimeout(function() {
    handler({
      didTimeout: false,
      timeRemaining: function() {
        return Math.max(0, 50.0 - (Date.now() - startTime));
      }
    });
  }, 1);
}
注意:

因为requestIdleCallback在一帧的最后执行,此时页面布局已经完成,所以不建议在 requestIdleCallback 里再操作 DOM,这样会导致页面再次重绘。可以在requestIdleCallback里面构建Document Fragment,然后在下一帧的requestAnimationFrame里面应用Fragment。DOM 操作建议在 rAF 中进行。同时,因为操作 DOM 所需要的耗时是不确定的,容易超出当前帧空闲时间的阈值。


Promise 也不建议在这里面进行,因为 Promise 的回调在 Event loop 中优先级较高的一种微任务,会在 requestIdleCallback 结束时立即执行,不管此时是否还有富余的时间,这样有很大可能会让一帧超过 16 ms,造成页面卡顿。


什么是浏览器空闲时间?
我们假定浏览器的某连续2帧如下:

我们可以看到,第一帧由于宏任务占用了大量的时间,浏览器繁忙,没有空闲时间;第二帧有大量空闲时间(最长50ms),因此requestIdleCallback的回调函数可以执行。
 
React Fiber
在React的旧的任务调度策略里,采用的是自顶向下递归便利的策略,一旦开始了diff算法,递归对比前后两棵虚拟树,则React就会一直控制主线程,直到diff完毕,其过程不可打断。此时,如果还有其他交互操作,就会造成页面的卡顿和掉帧。
React16.x版本后,则引进入了fiber,将时间分片,这是一种新的任务调度策略。首先会根据虚拟Dom树生成fiber树,以双向链表的结构存储:
     
React Scheduler
有鉴于此,React Scheduler将diff过程,按节点拆分成n个任务,目前React利用类似于requestIdleCallback的原理(伪实现ric),进行任务调度。
注:为什么不直接使用requestIdleCallback,主要原因有:
兼容性,对浏览器版本要求高。 
在空闲状态下,requestIdleCallback(callback) 回调函数的执行间隔是 50ms(W3C规定),也就是 20FPS,1秒内执行20次。(最重要的一点)

React Scheduler调度过程大致如下:
    
小结
当我们需要用js控制动画的时候(如canvas等),requestAnimationFrame是我们最好的选择;当需要执行一些优先级较低的时候(如一些逻辑运算,日志上传等),可以使用requestIdleCallback,合理利用浏览器空闲时间。
用户评论