React 源码揭秘 | Ref更新原理
Ref主要作用于HostComponent组件,用来获取其真实的DOM节点。
除此以外,我们也通常用useRef等hooks来维护一个稳定的值,并且在该值改变的时候不触发更新
Ref 结构
Ref的结构一般为 Ref: { current: 你要保存的值 }
多套一层current保存的原因主要是,为了保证其保存值的稳定。
比如,对于函数组件来说,每次更新都会重新渲染执行函数,如果用ref直接保存变量 如
const myRef = useRef({a:100}) // 假设 myRef 直接就是对象 { a: 100 }
那么,当我们修改myRef时,比如
myRef = { b: 200 }
那么此时修改的,其实是当前函数作用域下myRef变量的指向,也就是说吧myRef指向了另外一个对象,对useRef中保存的Ref对象没有任何影响,那么在下次Update时,这个新的Ref值就会丢失!
所以采用修改一个对象内current属性的方式,保持值的稳定。
Ref的使用
Ref的使用方式一般有两种
- ref = {ref} 直接传入ref属性
- ref = {dom=>ref.current=dom} 传入函数进行赋值
我们通常使用useRef() 获取Ref,但是其实,其本质就是创建了一个包含current属性的对象而已,如果你能保证这个对象在每次更新时的稳定,你甚至可以自己new一个对象作为Ref
useRef
useRef 用来在函数组件中创建并且维护一个稳定的Ref,其实现非常简单
/** 挂载Ref */
function mountRef<T>(initialValue: T): { current: T } {
const hook = mountWorkInProgressHook();
hook.memorizedState = { current: initialValue };
return hook.memorizedState;
}
/** 更新Ref 其实就是保存一个值 */
function updateRef<T>(): { current: T } {
const hook = updateWorkInProgressHook();
return hook.memorizedState;
}
把你传入的值,封装成 { current: value } 的形式,保存在Hook.memorizedState上,并且在下次调用的时候返回,即可。
所以,useRef不一定要和dom绑定,我们通常的做法都是将其作为一个可以稳定存储某个值,并且在修改时不触发更新的hook
Ref 更新流程
Ref的本意是,方便获取HostComponent的真实dom元素,或者ClassComponent的实例对象。
函数组件中没有Ref,因为Ref不知道要绑定什么,所以需要使用forwardRef和useImpreciateHa
ndle一同来确定Ref的指向。
在这里我们只讨论HostComponent的Ref更新和卸载。
和action的处理方式类似,Ref在Host的组件的 挂载,卸载,或者Ref对象变化的时候,都会检查。
如果传入的Ref为对象,则
- 挂载阶段,直接 ref.current = dom
- 卸载阶段 ref.current = null
- 更新阶段 先卸载oldRef oldRef.current = null 再更新 新的ref newRef.current = dom
如果传入Ref为函数,则
- 挂载阶段 ref(dom)
- 卸载阶段 ref(null)
- 更新阶段 oldRef(null) newRef(dom)
在调用createElement创建Element元素时,会对传入的props.ref进行特殊处理
/** 实现createElement方法 */
export function createElement(
type: ReactElementType,
props: ReactElementProps,
...children: ReactElementChildren[]
): ReactElement {
return {
$$typeof: REACT_ELEMENT_TYPE,
type,
// 特殊处理REF 如果没传入,默认赋null
ref: props.ref ? props.ref : null,
key: props.key ? String(props.key) : null,
props: {
...props,
/** 源码这里做了处理 如果只有一个child 直接放到children 如果有多个 则children为一个数组 */
children:
children?.length === 1
? handleChildren(children[0])
: children.map(handleChildren),
},
};
}
ref会被单独作为一个字段保存,在通过element创建Fiber的时候, 会默认吧Fiber.ref置null
在beginwork阶段,对每个HostComponent元素,都会执行markRef(wip)的操作
/** 处理普通节点的比较 */
function updateHostComponent(wip: FiberNode): FiberNode {
/** 1.获取element.children */
const hostChildren = wip.pendingProps?.children;
// 目前只有在HostComponent中标记Ref
markRef(wip);
/** 2. 协调子元素 */
reconcileChildren(wip, hostChildren);
/** 3.返回第一个child */
return wip.child;
}
markRef的逻辑很简单,会根据不同阶段判断
- 如果是挂载阶段,如果ref不为null,就给当前HostComponent的fiber对象打上Ref标记,在commit阶段会处理。
- 如果是在更新阶段,如果本次更新的Ref不等于currentFiber上的Ref 说明更新变动了Ref,需要给Fiber打Ref标记。
实现如下
Ref的变动 只有在
1. 挂载
2. 卸载
3. Ref对象变动
其中 卸载不需要标记Ref 只有挂载和Ref对象变动时才标记
/** 标记Ref [生产Ref] */
function markRef(wip: FiberNode) {
const current = wip.alternate;
const ref = wip.ref;
if (current === null && ref !== null) {
// mount阶段 如果wip有ref则绑定flag
wip.flags |= Ref;
return;
}
if (current !== null && ref !== current.ref) {
// update阶段 wip.ref和current.ref不相等 (useImmpreciatHandle改变ref)需要重新挂载ref
wip.flags |= Ref;
return;
}
}
commit阶段,真正完成对Ref对象的卸载和挂载。其中
- 卸载Ref在Mutation阶段,对有所打Ref标记的Fiber和被删除的Fiber的Ref进行卸载操作,其中完成卸载的函数为saftyDetachRef
- 挂载Ref在Layout阶段,其中完成挂载Ref的函数为saftyAttachRef
在commitMutationEffectOnFiber中,有对Ref的判断
// commitMutationEffectOnFiber
// 卸载Ref 只有hostComponent需要卸载
if (finishedWork.tag === HostComponent && (flags & Ref) !== NoFlags) {
const current = finishedWork.alternate;
if (current) {
// 需要卸载current的ref 其实本质上current和finishedWork的ref都是一个
saftyDetachRef(current);
}
// 卸载之后由于可能还会加载ref 所以这里的flag不能~Ref
}
在commitDeletion中,也包含了对Ref节点的安全卸载
卸载函数如下:
/** 卸载Ref
* 卸载时机 commit的mutation阶段 包括
* 1. 组件卸载
* 2. 组件更新时包含Ref (Ref变动)
*/
function saftyDetachRef(current: FiberNode) {
// 这里传入的是current的fiber 也就是旧的fiber 卸载的也是旧的fiber
// fiber会在createWorkinprogress复用传递 这里的作用就是 ref.current = null / ref(null)
const ref = current.ref;
if (ref === null) return;
// ref可以是函数或者对象 判读类型
if (typeof ref === "function") {
// 卸载/更新变动之前卸载时 都会执行下ref函数 并且传入null
ref(null);
} else {
ref.current = null;
}
}
其中,会先检测Ref是否存在,如果Ref不存在则直接return
Ref存在 则会根据Ref的类型,执行函数传入null或者直接把对象的current置为null
如果传入的是基本类型,不是对象,由于在对基本类型 . 属性 的时候,会先创建包装类,然后赋值,并且使用之后销毁包装类,所以也不会报错,只是没有任何的效果!
挂载Ref
次阶段在Layout阶段,此时调用saftyAttachRef
/** 附加Ref 附加时机
* commit的layout阶段 也就是真实dom更新完成 渲染之前
*/
function saftyAttachRef(finishedWork: FiberNode) {
const ref = finishedWork.ref;
const dom = finishedWork.stateNode;
if (ref !== null) {
if (typeof ref === "function") {
ref(dom);
} else {
ref.current = dom;
}
}
}
和卸载类似,只不过传入dom
其在commitLayEffect中被调用
/** 用来处理 Layout副作用 [Ref] */
const commitLayoutEffectsOnFiber: CommitCallback = (finishedWork) => {
// 处理每个节点的Effect
// 获取节点的flags
const flags = finishedWork.flags;
if (finishedWork.tag === HostComponent && (flags & Ref) !== NoFlags) {
saftyAttachRef(finishedWork);
finishedWork.flags &= ~Ref;
}
};
所以,在commit的Mutation阶段和Layout阶段都会设置Ref 在layout阶段更新完Ref之后,才将Ref的flag删除!