react的核心可以用ui=fn(state)来表示,更详细可以用
const state = reconcile(update);
const UI = commit(state);
上面的fn可以分为如下一个部分:
-
Scheduler(调度器): 排序优先级,让优先级高的任务先进行reconcile
-
Reconciler(协调器): 找出哪些节点发生了改变,并打上不同的Flags(旧版本react叫Tag)
-
Renderer(渲染器): 将Reconciler中打好标签的节点渲染到视图上
-
工作流程如下图
一图胜千言:
1.diff算法的过程
协调器是在render阶段工作的,简单一句话概括就是Reconciler会创建或者更新Fiber节点。在mount的时候会根据jsx生成Fiber对象,在update的时候会根据最新的state形成的jsx对象和current Fiber树对比构建workInProgress Fiber树,这个对比的过程就是diff算法。
2.diff算法
diff算法发生在render阶段的reconcileChildFibers函数中,diff算法分为单节点的diff和多节点的diff(例如一个节点中包含多个子节点就属于多节点的diff),单节点会根据节点的key和type,props等来判断节点是复用还是直接新创建节点,多节点diff会涉及节点的增删和节点位置的变化
react的多节点diff算法
新的dom数组和当前的fiber链表进行对比,有两轮遍历
第一轮遍历:处理更新 的节点。
第二轮遍历:处理剩下的不属于 更新 的节点。
第一轮遍历,从头开始遍历 newChildren ,逐个与 oldFiber 链中的节点进行比较,判断 DOM 节点是否可复用。如果节点的 key 不同,则不可复用,直接跳出循环,第一轮遍历结束。如果 key 相同,但是 type 不同,则会重新创建节点,将 oldFiber 标记为 Deletion ,并继续遍历。
lastPlacedIndex 是最后一个可复用的节点在 oldFiber 中的位置索引,用于后续判断节点是否需要复用。
第一轮遍历完毕后,会有以下几种情况:
newChildren与oldFiber同时遍历完newChildren没遍历完,oldFiber遍历完newChildren遍历完,oldFiber没遍历完newChildren与oldFiber都没遍历完
newChildren 与 oldFiber 同时遍历完,这个是最理想的情况,只需在第一轮遍历进行组件 更新,此时 Diff 结束。
newChildren 没遍历完,oldFiber 遍历完,这说明 newChildren 中剩下的节点都是新插入的节点,只需遍历剩下的 newChildren 创建新的 Fiber 节点并以此标记为 Placement 。
newChildren 遍历完,oldFiber 没遍历完,意味着本次更新比之前的节点数量少,有节点被删除了。所以需要遍历剩下的 oldFiber ,依次标记 Deletion 。
newChildren 与 oldFiber 都没遍历完,这是 Diff 算法最难的部分。
newChildren 与 oldFiber 都没遍历完,则有可能存在移动了位置的节点,所以为了快速地找到 oldFiber 中可以复用的节点,则创建一个以 oldFiber 的 key 为 key ,oldFiber 为 value 的 Map 数据结构。
然后会遍历剩余的 newChildren ,逐个在 map 中寻找 oldFiber 中可复用的节点,如果找到可复用的节点,则将 oldIndex 与 lastPlacedIndex 比较,如果 oldIndex 与 lastPlacedIndex 小,则该节点需要右移,将新的 Fiber 节点标记为 Placement 。否则,将 lastPlacedIndex 更新为 oldIndex 。
遍历完 newChildren 后,map 中还有节点剩余,则那些节点属于多余的节点,需要标记为删除(Deletion)。
fiber对比及移动的策略
react对于位置移动的fiber节点,采用的是仅向右移的策略,大体上是,遍历newChldren数组,对比新节点对应的可复用的老节点在老fiber中的index(位置),如果位置在左边(oldIndex<lastPlacedIndex)则老fiber需要向右移,并将它放入新的fiber的sibling指针,如果在右边(oldIndex>lastPlacedIndex),则不需要移动,但是lastPlacedIndex要更新为更大的oldIndex,目的是为了始终使用最右边的老fiber作为位置基准。 更多react与vue的diff详细比较,推荐该文章
案例
12345 old
12435 new
第一轮直接复用 :对比结束 1 2 可复用,lastPlacedIndex = 0
第二轮 移动
4 oldIndex = 3 > lastPlacedIndex:0 =》 不用移动
lastPlacedIndex = 3
3 oldIndex = 2 < lastPlacedIndex:3 =》 打上 Placement 标志
5 oldIndex = 4 > lastPlacedIndex:3 => 不用移动
lastPlacedIndex = 4
在 commit 阶段,深度优先遍历每个新 fiber 节点,对 fiber 节点对应的 DOM 节点做以下变更:
删除 deletions 数组中 fiber 对应的 DOM 节点
**!如有 Placement 标志,将节点移动到往后第一个没有 Placement 标记的 fiber 的 DOM 节点之前。**
更新节点。以 DOM 节点为例,在生成 fiber 树的「归」阶段,会找出属性的变更集,在 commit 阶段更新属性。
React Diff 流程图
lastPlacedIndex:最后一个可复用节点的在 oldFiber 中的位置索引
oldIndex:当前遍历到的 oldFiber 节点在 oldFiber 中的位置索引
React 会调用 updateSlot 方法,在 updateSlot 中判断 fiber 节点能否复用,只要是 key 相同,updateSlot 都会返回 newFiber ,key 不同,则会返回 null ,第 1 轮遍历结束。
3.effectList
在 beginWork 中我们知道有的节点被打上了 effectTag 的标记,有的没有,而在 commit 阶段时要遍历所有包含 effectTag 的 Fiber 来执行对应的增删改,那我们还需要从 Fiber 树中找到这些带 effectTag 的节点嘛,答案是不需要的,这里是以空间换时间,在执行 completeUnitOfWork 的时候遇到了带 effectTag 的节点,会将这个节点加入一个叫 effectList 中,所以在 commit 阶段只要遍历 effectList 就可以了(rootFiber.firstEffect.nextEffect 就可以访问带 effectTag 的 Fiber 了)每个 fiber 节点上都保存了该 fiber 节点的子节点的 effectList,通过 firstEffect、nextEffect、LastEffect 来保存,在 completeWork 的时候就会将每个 fiber 的 effectList 更新到其父 Fiber 节点上,所以 complete 之后,rootFiber 上就保存了完整的 effectList,我们在 commit 阶段就直接遍历 rootFiber 上的 effectList 来执行副作用即可
EffectList 不是全局变量,只是在 Fiber 树创建过程中,一层层向上收集有 effect 的 Fiber 节点,最终的 root 节点就会收集到所有有 effect 到 Fiber 节点,我们就把这条包含 effect 节点的链表叫做 EffectList。
由于收集的过程是深度优先,子级会先被收集,所以遍历的时候也会先操作子级,所以如果有面试官问子级和父级的生命周期或者 useEffect 谁先执行,就很清楚的知道会先执行子级操作了。
4.concurrent异步可中断、带优先级的更新
它是一类功能的合集(如fiber、schduler、lane、suspense),其目的是为了提高应用的响应速度,使应用cpu密集型的更新不在那么卡顿,其核心是实现了一套异步可中断、带优先级的更新。
我们知道一般浏览器的fps是60Hz,也就是每16.6ms会刷新一次,而js执行线程和GUI也就是浏览器的绘制是互斥的,因为js可以操作dom,影响最后呈现的结果,所以如果js执行的时间过长,会导致浏览器没时间绘制dom,造成卡顿。react17会在每一帧分配一个时间(时间片)给js执行,如果在这个时间内js还没执行完,那就要暂停它的执行,等下一帧继续执行,把执行权交回给浏览器去绘制。
5.react的工作机制(简述)
render阶段
render阶段的主要工作是构建Fiber树和生成effectList
reconciler 调和
1.mounted通过jsx到react.createElement创建curentFiber
2.update时,通过jsx到react.createElement对比curentFiber(diff算法)schedule调度 生成 workinprogressFiber,workinprogressFiber成为curentFiber
3.在 Fiber 树构建过程中,每当一个 Fiber 节点的 effectTag 字段不为 NoEffect 时(代表需要执行副作用),就把该 Fiber 节点添加到 EffectList,在 Fiber 树构建完成后,Fiber 树的 Effect List 也就构建完成
commit阶段
render 渲染
4.遍历effectList执行对应的dom操作或部分生命周期。
概述
从 vdom 转换成 fiber 的过程就叫做 reconcile,转换过程中会顺便创建对应的 dom 元素,然后全部转换完后一次性 commit 到 dom。
这个过程不是一次性的,是通过 scheduler 调度执行的,那也就可以分批次进行,也就是可打断的含义。
这就是 React 的 fiber 架构下的渲染流程。
react 把 schedule 和 reconcile 叫做 render 阶段,这个阶段就是把 vdom 转为 fiber。(schedule 只是让 reconcile 可以分多次执行,可以打断,但做的事情是不变的,所以 schedule 也是 render 阶段的一部分)
之后把 fiber 更新到 dom 的过程就叫做 commit 阶段。
6、react 工作的2大循环
这张图也就对应了在16版本及以后react采用的fiber 架构,所对应的其中2个部分,一个是scheduler部分,也就是负责任务调度循环,另一个就是reconciler部分,负责fiber构造循环。
fiber 构造循环,也就是刚才所讲的采用FSD的方式向下遍历,可以分为“递”和“归”2个阶段 ,而递和归又分别对应mount和update两种场景
在递的阶段,从根节点rootFiber开始,遍历到叶子节点,每次遍历到的节点都会执行beginWork,并且传入当前Fiber节点,然后创建或复用它的子Fiber节点,并赋值给workInProgress.child。
在归的阶段, 在归阶段遍历到子节点之后,会执行completeWork方法,执行完成之后会判断此节点的兄弟节点存不存在,如果存在就会为兄弟节点执行completeWork,当全部兄弟节点执行完之后,会向上冒泡到父节点执行completeWork,直到rootFiber。
7. Scheduler 、Reconciler运行原理
在 React 中宏观来看,针对浏览器、Scheduler 、Reconciler 其实是有3层 Loop。浏览器级别的 eventLoop,Scheduler 级别的 workLoop,Reconciler 级别 workLoopConcurrent 。
-
浏览器的 eventLoop 与 Scheduler 的关系
-
每次 eventLoop 会执行宏任务的队列的宏任务,而 React 中的 Scheduler 就是用宏任务 messageChannel 触发的。由于requestIdleCallback存在兼容和触发时机不稳定的问题,scheduler中采用MessageChannel来实现requestIdleCallback,当前环境不支持MessageChannel就采用setTimeout。
-
当 eventLoop 开始执行跟 Scheduler 有关的宏任务时,Scheduler 会启动一次 workloop,就是在遍历执行 Scheduler 中已存在的 taskQueue 队列的每个 task。
-
-
Scheduler 与 Reconciler 的关系
- Scheduler中的 workLoop 中每执行一次 task,是通过调用 Reconciler 中的 performConcurrentWorkOnRoot 方法,即每一个 task 可以理解为是一个 performConcurrentWorkOnRoot 方法的调用。
- performConcurrentWorkOnRoot 方法每次调用,其本质是在执行 workLoopConcurrent 方法,这个方法是在循环 performUnitOfWork 这个构建 Fiber 树中每个 Fiber 的方法。
因此可以梳理出来,3个大循环,从最开始的 eventLoop 的单个宏任务执行,会逐步触发 Scheduler 和 Reconciler 的任务循环执行。
任务的中断与恢复,实现中断与恢复的逻辑分了2个部分,第一个是 Scheduler 中正在执行的 workloop 的任务中断,第二个是 Reconciler 中正在执行的 workLoopConcurrent 的任务中断
-
Reconciler 中的任务中断与恢复:在 workLoopConcurrent 的 while 循环中,通过 shouldYield() 方法来判断当前构建 fiber 树的执行过程是否超时,如果超时,则中断当前的 while 循环。由于每次 while 执行的 fiber 构建方法,即 performUnitOfWork 是按照每个 fiberNode 来遍历的,也就是说每完成一次 fiberNode 的 beginWork + completeWork 树的构建过程,会设置下一次 nextNode 的值 ,可以理解为中断时已经保留了下一次要构建的 fiberNode 指针,以至于不会下一次不知道从哪里继续。
-
Scheduler 中的任务中断与恢复:当执行任务时间超时后,如果 Reconciler 中的 performConcurrentWorkOnRoot 方法没有执行完成,会返回其自身。在 Scheduler 中,发现当前任务还有下一个任务没有执行完,则不会将当前任务从 taskQueue 中取出,同时会把 reconciler 中返回的待执行的回调函数继续赋值给当前任务,于是下一次继续启动 Scheduler 的任务时,也就连接上了。同时退出这次中断的任务前,会通过 messageChannel 向 eventLoop 的宏任务队列放入一个新的宏任务。
-
所以任务的恢复,其实就是从下一次 eventLoop 开始执行 Scheduler 相关的宏任务,而执行的宏任务也是 Reconciler 中断前赋值的 fiberNode,也就实现了整体的任务恢复。
8. 浏览器的绘制事件执行顺序( requestIdleCallback和requestAnimationFrame有什么区别?)
-
requestAnimationFrame的回调会在每一帧确定执行,属于高优先级任务,而requestIdleCallback的回调则不一定,属于低优先级任务。
-
我们所看到的网页,都是浏览器一帧一帧绘制出来的,通常认为FPS为60的时候是比较流畅的,而FPS为个位数的时候就属于用户可以感知到的卡顿了,那么在一帧里面浏览器都要做哪些事情呢,如下所示:
假如某一帧里面要执行的任务不多,在不到16ms(1000/60)的时间内就完成了上述任务的话,那么这一帧就会有一定的空闲时间,这段时间就恰好可以用来执行requestIdleCallback的回调
同步任务>promise等微任务>制作render树(display = ‘block’)>requestAnimationFrame>制作render树(height = ‘200px’(对比前一颗render的相同元素产生动画))>第一帧重绘完成>setTimeout等宏任务
HOOKS
useState
用于创建状态,参数可以传入一个变量或者一个函数(可以对初始值做一些计算),解构的形式来取值,state和setState
setState是异步的,同时多次执行只会执行最后一次,但是在settimout中是同步的
setState 会创建 update 对象挂到 fiber 对象上,然后调度 performSyncWorkOnRoot 重新渲染。 在 react17 中,setState 是批量执行的,因为执行前会设置 executionContext。但如果在 setTimeout、事件监听器等函数里,就不会设置 executionContext 了,这时候 setState 会同步执行。可以在外面包一层 batchUpdates 函数,手动设置下 excutionContext 来切换成异步批量执行。 在 react18 里面,如果用 createRoot 的 api,就不会有这种问题了。 setState 是同步还是异步这个问题等 react18 普及以后就不会再有了,因为所有的 setState 都是异步批量执行了。
useReducer
用于在修改值之前执行一些固定逻辑,可以传3个参数:1.reducer(state,action)=>newState,2,initState,3initializer类似usestate的 '()=>state'
useEffect
回答
jsx + react.createElement => vDom:js对象 vDom 与 curentFiber对比 (diff 算法); diff 算法: React16之前组件的更新是递归执行,所以更新一旦开始,中途就无法中断,当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿。
react16之后diff算法使用fiber架构,同时配合 Schedduler 的任务调度器(Scheduler是一个独立的包,不仅仅在React中可以使用。),在 Concurrent(并行) 模式下可以将 React 的组件更新任务变成可中断、恢复的执行,就减少了组件更新所造成的页面卡顿。
记录了chid(子),silbing(兄弟),return(父) 可以中断与恢复, 使用MessageChannel中的onmessage的回调函数是在一帧的paint完成之后调用的原理,利用绘制后的浏览器空余时间来执行任务; 在不支持MessageChannel的环境下会使用setTimeout来实现:比MessageChannel执行时机更慢,更久(差4ms)
如何判断是否要中断
使用schedule调度的形式进行计算,在源码中使用shouldYield()方法来判断是否要继续执行,
如何中断继续
以task形式度存于taskQueue ,workLoop会去taskQueue栈顶取task进行执行他的callback(构建fiber)执行完会将它从执行队列删除,当任务需要中断时会把本次callback存起来,且不删除队列,下次执行的时候则会继续执行
为什么hook需要写在第一层,不能写到函数或条件判断中
因为函数组件中的hook在mount时会创建一个有序hook链表,每个hook有一个next属性指向下一个hook。如果写到函数或条件判断中不能保证每次函数组件执行的时候hook链表和mount的时候一样导致异常。
react中为什么需要使用setState而不是直接state.value去改变状态
直接修改不能触发页面更新,immutability react的数据不可变性,setState会创建update,然后在queue队列中进行批量更新; 要知道setState本质是通过一个队列机制实现state更新的。 执行setState时,会将需要更新的state合并后放入状态队列,而不会立刻更新state,队列机制可以批量更新state,进而重新渲染页面。包括计算虚拟DOM树、找出差异、应用差异。
如果不通过setState而直接修改this.state,那么这个state不会放入状态队列中。不会触发重新渲 染。
or
执行setState后会调用dispatchAction,dispatchAction做的事情就是讲Update加入queue.pending中,然后开始调度。进而重新渲染页面。而直接state.value去改变状态则不会。
setState是同步还是异步
setState 并不是单纯的同步函数或者异步函数,他的同步和异步的表现差异体现在调用的场景不同。在React 的生命周期和合成事件中他表现为异步函数。而在DOM的原生事件等非合成事件中表现为同步函数
React 为什么需要合成事件
合成事件全部挂载到 document 节点上
-
减少内存消耗,提升性能,不需要注册那么多的事件了,一种事件类型只在 document 上注册一次。
-
统一规范,解决 ie 事件兼容问题,简化事件逻辑。
-
跨端复用。