面试:轻松拿捏React事件机制、fiber架构
1. React事件机制(面试简述版)
React的事件机制可以分为2个阶段:事件绑定和事件触发。
事件绑定
React提供了合成事件,合成事件与原生事件有着一定的对应关系。
有3个对象需要前置了解一下:
- registrationNameModule:包含了React事件与对应的
plugin
的映射。
{
onBlur: SimpleEventPlugin,
onClick: SimpleEventPlugin,
onClickCapture: SimpleEventPlugin,
onChange: ChangeEventPlugin,
onChangeCapture: ChangeEventPlugin,
onMouseEnter: EnterLeaveEventPlugin,
onMouseLeave: EnterLeaveEventPlugin,
...
}
- registrationNameDependencies:包含了React事件到原生事件的映射。
{
onBlur: ['blur'],
onClick: ['click'],
onClickCapture: ['click'],
onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'],
onMouseEnter: ['mouseout', 'mouseover'],
onMouseLeave: ['mouseout', 'mouseover'],
...
}
- plugins:这个对象就是上面注册的所有插件列表。
plugins = [
LegacySimpleEventPlugin,
LegacyEnterLeaveEventPlugin,
...
];
了解了上述3个对象的概念后,我们可以了解一下他的绑定流程:
- React执行diff操作,标记出那些
DOM类型
的节点需要添加或者更新。 - 当检测到需要创建/更新一个节点的时候,使用
registrationNameModule
查看一个prop是否是一个事件类型。 - 如果是一个事件类型,则通过
registrationNameDependencies
检查这个事件依赖了哪些原生事件类型
- 检查这些一个或多个原生事件类型有没有注册过,如果有则忽略。
- 如果这个原生事件类型没有注册过,则注册这个原生事件到
document
上,回调为React提供的dispatchEvent函数
。
所以react中所有事件类型都注册到了document上,react17及之后是委托到了根节点。
所有原生事件的 listener 都是
dispatchEvent
函数,而且同一类型的事件React只会绑定一次原生事件并没有将我们业务逻辑里的listener绑定在原生事件上,也没有去维护一个类似
eventlistenermap
的东西存放我们的listener
事件触发
所有的类型事件都绑定了React的dispatchEvent
函数。
整个触发流程:
- 任意一个事件触发,执行
dispatchEvent 函数
。 dispatchEvent
执行batchedEventUpdates(handleTopLevel)
,batchedEventUpdates
会打开批量渲染开关并调用handleTopLevel
。handleTopLevel
会依次执行plugins
里所有的事件插件。- 如果一个插件检测到自己需要处理的事件类型时,则处理该事件。
而事件的处理逻辑如下:
- 通过原生事件类型决定使用哪个合成事件类型
- 在react17之前,如果事件池里有这个类型的实例,则取出这个实例,覆盖其属性,作为本次派发的事件对象(事件对象复用),若没有则新建一个实例。
事件池(Event Pooling):在 16.8 及之前的版本,react 为了更好的性能管理会尝试重用事件,即 react 会保存引用,只是修改对应的属性值,异步地方式去读取 event 的属性会有问题,因为 event 是一个合成事件,当异步读取时,event 已经被回收了,所以会报错。
解决:e.persist()
- 从触发的原生事件中找到对应的DOM节点,找到最近的React组件实例,从而找到一条不断向上组成的链,这个链就是我们要触发的合成事件的链。
- 反向触发这条链,父 -> 子,模拟的是捕获阶段
- 正向触发这条链,子 -> 父,模拟冒泡阶段
React的冒泡和捕获并不是真正的DOM 级别的冒泡和捕获(17中支持了原生捕获事件)。
React 会在一个原生事件里触发所有相关节点的
onClick
事件, 在执行这些onClick
之前 React 会打开批量渲染开关,这个开关会将所有的setState
变成异步函数。
react 渲染原理
2个模块:
- fiber架构
- concurrency:concurrent mode【这个是react18之前的叫法,18更名为concurrency】,中文意思并发性,即可中断渲染。
react 首次渲染一个组件到页面中需要做哪些【不考虑babel编译jsx流程】
-
拿到 React.createElement 返回的 react 节点【就是一个对象】
最终会拿到一个树形结构的对象,如果是组件也会生成对应的 react 节点,就是
type
的值是 Component -
通过 render 方法进行渲染 render方法进行渲染要做的事情有很多:
-
如果是组件节点,则会在执行渲染的过程中保存对应的 Hooks 以及触发对应的 hooks【比如说像 useState 是要立即触发的,useEffect 是要留存下来等到后续 dom 挂载完毕以后触发的】
-
如果是 react 元素节点,不会生成对应的真实 dom,而是生成一个描述对象【描述了当前要创建的真实 dom 的一些信息,以及这个描述对象要做的操作】这个描述对象叫 fiber
-
-
通过整个清单会依次将清单内部的东西编译成真实 dom,然后插入父元素的子节点 appendChild
-
等整个渲染流程结束以后,得到一个完整的真实 dom 树,然后插入到页面中
-
触发对应的生命周期事件
react 更新一个组件到页面中
也会去生成一个新的 react 节点,但是每次更新都回去重新生成么?
- 不会全部重新生成,比如说 Counter 组件状态变化了,那么 Counter 及以下的所有元素全部重新渲染【重新生成 react】
- 直接进入 diff 阶段【diff 算法】,比较以 Counter 节点为根元素的两棵树的差异【因为就算是组件重新渲染了,也只是生成一个新的这个 react 节点对象,不意味着一定要变化最终的真实 dom】
- diff 算法完结之后也会生成一个清单,这个清单里也都是 fiber,此时每个 fiber 的操作可以是 create\delete\update 中的一个,一个节点的 className 发生变化了我们可以只用 update
- 最终将差异点应用到真实 dom 上去
- 触发对应的生命周期
React的concurrency
问题:如果我们元素和组件写的够多,那么执行react.createElement和render这2个方法的时间就越长,但是游览器一帧要控制在16ms以内,超过了时间就会掉帧,用户的交互就会失效。
但是我们肯定希望总代码量是不会变化的,而且不希望出现掉帧的问题。
在react的渲染中,我们可以把渲染拆分成2个阶段:
- render:(耗时) 执行自己的逻辑以及 react 代码逻辑的节点【负责将需要渲染的组件【首次渲染就是 App 组件,更新阶段就是哪个组件需要更新就是哪个】的内部逻辑以及 react 的内部逻辑进行执行,并最终得出一份 fiber 清单【记录了最重要展示给用户看的真实 dom 树是什么样】】
- commit:(不耗时)根据上阶段提供的描述表格将虚拟 dom 映射到真实 dom 这个节点并塞入到页面【该阶段就是创建真实 dom 然后塞入到页面中】
因此我们需要对render阶段进行优化
- requestAnimationFrame: 一帧内必定要执行的函数
requestAnimationFrame(cb) // 这个cb会在游览器每一帧重排前都会执行
- requestIdleCallback: 一帧内如果有空闲时间就执行的函数
requestIdleCallback(cb) // 这个cb会在每一帧还有多余时间的时候去执行
- 执行我们自己的工作的时候,去将一个大的任务拆分成多个任务去执行,让每一帧的最大可渲染单元为组件【当然如果本帧时间充裕的话会渲染多个组件的,立马推入下一帧】如果说有连续两帧都没时间 是不是渲染被推后了两帧,也意味着停止了两帧没有进行渲染,停止就是中断。所以这是可中断渲染。
render 阶段用户看到的是什么?
首次渲染:
- 白屏 2s 内可以被接受 用户可不可以进行交互?
- 整个页面都是由 react 写的,而且只有一个根组件,这种情况用户是没法和页面交互的
-
- 整个页面只有一部分是由 react 接管和管理的,或者说整个页面有多个根组件【多个 React 容器】,这种情况用户是可以和页面交互的,因为不被 react 所管理的地方可能已经渲染出来了
更新时:
- 更新前的画面,用户可以进行页面交互
说说fiber
出现原因
在react15及之前是没有fiber的,react执行一次更新操作都是同步的,他是通过递归去进行更新,一旦开始就不会中断直到结束。这也就造成了页面性能的低下,体验非常差。
而fiber的出现,他将更新渲染耗时长的大任务变成很多小切片,小切片执行完后就去执行高优先级的任务,比如:用户点击输入等等,将不可中断的渲染变成可中断的渲染,提高了页面的流畅度和性能。
有了fiber,React 渲染的过程可以被中断,可以将控制权交回浏览器,让位给高优先级的任务,浏览器空闲后再恢复渲染。它采用的是一种主动让出机制。(可以由游览器给我们分配执行时间片,通过requestIdleCallback实现)
fiber构成
fiber保存了DOM相关信息以及与其他fiber的相关引用等等,在React中是最小粒度的执行单元。
DOM相关信息(此处列举部分):
- tag: 组件类型,取决于react的元素类型
- key: 唯一标识
- elementType: 元素类型
- type:// 定义与此fiber关联的功能或类。对于组件,它指向构造函数;对于DOM元素,它指定HTML tag
- stateNode: any, // 真实 dom 节点
fiber链表树相关
- return:父 fiber
- child:第一个子 fiber
- sibling:下一个兄弟 fiber
- index:在父 fiber 下面的子 fiber 中的下标
- ref:ref指向,ref函数或者ref对象
fiber就是通过return
、child
、sibling
将每一个 fiber 对象联系起来。
优先级相关
- lanes:通过不同过期时间,判断任务是否过期, 在v17版本用lane表示,之前版本expirationTime
缓存树相关
- alternate:指向workInProgress fiber树中对应的节点;更新阶段,两棵树互相交替。
当前页面所对应的 fiber 树称为 current Fiber,同时 react 会根据新的状态构建一颗新的 fiber 树,称为 workInProgress Fiber
其他信息
- mode:描述fiber树的模式,比如ConcurrentMode 模式
- effectTag:effect标签,用于收集effectList
- nextEffect:指向下一个effect
- ...
Fiber更新机制
初始化
-
创建fiberRoot和rootFiber,并建立关联。
- fiberRoot: 首次构建应用, 创建一个 fiberRoot ,作为整个 React 应用的根基,只能有一个。
- rootFiber:通过 ReactDOM.render 渲染出来的,一个 React 应用可以有多 ReactDOM.render 创建的 rootFiber ,但是只能有一个 fiberRoot(应用根节点)
-
正式渲染,复用当前
current树
中的alternate
作为workInProgress
,如果没有则会创建一个fiber
作为workInProgress
。- current: 正在视图层渲染的树叫做 current 树。
- workInProgress: 正在内存中构建的 Fiber 树称为 workInProgress Fiber 树。在一次更新中,所有的更新都是发生在 workInProgress 树上。更新之后会变成current树作为渲染视图。
-
深度调和子节点并渲染
在新创建的
alternates
上,完成整个fiber 树
的遍历,包括 fiber 的创建。最后会以workInProgress
作为最新的渲染树,fiberRoot 的 current 指针指向 workInProgress 使其变为 current Fiber 树。到此完成初始化流程。
更新
会走上述的逻辑,将 current 的 alternate 作为基础,复制一份作为 workInProgresss,由于初始化 rootfiber 有 alternate ,所以对于剩余的子节点,React 还需要创建一份,和 current 树上的 fiber 建立起 alternate 关联。渲染完毕后,workInProgresss 再次变成 current 树。
React fiber采用了双缓存技术,用workInProgress 树(内存中构建的树) 和 current (渲染树) 来实现更新逻辑。
React的diff
React的更新会经历2个阶段:render阶段和commit阶段。diff就是发生在render阶段。通过深度优先遍历来生成fiber树。
实际上就是新的ReactElement与旧的fiber树做比较,构建新的fiber树。
第一轮遍历(遍历新旧子节点):
- 新旧子节点的
key
和type
都相同,则根据旧fiber
和新的ReactElement的props生成新fiber
- 新旧子节点的
key 相同
,但type 不同
,将根据新 ReactElement 生成新 fiber,旧 fiber 将被添加到它的父级 fiber 的 deletions 数组中,后续将被移除。 - 如果新旧子节点的
key
和type
都不相同,结束遍历。
第二轮遍历:
如果第一轮提前结束了说明还没被遍历完,就会有第二次遍历
只剩下子节点
将剩余的旧 fiber 放到父 fiber 的 deletions 数组中,这些旧 fiber 对应的 DOM 节点将会在 commit 阶段被移除。
只剩下新子节点
创建新的 fiber 节点,然后打上 Placement 标记,我们将在遍历 fiber 树的「归」阶段生成这些新 fiber 对应的 DOM 节点。
新旧都有剩
- 遍历剩下未处理的旧子节点,生成
existingChildren Map
- 遍历新子节点
-
如果能在
existingChildren Map
找到对应的旧fiber,则根据旧fiber生成新fiber,打上Placement标志。 -
删除
existingChildren Map
中已经处理掉的节点 -
如果新子节点有对应的旧fiber,当oldIndex < lastPlacedIndex,给新fiber打上Placement标识;否则lastPlacedIndex = newIndex
- oldIndex: 旧 fiber 上有 index 属性,index 属性记录了在上一次渲染时该 fiber 所在的位置索引
- lastPlacedIndex: 遍历新子节点过程中访问过的最大 oldIndex
- 只要当前新子节点有对应的旧 fiber,且 oldIndex < lastPlacedIndex,就可以认为该新子节点对应的 DOM 节点需要往后移动,并打上一个 Placement 标志,以便于在 commit 阶段识别出这个需要移动 DOM 节点的 fiber
-
如果新子节点没有对应的旧 fiber,创建一个新 fiber 并 打上 Placement 标志
-
- 遍历 existingChildren Map,将 Map 中所有节点添加到父节点的 deletions 数组中
DOM变更
在 commit 阶段,深度优先遍历每个新 fiber 节点,对 fiber 节点对应的 DOM 节点做以下变更:
- 删除 deletions 数组中 fiber 对应的 DOM 节点
- 如有 Placement 标志,将节点移动到往后第一个没有 Placement 标记的 fiber 的 DOM 节点之前。
- 更新节点。
提问:什么情况下,React的diff算法表现会不太好
在处理节点往前移的情况,React 的 diff 算法表现得就不太好了
转载自:https://juejin.cn/post/7362576303953264650