react渲染机制
注意:知道这个如何在面试中成为亮点:当问到vue渲染的时候,可以穿插着和react渲染做对比,这样才显示出两个框架都熟悉
我们通常认为的渲染的过程是指:从template模版编译到最后dom更新,这整个阶段都可以认为是渲染。但是在react中其实应该更细一点,我们可以把react分为渲染和提交,这里的提交就是更新到dom上。
为了实现异步可中断更新,React 将新的架构划为为三部分:
- Scheduler(调度器)—— 调度任务的优先级,高优任务优先进入 Reconciler
- Reconciler(协调器)—— 负责找出变化的组件
- Renderer(渲染器)—— 负责将变化的组件渲染到页面上
可以看到 Scheduler 是一个新事物,它就是用来负责判断当前的更新任务需不需要暂停,如果暂停了在什么时机继续更新的。既然我们以浏览器是否有剩余时间作为任务中断的依据,那么我们需要一种机制,当浏览器有剩余时间时通知我们。其实 chrome 浏览器已经实现了这个 API,这就是requestIdleCallback[4]。但由于兼容性,及 React 团队有着更加复杂的需求,
React放弃了对它的使用,转而,自己动手实现了一个更高级的Scheduler, 不仅支持空闲时调度,还提供了多种调度优先级供任务自由设置,后边会详细介绍。
一、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吗,怎么剩余时间比一帧周期时间还要长呢?
因为我们对“空闲时间”的认知和定义过于狭隘:
(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),它是不稳定排序。 而完全二叉树可以很好的表述堆排序的过程(我们都知道满二叉树是所有的节点都含有两个儿子节点,倒数第二层是满的,最后一层是叶子结点。完全二叉树则是倒数第二层可以不是满的,但是最后一层必须是从左到右排列,如下图所示:完全二叉树 )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; } };
这里进行堆排序,我们需要进行如下步骤:
1.先将一个无序数组初始化为“最大堆”
2.然后将堆顶元素与末尾元素交换,将最大元素「沉」到数组末端,直至整个数组为有序数组对于react只要构建最大堆,然后就是处理后面任务动态加入后的,二次调整
掌握知识点:堆排序需要掌握的点:
1.如何初始化一个最大堆
2.如果使用最大堆进行排序(整个数组排序)
3.如果只要取出前K个大的数据(非整个数组排序)
4.如果动态加入数据,如何排序(react多任务调度机制)
5.堆排序的使用场景:www.51cto.com/article/720…
(2)单任务可中断执行
二、reconclier协调 - 找出变化的组件
协调器是react「渲染UI」和「diff更新」的核心逻辑,其中主要的数据结构是fiberNode。对于协调器来说,他对上承接调度器的调度工作(与调度器的交互工作:注册调度任务Task,等待回调),往下对接渲染器的渲染工作(在内存中创建与fiber-node对应的DOM节点)。
2.0 预知识
2.0.1 fiberNode
背景: 问题:为什么要引入fiber结构?或者说为什么react-v16之后,渲染过程的对比不再是仅仅对比虚拟dom,而是改为对比fiberNode树呢?
答:
reactV16 之前的两个问题
- 不可中断的渲染过程:在React16之前,一旦开始渲染,React会阻塞主线程直到渲染完成。这可能导致UI冻结,特别是在处理大型组件树或高优先级任务(比如用户输入)时。(想象一下,在这 200 毫秒内,用户往一个 input 元素中输入点什么,敲击键盘也不会获得响应,因为渲染输入按键结果也是浏览器主线程的工作,但是浏览器主线程被 React 占着呢,抽不出空,最后的结果就是用户敲了按键看不到反应,等 React 更新过程结束之后,咔咔咔那些按键一下子出现在 input 元素里了。这就是所谓的界面卡顿,很不好的用户体验。)
- 固定的任务优先级:之前的React版本无法区分任务的优先级,导致所有任务都按照相同的顺序执行。这不利于响应性,因为高优先级的任务(比如用户交互)可能会被低优先级的任务(比如数据获取)阻塞。
Fiber通过以下方式解决了这些问题:
- 任务拆分与中断:Fiber架构将渲染过程拆分成多个小任务,在空闲时间执行。并且可以在任意时间点中断和恢复这些任务。这使得React能够在渲染过程中响应其他高优先级任务,提高了应用的响应性。(注意:中断是指工作单元前后之间的中断,任务单元的执行必须是一次完成的,不能出现暂停。这里一个fiber节点就是一个工作单元,这里的中断是指执行完了一个工作单元之后可以中断,暂时不执行下一个指向的工作单元。后面恢复继续执行下一个工作单元,而不是一个工作单元执行一半再恢复,因为工作单元具有原子性不可分割。)
- 优先级调度:Fiber架构引入了任务优先级的概念,允许React根据任务的优先级来调度工作。高优先级的任务会优先得到处理,从而确保用户交互等关键任务的流畅执行。
(1)fiber什么?
fiber从结构上看可以认为是一棵树,但是从每个节点的上下关系又可以看成是链表。
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 - 工作单元
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区别
问题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