React如何利用浏览器的事件循环来实现“并发特性”

发布于:2023-03-18 ⋅ 阅读:(569) ⋅ 点赞:(0)

本文只是用于将学习到的知识做一个梳理与总结

浏览器架构

现代浏览器通常采用多进程架构。每个进程都有独立的内存空间,相互隔离,提高浏览器的稳定性、安全性和性能。

以Chrome为例,浏览器的进程包含以下几个主要进程:

  • 浏览器主进程: 负责协调整个浏览器的运行,包括用户界面、网络请求、子进程的创建和销毁等。
  • 渲染进程: 将HTML/CSS/JS转化为用户可以交互的网页
  • 网络进程: 处理网络请求、响应、DNS等
  • GPU进程:负责处理图形渲染相关的任务,如2D、3D绘图等
  • 插件进程:运行浏览器插件

渲染进程

对于我们的页面来说,最重要的就是渲染进程,它包含了以下的多个线程:

  1. 主线程:负责处理用户输入、JavaScript 执行和页面布局计算等任务,是渲染进程中最重要的线程之一。
  2. 渲染线程:负责将 HTML、CSS 和 JavaScript 转换为可视化的页面,其中包括页面布局、样式计算、绘制和合成等任务。
  3. 合成线程:负责将页面中的多个图层合成为最终的显示内容,并将其发送到 GPU 进行渲染。
  4. JavaScript引擎线程:JavaScript 引擎线程负责解析和执行页面中的 JavaScript 代码
  5. 事件线程:负责处理用户输入事件,如鼠标点击、键盘输入等,以及页面中的事件触发和处理
  6. IO线程: 负责接收其他进程传进来的消息
  7. ...

在这当中,主线程最为繁忙,既要处理 DOM,又要计算样式,还要处理布局,同时还需要处理 JavaScript 任务以及各种输入事件。而浏览器则通过在主线程中实现消息队列事件循环系统来调度这么多不同类型的任务。

我们可以通过下面的图片来了解主线程事件循环消息队列和其他线程之间的关系

image.png

但是,消息队列先进先出的。主线程所有执行任务都来自于消息队列。会面临以下两个问题

1. 如何处理高优先级的任务

比如,如何监控DOM节点的变化情况(节点的插入、修改、删除等动态变化),然后根据变化来处理相应的业务逻辑。一个通用设计就是利用js设计一套监听接口,当变化发生时,渲染引擎同步调用这些接口,这是一个典型的观察者模式。

不过这个模式有个问题,因为 DOM 变化非常频繁,如果每次发生变化的时候,都直接调用相应的 JavaScript 接口,那么这个当前的任务执行时间会被拉长,从而导致执行效率的下降。如果将这些 DOM 变化做成异步的消息事件,添加到消息队列的尾部,那么又会影响到监控的实时性,因为在添加到消息队列的过程中,可能前面就有很多任务在排队了。

这也就是说,如果 DOM 发生变化,采用同步通知的方式,会影响当前任务的执行效率;如果采用异步方式,又会影响到监控的实时性

2. 如何解决单个任务执行时长过久的问题。

image.png

从图中可以看到,所有任务都是在单线程中执行的,而由于每一帧的时间有限,如果某一个js任务非常的耗时,那么下面的任务(DOM解析、JS事件、布局计算、用户输入事件等)就需要等待很长时间。这也就是我们页面中卡顿的由来。

第一个问题就可以通过下面的微任务来解决

宏任务,微任务

首先,我们需要知道任务队列中包含有以下两种类型的任务

宏任务

  • 渲染事件(如解析 DOM、计算布局、绘制);
  • 用户交互事件(如鼠标点击、滚动页面、放大缩小等);
  • JavaScript 脚本执行事件;
  • 网络请求完成、文件读写完成事件。
  • setTimeout的回调函数属于宏任务

微任务

  • Promise的回调属于微任务
  • MutationObserver 的回调函数:当被观察的 DOM 节点发生变化时,MutationObserver 的回调函数会被添加到微任务队列中。
  • queueMicrotask 方法:该方法可以将回调函数添加到微任务队列中,等待执行。该方法是 ES2020 标准中新增的。

宏任务微任务的最主要区别在于它们的执行时机

宏任务是添加一个新的任务到消息队列中,如果使用setTimeout来异步执行一个操作时,时间间隔无法精准掌控,对于一些高实时性的需求不太符合。比如你在程序中使用setTimeout延迟1000ms去执行某个任务时,可能在这1000ms中已经触发了很多系统级的任务,它们已经被插入到了消息队列中。等到过了1000mssetTimeout才将会它的回调插入到消息队列中,这就需要等待队列前的任务全部执行完了才能到它的回调

微任务是 在当前宏任务结束前再执行微任务,每个宏任务都关联了一个微任务队列。所以,只要在当前宏任务中触发了微任务,所有微任务的回调都会被添加到微任务队列中等待执行。这样,你再怎么交互,生成的宏任务都会排在当前的宏任务之后。这样,实时性问题就解决了。

React如何利用浏览器的特性来做“并发”

在了解了前面关于浏览器的特性以及相关问题后。我们再回到react中看React为了并发特性做了哪些改动。

time slice 与 fiber

在react16之前,一直是递归更新。而16之后,react提出了一个新的概念 time slice,便于将任务切分,然后在浏览器的空闲时间来执行任务,超出了空闲时间则将剩余任务往后推。但由于递归更新中断后无法再继续,所以react重构了它的代码,将递归更新改成了fiber这种链表结构。这样即使是暂停了,还能从暂停出的链表继续执行。这样就解决了组件单个执行任务过长的问题。

异步更新

我们可以在react的react-reconciler包中找到scheduleSyncCallback方法,所有的更新操作都保存到了syncQueue队列中,然后通过scheduleMicrotask这个方法创建微任务,flushSyncCallbacks就是这个微任务的异步回调,而flushSyncCallbacks当中执行的就是所有的更新操作。这就解决了组件更新效率的问题。

Scheduler 调度器

现在,有了可中断的任务,并且同步任务被放到了微任务中执行。而且因为一般主流浏览器刷新频率为60Hz,即每16.6ms(1000ms / 60Hz)浏览器刷新一次。

所以react需要解决的就是如何利用每一帧中预留给js线程的时间来更新组件(在scheduler源码中,react预留了5ms)。当超过预留时间后,react就会中断更新,等待下一帧的空闲时间继续从被中断的fiber处执行。这样就尽可能的避免了任务执行时间过长而出现掉帧卡顿的现象。

总结

react 利用浏览器的渲染进程主线程的事件循环以及宏任务微任务的特点,将原有的数据结构改变为Fiber这种可中断的链表结构。
并且通过将所有的更新操作使用微任务来执行,解决组件更新的实时性问题。然后再实现了调度器来完成任务的中断和继续来解决任务执行时间过长的问题。

参考

本文含有隐藏内容,请 开通VIP 后查看