13.react

53 min

react 的渲染分哪几个阶段

React 渲染主要分为两个阶段:Render 阶段和 Commit 阶段。Render(也叫 reconciliation 阶段)

render 阶段里面会经由调度器和协调器处理,此过程是在内存中运行,是异步可中断的。这个阶段的产物是生成 effect list(副作用列表,记录要做哪些更新)。

commit 阶段会由渲染器进行处理,根据副作用进行 UI 的更新,此过程是同步不可中断的,否则会造成 UI 和数据显示不一致。这个阶段的产物是更新真实 DOM,执行副作用(如 useEffect、生命周期)。

介绍一下 scheduler

React 的目标之一是实现高性能的 UI 更新,尤其是在复杂页面和慢设备上。为了避免一次性处理大量更新造成主线程阻塞、掉帧卡顿,React 从 16 开始引入了 Fiber 架构,而 Scheduler 正是这套架构背后负责“任务优先级控制和调度”的核心模块。

scheduler 是 React 独立出来的一个调度器库,用于按优先级分配和安排任务执行时间,实现任务的中断、恢复与抢占,确保更紧急的任务优先执行,提升交互体验。

是 render 的第一阶段

  • 分配优先级:React 定义了多个优先级(如 Immediate, UserBlocking, Normal, Idle),由 scheduler 控制任务处理顺序。
  • 可中断渲染:messageChannel 创建宏任务,任务可以在需要时中断,让出主线程
  • 时间切片:把大的任务拆成小的任务,每帧执行一部分,避免主线程卡顿
  • 任务过期控制:Scheduler 会跟踪任务是否过期,决定是否立即同步执行

核心数组:

  • taskQueue 普通任务,
  • timerQueue 延时任务,advancerTimer 方法:遍历整个 timerQueue,查看是否有已经过期的方法,如果有,不是说直接执行,而是将这个过期的方法添加到 taskQueue 里面

核心算法:

小顶堆算法 始终取出优先级最高的任务

描述下 React 的任务调度机制?

React 中实现了一个单线程任务调度器,使用最小堆的数据结构管理这些任务,每次来了新的任务都会先放入最小堆任务池中。 在时间切片内,循环执行任务,如果超时,那么再次重新调度。这样就避免了一些高优先级任务因为来得晚而迟迟得不到处理的问题,从而提升页面流畅度。 任务执行的顺序取决于他们的优先级与过期时间,所以值越小,证明这个任务越应该先被执行,而单线程任务调度器每次只能执行一个最任务,因此采用最小堆的数据结构

scheduler 为什么选择 message channel,而不是 requestIdleCallback,微任务,requestAnimationFrame,setTimeout

MessageChannel 接口本身是用来做消息通信的,允许我们创建一个消息通道,通过它的两个 MessagePort 来进行信息的发送和接收。 我们有说过 scheduler 是用来调度任务,调度任务需要满足两个条件:

  • JS 暂停,将主线程还给浏览器,让浏览器能够有序的重新渲染页面
  • 暂停了的 JS(说明还没有执行完),需要再下一次接着来执行

那么这里自然而然就会想到事件循环,我们可以将没有执行完的 JS 放入到任务队列,下一次事件循环的时候再取出来执行。

那么,如何将没有执行完的任务放入到任务队列中呢?

那么这里就需要产生一个任务(宏任务),这里就可以使用 MessageChannel,因为 MessageChannel 能够产生宏任务。

为什么没有选择 requestIdleCallback

  • 兼容性问题,一个是不是所有浏览器都支持,caniuse 79%,另一个是 React 是一个跨平台,跨浏览器的解决方案,不能依赖于特定 API
  • 无法保证优先级
  • 无法保证任务的执行时间

为什么不选择 setTimeout

以前要创建一个宏任务,可以采用 setTimeout(fn, 0) 这种方式,但是 react 团队没有采用这种方式。

这是因为 setTimeout 在嵌套层级超过 5 层,timeout(延时)如果小于 4ms,那么则会设置为 4ms。而 scheduler 的时间切片是 5ms

为什么没有选择 requestAnimationFrame

这个也不合适,因为这个只能在重新渲染之前,才能够执行一次,而如果我们包装成一个任务,放入到任务队列中,那么只要没到重新渲染的时间,就可以一直从任务队列里面获取任务来执行。

而且 requestAnimationFrame 还会有一定的兼容性问题,safari 和 edge 浏览器是将 requestAnimationFrame 放到渲染之后执行的,chrome 和 firefox 是将 requestAnimationFrame 放到渲染之前执行的,所以这里存在不同的浏览器有不同的执行顺序的问题。

根据标准,应该是放在渲染之前。

为什么没有选择包装成一个微任务?

这是因为和微任务的执行机制有关系,微任务队列会在清空整个队列之后才会结束。那么微任务会在页面更新前一直执行,直到队列被清空,达不到将主线程还给浏览器的目的

react 时间切片是什么?

可以简单理解为一个时间段。

现在广泛使用的屏幕的刷新率一般是 60Hz,而在两次硬件刷新之间浏览器进行两次重绘是没有意义的,只会消耗性能。因此浏览器会利用这个时间间隔 1000ms/60 适当的对绘制进行节流,因此 16ms 就成为渲染页面的一个关键时间。

React 中使用的是 5ms,并没有使用传统的 16ms,也就是说没有实现帧对齐,因为大部分任务不需要与帧对齐,如果需要的话,可以使用 requestAnimationFrame。

调度器周期性的执行任务,防止主线程上还有其他高优先级任务,如用户交互事件。默认情况下,每帧内周期性执行几次

React 中哪些地方用到了位运算?

位运算可以很方便的表达“增、删、改、查”。在 React 内部,像 flags、状态、优先级等操作都大量使用到了位运算。

  • 增:使用或运算即可。
  • 删:使用异或
  • 判断是否有某一个权限:使用与来进行判断

在 react 中:

  • 用来标记 fiber 操作的 flags,使用的就是二进制;针对一个 fiber 的操作,可能有增加、删除、修改,但是我不直接进行操作,而是给这个 fiber 打上一个 flag,接下来在后面的流程中针对有 flag 的 fiber 统一进行操作。通过位运算,就可以很好的解决一个 fiber 有多个 flag 标记的问题,方便合并多个状态
  • lane 模型:优先级机制,相比 Scheduler,lane 模型能够对任务进行更细粒度的控制,
  • 上下文

是否了解过 React 中的 lane 模型?为什么要从之前的 expirationTime 模型转换为 lane 模型?

在 React 中有一套独立的粒度更细的优先级算法,这就是 lane 模型。

这是一个基于位运算的算法,每一个 lane 是一个 32 bit Integer,不同的优先级对应了不同的 lane,越低的位代表越高的优先级。

早期的 React 并没有使用 lane 模型,而是采用的的基于 expirationTime 模型的算法,但是这种算法耦合了“优先级”和“批”这两个概念,限制了模型的表达能力。优先级算法的本质是“为 update 排序”,但 expirationTime 模型在完成排序的同时还默认的划定了“批”。

使用 lane 模型就不存在这个问题,因为是基于位运算,所以在批的划分上会更加的灵活。

lane <=> EventPriority <=> Scheduler 优先级

Reconciler

render 的第二阶段

递归

  • 递:beginWork 根据传入的 FiberNode,创建下一级 FiberNode
  • 归:completeWork

Diff 算法是怎么样的

diff 计算发生在更新阶段,当第一次渲染完成后,就会产生 Fiber 树,再次渲染的时候(更新),就会拿新的 JSX 对象(vdom)和旧的 FiberNode 节点进行一个对比,再决定如何来产生新的 FiberNode,它的目标是尽可能的复用已有的 Fiber 节点。这个就是 diff 算法。

在 React 中整个 diff 分为单节点 diff 和多节点 diff。

所谓单节点是指新的节点为单一节点,但是旧节点的数量是不一定的。

单节点 diff 是否能够复用遵循如下的顺序:

  1. 判断 key 是否相同

    • 如果更新前后均未设置 key,则 key 均为 null,也属于相同的情况

    • 如果 key 相同,进入步骤二

    • 如果 key 不同,则无需判断 type,结果为不能复用(有兄弟节点还会去遍历兄弟节点)

  2. 如果 key 相同,再判断 type 是否相同

    • 如果 type 相同,那么就复用
    • 如果 type 不同,则无法复用(并且兄弟节点也一并标记为删除)

多节点 diff 会分为两轮遍历:

第一轮遍历会从前往后进行遍历,存在以下三种情况:

  • 如果新旧子节点的 key 和 type 都相同,说明可以复用
  • 如果新旧子节点的 key 相同,但是 type 不相同,这个时候就会根据 ReactElement 来生成一个全新的 fiber,旧的 fiber 被放入到 deletions 数组里面,回头统一删除。但是注意,此时遍历并不会终止
  • 如果新旧子节点的 key 和 type 都不相同,结束遍历

如果第一轮遍历被提前终止了,那么意味着还有新的 JSX 元素或者旧的 FiberNode 没有被遍历,因此会采用第二轮遍历去处理。

第二轮遍历会遇到三种情况:

  • 只剩下旧子节点:将旧的子节点添加到 deletions 数组里面直接删除掉(删除的情况)

  • 只剩下新的 JSX 元素:根据 ReactElement 元素来创建 FiberNode 节点(新增的情况)

  • 新旧子节点都有剩余:会将剩余的 FiberNode 节点放入一个 map 里面,遍历剩余的新的 JSX 元素,然后从 map 中去寻找能够复用的 FiberNode 节点,如果能够找到,就拿来复用。(移动的情况)

    如果不能找到,就新增呗。然后如果剩余的 JSX 元素都遍历完了,map 结构中还有剩余的 Fiber 节点,就将这些 Fiber 节点添加到 deletions 数组里面,之后统一做删除操作

整个 diff 算法最最核心的就是两个字“复用”。

React 不使用双端 diff 的原因:

由于双端 diff 需要向前查找节点,但每个 FiberNode 节点上都没有反向指针,即前一个 FiberNode 通过 sibling 属性指向后一个 FiberNode,只能从前往后遍历,而不能反过来,因此该算法无法通过双端搜索来进行优化。

React 想看下现在用这种方式能走多远,如果这种方式不理想,以后再考虑实现双端 diff。React 认为对于链表反转和需要进行双端搜索的场景是少见的,所以在这一版的实现中,先不对 bad case 做额外的优化。

对比 React18 与 Vue3-VDOM-DIFF?

  1. 子节点数据结构上:react 的 old 是单链表,vue 的 old 是数组,因此 React 只能单向查找,vue 双向查找
  2. 哈希表:为了快速通过 key 值找到节点,双方都用到了 map,React 根据 old 做出 map(value 是节点),vue 则是根据 new 做出 map(value 是 index, 因为可以根据 数组 [index] 找到节点);
  3. 如果 old 和 new 其中一方已经遍历完毕,两者处理相同,这也是必然的。
  4. vue 用到了 LIS(最长递增子序列)

延申:为什么 map 不是 object?

vdom diff 怎么确定节点的新增,删除,修改,怎么确定是要新增还是更新呢,如果没有 key 呢

React 的 VDOM diff 会通过 type 和 key 判断节点是否相同。如果 type 和 key 相同,就做属性和子节点更新;如果不同,就删除旧节点、新增新节点。

对于列表,带 key 时会用 key 做映射,精准定位新增、删除、移动;没有 key 时按顺序比较,容易误判修改,导致性能下降。建议列表场景一定加 key,推荐用稳定 ID,不用索引。

如果都没有设置 key,则认为都是 null,key 相同,则继续比较 type

JSX 是什么?

React 中用 jsx 来描述 view。 17 之前需要导入 React,否则会报错,因为 jsx 转换使用的是 React.createElement 17 之后,新的 jsx 自动从 React package 中引入新的入口函数并调用。

vdom 是什么,为什么要使用它?

vdom 最初是由 React 团队所提出的概念,这是一种编程的思想,指的是针对真实 UI DOM 的一种描述能力。 在 React 中,使用了 JS 对象来描述真实的 DOM 结构。vdom 和 JS 对象之间的关系:前者是一种思想,后者是这种思想的具体实现。 使用 vdom 有如下的优点:

  • 相较于 DOM 的体积和速度优势
    • JS 层面的计算的速度,要比 DOM 层面的计算快得多,且 DOM 上面的属性也是非常多的
    • vdom 发挥优势的时机主要体现在更新的时候,相比较 innerHTML 要将已有的 DOM 节点全部销毁,vdom 能够做到针对 DOM 节点做最小程度的修改
  • 多平台渲染的抽象能力
    • 浏览器、Node.js 宿主环境使用 ReactDOM 包
    • Native 宿主环境使用 ReactNative 包
    • Canvas、SVG 或者 VML(IE8)宿主环境使用 ReactArt 包
    • ReactTest 包用于渲染出 JS 对象,可以很方便地测试“不隶属于任何宿主环境的通用功能”

在 React 中,通过 JSX 来描述 UI,JSX 仅仅是一个语法糖,会被 Babel 编译为 createElement 方法的调用。该方法调用之后会返回一个 JS 对象,该对象就是 vdom 对象,官方更倾向于称之为一个 React 元素。

在循环渲染多个组件的时候,key 如何取值?

因为在协调阶段,组件复用的前提是同时满足三个条件,同一层级,同一类型,同一 key;

key 决定节点在当前层级下的唯一性,因此尽量不要取值 index,因为多个循环下 index 容易重复,并且如果涉及节点的增加删除移动,key 的稳定性会被破坏,节点就会出现混乱

介绍一下 Fiber 新架构

React v15 及其之前的架构:

  • Reconciler(协调器):VDOM 的实现,负责根据自变量变化计算出 UI 变化
  • Renderer(渲染器):负责将 UI 变化渲染到宿主环境中

这种架构称之为 Stack 架构,在 Reconciler 中,mount 的组件会调用 mountComponent,update 的组件会调用 updateComponent,这两个方法都会递归更新子组件,更新流程一旦开始,中途无法中断。

但是随着应用规模的逐渐增大,之前的架构模式无法再满足“快速响应”这一需求,主要受限于如下两个方面:

  • CPU 瓶颈:由于 VDOM 在进行差异比较时,采用的是递归的方式,JS 计算会消耗大量的时间,从而导致动画、还有一些需要实时更新的内容产生视觉上的卡顿。
  • I/O 瓶颈:由于各种基于“自变量”变化而产生的更新任务没有优先级的概念,因此在某些更新任务(例如文本框的输入)有稍微的延迟,对于用户来讲也是非常敏感的,会让用户产生卡顿的感觉。

新的架构称之为 Fiber 架构:

  • Scheduler(调度器):调度任务的优先级,高优先级任务会优先进入到 Reconciler
  • Reconciler(协调器):VDOM 的实现,负责根据自变量变化计算出 UI 变化
  • Renderer(渲染器):负责将 UI 变化渲染到宿主环境中

首先引入了 Fiber 的概念,通过一个对象来描述一个 DOM 节点,但是和之前方案不同的地方在于,每个 Fiber 对象之间通过链表的方式来进行串联。通过 child 来指向子元素,通过 sibling 指向兄弟元素,通过 return 来指向父元素。

在新架构中,Reconciler 中的更新流程从递归变为了“可中断的循环过程”。每次循环都会调用 shouldYield 判断当前的 TimeSlice 是否有剩余时间,没有剩余时间则暂停更新流程,将主线程还给渲染流水线,等待下一个宏任务再继续执行。这样就解决了 CPU 的瓶颈问题。 另外在新架构中还引入了 Scheduler 调度器,用来调度任务的优先级,从而解决了 I/O 的瓶颈问题。

Fiber 是什么?

Fiber 可以从三个方面去理解:

  • FiberNode 作为一种架构:在 React v15 以及之前的版本中,Reconceiler 采用的是递归的方式,因此被称之为 Stack Reconciler,到了 React v16 版本之后,引入了 Fiber,Reconceiler 也从 Stack Reconciler 变为了 Fiber Reconceiler,各个 FiberNode 之间通过链表的形式串联了起来。
  • FiberNode 作为一种数据类型:Fiber 本质上也是一个对象,是之前虚拟 DOM 对象(React 元素,createElement 的返回值)的一种升级版本,每个 Fiber 对象里面会包含 React 元素的类型,周围链接的 FiberNode,DOM 相关信息。
  • FiberNode 作为动态的工作单元:在每个 FiberNode 中,保存了“本次更新中该 React 元素变化的数据、要执行的工作(增、删、改、更新 Ref、副作用等)”等信息。

return chile sibling

为什么指向父 FiberNode 的字段叫做 return 而非 parent? 因为作为一个动态的工作单元,return 指代的是 FiberNode 执行完 completeWork 后返回的下一个 FiberNode,这里会有一个返回的动作,因此通过 return 来指代父 FiberNode

Fiber 双缓冲是什么

指的是在内存中构建两颗树,并直接在内存中进行替换的技术。在 React 中使用 Wip Fiber Tree 和 Current Fiber Tree 这两颗树来实现更新的逻辑。Wip Fiber Tree 在内存中完成更新,而 Current Fiber Tree 是最终要渲染的树,可以简单理解为真实 UI 对应的 Fiber Tree,两颗树通过 alternate 指针相互指向,这样在下一次渲染的时候,直接复用 Wip Fiber Tree 作为下一次的渲染树,而上一次的渲染树又作为新的 Wip Fiber Tree,这样可以加快 DOM 节点的替换与更新。

为什么要引入 Hooks,Hooks 解决了什么样的问题?

React 出现最初,99% 多少类组件,因为可以在类组件内部使用状态,使用副作用等。而这些在函数组件内部都做不到,因此以前的函数组件基本只能作为静态组件展示。

但类组件中有以下缺点:

  1. 组件之间复用状态逻辑很难:React 没有提供将可复用性行为附加到组件的途径,例如把组件连接到 store,因此我们只能使用 render props 或者高阶组件,而这很容易形成嵌套地狱
  2. 复杂组件变得难以理解:组件每个生命周期函数只能写一次,复杂组件的某个生命周期函数可能会存在多个不相关但是不得不组合在一起的代码。
  3. 难以理解:找不到 this

Hooks (React16.8 )的引入,使得在函数组件内部可以定义状态,可以使用副作用,可以自定义 hook 复用状态逻辑,也可以定义多个副作用,完美解决类组件臃肿的问题;

具体可参考 AntD3Form(HOC)到 AntD4/5Form 的演进;

什么是自定义 Hook?

useXyz,可以在里面使用 hooks api;推荐 ahooks

为什么 Hook 出现之后,函数组件中可以定义 state,保存在了哪里?

hook 出现之前,函数组件内部无法定义 state,主要是因为函数组件每次更新,定义在函数体的值都要重新初始化,没法保存。 而 hooks 提供的 useState 或者 useReducer 可以用函数组件在组件内定义 state,每一个 hook 都有对应的 hook 对象,这个对象上会存储状态值,这个 hook 对象又以单链表的数据结构存在 fiber 上,而 fiber 是 React 的 vdom,存在于内存中。

useState 与 useReducer 区别以及原理?

都是用于函数组件内部定义状态,状态更新,组件更新。 状态值存储在函数组件的 fiber.memoizedState 上。

useReducer 可以接受一个 reducer 函数,意味着可以把状态修改逻辑放在 reducer 函数中,还可以多次复用;

不同点是,useState 如果 setState 的时候, 新旧 state 一样,组件就不会更新;useReducer 如果 dispatch ,新旧一样也会更新;

组件初次渲染阶段:

  1. 把 state 存储到 hook.memoizedState
  2. 初始化更新队列,存储到 hook.queue 上
  3. 定义 dispatch 事件,并存储到 hook.queue 上。(注意现在 useState/useReducer 的 dispatch 事件不相同)
  4. 返回 [hook.memoizedState, dispatch]

组件更新阶段(批量更新)

  1. 检查是否有上次未处理的更新,如果有,则添加到更新队列(环形链表)上
  2. 循环遍历更新队列,得到 newState
  3. 把最终得到的 newState 复制到 hook.memoizedState 上从一个视图过渡到另一个视图
  4. 返回 [hook.memoizedState, dispatch]

执行 useReducer 的 dispatch 事件:dispatchReducerAction 创建一个 update 对象,存储到更新队列中,然后执行 scheduleUpdateOnFiber 函数,去更新

执行 useState 的 dispatch 事件:dispatchSetState 创建一个 update 对象,如果新的 state 和老的 state 相同,则退出更新,进入 bailout。否则存储 update 到更新队列中,然后执行 scheduleUpdateOnFiber 函数,去更新。

setState 批量更新的过程

18 之前,只有在 react 控制的上下文中触发批量更新,比如 react 生命周期函数,react 合成事件,useEffect/useLayoutEffect,如果存在异步代码比如 setTimeout, react 无法感知上下文,会立即执行。

18 之后自动批量更新拓展到了所有上下文,包括异步代码。

核心:将多个 setState 调用合并为一次更新,减少渲染次数。

执行流程:

  1. setState 被调用后,React 会将更新任务存入组件对应的更新队列中,而不是立即执行
  2. 检查 isBatchingUpdate
    • true 将更新任务暂存到队列中,等待批量更新
    • false 立即执行
  3. 生命周期或者合成事件结束后,react 会调用 flushSync 开始处理更新队列
  4. react 依次取出更新队列的任务,并将所有的 setState 合并为一个新的 state,规则是用 object.assign, 即后面的 setState 会覆盖前面的
  5. 触发渲染,更新视图

setState 是同步还是异步?

在 React 中 setState 并不会立刻同步更新,而是被加入批量更新队列,等到 React 完成当前事件循环后统一更新。这么做的好处是可以减少多次渲染,提升性能。从 React 18 开始,批处理行为不仅局限在 React 事件中,也支持了像 setTimeout、Promise.then 等异步回调内。也就是说,在这些场景下的 setState 也会被自动批处理,从而表现出“异步”的行为。如果确实需要立即同步更新,可以使用 ReactDOM.flushSync() 来强制同步更新。但通常不推荐频繁使用,除非特殊场景,比如表单状态的实时反馈。

如何理解 React 中的 state(状态)与 props(属性)?

React UI = fn(state); state 是变量,一般情况下,state 更新,组件会更新; props 是属性,用于父子通信,且不可修改; 但如果更新被拦截,比如使用了 shouldComponentUpdate 或者 PureComponent 或者 memo,更新会被按需拦截;

在函数/类组件中如何使用 state?

  • 组件内部 state,适合只在本组件内部使用 state,优点是灵活,随时定义可用,缺点是难以实现组件间共享。 函数组件内部 state 可以使用 useStateuseReducer 定义; 类组件内部可以使用 this.state 定义,使用 this.setState 更改 state

  • 组件外部 state,也就是所谓的状态管理库,目前用的比较多的是 Redux,MobX,DVA/umi(基于 redux 封装的),AntD4 Form 也是自己在外部定义的状态管理;

为什么 useState/useReducer 返回一个数组,而不是其它结构,比如对象?

可以用户自定义命名

useRef 是干什么的?ref 的工作流程是怎样的?什么叫做 ref 的失控?

useRef 的主要作用就是用来创建 ref 保存对 DOM 元素的引用。 当开发者调用 useRef 来创建 ref 时,在 mount 阶段,会创建一个 hook 对象,该 hook 对象的 memoizedState 存储的是 { current: initialValue } 对象,之后向外部返回了这个对象。在 update 阶段就是从 hook 对象的 memoizedState 拿到 { current: initialValue } 对象。

ref 内部的工作流程整体上可以分为两个阶段:

  • render 阶段:标记 Ref flag,对应的内部函数为 markRef
  • commit 阶段:根据 Ref flag,执行 ref 相关的操作,对应的相关函数有 commitDetachRef、commitAttachRef

所谓 ref 的失控,本质是由于开发者通过 ref 操作了 DOM,而这一行为本身是应该由 React 来进行接管的,所以两者之间发生了冲突导致的。

useEffect/useLayoutEffect 用法与区别?

因为在函数主体内改变 DOM,添加订阅,设置定时以及执行其他包含副作用都是不被允许的,因为这可能会产生莫名其妙的 bug 并破坏 UI 的一致性。

共同点是函数签名一模一样,第一个参数接收一个函数 effect,第二个参数接受一个依赖数组。返回一个 destroy,如果 destroy 的函数,则会在组件更新或者卸载前执行。比如清除订阅,定时器等

  • useEffect:回调函数会在 commit 阶段完成后异步(异步)执行,所以不会阻塞视图渲染
  • useLayoutEffect:回调函数会在 commit 阶段的 Layout 子阶段同步执行,一般用于执行 DOM 相关的操作

每一个 effect 会与当前 FC 其他的 effect 形成环状链表,连接方式为单向环状链表。

其中 useEffect 工作流程可以分为:

  • 声明阶段
  • 调度阶段
  • 执行阶段

useLayoutEffect 的工作流程可以分为:

  • 声明阶段
  • 执行阶段 之所以 useEffect 会比 useLayoutEffect 多一个阶段,就是因为 useEffect 的回调函数会在 commit 阶段完成后异步执行,因此需要经历调度阶段。

useEffect/useLayoutEffect 中的延迟、同步是什么意思?

这里所谓的延迟,同步,指的是 React 任务调度中的任务调度,所谓延迟就是 useEffect 的 effect 不与组件渲染使用同一个任务调度函数,而是再单独调用一次任务调度函数,即用的不是一个 task,因为如果 effect 和组件渲染用的同一个 task,那么 effect 势必会加长这个 task 的执行时间,阻碍组件渲染。 同理,useLayoutEffect 所谓同步,指的是 useLayoutEffect 的 effect 和组件渲染使用的是同一个 task,那么就会阻碍组件渲染。

因此大多数情况下,尽可能使用标准的 useEffect 以避免阻塞视觉更新。

在源码的 ReactFiberWorkLoop.js 的 commitRoot 函数中,useLayoutEffect 调用了 flushLayoutEffects,该函数里直接 commitLayoutEffects 了;而如果是 useEffect 的话,则会用 进入 scheduleCallback 里执行 flushPassiveEffects

AntD4/5 Form 的底层就是 form,也就是 rc-field-form 中的 field 如果用函数组件实现,就得使用 useLayoutEffect,如果使用 useEffect,就会发现组件没有初始值,这是因为 useEffect 的时候订阅会延迟,那么组件接收到 store 变更,却没有执行组件更新的操作,因为这个时候订阅没有发生;

react-router6 也是使用 useLayoutEffect 来监听;

为什么不能在循环,条件或嵌套函数中调用 Hook?

Hooks 使用规则:不能在条件,循环或者嵌套函数中调用 hook

React 中每个组件都有个对应的 FiberNode,其实就是一个对象,这个对象有个属性叫做 memoizedState。当组件是函数组件的时候,fiber.memoizedState 上存储的就是 Hooks 单链表。

单链表的每个 Hook 节点没有名字或者 key,因为除了他们的顺序,我们无法记录他们唯一性,因此为了确保每个 Hook 是它本身,我们不能破坏这个链表的唯一性。

解释下 useImperativeHandle 场景?

让用户可以把一个变量当作 ref 暴露出来,经常和 forwardRef 一起使用

  1. 把 ref 暴露给父组件,比如 antD4/5 中支持类组件实现。
  2. 暴露方法给父组件

封装了哪些 hooks

useUpdate?主要是强制更新,内部维护了一个 state,通过更新 state 去刷新页面 useLocalStorage? useCountdown

简述前端路由:前端路由解决的问题?

在前端开发中,我们可以使用路由设置访问路径,并根据路径与组件的映射关系切换组件的显示,而这整个过程都是在同一个 html 中实现的,不涉及页面间的跳转,这也就是我们常说的 SPA。

相比于 MPA,SPA 有以下优点:

  1. 不涉及 html 页面跳转,内容改变不需要重新加载页面,对服务器压力小。
  2. 只涉及组件之间的切换,跳转流畅,用户体验好
  3. 组件化开发边界

同时也有以下缺点:

  1. 首屏加载过慢
  2. 不利于 seo
  3. 页面复杂度提高很多

前端路由如何切换页面?

React-Router6 有三种路由模式,分别为 BrowserRouter,HashRouter,MemoryRouter。

React-Router 中 history、hash 路由差异?

HashRouter 最简单,因为服务端不解析 # 之后的字符,但是前端可以依据 hash 这个变化渲染组件。

BrowserRouter 就不同,使用 HTML5 history API,让页面的 UI 与 URL 同步。需要服务端配合,不然页面刷新会 404

react-router6 原理?

使用 Context 机制,从 Router 层传递 navigator,location,match 等参数给后代组件,同时 BrowserRouter,HashRouter 组件会监听 location,一旦 location 变化,即路由变化,那么就会执行 setState,导致组件更新,后代消费 navigator,location,match 等参数的组件也会更新

Context 使用方法?

使用场景:当祖先组件想要和后代组件快速通信,三步走

  1. 创建 context,可以设置默认值,如果缺少匹配的 Provider,那么后代组件将会读取这里的默认值
  2. Provider 传递 value 给后代组件
  3. 后代组件消费 value
    • contextType: 只能在类组件上使用且只能订阅单一的 Context 来源
    • useContext:函数组件或者自定义 hooks 中
    • Consumer 组件,无限制

讲述 Context 原理?

单链表的结构存在 fiber 上。

记录了一个全局变量:

  • currentlyRenderingFiber:记录当前可以消费的 Provider value 的后代组件,
  • valueCursor:栈,记录每一层 Provider value 值;
  • lastContextDependency:记录最后一个 currentlyRenderingFiber 上的最后一个 context
  1. 能消费 Provider value 的后代组件类型只能是函数组件,类组件,consumer 组件,forwardRef 组件,因此在这些组件开始更新的时候,会执行一个 prepareToReadContext 函数,用于记录当前组件的 fiber,记录到一个叫做 currentlyRenderingFiber 全局值中
  2. 当执行到 provider 组件的更新函数时,执行 pushProvider 函数,用一股把 value 存在一个栈中,当这个 Provider 组件执行完毕,则把这个 value 出栈。
  3. Provider 组件更新完成之后,把 value 出栈

组件如何通信以及不同通信方式的特点?

  • 父子组件:props, 缺点是不适合多层级的祖先与后代。
  • 子父组件:函数返回值
  • 兄弟组件:交给共同的父组件管理,比如 AntD3 Form,缺点是一个子组件更新,会导致父组件更新。
  • 祖先/后代组件:Context。不适合大量使用,因为一旦 Context 发生改变,涉及所有组件都会更新,影响比较广,因此项目中应该谨慎使用。
  • 远亲组件:即组件层级不确定,此时比较适合使用第三方组件状态管理库,如 Redux,MobX,Recoil 等,AntD4/5 Form 中也是这个用法。

什么是 HOC,如何”修改”组件属性 props?

HOC 是高阶组件,他是个函数,接收组件作为参数,返回一个新的组件(所以不叫作 props 被修改了),hooks 出现之前,常用于复用逻辑; 比如 react-redux 的 connect,router5 的 withRouter,AntD3 的 create,mobX 的 inject 等等; 但是现在用的不多了,原因是容易形成嵌套地狱;

在函数/类组件中如何使用 state?

  • 组件内部 state,适合只在本组件内部使用 state,优点是灵活,随时定义可用,缺点是难以实现组件间共享。 函数组件内部 state 可以使用 useStateuseReducer 定义; 类组件内部可以使用 this.state 定义,使用 this.setState 更改 state

  • 组件外部 state,也就是所谓的状态管理库,目前用的比较多的是 Redux,MobX,DVA/umi(基于 redux 封装的),AntD4 Form 也是自己在外部定义的状态管理;

比较函数组件与类组件的内部状态?

相同点是都是用来定义组件状态,并且状态更新,组件也更新。 不同点有:

  1. API 不同:类组件 this.state/setState; 函数组件 useState, useReducer
  2. 存储方式不同:类组件的 state 存储在类组件实例和 fiber 上(这也是不可以使用 this.state.count = xxx 来更新 state 的原因, 这样的话只是更新了类组件实例, fiber 没更新);函数组件的 state 存储在 fiber 的 hook 上
  3. 更新不同:this.setState 的时候,类组件的新的 state 与旧的 state 合并对象(Object.asign), 组件都会进行更新(手动拦截除外);函数组件是新的 state 覆盖旧的 state,并且在 useState 的 setState 中,新旧 state 相同,则函数组件拒绝更新(bailout)
  4. 组件使用 state 的时候,取值来源不同:类组件中使用 state 直接使用 this.state, 他的值来自于类组件实例(fiber 与类组件实例上的 state 保持同步);函数组件中使用 state,直接使用 useState 或者 useReducer 函数返回数组的第 0 个元素,这个值来自于 fiber 上的 hook 对象。

换句话说,如果想要获取类组件的新的状态值,可以直接访问 this.state; 而如果想要获取函数组件中的一个新的状态值,必须重新执行 useState 或者 useReducer 函数,即必须执行函数组件;

类组件的 componentDidMount 与 useEffect/useLayout?

componentDidMount 执行时机同 useLayoutEffect,源码中体现在 commitLayoutEffectOnFiber 上。

  switch (finishedWork.tag) {
    case FunctionComponent:
    case ForwardRef:
    case SimpleMemoComponent: {
      // ...
      if (flags & Update) {
        commitHookLayoutEffects(finishedWork, HookLayout | HookHasEffect); // useLayoutEffect
      }
      break;
    }
    case ClassComponent: {
      // ...
      if (flags & Update) {
        commitClassLayoutLifecycles(finishedWork, current);
      } // componentDidMount
      // ...
      break;
    }
    // ...
  }

引发的思考:涉及的更新的订阅应当写在 componentDidMount 或者 useLayoutEffect 中,比如用函数组件实现 AntD4/5 Form 的 field,(注意 rc-field-form 的 Field 使用的是类组件实现的,因此使用的是 componentDidMount 生命周期)如果要使用函数组件实现,则要使用 useLayoutEffect

类组件生命周期,以及废除三个老生命周期的原因?

废弃了三个 will_xxx;改为了 UNSAFE_componentWillMount/Update/ReceiveProps

从 16.3(引入 fiber 架构) 开始,这三个生命周期不再被推荐使用了,因为随着 React 架构的迭代,组件的更新事件将不再确定(异步渲染),并且可能会被打断中止,那么“将要挂载,更新,接收参数”都将变得不再可靠。

受控组件与非受控组件

在 React 中,**受控组件(Controlled Components)**指的是表单元素的值由 React 的 state 控制,**非受控组件(Uncontrolled Components)**则是由 DOM 自己管理其状态,通过 ref 访问。受控组件更符合 React 的理念(数据驱动 UI),推荐默认使用

eagerState 策略

eagerState 的核心逻辑是如果某个状态更新前后没有变化,则可以跳过后续的更新流程。该策略将状态的计算提前到了 schedule 阶段之前。当有 FiberNode 命中 eagerState 策略后,就不会再进入 schedule 阶段,直接使用上一次的状态。

该策略有一个前提条件,那就是当前的 FiberNode 不存在待执行的更新,因为如果不存在待执行的更新,当前的更新就是第一个更新,计算出来的 state 即便不能命中 eagerState,也能够在后面作为基础 state 来使用,这就是为什么 FC 所使用的 Update 数据中有 hasEagerState 以及 eagerState 字段的原因

bailout 策略

在 beginWork 中,会根据 wip FiberNode 生成对应的子 FiberNode,此时会有两次“是否命中 bailout 策略”的相关判断。

  1. 第一次判断

    • oldProps 全等于 newProps
    • Legacy Context 没有变化
    • FiberNode.type 没有变化
    • 当前 FiberNode 没有更新发生

当以上条件都满足时会命中 bailout 策略,之后会执行 bailoutOnAlreadyFinishedWork 方法,该方法会进一步判断能够优化到何种程度。

通过 wip.childLanes 可以快速排查“当前 FiberNode 的整颗子树中是否存在更新”,如果不存在,则可以跳过整个子树的 beginWork。这其实也是为什么 React 每次更新都要生成一棵完整的 Fibrt Tree 但是性能并不差的原因。

  1. 第二次判断:
    • 开发者使用了性能优化 API,此时要求当前的 FiberNode 要同时满足:
      • 不存在更新
      • 经过比较(默认浅比较)后 props 未变化
      • ref 不变
    • 虽然有更新,但是 state 没有变化

组件的常见性能优化手段?

  • 复用组件:前提必须同时满足同一层级,同一类型,同一个 key,所以我们要尽量保证这三者的稳定性

  • 减少不必要的更新:组件更新会导致组件进入协调,协调的核心就是我们常说的 vdom diff,所以协调本身就是比较耗时的算法。因此如果能够减少协调,复用旧 fiber 节点,那么肯定会加快渲染完成的速度。组件如果没有进入协调阶段,我们称之为进入 bailout 阶段,意思就是退出更新。

让组件 bailout 阶段有以下方法:

  1. shouldComponentUpdate:类组件的生命周期之一,当用户定义这个函数并且返回 false,则进入 boilout;
  2. PureComponent:更新会自动进行 shallow compare 新旧 props 与 state,如果没变化,就进入 boilout;
  3. memo:第一个参数是组件,第二个参数是一个比较函数,默认为浅比较 props。
  4. useMeme 和 useCallback 缓存

React 事件机制?

我们在 React 中使用的 onClick,onChange 这种驼峰命名的事件,通常称之为合成事件。

React 自定义合成事件机制,帮助用户解决了平台兼容性,事件委托等优化机制,消除不同浏览器在事件处理上的差异性,用户只需要关注写 React 本身就行了。

关于合成事件,React17 曾发生过一次变化,17 以前,事件是委托在 document 上,但是实际上,React 项目是可以作为其他项目的子项目的,17 之后就把事件委托在了自己的 container 层 事件委托又称为事件代理机制,这种事件机制是指把所有子节点的事件都把规定在父级,使用一个统一的事件监听和处理函数。这样可以简化事件处理和回收机制,从而提升效率

React 版本之间的差异?

  • 16.3 引入 fiber 架构,willXXX 生命周期被标记为 unsafe 了
  • 16.8 引入 hooks
  • 17 垫脚石版本
    • jsx 转换
    • 事件委托的变更
    • 时间系统相关更改
    • 去除事件池
    • 返回一致的 undefined 错误
    • 启发式更新算法更新
  • 18 大量新特性出现,如自动批量处理,非紧急更新,concurrent 等

17 之后弃用了 event pooling?

event pool 就是 React 用来复用事件对象的。事件触发前,从池中取出事件对象,填充相关信息,事件处理完成后,将事件对象重置并放回事件池,以便下次复用。

因为对象池的机制,经常导致 React 中的 event 在下个事件循环中被释放的情况,比如异步的情况,不得不使用 persist 方法去阻止对象的释放回收,对象池给 React 用户带来了一些负担;

function handleClick(event) {
  event.persist(); // 防止事件对象被重置
  setTimeout(() => {
    console.log(event.type); // 正常工作,但如果没添加 persist ,就会报错,因为事件对象被重置
  }, 1000);
}

设计的初衷是:在早期的 JavaScript 引擎中,频繁创建和销毁对象(如事件对象)会导致性能问题。但是现在引擎已经相当高效了

V8 在堆内存中开辟出新生代和老生代的划分区,分代式机制把一些新、小、存活时间短的对象作为新生代,采用一小块内存频率较高的快速清理,事件对象的生命周期通常较短(仅在事件处理期间有效);

其次,V8 使用增量垃圾回收(Incremental GC)和并发垃圾回收(Concurrent GC),可以在不阻塞主线程的情况下高效回收内存。 这意味着即使 React 每次事件触发都创建新的事件对象,V8 也能快速回收这些对象,不会对性能造成明显影响。

基于 V8 的上述两点主要优化可以看到,对于小的对象创建,实际上 GC 的压力已经不再是瓶颈了,将老生代剥离出去和多线程的机制,已经让 GC 是一个非常轻量的过程,而 JS 创建对象的数量始终是有限的,所以在目前看来,在大多数应用中,使用 JS 的对象池技术是没有太大必要的。

react19 新特性

  • 新 hooks