react渲染

262 阅读15分钟

react渲染机制

注意:知道这个如何在面试中成为亮点:当问到vue渲染的时候,可以穿插着和react渲染做对比,这样才显示出两个框架都熟悉

我们通常认为的渲染的过程是指:从template模版编译到最后dom更新,这整个阶段都可以认为是渲染。但是在react中其实应该更细一点,我们可以把react分为渲染和提交,这里的提交就是更新到dom上。

为了实现异步可中断更新,React 将新的架构划为为三部分:

  • Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入 Reconciler
  • Reconciler(协调器)—— 负责找出变化的组件
  • Renderer(渲染器)—— 负责将变化的组件渲染到页面上

可以看到 Scheduler 是一个新事物,它就是用来负责判断当前的更新任务需不需要暂停,如果暂停了在什么时机继续更新的。既然我们以浏览器是否有剩余时间作为任务中断的依据,那么我们需要一种机制,当浏览器有剩余时间时通知我们。其实 chrome 浏览器已经实现了这个 API,这就是requestIdleCallback[4]。但由于兼容性,及 React 团队有着更加复杂的需求,React放弃了对它的使用,转而,自己动手实现了一个更高级的Scheduler, 不仅支持空闲时调度,还提供了多种调度优先级供任务自由设置,后边会详细介绍。

image.png

一、schedule调度 - 调度任务的优先级(新增)

1.1 什么是调度?为什么需要调度?

想象一个场景,当我们只有一个js引擎来执行的时候,如果有多个任务,那么为了保证尽可能好的用户体验丝滑,我肯定需要对这些要处理的任务进行优先级划分,等级高的我优先执行。这种处理不同优先级任务放入到引擎中执行的机制就是调度。此外,还有一种场景,当我们把某一个优先级高的任务放入到引擎中执行的时候,如果这个任务执行时间是100ms且同步执行不可中断,那么就会产生延迟(浏览器中的延迟定义是: 浏览器渲染周期是60hz,一秒钟执行60次,每次执行16.6ms,如果长时间霸占引擎超过100ms,就会产生用户感知。)因此需要在16.6ms的时候如果一个任务执行不完,那么先中断,等待下个浏览器渲染周期到来之后再继续执行。而不是一直霸占着引擎直到执行完毕。这种处理单个任务中断&继续执行的机制也是调度。
这种调度其实是一种模式,即Concurrent并行模式(之前Legacy mode 同步阻塞模式,会阻塞渲染)

总结:调度的功能就是:1.多个任务的优先级划分;2.当个任务的时间片内的任务中断和恢复

浏览器相关知识
知识点1 : 理解浏览器一帧周期
浏览器以16.6ms为周期内,做了哪些事情?
1.事件处理时期
2.js执行时期
3.视图绘制时期
4.浏览器空闲时期

知识点2: 认识浏览器API - requestIdleCallback( callback, options )
idle是空闲的意思,requestIdleCallback就是请求空闲函数,该函数是浏览器提供给外界在浏览器一帧周期内中“空闲时间周期”自动执行的函数,通常用来执行不重要的任务,以确保这些任务的执行不会阻塞主线程,进而提高页面的响应性。

// deadlineObj中主要的参数
{
   timeRemaining: () => {} // 返回当前帧还剩的时间
   didTimeout: true // 表示回调函数是否在timeout超时时间之前就已经执行
}
const ID = requestIdleCallback((deadlineObj) => { // deadlineObj为当前帧对象,可以通过该对象查看当前帧的一些情况,例如当前帧还剩下多少空闲时间
  deadlineObj.deadlineObj() // 当前帧剩余的毫秒数
}, {timeout: 1000}) 
// 这里的 timeout 是指,当过了timeout之后,该回调函数依然没有执行的话,那么就需要将这个回调放入到事件循环队列中执行
window.cancelIdleCallback(ID) 
// 通过将对应的ID传入调用的window.cancelIdleCallbeck,则可以取消调用
//
//  注意:如果多个回调调用requestIdleCallback的话,那么就都会放入到一个统一队列中,先进先出。

举例:

requestIdleCallback((deadline) => { 
    console.log(deadline.timeRemaining())
}, {timeout: 1000})

打印结果:剩余时间是35.6ms,有人会问,不是一帧16.6ms吗,怎么剩余时间比一帧周期时间还要长呢? image.png 因为我们对“空闲时间”的认知和定义过于狭隘:
(1)当出现短时间内多次屏幕刷新的时候,那么浏览器会严格按照一帧16.6ms最小间隔去执行事件处理&屏幕刷新,这就是最小刷新间隔,这里存在一个空闲时间
(2)如果屏幕空闲时间很长,那么下一次刷新可能要等很久(用户不交互就不刷新),那么浏览器也不能一直处于空闲时间,因此也要给他一个最大时间限制 - 50ms

知识点3: 认识浏览器API - requestAnimationFrame
请求动画帧这个API本意上是专门用来处理动画的,因为之前如果使用定时器来做动画(每个100ms变换坐标来实现位移的效果)此时就会出现抖动现象,但是如果使用请求动画帧这个API就会丝滑很多。 那么为什么请求动画帧比定时器丝滑?因为他在特定的时间点进行代码执行,而定时器与绘制频率周期没有保持共振,这就导致前两个周期绘制一次,然后在等一个周期绘制一次,在等3个周期绘制一次。这样就会导致下个绘制周期动画不变,造成视觉上感知到短暂静止,连续起来就会形成卡顿。而绘制动画帧就就是保证每次渲染都会执行,这样的严格卡点就会感觉很丝滑。

1.2 调度机制流程

react调度机制按照功能主要分为两块:
1.多个任务的优先级排列调整问题(包括现有队列的排序 && 后续新任务加进来后的排序调整)
2.从队列中挑出最高优先级的单个任务执行(这里单个执行需要实现可中断/可恢复功能)

(1)多任务排序 - 堆排序

这里不仅仅是一个taskId来sort排序,但是因为每次只取最大优先值任务拿出来执行,且可能会任务新加进来(动态添加),所以这里盲目的使用sort是耗时的。所以react采用了堆排序来应对这两种场景(只取最大 & 动态新增任务)

堆排序

1.概念
堆排序是利用  这种 数据结构 而设计的一种排序算法,它是一种选择排序,最坏 、最好、平均时间复杂度均为 O(nlogn),它是不稳定排序。 而完全二叉树可以很好的表述堆排序的过程(我们都知道满二叉树是所有的节点都含有两个儿子节点,倒数第二层是满的,最后一层是叶子结点。完全二叉树则是倒数第二层可以不是满的,但是最后一层必须是从左到右排列,如下图所示:完全二叉树image.png

2.完全二叉树性质
(1)第 index=i 个元素的两个子节点index分别为:2i+1,2i+2
(2)第 index=i 个元素父节点为: i/2
(3)整个完全二叉树的最后一个非叶子结点index为:Math.floor(arr.length / 2) - 1
因为存在这种一一对应的性质,所以我们可以以数组形式来存储堆(index可以一一对应到数组中的元素)

3.堆排序算法

/**
 * @param {number[]} nums
 * @param {number} k
 * @return {number}
 */
 // 整个流程就是上浮下沉
var findKthLargest = function(nums, k) {
   let heapSize=nums.length
    buildMaxHeap(nums,heapSize) // 初始化:构建一个大顶堆
    // 从最后一个非叶子的根结点开始调整,那么一定是从最后一层开始完成局部堆,然后逐层向上。
    // 进行下沉 大顶堆是最大元素下沉到末尾
    for(let i=nums.length-1; i>=nums.length-k+1; i--){
        swap(nums,0,i)
        --heapSize // 下沉后的元素不参与到大顶堆的调整
        // 重新调整大顶堆
         maxHeapify(nums, 0, heapSize);
    }
    return nums[0]
   // 自下而上构建一颗大顶堆
   function buildMaxHeap(nums,heapSize){
     for(let i=Math.floor(heapSize/2)-1;i>=0;i--){
        maxHeapify(nums,i,heapSize)
     }
   }
   // 从左向右,自上而下的调整节点
   // maxHeapify函数可以认为就是拿到一个i结点,就根据完全二叉树性质找到两个子节点,然后这三个结点对比找到最大值(调整为局部堆)
   function maxHeapify(nums,i,heapSize){
       let l=i*2+1
       let r=i*2+2
       let largest=i
       if(l < heapSize && nums[l] > nums[largest]){
           largest=l
       }
       if(r < heapSize && nums[r] > nums[largest]){
           largest=r
       }
       if(largest!==i){
           swap(nums,i,largest) // 进行节点调整
           // 继续调整下面的非叶子节点
           maxHeapify(nums,largest,heapSize)
       }
   }
   function swap(a,  i,  j){
        let temp = a[i];
        a[i] = a[j];
        a[j] = temp;
   }
};

image.png 这里进行堆排序,我们需要进行如下步骤:
1.先将一个无序数组初始化为“最大堆”
2.然后将堆顶元素与末尾元素交换,将最大元素「沉」到数组末端,直至整个数组为有序数组

对于react只要构建最大堆,然后就是处理后面任务动态加入后的,二次调整

掌握知识点:堆排序需要掌握的点:
1.如何初始化一个最大堆
2.如果使用最大堆进行排序(整个数组排序)
3.如果只要取出前K个大的数据(非整个数组排序)
4.如果动态加入数据,如何排序(react多任务调度机制)
5.堆排序的使用场景:www.51cto.com/article/720…

参考文献:juejin.cn/post/698651…

(2)单任务可中断执行

二、reconclier协调 - 找出变化的组件

协调器是react「渲染UI」和「diff更新」的核心逻辑,其中主要的数据结构是fiberNode。对于协调器来说,他对上承接调度器的调度工作(与调度器的交互工作:注册调度任务Task,等待回调),往下对接渲染器的渲染工作(在内存中创建与fiber-node对应的DOM节点)。

2.0 预知识

2.0.1 fiberNode

背景: 问题:为什么要引入fiber结构?或者说为什么react-v16之后,渲染过程的对比不再是仅仅对比虚拟dom,而是改为对比fiberNode树呢?

reactV16 之前的两个问题

  1. 不可中断的渲染过程:在React16之前,一旦开始渲染,React会阻塞主线程直到渲染完成。这可能导致UI冻结,特别是在处理大型组件树或高优先级任务(比如用户输入)时。(想象一下,在这 200 毫秒内,用户往一个 input 元素中输入点什么,敲击键盘也不会获得响应,因为渲染输入按键结果也是浏览器主线程的工作,但是浏览器主线程被 React 占着呢,抽不出空,最后的结果就是用户敲了按键看不到反应,等 React 更新过程结束之后,咔咔咔那些按键一下子出现在 input 元素里了。这就是所谓的界面卡顿,很不好的用户体验。)
  2. 固定的任务优先级:之前的React版本无法区分任务的优先级,导致所有任务都按照相同的顺序执行。这不利于响应性,因为高优先级的任务(比如用户交互)可能会被低优先级的任务(比如数据获取)阻塞。

Fiber通过以下方式解决了这些问题:

  • 任务拆分与中断:Fiber架构将渲染过程拆分成多个小任务,在空闲时间执行。并且可以在任意时间点中断和恢复这些任务。这使得React能够在渲染过程中响应其他高优先级任务,提高了应用的响应性。(注意:中断是指工作单元前后之间的中断,任务单元的执行必须是一次完成的,不能出现暂停。这里一个fiber节点就是一个工作单元,这里的中断是指执行完了一个工作单元之后可以中断,暂时不执行下一个指向的工作单元。后面恢复继续执行下一个工作单元,而不是一个工作单元执行一半再恢复,因为工作单元具有原子性不可分割。)
  • 优先级调度:Fiber架构引入了任务优先级的概念,允许React根据任务的优先级来调度工作。高优先级的任务会优先得到处理,从而确保用户交互等关键任务的流畅执行。

(1)fiber什么?

fiber从结构上看可以认为是一棵树,但是从每个节点的上下关系又可以看成是链表。

image.png

fiber-node中每个节点重要的字段

// 1.节点自身描述字段
this.tag = tag;
this.key = key;
this.ref = null;
this.stateNode = null; // 节点对应的实际 DOM 节点或组件实例 
this.type = null; // 节点的类型,可以是原生 DOM 元素、函数组件或类组件等

// 2.节点需要存储的上下结构指针(这里体现了fiber是一个链表结构)
this.return = null; // 指向节点的父节点
this.sibling = null; // 指向节点的下一个兄弟节点
this.child = null; // 指向节点的第一个子节点
this.index = 0; // 索引

// 3.作为工作单元
this.pendingProps = pendingProps; // 表示节点的新属性,用于在协调过程中进行更新 
this.memoizedProps = null; // 已经更新完的属性
this.memoizedState = null; // 更新完成后新的State 
this.alternate = null; // 指向节点的备份节点,用于在协调过程中进行比较 
this.flags = NoFlags; // 表示节点的副作用类型,如更新、插入、删除等 
this.subtreeFlags = NoFlags; // 表示子节点的副作用类型,如更新、插入、删除等 
this.updateQueue = null; // 更新计划队列

节点上下文结构指针字段
工作单元字段
参考文献:juejin.cn/post/734683…

2.0.1 workUnit - 工作单元

juejin.cn/post/717072…

2.1 中断可恢复实现 - requestIdleCallback(任务单元callback, options)

目前这个API还在实验阶段,因此可能存在兼容性问题,故react团队基于该原理,自己实现了一套。但是思想是一样的,都是在每一次空闲的时候执行一个任务单元。

function workLoop(deadline) {
  while(nextUnitOfWork && deadline.timeRemaining() > 1) {
    // deadline.timeRemaining() > 1 判断如果还有剩余时间,就沿着当前任务单元执行,且执行完会返回下一个任务单元地址,
    // 然后继续判断是否还有剩余时间,如果有接着执行单元,等到没有剩余时间后,挑出循环
    // 这个while循环会在任务执行完或者时间到了的时候结束
    nextUnitOfWork = performUnitOfWork(nextUnitOfWork);
  }

  // 如果任务还没完,但是时间到了,这个时候跳出循环了,但是我们需要把这个循环执行任务单元的函数注册到requestIdleCallback,这个下个帧来的时候才可以继续恢复执行。
  requestIdleCallback(workLoop);
}

// performUnitOfWork用来执行任务,参数是我们的当前fiber任务,返回值是下一个任务
function performUnitOfWork(fiber) {
  
}
requestIdleCallback(workLoop);

2.2 增量渲染

重点参考文献:juejin.cn/post/684490…

三、render渲染 - 将变化的组件渲染到页面

设定一下任务优先级 react在内部定义了 5 种类型的优先级,以及对应的超时时间timeout

ImmediatePriority, 直接优先级,对应用户的 click、input、focus 等操作; timeout为 -1,表示任务要尽快处理; UserBlockingPriority,用户阻塞优先级,对应用户的 mousemove、scroll 等操作;timeout 为 250 ms; NormalPriority,普通优先级,对应网络请求、useTransition 等操作; timeout 为 5000 ms; LowPriority,低优先级(未找到应用场景);timeout 为 10000 ms; IdlePriority,空闲优先级,如 OffScreen; timeout 为 1073741823 ms;

5 种优先级的顺序为: ImmediatePriority > UserBlockingPriority > NormalPriority > LowPriority > IdlePriority。

四、react中fiber双缓存机制

因为之前屏幕渲染的前一帧需要更新后,会清除掉前一帧。然后屏幕会等待下一帧的绘制,在下一帧绘制完成之前整个屏幕都是空白的。这是因为在下一帧绘制好之前就把前一帧清空了,进而导致用户看见了空白。 而使用双缓存则是告诉浏览器在下一帧绘制好之前,一直使用上一帧来展示到屏幕中。用“前一帧”来代替空白的显示,更为友好,不会出现卡顿。因为帧的替换的瞬间的。

五、vue和react区别

image.png 问题1: Vue 有了数据劫持已经知道了哪个数据变化为什么还要 DOM diff? vue 的响应式系统通过依赖收集可以直接知道在哪里变化。但是绑定需要watcher,如果粒度很细就需要很多watcher很大的开销(一个变量一个watcher)。所以vue采用的是中等粒度,在组件级别监听(变量级别watcher变为了组件级别watcher)。然后系统可以知道哪个组件变化,对该组件进行dom diff。

  • pull:React 通过 setState 显示更新,然后 VDOM 层层 Diff,查找变化
  • push:Vue 数据变化,响应式系统立刻知道哪里变化,但是一个数据就要绑定一个 Watcher。一旦绑定的颗粒度过高,就会产生大量 Watcher,增加内存消耗及依赖追踪的开销;而颗粒度过低就不能精准侦测变化。Vue 在组件级别进行响应式侦测,组件内部通过 Diff 算法查找变化。所以 Vue 是 pull + push