探索 React 的生长逻辑 - React 渲染原理

架构和流程

React 原理概要

React Fiber 架构原理概要

三大架构

  1. scheduler 空置优先级调度和时间分片
  2. reconciler 负责将数据的变更映射为 fiber
  3. renderer 负责将 fiber 挂载到宿主身上

两大流程

  1. render 异步可中断过程,负责处理数据变更为 fiber
  2. commit 同步过程,将 fiber 实现为 DOM

虚拟 DOM

优缺点

  • 频繁操作真实 DOM 的成本高,使用虚拟 DOM 最小化 DOM 操作
  • 维护大量 DOM 工作量大且繁琐,使用虚拟 DOM 抽象渲染过程,提升开发效率
  • 借助虚拟 DOM ,抹平底层的平台差异,带来跨平台的渲染能力
  • 但是由于多了计算虚拟 DOM 的过程,首次渲染的时候也需要挂载大量 DOM,首屏渲染比较慢

数据的前世今生

在 React 中,虚拟 DOM 是由函数组件的返回值,或者 class 函数的 render 函数生成而来的。

而这个生成是在编译过程中生成的,babel 将 jsx 编译成虚拟 DOM 节点,连接成虚拟 DOM 树

React16 + Fiber

fiber 是 react 的一种数据结构,也是调度的最小执行单位

Scheduler

对于超过 16.6ms (一帧)的同步任务会阻塞主线程导致页面卡顿

数据变更,调度优先级,触发reconciler ( 数据触发渲染 )

  • 浏览器的每一帧中预留 5ms 给JS线程计算,Scheduler 在空闲的时候触发 reconciler

    • performance.now() 获取当前帧时间
    • 这个 5ms 是对于一帧 16.6 ms 而言的,对于不同的设备,可以动态的调整分片的优先级
  • 除此之外 scheduler 还对更新的任务提供了多种优先级,实行优先级调度

javaScript 复制代码
function workLoop(hasTimeRemaining, initialTime) {
  while (currentTask !== null && !shouldYieldToHost()) {
    const callback = currentTask.callback;
    const continuationCallback = callback();
    if (typeof continuationCallback === 'function') {
      // 任务未完成,保留回调以便恢复
      currentTask.callback = continuationCallback;
    } else {
      // 任务完成,移出队列
      pop(taskQueue);
    }
    currentTask = peek(taskQueue);
  }
}

浏览器事件循环 (前置知识)

requestIdleCallback 核心逻辑是基于事件循环和通信机制 (MessageChannel)实现的

我们前端常说的JS事件循环其实也是依赖浏览器事件循环的,JS事件循环是浏览器事件循环的子集

  1. 宏任务队列 ,同步任务其实就是当前已经在栈中的宏任务(MessageChannel,I/O)
  2. 微任务队列 (promise,MutationObserver)
  3. 渲染队列( requestAnimationFrame ) 浏览器是否需要渲染画面取决于帧率(16.6ms/帧,一帧只需要渲染一次)
  4. 空闲队列 requestIdelCallback 被执行

空闲触发

使用 messageChannel 触发异步宏任务

  • 同时每一帧内动态计算时间,追踪可用的时间片(shouldYieldToHost)
  • 当占用时间大于 5ms 或者剩下的时间不足以执行一个 fiber reconciler 则 yield,触发一个异步宏任务,等待下次循环
  • 当 reconciler 未完成,在递归中返回的是一个 function
  • Fiber 既是 react 的数据结构,也是 scheduler 和时间分片的最小单位

未完成时返回一个函数,这让我联想到函数科里化。

React TimeSlice 和 curry 都是一种延迟函数执行的手段,他们有以下的区别

  • react 对与 reconciler 是否执行完成时由 reconciler 内部执行逻辑确定的
  • curry 函数是否执行完毕是由参数的数量决定的。

我认为函数柯里化的思想对 react time slice 的设计是具有启发意义的

为什么不用requestIdleCallback

  • 触发频率太低了,大概 50ms 一次

为什么不直接用 rAF

  1. rAF 只在浏览器在前台的时候才执行

  2. rAF 的触发频率受到浏览器的刷新频率限制

  3. rAF 优先级高,与用户的输入优先级冲突

  4. 不能主动控制执行发出 rAF ,完全依赖浏览器调用(粒度不够)

为什么不用 setTimeout 发出异步宏任务

  • setTimeout 在浏览器环境有 4ms 的延迟

  • messageChannel 则没有

调度流程图

Reconciler 执行 render(异步可中断)

根据数据的变更情况,指计算出虚拟DOM的变化 ( 从数据到视图 )

双缓存

fiberRootNode 是整个应用的根节点

rootFiber 是 所在组件树的节点

React 通过优先级调度双缓存,完全杜绝了中间态问题

  • 每次触发的更新会在 WIP 树上修改,最后一次性挂载 WIP 树

Vue 通过 nextTick ployfill 异步渲染防治了一部分中间态问题

  • 每次触发更新都会渲染(同步的)

performUnitOfWork (render阶段)

这个 reconciler 循环包含了 beginWork (递) ,completeWork (归)的过程,构建出一个 workInProgress Fiber 树

  • 生成虚拟 DOM (递)
  • 执行 diff 算法标记变化的 DOM 节点
  • 收集 effect list (归)
  • 调用 Renderer

performUnifofWork 执行顺序

  1. rootFiber beginWork
  2. node1 beginWork
  3. node1 completeWork
  4. node2 beginWork
  5. node3 beginWork
  6. node3 completeWork
  7. node4 beginWork
  8. node4 completeWork
  9. node2 completeWork
  10. rootFiber completeWork
  1. 从 APP 节点对应的 rootFiber 开始执行 beginWork
  2. 如果有子节点(child)则继续执行子节点的 beginWork
  3. 如果没有子节点(child)则执行当前节点的 completeWork
  4. 如果有兄弟(slibing)节点,进入兄弟节点的 beginWork

只有子节点全部遍历完之后才能 completeWork,因为他需要收集子元素的所有 effect

beginWork 构建一个新的 fiber 树

  1. 从 current 树开始遍历,构建对应的WIP

    1. 如果当前 fiber 没有子节点,判断收否能够复用
    2. 如果能够复用则创建'alternate'指向WIP
    3. 不能复用则销毁旧节点,创建新的WIP节点
    4. 有子节点则进入Diff算法,diff 算法中能够复用的部分又递归回到 beginWork
  2. 如何对比:state的更新会被保存在current的fiber 结构上,从current 根节点开始遍历的时候就能够对比新旧state,判断是否复用

completeWork 自下而上的收集 effect

  1. 将当前 fiber 需要更新的信息放到 updateQueue 中, 偶数为key,奇数为value。
  2. 将updateQueue 的指针连接到子节点头部(effect 链)
  3. 除了以上内容,completeWork 还做一些向上收集的内容,比如事件的收集,最后挂载到根节点上。

借用React团队成员Dan Abramov的话:effectList相较于Fiber树,就像圣诞树上挂的那一串彩灯

reconcileChildren - reconcileChildFibers

在 beginWork 中执行这个方法,对于都有子节点的fiberNode执行这个方法

  • 对于 mount 执行 mountChildFibers
  • 对于 update 执行 reconcileChildFibers
  1. reconcileChildFibers 会对 Fiber 的 children 执行 diff 算法并创建/复用 fiber 节点(WIP树)
  2. 最终生成带有 effectTag 的 WIP Fiber
  3. 然后继续执行 beginWork (新生成的子 Fiber 调用)

对比 vue

react 空间换时间

  1. 更新的时候从 current 树开始构建 WIP(构建整颗树)

  2. 可以中断

  3. diff 结果: 通过 effect Tag + effect List 连接起来,更新的时候直接遍历

  4. 连续更新(未命中批处理):

    1. 如果更新的组件还未构建WIPtree,则将状态更新到current Tree 上等待构建,不影响流程
    2. 如果组件的WIPtree已经被构建的,则状态更新到全局更新队列,则根据优先级调度,判断是否需要重新构建。
  5. 变更 -> currentTree -> WIP tree -> effectList -> mutation -> container (统一挂载)-> dom

vue 时间换空间

  1. 更新的时候通过响应式数据定位到树的节点,对比动态区块 (不用构建整颗树)

  2. 不可中断

  3. diff 结果:通过描述 DOM 变更的补丁然后直接挂载到container上 (边 diff 边操作)

  4. 连续更新(未命中批处理):

    1. 由于构建 vnode tree 的过程是同步的
    2. 所有必然触发两次渲染
  5. 变更 -> watcher -> render -> patch -> container (边 patch 边挂载) -> dom

Renderer 执行 commit(同步)

接收到新的虚拟 DOM , 对 Update 标记的节点执行 DOM 操作 ( 从虚拟 DOM 到 DOM )

  • 将新的虚拟 DOM 更新到宿主环境上(根据虚拟 DOM 的 update 标签)
  • 不同的平台有不同的 Renderer ,浏览器 ReactDom ,App ReactNative

hx1s40ctlot.feishu.cn/sync/Pcladu...

before mutation

在 DOM 节点挂载之前,触发生命周期

mutation

负责 DOM 节点的渲染,将 effectList 中的内容实现到 container 中

layout

将 DOM 节点挂载到浏览器页面上,并执行收尾逻辑 (生命周期,移动 fiberRootNode 的 current 指针指到 WIP )

虚拟DOM & Diff

Fiber 树链表结构

对比单个节点

通过 key 和 type 判断节点是否能够复用

对比多个节点

react的diff算法比vue更好理解

  • vue 是双向链表,所以他可以前后同时遍历,找两头相同的,最小化更新
  • 而 react 是单向链表,通过两次遍历,尽量小的更新元素位置
  1. 在进入 diff 具体流程前,react 维护两个map ,分别是新旧数组的key:index对

  2. 第一层遍历

    1. 如果当前 key 在旧数组中不存在,则新增,挂载到当前遍历下标前位置domAPI.insertBefore(parent, node, parent.children[i] || null)
    2. 如果当前 key 在旧数组中存在,但是其下标比最后一个维护好的元素下表小,则复用并向后移动到当前遍历前下标
    3. 否则可以不用移动元素
  3. 第二层遍历:删除了所有在新数组中不存在的元素

React 优先级调度

优先级调度要从 react 的两个架构开始讨论

Scheduler

scheduler 提供了时间分片的能力

时间分片的调度原理: 通过 rAF polyfill 实现宏任务的触发。每帧中预留5ms 给react执行render。(通过 performance.now() 精确计算帧剩余时间)

优先级的排序方法: 优先级调度通过维护一个优先级的小顶堆实现

Reconciler

reconciler 实现了允许中断的 Fiber 架构

时间分片的基本数据结构: 基于 fiber 架构实现任务的微观调度,fiber 是 react 中的最小执行单位

优先级在各个核心中转换和透传: 通过 Lane 模型将31种语义化优先级映射为6种基础优先级,经过 Scheduler 通过小顶堆实现优先级调度

concurrent mode

concurrent mode 暴露语义化 API,让开发者能够显示声明优先级

在 Scheduler 和 Reconciler 实现了时间分片的情况下,对 fiber 的任务做优先级排序,通过 scheduler 实现不同的渲染顺序,从而实现了优先级调度

再结合 useTrasition useDeferredValue 等 API 标记低优先级任务,允许高优先级任务在低优先级任务执行过程中大打断低优先级任务,实现高优先抢占,完成后通过Fiber指针恢复,这叫并发模式

饥饿控制

对于一个低优先级任务,等待的时间超过了他内置的超时时间的时候也会升级为同步任务,防止低优先级任务一直等待无法执行

同一个优先级的任务会做合并(比如多个setState)

React事件

合成事件

  • 除不可冒泡事件外,React 在 UnitWorkLoop 的 completeWork 中向上收集冒泡和捕获事件,记录相应元数据
  • 事件触发后,最终会冒泡到 document / createRoot 根节点 , 所以 react 在 document / createRoot 根节点 上挂载了处理收集到的事件的任务分发器

不可冒泡事件:

  1. 焦点事件: focus, blur
  2. 资源事件: load, unload, abort, errro
  3. 滚动事件: scroll(因浏览器而不同)
  4. 部分鼠标事件: mouseenter , mouseleave (注意over和out是能够冒泡的)
  5. 媒体事件: play, pause, ratechange
  6. 表单事件: reset, change ...

从 document 到 creatRoot 节点

React 16 : 绑定到 document

React 17 + : 绑定到 createRoot(实际上是 RootFiber 关联的节点 container 上)

  • 为了隔离微前端等场景的事件,防止冲突
  • 为适配 Fiber 架构的并发模式做准备

模拟冒泡

  • 由于冒泡和捕获任务有不同的执行顺序,而 react 是自底向上收集事件的,最终是一个数组。
  • 通过从后向前,模拟自顶向下的捕获过程
  • 通过从前向后遍历,模拟自底向上的的冒泡过程

设计初衷

  • 采用事件委托,防止高耗能的事件滥用(scroll)。
  • 这种挂载方式有点类似于原生 DOM 事件中的事件委托,但是 React 合成事件有除了提高性能以外的其他原因
  • 通过将事件收集到 Document ,能够实现对全局事件的统一管理,从而像虚拟 DOM 一样,提供一个 API 抽象层,React 去抹平底层的框架,开发者只需要如同在 Web 上一样编写不同平台的代码即可。

React性能优化

React错误边界

React 18

  1. ConcurrentRendering 并发渲染在17的时候就开始引入,作为实验性质,18正式启用

  2. automatic Batching 自动批处理是通过上锁实现的 batching = true,在setTimeout中可能变成同步的

  3. startTransition 被这个函数包裹的行为是可以被中断的,用于更新非紧急的UI

  4. 新的服务端渲染API

  5. newHook

    1. useId
    2. useTransition 其实是startTransition的加强版,导出[isPending ,startTransition],允许设置超时时间
    3. useDeferredValue 用于延迟更新可能阻塞渲染的值 , 允许设置超时时间
    4. useSyncExternalStore 用于并发更新外部store 用于替代 forceUpdate
    5. useInsertionEffect 在DOM插入之后执行,在ref之前,不建议使用setState

才疏学浅,请各路大神不吝赐教。部分插图来自网络,侵删。

相关推荐
HaanLen5 小时前
React19源码系列之Hooks(useRef)
javascript·react.js
前端大白话5 小时前
React 中shouldComponentUpdate生命周期方法的作用,如何利用它优化组件性能?
react.js
凉生阿新5 小时前
【React】基于 React+Tailwind 的 EmojiPicker 选择器组件
前端·react.js·前端框架
公子小六5 小时前
ASP.NET Core WebApi+React UI开发入门详解
react.js·ui·c#·asp.net·.netcore
学渣y6 小时前
React-响应事件
前端·javascript·react.js
晚风3086 小时前
React
react.js
IT、木易6 小时前
React 中shouldComponentUpdate生命周期方法的作用,如何利用它优化组件性能?
前端·javascript·react.js
Jia87087 小时前
实现你的第一个React项目
react.js
wfsm15 小时前
React多层级对象改变值--immer
前端·javascript·react.js
一个天蝎座 白勺 程序猿15 小时前
JavaScript性能优化实战手册:从V8引擎到React的毫秒级性能革命
javascript·react.js·性能优化