架构和流程
React 原理概要
React Fiber 架构原理概要
三大架构
- scheduler 空置优先级调度和时间分片
- reconciler 负责将数据的变更映射为 fiber
- renderer 负责将 fiber 挂载到宿主身上
两大流程
- render 异步可中断过程,负责处理数据变更为 fiber
- 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事件循环是浏览器事件循环的子集
- 宏任务队列 ,同步任务其实就是当前已经在栈中的宏任务(MessageChannel,I/O)
- 微任务队列 (promise,MutationObserver)
- 渲染队列( requestAnimationFrame ) 浏览器是否需要渲染画面取决于帧率(16.6ms/帧,一帧只需要渲染一次)
- 空闲队列 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
-
rAF 只在浏览器在前台的时候才执行
-
rAF 的触发频率受到浏览器的刷新频率限制
-
rAF 优先级高,与用户的输入优先级冲突
-
不能主动控制执行发出 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 执行顺序
- rootFiber beginWork
- node1 beginWork
- node1 completeWork
- node2 beginWork
- node3 beginWork
- node3 completeWork
- node4 beginWork
- node4 completeWork
- node2 completeWork
- rootFiber completeWork
- 从 APP 节点对应的 rootFiber 开始执行 beginWork
- 如果有子节点(child)则继续执行子节点的 beginWork
- 如果没有子节点(child)则执行当前节点的 completeWork
- 如果有兄弟(slibing)节点,进入兄弟节点的 beginWork
只有子节点全部遍历完之后才能 completeWork,因为他需要收集子元素的所有 effect
beginWork 构建一个新的 fiber 树
-
从 current 树开始遍历,构建对应的WIP
- 如果当前 fiber 没有子节点,判断收否能够复用
- 如果能够复用则创建'alternate'指向WIP
- 不能复用则销毁旧节点,创建新的WIP节点
- 有子节点则进入Diff算法,diff 算法中能够复用的部分又递归回到 beginWork
-
如何对比:state的更新会被保存在current的fiber 结构上,从current 根节点开始遍历的时候就能够对比新旧state,判断是否复用
completeWork 自下而上的收集 effect
- 将当前 fiber 需要更新的信息放到 updateQueue 中, 偶数为key,奇数为value。
- 将updateQueue 的指针连接到子节点头部(effect 链)
- 除了以上内容,completeWork 还做一些向上收集的内容,比如事件的收集,最后挂载到根节点上。
借用React团队成员Dan Abramov的话:effectList相较于Fiber树,就像圣诞树上挂的那一串彩灯。
reconcileChildren - reconcileChildFibers
在 beginWork 中执行这个方法,对于都有子节点的fiberNode执行这个方法
- 对于 mount 执行 mountChildFibers
- 对于 update 执行 reconcileChildFibers
- reconcileChildFibers 会对 Fiber 的 children 执行 diff 算法并创建/复用 fiber 节点(WIP树)
- 最终生成带有 effectTag 的 WIP Fiber
- 然后继续执行 beginWork (新生成的子 Fiber 调用)
对比 vue
react 空间换时间
-
更新的时候从 current 树开始构建 WIP(构建整颗树)
-
可以中断
-
diff 结果: 通过 effect Tag + effect List 连接起来,更新的时候直接遍历
-
连续更新(未命中批处理):
- 如果更新的组件还未构建WIPtree,则将状态更新到current Tree 上等待构建,不影响流程
- 如果组件的WIPtree已经被构建的,则状态更新到全局更新队列,则根据优先级调度,判断是否需要重新构建。
-
变更 -> currentTree -> WIP tree -> effectList -> mutation -> container (统一挂载)-> dom
vue 时间换空间
-
更新的时候通过响应式数据定位到树的节点,对比动态区块 (不用构建整颗树)
-
不可中断
-
diff 结果:通过描述 DOM 变更的补丁然后直接挂载到container上 (边 diff 边操作)
-
连续更新(未命中批处理):
- 由于构建 vnode tree 的过程是同步的
- 所有必然触发两次渲染
-
变更 -> 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 是单向链表,通过两次遍历,尽量小的更新元素位置
-
在进入 diff 具体流程前,react 维护两个map ,分别是新旧数组的key:index对
-
第一层遍历
- 如果当前 key 在旧数组中不存在,则新增,挂载到当前遍历下标前位置
domAPI.insertBefore(parent, node, parent.children[i] || null)
- 如果当前 key 在旧数组中存在,但是其下标比最后一个维护好的元素下表小,则复用并向后移动到当前遍历前下标
- 否则可以不用移动元素
- 如果当前 key 在旧数组中不存在,则新增,挂载到当前遍历下标前位置
-
第二层遍历:删除了所有在新数组中不存在的元素
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 根节点 上挂载了处理收集到的事件的任务分发器
不可冒泡事件:
- 焦点事件: focus, blur
- 资源事件: load, unload, abort, errro
- 滚动事件: scroll(因浏览器而不同)
- 部分鼠标事件: mouseenter , mouseleave (注意over和out是能够冒泡的)
- 媒体事件: play, pause, ratechange
- 表单事件: 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
-
ConcurrentRendering 并发渲染在17的时候就开始引入,作为实验性质,18正式启用
-
automatic Batching 自动批处理是通过上锁实现的 batching = true,在setTimeout中可能变成同步的
-
startTransition 被这个函数包裹的行为是可以被中断的,用于更新非紧急的UI
-
新的服务端渲染API
-
newHook
- useId
- useTransition 其实是startTransition的加强版,导出[isPending ,startTransition],允许设置超时时间
- useDeferredValue 用于延迟更新可能阻塞渲染的值 , 允许设置超时时间
- useSyncExternalStore 用于并发更新外部store 用于替代 forceUpdate
- useInsertionEffect 在DOM插入之后执行,在ref之前,不建议使用setState
才疏学浅,请各路大神不吝赐教。部分插图来自网络,侵删。