react技术揭秘
架构
新架构
调度器 Scheduler
调度任务的优先级,高优任务优先进入Reconciler
原理 基于更完备的requestIdleCallbackpolyfill
放弃requestldleCallback
浏览器兼容性
发频率不稳定,受很多因素影响。比如当我们的浏览器切换tab后,之前tab注册的requestIdleCallback触发的频率会变得很低
但是当我们配合时间切片,就能根据宿主环境性能,为每个工作单元分配一个可运行时间,实现“异步可中断的更新”。
时间切片原理
- 时间切片的本质是模拟实现requestIdleCallback ,requestIdleCallback是在“浏览器重排/重绘”后如果当前帧还有空余时间时被调用的。
浏览器并没有提供其他API能够在同样的时机(浏览器重排/重绘后)调用以模拟其实现。
- 时间切片的本质是模拟实现requestIdleCallback ,requestIdleCallback是在“浏览器重排/重绘”后如果当前帧还有空余时间时被调用的。
唯一能精准控制调用时机的API是requestAnimationFrame,他能让我们在“浏览器重排/重绘”之前执行JS。
这也是为什么我们通常用这个API实现JS动画 —— 这是浏览器渲染前的最后时机,所以动画能快速被渲染。
一个task(宏任务) -- 队列中全部job(微任务) -- requestAnimationFrame -- 浏览器重排/重绘 -- requestIdleCallback
- 通过task(宏任务)实现的。
- Scheduler将需要被执行的回调函数作为MessageChannel的回调执行
- 如果当前宿主环境不支持MessageChannel,则使用setTimeout。
- 在Schdeduler中,为任务分配的初始剩余时间为5ms。都会通过Scheduler提供的shouldYield方法判断是否需要中断遍历,使浏览器有时间渲染:
- 优先级调度
- 对外暴露了一个方法unstable_runWithPriority (opens new window)
- commit阶段是同步执行的。可以看到,commit阶段的起点commitRoot方法的优先级为ImmediateSchedulerPriority。
- 不同优先级任务的排序
- timerQueue:保存未就绪任务
- taskQueue:保存已就绪任务
协调器
负责找出变化的组件
可以中断的循环过程(调用shouldYield判断当前是否有剩余时间。)
Reconciler会为变化的虚拟DOM打上代表增/删/更新的标记
整个Scheduler与Reconciler的工作都在内存中进行。只有当所有组件都完成Reconciler的工作,才会统一交给Renderer。
渲染器
负责将变化的组件渲染到页面上
- Renderer根据Reconciler为虚拟DOM打的标记,同步执行对应的DOM操作。
中断更新时DOM渲染不完全
- 因为都在内存中进行,不会更新页面上的DOM 所以不影响
lane模型
优先级机制:
可以表示优先级的不同
可能同时存在几个同优先级的更新,所以还得能表示批的概念
方便进行优先级相关计算
lane模型借鉴了同样的概念,使用31位的二进制表示31条赛道,位数越小的赛道优先级越高,某些相邻的赛道拥有相同优先级。
其中,同步优先级占用的赛道为第一位:
- 原因在于:越低优先级的更新越容易被打断,导致积压下来,所以需要更多的位。相反,最高优的同步更新的SyncLane不需要多余的lanes。
- 那么优先级相关计算其实就是位运算
老架构
协调器 Reconciler
负责找出变化的组件
方式
this.setState
this.forceUpdate
ReactDOM.render
步骤
调用函数组件、或class组件的render方法,然后将jsx 返回虚拟dom
虚拟DOM和上次更新时的虚拟DOM进行对比
过对比找出本次更新中变化的虚拟DOM
通知Renderer 渲染器将变化的虚拟DOM渲染到页面上
渲染器 Renderer
负责将变化的组件渲染到页面
ReactNative (opens new window)渲染器,渲染App原生组件
ReactTest (opens new window)渲染器,渲染出纯Js对象用于测试
ReactArt (opens new window)渲染器,渲染到Canvas, SVG 或 VML (IE8)
缺点
- 递归更新子组件。(中途就无法中断。当层级很深时,递归更新时间超过了16ms,用户交互就会卡顿)
Reconciler与Renderer交替工作
batchedUpdates
如果我们在一次事件回调中触发多次更新,他们会被合并为一次更新进行处理。
Suspense
Suspense (opens new window)可以在组件请求数据时展示一个pending状态。请求成功后渲染数据。
本质上讲Suspense内的组件子树比组件树的其他部分拥有更低的优先级
useDeferredValue
- 返回一个延迟响应的值,该值可能“延后”的最长时间为timeoutMs。 const deferredValue = useDeferredValue(value, { timeoutMs: 2000 });
- 在useDeferredValue内部会调用useState并触发一次更新。
Fiber心智模型
代数效应
用于将副作用从函数调用((例子中为请求图片数量)从函数逻辑中分离)中分离 ,,使函数关注点保持纯粹。
async await是有传染性的,这意味着调用他的函数也需要是async,这破坏了getTotalPicNum的同步特性
虚构一个语法 try…handle与两个操作符perform、resume
- try…catch最大的不同在于:当Error被catch捕获后,之前的调用栈就销毁了。而handle执行resume后会回到之前perform的调用栈。
Generator
Generator也是传染性的,使用了Generator则上下文的其他函数也需要作出改变。这样心智负担比较重。
Generator执行的中间状态是上下文关联的
Fiber
- 实现的一套状态更新机制。支持任务不同优先级,可中断与恢复,并且恢复后可以复用之前的中间状态。
代数效应在React中的应用
- 对于类似useState、useReducer、useRef这样的Hook,我们不需要关注FunctionComponent的state在Hook中是如何保存的,React会为我们处理
更新机制
同步更新的React
- 即没有优先级概念,高优更新(红色节点)需要排在其他更新后面执行。
并发更新的React
- 高优更新(红色节点)中断正在进行中的低优更新(蓝色节点),先完成render - commit流程。待高优更新完成后,低优更新基于高优更新的结果重新更新。
理念
JavaScript 构建快速响应的大型 Web 应用程序的首选方式
CPU的瓶颈
- 当项目变得庞大、组件数量繁多时,就容易遇到CPU的瓶颈。
- JS脚本执行 ----- 样式布局 ----- 样式绘制
js执行脚本太长影响组件渲染
- 时间切片
IO的瓶颈
网络延迟
将人机交互研究的结果整合到真实的 UI 中 (opens new window)。
- ,React实现了Suspense (opens new window)功能及配套的hook——useDeferredValue (opens new window)。
Fiber架构实现原理
Fiber的含义
架构
15 之前 stack Reconciler
16 基于Fiber节点实现,被称为Fiber Reconciler。
静态单元
- Fiber节点对应一个React element,保存了该组件的类型,对应dom节点信息
动态单元
- Fiber节点保存了本次更新中该组件改变的状态,要执行的工作
Fiber链接
指向父节点 this.return = null;
指向子Fiber节点 this.child = null;
指向右边第一个兄弟Fiber节点 this.sibling = null;
Fiber架构工作原理
双缓存
在内存中构建并直接替换的技术
解决问题
- 由于canvas 绘制动画会调用 ctx.clearRect清除上一帧动画,如果当前帧画面计算量比较大,替换时间变长就会出现白屏,这个时候在内存中构建当前帧,并替换上一帧的技术
mount
update阶段
开启一次新的render阶段并构建一棵新的workInProgress Fiber 树。
和mount时一样,workInProgress fiber的创建可以复用current Fiber树对应的节点数据。
触发状态更新(创建Update对象)-render-commit
update 分类
ReactDOM.render
ReactDOM.render
ReactDOM.render会创建fiberRootNode
- 整个应用的根节点
rootFiber
所在组件树的根节点 - 渲染不同的组件树所以有不同的rootFIber,但是根节点只有一个
产生HostRoot
创建fiberRootNode、rootFiber、updateQueue(
legacyCreateRootFromDOMContainer
)
- 创建Update对象(`updateContainer`)
- 从fiber到root(`markUpdateLaneFromFiberToRoot`)
- 调度更新(`ensureRootIsScheduled`)
- render
- 入口
- legacy,这是当前React使用的方式
- blocking,开启部分concurrent模式特性的中间模式。
- concurrent,面向未来的开发模式
- this.setState
- ClassComponent
- this.updater.enqueueSetState方法。
- this.forceUpdate
- ClassComponent
- enqueueSetState
- enqueueForceUpdate
- useReducer
- FunctionComponent
- useState
- FunctionComponent
- update 与Fiber 关系
- Fiber节点上的多个Update会组成链表并被包含在fiber.updateQueue中。
- 多次触发 更新例如 setState 就会产生多个update
- Fiber节点最多同时存在两个updateQueue:
- urrent fiber保存的updateQueue即current updateQueue
- workInProgress fiber保存的updateQueue即workInProgress updateQueue
- 更新的优先级(React通过Scheduler调度任务。)runWithPriority。接收一个优先级常量与一个回调函数作为参数,回调函数会以优先级高低为顺序排列在一个定时器中并在合适的时间触发。
- 生命周期方法:同步执行。
- 受控的用户输入:比如输入框内输入文字,同步执行
- 交互事件:比如动画,高优先级执行。
- 其他:比如数据请求,低优先级执行。
- render阶段的生命周期勾子componentWillXXX也会触发两次
- 在commit阶段结尾会再调度一次更新
render阶段(在内存中进行),在performSyncWorkOnRoot函数中fiberRootNode被传递给commitRoot方法,开启commit阶段工作流程
构建的时候最多会同时存在两棵Fiber树
当前屏幕显示的dom树
- current Fiber树(根节点通过current指针在不同的FIber树 rootFiber切换 形成的)
内存中构建的树
- workInProgress Fiber树
构建阶段
“递”阶段
rootFiber开始向下深度优先遍历,遍历到的每个Fiber节点调用beginWork方法,这个方法会根据传入的Fiber节点创建子Fiber节点,将这两个Fiber节点连接起来,形成fiber树
当遍历到叶子节点(即没有子组件的组件)时就会进入“归”阶段
begin work
mount
- 除fiberRootNode以外,current === null。会根据fiber.tag不同,创建不同类型的子Fiber节点((FunctionComponent/ClassComponent/HostComponent))
update
如果current存在,在满足一定条件时可以复用current节点,这样就能克隆current.child作为workInProgress.child,而不需要新建workInProgress.child。
didReceiveUpdate === false
直接复用前一次更新的子Fiber,不需要新建子Fiber
oldProps === newProps && workInProgress.type === current.type,即props与fiber.type不变
!includesSomeLane(renderLanes, updateLanes),即当前Fiber节点优先级不够,
didReceiveUpdate === true
reconcileChildren
对于mount的组件
- 会创建新的子Fiber节点
对于update的组件
- 他会将当前组件与该组件在上次更新时对应的Fiber节点比较(也就是俗称的Diff算法),将比较的结果生成新Fiber节点
effectTag 通过diff算法到的这
(执行DOM操作的具体类型就保存在fiber.effectTag中)插入dom节点条件
fiber.stateNode存在,即Fiber节点中保存了对应的DOM节点
fiber.effectTag & Placement) !== 0,即Fiber节点存在Placement effectTag
首评渲染
fiber.stateNode会在completeWork中创建
mount时只有rootFiber会赋值Placement effectTag,在commit阶段只会执行一次插入操作。
commit阶段是如何通过一次插入DOM操作(对应一个Placement effectTag)将整棵DOM树插入页面的呢
completeWork中的appendAllChildren
由于completeWork属于“归”阶段调用的函数,每次调用appendAllChildren时都会将已生成的子孙DOM节点插入当前生成的DOM节点下。那么当“归”到rootFiber时,我们已经有一个构建好的离屏DOM树。
dom diff算法(beginwork update)
一个DOM节点在某一时刻最多会有4个节点和他相关。
current Fiber。如果该DOM节点已在页面中,current Fiber代表该DOM节点对应的Fiber节点。
workInProgress Fiber。如果该DOM节点将在本次更新中渲染到页面中,workInProgress Fiber代表该DOM节点对应的Fiber节点
DOM节点本身。
JSX对象。即ClassComponent的render方法的返回结果,或FunctionComponent的调用结果。JSX对象中包含描述DOM节点的信息
diff 本质就是 对比 current fiber 和 jsx 生成 wokkinprogress fiber 渲染dom
时间复杂度
将前后两棵树完全比对的算法的复杂程度为 O(n 3 ),
- 由于左树中任意节点都可能出现在右树,所以必须在对左树深度遍历的同时,对右树进行深度遍历,找到每个节点的对应关系,这里的时间复杂度是 O(n²) 之后需要对树的各节点进行增删移的操作,这个过程简单可以理解为加了一层遍历循环,因此再乘一个 n
简化时间复杂度做法
1.只对同级元素进行Diff。如果一个DOM节点在前后两次更新中跨越了层级,那么React不会尝试复用他。
两个不同类型的元素会产生出不同的树。如果元素由div变为p,React会销毁div及其子孙节点,并新建p及其子孙节点。
开发者可以通过 key prop来暗示哪些子元素在不同的渲染下能保持稳定
为什么降级可行?因为跨层级很少发生,可以忽略。
具体实现
入口函数reconcileChildFibers出发,该函数会根据newChild(即JSX对象)类型调用不同的处理函数。
当newChild类型为object、number、string,代表同级只有一个节点
reconcileSingleElement
先判断key是否相同
key相同
type不同
- 执行deleteRemainingChildren将child及其兄弟fiber都标记删除。
type相同
- DOM节点才能复用
key不同
- 仅将child标记删除。
当newChild类型为Array,同级有多个节点
reconcileChildFibers函数内部对应如下情况:
节点更新
节点属性变化
节点类型更新
节点新增或减少
节点位置变化
遍历顺序
第一轮遍历
处理更新的节点
newChildren[i]与oldFiber比较,DOM节点是否可复用。
复用 继续遍历newChildren[i]与oldFiber.sibling
- 继续遍历 i++
不可复用
key不同导致不可复用,立即跳出整个遍历
第一轮结束
- 此时newChildren没有遍历完,oldFiber也没有遍历完。
key相同type不同导致不可复用,会将oldFiber标记为DELETION
- 继续遍历
i === newChildren.length - 1
newChildren遍历完或者oldFiber遍历完
第一轮结束
newChildren遍历完,oldFiber 没遍历完
oldFiber 遍历完 ,newChildren 没遍历完
同时遍历完
第二轮遍历
处理剩下的不属于更新的节点。
此时newChildren没有遍历完,oldFiber也没有遍历完。
更新中改变了位置
- 将所有还未处理的oldFiber存入以key为key,oldFiber为value的Map中。
(const existingChildren = mapRemainingChildren(returnFiber, oldFiber);
) 通过newChildren[i].key就能在existingChildren中找到key相同的oldFiber。
- 将所有还未处理的oldFiber存入以key为key,oldFiber为value的Map中。
- 标记节点是否移动
- 最后一个可复用的节点在oldFiber中的位置索引(用变量lastPlacedIndex表示)。
- oldIndex(本次复用节点在oldFiber中的位置) < lastPlacedIndex(最后可复用节点在oldFiber中的位置)
- 本次更新该节点需要向右移动,只需要进行右移操作
- oldIndex >= lastPlacedIndex
- lastPlacedIndex = oldIndex。
- newChildren遍历完,oldFiber 没遍历完
- 本次更新的新节点比较少,这个时候oldFiber 依次标记Deletion。
- oldFiber 遍历完 ,newChildren 没遍历完
- 已有的DOM节点都复用了,这时候newChildren为生成的workInProgress fiber依次标记Placement
- 同时遍历完
- 此时Diff结束。就一遍
- vue 与react diff 区别
- Vue 的 Dom diff
- 第一和第二步分别从首尾两头向中间逼近,尽可能跳过首位相同的元素,因为我们的目的是 尽量保证不要发生 dom 位移。(双指针)
- 如果前两步做完后,发现旧树指针重合了,新树还未重合,说明什么?说明新树剩下来的都是要新增的节点,批量插入即可
- 第一和第二步完成后,发现新树指针重合了,但旧树还未重合,说明什么?说明旧树剩下来的在新树都不存在了,批量删除即可。
- Old 和 New 都有剩余
- 遍历 Old 创建一个 Map,这个就是那个换时间的空间消耗,它记录了每个旧节点的 index 下标,一会好在 New 里查出来。
- 遍历 New,顺便利用上面的 Map 记录下下标,同时 Old 在 New 中不存在的说明被删除了,直接删除。
- 不存在的位置补 0,我们拿到 e:4 d:3 c:2 h:0 这样一个数组,下标 0 是新增,非 0 就是移过来的,批量转化为插入操作即可。
- 最后一步的优化也很关键
- 因此我们只需要找到 New 数组中的 最长子序列,找到那些相对位置有序的元素保持不变,让那些位置明显错误的元素挪动即是最优的。
- 换成程序去做,可以采用贪心 + 二分法进行查找,详细可以看这道题 最长递增子序列,时间复杂度 O(nlogn)。由于该算法得出的结果顺序是乱的,Vue 采用提前复制数组的方式辅助找到了正确序列。
- react Dom diff
- ?React 采用了 仅右移策略,即对元素发生的位置变化,只会将其移动到右边,那么右边移完了,其他位置也就有序了。
- “归”阶段
- 在“归”阶段会调用completeWork (opens new window)处理Fiber节点
- 如果其存在兄弟Fiber节点(即fiber.sibling !== null),会进入其兄弟Fiber的“递”阶段。
- 如果不存在兄弟Fiber,会进入父级Fiber的“归”阶段
- 阶段
- workProgress.tag
- update((current === null ?),HostComponent 还需要判断 Fiber节点是否存在对应的DOM节点)
- 需要做的主要是处理props,被处理完的props会被赋值给workInProgress.updateQueue
- 并最终会在commit阶段被渲染在页面上。
- workInProgress.updateQueue = (updatePayload: any);
其中updatePayload为数组形式,他的偶数索引的值为变化的prop key,奇数索引的值为变化的prop value。
- mount
- 为Fiber节点生成对应的DOM节点
- 将子孙DOM节点插入刚生成的DOM节点中
- 与update逻辑中的updateHostComponent类似的处理props的过程
- 递”和“归”阶段会交错执行直到“归”到rootFiber,至此,render阶段的工作就结束了。
- commit阶段需要找到所有有effectTag的Fiber节点并依次执行effectTag对应操作。
- 所有有effectTag的Fiber节点都会被追加在effectList中,最终形成一条以rootFiber.firstEffect为起点的单向链表
commit阶段(副作用对应的DOM操作,commit阶段是同步的,不会多次调用)
workInProgress Fiber 树在render阶段完成构建后进入commit阶段渲染到页面上。渲染完毕后,workInProgress Fiber 树变为current Fiber 树。
before mutation阶段(执行DOM操作前)
相关工作
if (firstEffect !== null)之前属于before mutation之前。
before mutation之前主要做一些变量赋值,状态重置的工作
最后赋值的firstEffect,在commit的三个子阶段都会用到
操作
遍历effectList并调用commitBeforeMutationEffects函数处理
处理DOM节点渲染/删除后的 autoFocus、blur 逻辑。
调用getSnapshotBeforeUpdate生命周期钩子。
- 从Reactv16开始,componentWillXXX钩子前增加了UNSAFE_前缀。
是因为Stack Reconciler重构为Fiber Reconciler后,render阶段的任务可能中断/重新开始,对应的组件在render阶段的生命周期钩子(即componentWillXXX)可能触发多次。所以采用了getSnapshotBeforeUpdate,这是在commit 阶段调用的
- 从Reactv16开始,componentWillXXX钩子前增加了UNSAFE_前缀。
调度useEffect。(useEffect异步执行的原因主要是防止同步执行时阻塞浏览器渲染)
scheduleCallback方法由Scheduler模块提供,用于以某个优先级异步调度一个回调函数。被异步调度的回调函数就是触发useEffect的方法flushPassiveEffects
before mutation阶段在scheduleCallback中调度flushPassiveEffects
layout阶段之后将effectList赋值给rootWithPendingPassiveEffects
scheduleCallback触发flushPassiveEffects,flushPassiveEffects内部遍历rootWithPendingPassiveEffects((即effectList))
mutation阶段(执行DOM操作)
mutation阶段也是遍历effectList,执行函数。这里执行的是commitMutationEffects。对每个Fiber节点执行操作
根据ContentReset effectTag重置文字节点
更新ref
根据effectTag分别处理,其中effectTag包括(Placement | Update | Deletion | Hydrating)
Placement effect
调用的方法为commitPlacement。
获取父级DOM节点。其中finishedWork为传入的Fiber节点
获取Fiber节点的DOM兄弟节点
根据DOM兄弟节点是否存在决定调用parentNode.insertBefore或parentNode.appendChild执行DOM插入操作
Update effect
- 调用的方法为commitWork,根据Fiber.tag处理
- fiber.tag为FunctionComponent
- 会调用commitHookEffectListUnmount。该方法会遍历effectList,执行所有useLayoutEffect hook的销毁函数。
useLayoutEffect(() => {
// ...一些副作用逻辑
return () => {
// ...这就是销毁函数
}
})
- 当fiber.tag为HostComponent
- 调用commitUpdate。
- 最终会在updateDOMProperties (opens new window)中将render阶段 completeWork (opens new window)中为Fiber节点赋值的updateQueue对应的内容渲染在页面上。
- Deletion effect
- commitDeletion。
- 递归调用Fiber节点及其子孙Fiber节点中fiber.tag为ClassComponent的componentWillUnmount (opens new window)生命周期钩子,从页面移除Fiber节点对应DOM节点
- 解绑ref
- 调度useEffect的销毁函数
#
- layout阶段(执行DOM操作后)
- 工作
- useEffect相关的处理
- 在commit阶段会触发一些生命周期钩子(如 componentDidXXX)和hook(如useLayoutEffect、useEffect)
- 性能追踪相关。interaction相关的变量。他们都和追踪React渲染时间、性能相关
- 操作
- layout阶段也是遍历effectList,执行函数commitLayoutEffects
- commitLayoutEffectOnFiber(调用生命周期钩子和hook相关操作)根据fiber.tag对不同类型的节点分别处理
- 对于ClassComponent
- 他会通过current === null?区分是mount还是update,调用componentDidMount (opens new window)或componentDidUpdate (opens new window)。
- 触发状态更新的this.setState如果赋值了第二个参数回调函数,也会在此时调用。
- 对于FunctionComponent及相关类型
- useLayoutEffect hook的回调函数,调度useEffect的销毁与回调函数
- 指特殊处理后的FunctionComponent,比如ForwardRef、React.memo包裹的FunctionComponent
- useLayoutEffect与useEffect的区别
- useLayoutEffect hook从上一次更新的销毁函数调用到本次更新的回调函数调用是同步执行的。而useEffect则需要先调度,在Layout阶段完成后再异步执行。
- 对于HostRoot,即rootFiber
- 如果赋值了第三个参数回调函数,也会在此时调用
- commitAttachRef(赋值 ref)
#
- 获取DOM实例,更新ref
- root.current = finishedWork;
workInProgress Fiber树在commit阶段完成渲染后会变为current Fiber树。这行代码的作用就是切换fiberRootNode指向的current Fiber树
- (在mutation阶段结束后,layout阶段开始前。)
- componentWillUnmount会在mutation阶段执行。此时current Fiber树还指向前一次更新的Fiber树,在生命周期钩子内获取的DOM还是更新前的
- componentDidMount和componentDidUpdate会在layout阶段执行。此时current Fiber树已经指向更新后的Fiber树,在生命周期钩子内获取的DOM就是更新后的。
react分类
class component
他的生命周期(componentWillXXX/componentDidXXX)是为了介入React的运行流程而实现的更上层抽象,这么做是为了方便框架使用者更容易上手。
componentWillReceiveProps是在render阶段执行
hooks
相比于ClassComponent的更上层抽象,Hooks则更贴近React内部运行的各种概念(state | context | life-cycle)。
- 更新是什么
- 通过一些途径产生更新,更新会造成组件render。
- update数据结构
- 他们会形成环状单向链表。
- 状态如何保存
- FunctionComponent对应的fiber中。
- ClassComponent的实例可以存储数据
- Hook数据结构
- Hook是无环的单向链表
useEffect是在commit阶段完成渲染后异步执行。
hook数据结构
区分mount 与update
不同的disptcher
并将不同情况对应的dispatcher赋值给全局变量ReactCurrentDispatcher的current属性。则FunctionComponent render时调用的hook也是不同的函数。
当错误的书写了嵌套形式的hook
useEffect(() => {
useState(0);
})
- 此时ReactCurrentDispatcher.current已经指向ContextOnlyDispatcher,所以调用useState实际会调用throwInvalidHookError,直接抛出异常。
- FunctionComponent对应fiber 下current === null || current.memoizedState === null
区分 null
- null
- mount
- 不是null
- update
- 数据结构
- 比updateQueue 多一个memoizedState
const hook: Hook = {
memoizedState: null,
baseState: null,
baseQueue: null,
queue: null,
next: null,
};
- fiber.memoizedState
- FunctionComponent对应fiber保存的Hooks链表
- hook.memoizedState
- Hooks链表中保存的单一hook对应的数据
- useState
- memoizedState保存state的值
- useReducer
- memoizedState保存state的值
- useEffect
- memoizedState保存包含useEffect回调函数、依赖项等的链表数据结构effect,你可以在这里 (opens new window)看到effect的创建过程。effect链表同时会保存在fiber.updateQueue中
- useRef
- 对于useRef(1),memoizedState保存{current: 1}
- useMemo
- 对于useMemo(callback, [depA]),memoizedState保存[callback(), depA]
- useCallback
- memoizedState保存[callback, depA]
- usecall back 保存的是callback函数本身
- useMemo保存的是callback函数的执行结果
- useContext
- memoizedState 没有值
useState与useReducer
声明阶段
在app 调用的时候会依次执行useReducer与useState方法
mount时
mountWorkInProgressHookmountReducer
mountState
调用ReactDOM.render或相关初始化API产生的更新,只会执行一次
update(找到对应的hook,根据update计算该hook的新state并返回) updateWorkInProgressHook
useReducer与useState调用的则是同一个函数updateReducer (opens new window)。
事件回调或副作用中触发的更新或者是render阶段触发的更新,为了避免组件无限循环更新,后者需要区别对待。
- 基于这个原因,React用一个标记变量didScheduleRenderPhaseUpdate判断是否是render阶段触发的更新。
调用阶段
dispatch或updateNum被调用时
调用阶段会执行dispatchAction
创建update,将update加入queue.pending中,并开启调度。
state 计算逻辑
通过update计算state发生在声明阶段,这是因为有存在多个不同优先级update ,最终state的值由多个update共同决定
当fiber上不存在update,则调用阶段创建的update为该hook上第一个update,在声明阶段计算state时也只依赖于该update,完全不需要进入声明阶段再计算state
所以如果计算出的state与该hook之前保存的state一致,那么完全不需要开启一次调度。即使计算出的state与该hook之前保存的state不一致,在声明阶段也可以直接使用调用阶段已经计算出的state。
useEffect
在flushPassiveEffects方法内部会从全局变量rootWithPendingPassiveEffects获取effectList。
flushPassiveEffects内部会设置优先级,并执行flushPassiveEffectsImpl。
flushPassiveEffectsImpl主要做三件事:
- 调用该useEffect在上一次render时的销毁函数
副作用清理函数(如果存在)在 React 16 中同步运行。我们发现,对于大型应用程序来说,这不是理想选择,因为同步会减缓屏幕的过渡(例如,切换标签)。
基于这个原因,在v17.0.0中,useEffect的两个阶段会在页面渲染后(layout阶段后)异步执行。
- useEffect的执行需要保证所有组件useEffect的销毁函数必须都执行完后才能执行任意一个组件的useEffect的回调函数。 这是因为多个组件间可能共用同一个ref
- 调用该useEffect在本次render时的回调函数
- 如果存在同步任务,不需要等待下次事件循环的宏任务,提前执行他
useRef 赋值ref属性。
ref的工作流程
HostComponent
HostComponent在commit阶段的mutation阶段执行DOM操作,所以对应ref的更新也是发生在mutation阶段
render阶段为含有ref属性的fiber添加Ref effectTag
对于mount,workInProgress.ref !== null,即存在ref属性
对于update,current.ref !== workInProgress.ref,即ref属性改变
commit阶段为包含Ref effectTag的fiber执行对应操作
- 在commit阶段的mutation阶段中,对于ref属性改变的情况,需要先移除之前的ref。
- ClassComponent
- ForwardRef
- 其中,ForwardRef只是将ref作为第二个参数传递下去,不会进入ref的工作流程。 let children = Component(props, secondArg);
useMemo
mount
- mountMemo会将回调函数(nextCreate)的执行结果作为value保存
update
- 回调函数的执行结果作为vlaue
useCallback
mount
- mountCallback会将回调函数作为value保存
update
- 回调函数本身作为vlaue
hooks
Hooks 带来的最大好处
逻辑复用
就是非常难以实现逻辑的复用,必须借助于高阶组件等复杂的设计模式。但是高阶组件会产生冗余的组件节点,让调试变得困难
代码难理解,不直观,很多人甚至宁愿重复代码,也不愿用高阶组件;
会增加很多额外的组件节点。每一个高阶组件都会多一层节点,这就会给调试带来很大的负担。
这样当窗口大小发生变化时,使用这个 Hook 的组件就都会重新渲染。而且代码也更加简洁和直观,不会产生额外的组件节点。
有助于关注分离
- 这是过去在 Class 组件中很难做到的。因为在 Class 组件中,你不得不把同一个业务逻辑的代码分散在类组件的不同生命周期的方法中。
更好地体现了 React 的开发思想,即从 State => View 的函数式映射。
useState
类组件中的 state 只能有一个。所以我们一般都是把一个对象作为 一个 state,然后再通过不同的属性来表示不同的状态
函数组件中用 useState 则可以很容易地创建多个 state,所以它更加语义化。
我们要遵循的一个原则就是:state 中永远不要保存可以通过计算得到的值
从 props 传递过来的值。有时候 props 传递过来的值无法直接使用,而是要通过一定的计算后再在 UI 上展示,比如说排序
从 URL 中读到的值。比如有时需要读取 URL 中的参数
从 cookie、localStorage 中读取的值。
useEffect:执行副作用(一段和当前执行结果无关的代码)
其中依赖项是可选的,如果不指定,那么 callback 就会在每次函数组件执行完后都执行;如果指定了,那么只有依赖项中的值发生变化的时候,它才会执行。
没有依赖项,则每次 render 后都会重新执行
空数组作为依赖项,则只在首次执行时触发,对应到 Class 组件就是 componentDidMount。例如:
useEffect 是每次组件 render 完后判断依赖并执行
useEffect 还允许你返回一个函数,用于在组件销毁的时候做一些清理的操作。
定义依赖项时,我们需要注意以下三点:
依赖项中定义的变量一定是会在回调函数中用到的,否则声明依赖项其实是没有意义的。
依赖项一般是一个常量数组,而不是一个变量。因为一般在创建 callback 的时候,你其实非常清楚其中要用到哪些依赖项了
React 会使用浅比较来对比依赖项是否发生了变化,所以要特别注意数组或者对象类型。如果你是每次创建一个新对象,即使和之前的值是等价的,也会被认为是依赖项发生了变化。这是一个刚开始使用 Hooks 时很容易导致 Bug 的地方
useMemo:缓存计算的结果
如果某个数据是通过其它数据计算得到的,那么只有当用到的数据,也就是依赖的数据发生变化的时候,才应该需要重新计算。
useCallback 的功能其实是可以用 useMemo 来实现的
- const myEventHandler = useMemo(() => { // 返回一个函数作为缓存结果 return () => { // 在这里进行事件处理 } }, [dep1, dep2]);
useCallback:缓存回调函数
函数组件实际上都会重新执行一遍。在每次执行的时候,实际上都会创建一个新的事件处理函数 handleIncrement。每次创建新函数的方式会让接收事件处理函数的组件,需要重新渲染。
只有当 count 发生变化时,我们才需要重新定一个回调函数。而这正是 useCallback 这个 Hook 的作用。
useRef:在多次渲染之间共享数据
类成员变量可以保存一些数据,但是函数组件需让useRef
使用 useRef 保存的数据一般是和 UI 的渲染无关的,因此当 ref 的值发生变化时,是不会触发组件的重新渲染的,这也是 useRef 区别于 useState 的地方
就是保存某个 DOM 节点的引用。我们知道,在 React 中,几乎不需要关心真实的 DOM 节点是如何渲染和修改的。但是在某些场景中,我们必须要获得真实 DOM 节点的引用,所以结合 React 的 ref 属性和 useRef 这个 Hook,我们就可以获得真实的 DOM 节点,并对这个节点进行操作
useContext:定义全局状态
答案其实很简单,就是为了能够进行数据的绑定。当这个 Context 的数据发生变化时,使用这个数据的组件就能够自动刷新。但如果没有 Context,而是使用一个简单的全局变量,就很难去实现了。
Context 相当于提供了一个定义 React 世界中全局变量的机制,而全局变量则意味着两点:
会让调试变得困难,因为你很难跟踪某个 Context 的变化究竟是如何产生的
让组件的复用变得困难,因为一个组件如果使用了某个 Context,它就必须确保被用到的地方一定有这个 Context 的 Provider 在其父组件的路径上。
掌握 Hooks 的使用规则
只能在函数组件的顶级作用域使用;只能在函数组件或者其他 Hooks 中使用。
Hooks 只能在函数组件的顶级作用域使用
hooks 不能在循环、条件判断或者嵌套函数内执行,而必须是在顶层。同时 Hooks 在组件的多次渲染之间,必须按顺序被执行
- 因为在 React 组件内部,其实是维护了一个对应组件的固定 Hooks 执行列表的,以便在多次渲染之间保持 Hooks 的状态,并做对比。
,但是如果一定要在 Class 组件中使用,那应该如何做呢?其实有一个通用的机制,那就是利用高阶组件的模式,将 Hooks 封装成高阶组件,从而让类组件使用。
10|函数组件设计模式:如何应对复杂条件渲染场景?
一个和 Hooks 相关,用于解决 Hooks 无法在条件语句中执行带来的一些难题;
可以称之为容器模式。
- 把条件判断的结果放到两个组件之中,确保真正 render UI 的组件收到的所有属性都是有值的。
总体来说,通过这样一个容器模式,我们把原来需要条件运行的 Hooks 拆分成子组件,然后通过一个容器组件来进行实际的条件判断,从而渲染不同的组件,实现按条件渲染的目的。这
另一个则是经典的 render props 模式,用于实现 UI 逻辑的重用。
把一个 render 函数作为属性传递给某个组件,由这个组件去执行这个函数从而 render 实际的内容。
使用 Hooks 的方式是更简洁的。这也是为什么我们经常说 Hooks 能够替代 render props 这个设计模式。但是,需要注意的是,Hooks 仅能替代纯数据逻辑的 render props。如果有 UI 展示的逻辑需要重用,那么我们还是必须借助于 render props 的逻辑,这就是我一再强调必须要掌握 render props 这种设计模式的原因。
生命周期
import { useRef } from ‘react’;// 创建一个自定义 Hook 用于执行一次性代码function useSingleton(callback) { // 用一个 called ref 标记 callback 是否执行过 const called = useRef(false); // 如果已经执行过,则直接返回 if (called.current) return; // 第一次调用时直接执行 callBack(); // 设置标记为已执行过 called.current = true;}
但是 Class 组件中还有其它一些比较少用的方法,比如 getSnapshotBeforeUpdate, componentDidCatch, getDerivedStateFromError。比较遗憾的是目前 Hooks 还没法实现这些功能。因此如果必须用到,你的组件仍然需要用类组件去实现
自定义Hooks
Hooks 和普通函数在语义上是有区别的,就在于函数中有没有用到其它 Hooks。
自定义 Hooks 的两个特点:
名字一定是以 use 开头的函数,这样 React 才能够知道这个函数是一个 Hook;
函数内部一定调用了其它的 Hooks,可以是内置的 Hooks,也可以是其它自定义 Hooks。这样才能够让组件刷新,或者去产生副作用
三个典型的业务场景。
封装通用逻辑:useAsync
监听浏览器状态:useScroll
拆分复杂组件
那么如何建立 Redux 和 React 的联系呢?
React 组件能够在依赖的 Store 的数据发生变化时,重新 Render
在 React 组件中,能够在某些时机去 dispatch 一个 action,从而触发 Store 的更新。
在 react-redux 的实现中,为了确保需要绑定的组件能够访问到全局唯一的 Redux Store,利用了 React 的 Context 机制去存放 Store 的信息。通常我们会将这个 Context 作为整个 React 应用程序的根节点。因此,作为 Redux 的配置的一部分,我们通常需要如下的代码:
异步 Action 的概念。
简单来说,middleware 可以让你提供一个拦截器在 reducer 处理 action 之前被调用。在这个拦截器中,你可以自由处理获得的 action。无论是把这个 action 直接传递到 reducer,或者构建新的 action 发送到 reducer,都是可以的。
Redux 提供了 redux-thunk 这样一个中间件,它如果发现接受到的 action 是一个函数,那么就不会传递给 Reducer,而是执行这个函数,并把 dispatch 作为参数传给这个函数,从而在这个函数中你可以自由决定何时,如何发送 Action。
复杂状态处理:如何保证状态一致性
原则一:保证状态最小化
这个状态是必须的吗?是否能通过计算得到呢?在得到肯定的回答后,我们再去定义新的状态,就能避免大部分多余的状态定义问题了,也就能在简化状态管理的同时,保证状态的一致性。
某些数据如果能从已有的 State 中计算得到,那么我们就应该始终在用的时候去计算,而不要把计算的结果存到某个 State 中
原则二:避免中间状态,确保唯一数据源
React 原生事件的原理 合成事件(Synthetic Events)
由于虚拟 DOM 的存在,在 React 中即使绑定一个事件到原生的 DOM 节点,事件也并不是绑定在对应的节点上,而是所有的事件都是绑定在根节点上。然后由 React 统一监听和管理,获取事件后再分发到具体的虚拟 DOM 节点上。
在 React 17 之前,所有的事件都是绑定在 document 上的,而从 React 17 开始,所有的事件都绑定在整个 App 上的根节点上,
第一,虚拟 DOM render 的时候, DOM 很可能还没有真实地 render 到页面上,所以无法绑定事件。
第二,React 可以屏蔽底层事件的细节,避免浏览器的兼容性问题。同时呢,对于 React Native 这种不是通过浏览器 render 的运行时,也能提供一致的 API
我们知道,在浏览器的原生机制中,事件会从被触发的节点往父节点冒泡,然后沿着整个路径一直到根节点,所以根节点其实是可以收到所有的事件的。这也称之为浏览器事件的冒泡模型。
13|Form:Hooks 给 Form 处理带来了哪些新变化?
不过,受控组件的这种方式虽然统一了表单元素的处理,有时候却会产生性能问题。因为用户每输入一个字符,React 的状态都会发生变化,那么整个组件就会重新渲染
- 总结来说,在实际的项目中,我们一般都是用的受控组件,这也是 React 官方推荐的使用方式。不过对于一些个别的场景,比如对性能有极致的要求,那么非受控组件也是一种不错的选择。
所谓非受控组件,就是表单元素的值不是由父组件决定的,而是完全内部的状态
使用 Hooks 简化表单处理
把表单的状态管理单独提取出来,成为一个可重用的 Hook。这样在表单的实现组件中,我们就只需要更多地去关心 UI 的渲染,而无需关心状态是如何存储和管理的,从而方便表单组件的开发。
当我们基于 Hooks 实现了一个基本的表单状态管理机制之后,现在,我们就要在这个机制的基础之上,再增加一个表单处理必备的业务逻辑:表单验证。
15 | 路由管理:为什么每一个前端应用都需要使用路由机制?
所谓路由管理,就是让你的页面能够根据 URL 的变化进行页面的切换,这是前端应用中一个非常重要的机制,同时也是 Web 应用区别于桌面应用的一个重要特征。
路由机制提供了按页面去组织整个应用程序的能力,
URL 的全称是 Uniform Resource Locator,中文意思是“统一资源定位符”,表明 URL 是用于唯一的定位某个资源的
16 | 按需加载:如何提升应用打开速度?
首先,在打开某个页面时,只加载这个页面相关的内容,也就是按需加载。
ECMA Script 标准有一个提案,专门用于动态加载模块,语法是 import(someModule)。import() 函数会返回一个 Promise
Webpack 利用了动态 import 语句,自动实现了整个应用的拆包。而我们在实际开发中,其实并不需要关心 Webpack 是如何做到的,而只需要考虑:该在哪个位置使用 import 语句去定义动态加载的拆分点
按业务模块为目标去做隔离,尽量在每个模块的起始页面去定义这个拆分点。
同时,为了提升后续应用的打开速度,就需要采用高效的缓存策略,避免前端资源的重复下载。
- Service Worker 来缓存前端资源
react-loadable,正是这样一个开源的 npm 模块,专门用于 React 组件的按需加载。
定义一个加载器组件,在使用的地方依赖于这个加载器组件而不是原组件;
在加载器组件的执行过程中,使用 import 去动态加载真实的实现代码;
处理加载过程,和加载出错的场景,确保用户体验。
20 | React 的未来:什么是服务器端组件?
React 17.0:没有新特性的新版本
渐进升级
新的事件模型
在 React 17 中,为了支持多版本 React 的共存,React 的事件模型做了一个修改。让我们不需要再通过 Document 去监听事件,而是在 React 组件树的根节点上去监听。这样的话,多个版本的 React 就不会有事件的冲突了。
而且还让 React 在和其它一些技术栈(比如 JQuery)一起使用时,降低事件冲突的可能性。
新的 JSX 编译机制
如果我们要在 React 组件中使用 JSX,那么就需要使用 import 语句引入 React。这么做的原因就在于,在编译时 JSX 会被翻译成 React.createElement 这样的 API,所以就需要引入 React。
但是 _jsx 这个函数的引入是由编译器自动完成的。所以从开发角度看,带来的最明显的好处就是代码更直观了
Suspense: 悬停渲染
- Suspense,顾名思义,就是挂起当前组件的渲染,直到异步操作完成
Server Components:服务器端 React 组件
- ,就是能够在组件级别实现服务器端的渲染。也就是说,一个前端页面中,有些组件是客户端渲染的,而有的组件则可以是服务器端渲染的。为了帮助你加深理解,我们直接来看一段示意的代码。
hook执行
usestate
memoziedState 保持hooks 数据
单向链表
多个hooks
next 指向下一个hooks
- 更新
update 变更的状态
queue{ pending}
是个环形链表
因为有不同的优先级
有的需要执行,有个可能不需要执行
执行过程
通过变量 mout inworkpross hooks 或者update
判断是否mount 还是udpate
获取当前的hooks
将相关数据保存在memozied state 上
更新next 指向
- 剪开双向链表
useeffet
- 也是一样获取hooks,然后 memoziedState存放的是 crete 函数 和depths
useref
- 就是存放 crruent:initvalue 这个对象在那个memoziedState
useMemo
也是一样获取hooks,然后 memoziedState存放的是 next crete 函数 和nextvalue
mount 时候会执行一次函数
update 的时候需要比较依赖项的值 用的是OBject.is 这个方法
参考总结来源 https://react.iamkasong.com/preparation/idea.html#react%E7%90%86%E5%BF%B5