面试:轻松拿捏React事件机制、fiber架构

发布于:2024-04-28 ⋅ 阅读:(19) ⋅ 点赞:(0)

前端面试刷题(JS篇):

前端面试刷题必备(手撕代码篇):

前端面试刷题必备(CSS篇):

前端面试刷题必备(性能优化篇):

前端面试React(基础篇):

1. React事件机制(面试简述版)

React的事件机制可以分为2个阶段:事件绑定事件触发

事件绑定

React提供了合成事件,合成事件与原生事件有着一定的对应关系。

有3个对象需要前置了解一下:

  1. registrationNameModule:包含了React事件与对应的plugin的映射。
{ 
    onBlur: SimpleEventPlugin, 
    onClick: SimpleEventPlugin, 
    onClickCapture: SimpleEventPlugin, 
    onChange: ChangeEventPlugin, 
    onChangeCapture: ChangeEventPlugin, 
    onMouseEnter: EnterLeaveEventPlugin, 
    onMouseLeave: EnterLeaveEventPlugin, 
    ... 
}
  1. registrationNameDependencies:包含了React事件到原生事件的映射。
{ 
    onBlur: ['blur'], 
    onClick: ['click'], 
    onClickCapture: ['click'], 
    onChange: ['blur', 'change', 'click', 'focus', 'input', 'keydown', 'keyup', 'selectionchange'],
    onMouseEnter: ['mouseout', 'mouseover'], 
    onMouseLeave: ['mouseout', 'mouseover'], 
    ... 
}
  1. plugins:这个对象就是上面注册的所有插件列表。
plugins = [
    LegacySimpleEventPlugin, 
    LegacyEnterLeaveEventPlugin, 
    ...
];

了解了上述3个对象的概念后,我们可以了解一下他的绑定流程:

  1. React执行diff操作,标记出那些DOM类型的节点需要添加或者更新。
  2. 当检测到需要创建/更新一个节点的时候,使用registrationNameModule查看一个prop是否是一个事件类型。
  3. 如果是一个事件类型,则通过registrationNameDependencies检查这个事件依赖了哪些原生事件类型
  4. 检查这些一个或多个原生事件类型有没有注册过,如果有则忽略。
  5. 如果这个原生事件类型没有注册过,则注册这个原生事件到 document 上,回调为React提供的dispatchEvent函数

所以react中所有事件类型都注册到了document上,react17及之后是委托到了根节点。

所有原生事件的 listener 都是dispatchEvent函数,而且同一类型的事件React只会绑定一次原生事件

并没有将我们业务逻辑里的listener绑定在原生事件上,也没有去维护一个类似eventlistenermap的东西存放我们的listener

事件触发

所有的类型事件都绑定了React的dispatchEvent函数。

整个触发流程:

  1. 任意一个事件触发,执行 dispatchEvent 函数
  2. dispatchEvent 执行 batchedEventUpdates(handleTopLevel)batchedEventUpdates 会打开批量渲染开关并调用 handleTopLevel
  3. handleTopLevel 会依次执行 plugins 里所有的事件插件。
  4. 如果一个插件检测到自己需要处理的事件类型时,则处理该事件。

而事件的处理逻辑如下:

  1. 通过原生事件类型决定使用哪个合成事件类型
  2. 在react17之前,如果事件池里有这个类型的实例,则取出这个实例,覆盖其属性,作为本次派发的事件对象(事件对象复用),若没有则新建一个实例。

事件池(Event Pooling):在 16.8 及之前的版本,react 为了更好的性能管理会尝试重用事件,即 react 会保存引用,只是修改对应的属性值,异步地方式去读取 event 的属性会有问题,因为 event 是一个合成事件,当异步读取时,event 已经被回收了,所以会报错。

解决:e.persist()

  1. 从触发的原生事件中找到对应的DOM节点,找到最近的React组件实例,从而找到一条不断向上组成的链,这个链就是我们要触发的合成事件的链。
  2. 反向触发这条链,父 -> 子,模拟的是捕获阶段
  3. 正向触发这条链,子 -> 父,模拟冒泡阶段

React的冒泡和捕获并不是真正的DOM 级别的冒泡和捕获(17中支持了原生捕获事件)。

React 会在一个原生事件里触发所有相关节点的 onClick 事件, 在执行这些onClick之前 React 会打开批量渲染开关,这个开关会将所有的setState变成异步函数。

原文:

react 渲染原理

2个模块:

  • fiber架构
  • concurrency:concurrent mode【这个是react18之前的叫法,18更名为concurrency】,中文意思并发性,即可中断渲染。

react 首次渲染一个组件到页面中需要做哪些【不考虑babel编译jsx流程】

  1. 拿到 React.createElement 返回的 react 节点【就是一个对象】

    最终会拿到一个树形结构的对象,如果是组件也会生成对应的 react 节点,就是 type 的值是 Component

  2. 通过 render 方法进行渲染 render方法进行渲染要做的事情有很多:

    • 如果是组件节点,则会在执行渲染的过程中保存对应的 Hooks 以及触发对应的 hooks【比如说像 useState 是要立即触发的,useEffect 是要留存下来等到后续 dom 挂载完毕以后触发的】

    • 如果是 react 元素节点,不会生成对应的真实 dom,而是生成一个描述对象【描述了当前要创建的真实 dom 的一些信息,以及这个描述对象要做的操作】这个描述对象叫 fiber

  3. 通过整个清单会依次将清单内部的东西编译成真实 dom,然后插入父元素的子节点 appendChild

  4. 等整个渲染流程结束以后,得到一个完整的真实 dom 树,然后插入到页面中

  5. 触发对应的生命周期事件

react 更新一个组件到页面中

也会去生成一个新的 react 节点,但是每次更新都回去重新生成么?

  1. 不会全部重新生成,比如说 Counter 组件状态变化了,那么 Counter 及以下的所有元素全部重新渲染【重新生成 react】
  2. 直接进入 diff 阶段【diff 算法】,比较以 Counter 节点为根元素的两棵树的差异【因为就算是组件重新渲染了,也只是生成一个新的这个 react 节点对象,不意味着一定要变化最终的真实 dom】
  3. diff 算法完结之后也会生成一个清单,这个清单里也都是 fiber,此时每个 fiber 的操作可以是 create\delete\update 中的一个,一个节点的 className 发生变化了我们可以只用 update
  4. 最终将差异点应用到真实 dom 上去
  5. 触发对应的生命周期

React的concurrency

问题:如果我们元素和组件写的够多,那么执行react.createElement和render这2个方法的时间就越长,但是游览器一帧要控制在16ms以内,超过了时间就会掉帧,用户的交互就会失效。

但是我们肯定希望总代码量是不会变化的,而且不希望出现掉帧的问题。

在react的渲染中,我们可以把渲染拆分成2个阶段:

  1. render:(耗时) 执行自己的逻辑以及 react 代码逻辑的节点【负责将需要渲染的组件【首次渲染就是 App 组件,更新阶段就是哪个组件需要更新就是哪个】的内部逻辑以及 react 的内部逻辑进行执行,并最终得出一份 fiber 清单【记录了最重要展示给用户看的真实 dom 树是什么样】】
  2. 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实现)

目前 requestIdleCallback 目前只有Chrome支持。所以目前 React 。它利用 模拟将回调延迟到'绘制操作'之后执行

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就是通过returnchildsibling将每一个 fiber 对象联系起来。

优先级相关

  • lanes:通过不同过期时间,判断任务是否过期, 在v17版本用lane表示,之前版本expirationTime

缓存树相关

  • alternate:指向workInProgress fiber树中对应的节点;更新阶段,两棵树互相交替。

当前页面所对应的 fiber 树称为 current Fiber,同时 react 会根据新的状态构建一颗新的 fiber 树,称为 workInProgress Fiber

其他信息

  • mode:描述fiber树的模式,比如ConcurrentMode 模式
  • effectTag:effect标签,用于收集effectList
  • nextEffect:指向下一个effect
  • ...

Fiber更新机制

初始化

  1. 创建fiberRoot和rootFiber,并建立关联。

    • fiberRoot: 首次构建应用, 创建一个 fiberRoot ,作为整个 React 应用的根基,只能有一个。
    • rootFiber:通过 ReactDOM.render 渲染出来的,一个 React 应用可以有多 ReactDOM.render 创建的 rootFiber ,但是只能有一个 fiberRoot(应用根节点)
  2. 正式渲染,复用当前current树中的alternate作为workInProgress,如果没有则会创建一个fiber作为workInProgress

    • current: 正在视图层渲染的树叫做 current 树。
    • workInProgress: 正在内存中构建的 Fiber 树称为 workInProgress Fiber 树。在一次更新中,所有的更新都是发生在 workInProgress 树上。更新之后会变成current树作为渲染视图。
  3. 深度调和子节点并渲染

    在新创建的 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树。

第一轮遍历(遍历新旧子节点):

  • 新旧子节点的keytype都相同,则根据旧fiber新的ReactElement的props生成新fiber
  • 新旧子节点的 key 相同,但 type 不同,将根据新 ReactElement 生成新 fiber,旧 fiber 将被添加到它的父级 fiber 的 deletions 数组中,后续将被移除。
  • 如果新旧子节点的 keytype 都不相同,结束遍历。

第二轮遍历:

如果第一轮提前结束了说明还没被遍历完,就会有第二次遍历

只剩下子节点

将剩余的旧 fiber 放到父 fiber 的 deletions 数组中,这些旧 fiber 对应的 DOM 节点将会在 commit 阶段被移除。

只剩下新子节点

创建新的 fiber 节点,然后打上 Placement 标记,我们将在遍历 fiber 树的「归」阶段生成这些新 fiber 对应的 DOM 节点。

新旧都有剩

  1. 遍历剩下未处理的旧子节点,生成existingChildren Map
  2. 遍历新子节点
    • 如果能在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 标志

  3. 遍历 existingChildren Map,将 Map 中所有节点添加到父节点的 deletions 数组中

DOM变更

在 commit 阶段,深度优先遍历每个新 fiber 节点,对 fiber 节点对应的 DOM 节点做以下变更:

  1. 删除 deletions 数组中 fiber 对应的 DOM 节点
  2. 如有 Placement 标志,将节点移动到往后第一个没有 Placement 标记的 fiber 的 DOM 节点之前。
  3. 更新节点。

提问:什么情况下,React的diff算法表现会不太好

在处理节点往前移的情况,React 的 diff 算法表现得就不太好了


网站公告

今日签到

点亮在社区的每一天
去签到