写在前面
本系列会实现一个简单的react
,包含最基础的首次渲染,更新,hook
,lane
模型等等,本文是本系列的第一篇。这对于我也是一个很大的挑战,不过这也是一个学习和进步的过程,希望能坚持下去,一起加油!期待多多点赞!😘😘
本文致力于实现一个最简单的useRef
,代码均已上传至github
,期待star!✨:
本文是系列文章,阅读的联系性非常重要!!
手写mini-react!超万字实现mount首次渲染流程🎉🎉
进击的hooks!实现react(反应)(反应)中的hooks架构和useState 🚀🚀
面试官问我 react scheduler 调度机制原理? 我却支支吾吾答不上来...😭😭
react能力值+1!useTransition是如何实现的?
期待点赞!😁😁
食用前指南!本文涉及到react的源码知识,需要对react有基础的知识功底,建议没有接触过react的同学先去官网学习一下基础知识,再看本系列最佳!
一. 基本概念
useRef 用于获取当前元素的所有属性。
基本使用:
ts
const ref = useRef(initVal);
Params:
initVal
:初始值。
Result:
{ current: "" }
:返回的一个包含current
属性的对象,这个current
属性就是 ref 需要获取的内容。
js
import { useRef } from 'react';
export default function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<input ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
其中 ref 有两种定义方式:
(instance: T) => void
xml
<div ref={dom => console.log(dom)}></div>
{current: T}
xml
<div ref={domRef}></div>
二. 数据共享层
hook
架构在实现时,脱离了react部分的逻辑,在内部实现了一个数据共享层,类似于提供一个接口。任何满足了规范的函数都可以通过数据共享层接入处理hook
的逻辑。这样就可以与宿主环境解耦,灵活性更高。
js
// 内部数据共享层
export const __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = {
currentDispatcher
};
const currentDispatcher = {
current: null
};
currentDispatcher
为我们本次实现的hook
。
所以对应到我们的render
流程以及hook
的应用,他们之间的调用关系是这样的:
hook
怎么知道当前是mount
还是update
?
我们在使用hook
时,react在内部通过 currentDispatcher.current
赋予不同的函数来处理不同阶段的调用,判断hooks 是否在函数组件内部调用。
三. hooks
hooks
可以看做是函数组件和与其对应的fiber
节点进行沟通和的操作的纽带。在react
中处于不同阶段的fiber
节点会被赋予不同的处理函数执行hooks
:
- 初始化阶段 ----->
HookDispatcherOnMount
- 更新阶段 ----->
HookDispatcherOnUpdate
js
const HookDispatcherOnMount = {
useState: mountState,
useEffect: mountEffect,
useRef: mountRef
};
const HookDispatcherOnUpdate = {
useState: updateState,
useEffect: updateEffect,
useRef: updateRef
};
但是实现之前,还有几个问题需要解决:
如何确定fiber对应的hook上下文?
还记得我们在处理函数组件类型的fiber
节点时,调用renderWithHooks
函数进行处理,在我们在执行hook
相关的逻辑时,将当前fiber
节点信息保存在一个全局变量中:
js
// 当前正在render的fiber
let currentlyRenderingFiber = null;
js
export function renderWithHooks(wip) {
// 赋值操作
currentlyRenderingFiber = wip;
// 重置
wip.memoizedState = null;
const current = wip.alternate;
if (current !== null) {
// update
// hooks更新阶段
} else {
// mount
// hooks初始化阶段
}
const Component = wip.type;
const props = wip.pendingProps;
const children = Component(props);
// 重置操作
// 处理完当前fiber节点后清空currentlyRenderingFiber
currentlyRenderingFiber = null;
return children;
}
将当前正在处理的fiber
节点保存在全局变量currentlyRenderingFiber
中,我们在处理hook
的初始化及更新逻辑中就可以获取到当前的fiber
节点信息。
hook是如何存在的?保存在什么地方?
注意hook
函数只存在于函数组件中,但是一个函数组件的fiber
节点时如何保存hook
信息呢?
答案是:memoizedState
。
fiber
节点中保存着非常多的属性,有作为构造fiber
链表,用于保存位置信息的属性,有作为保存更新队列的属性等等。
而对于函数组件类型的fiber
节点,memoizedState
属性保存hook
信息。hook
在初始化时,会创建一个对象,保存此hook
所产生的计算值,更新队列,hook
链表。
js
const hook = {
// hooks计算产生的值 (初始化/更新)
memoizedState: "";
// 对此hook的更新行为
updateQueue: "";
// hooks链表指针
next: null;
}
多个hook如何处理?
例如有以下代码:
js
import { useState } from 'react';
export default function Counter() {
const [count, setCount] = useState(0);
const [age, setAge] = useState(10);
function handleClick() {
setCount(count + 1);
}
function handleAgeClick() {
setCount(age + 18);
}
return (
<button onClick={handleClick}>
add
</button>
<button onClick={handleAgeClick}>
age
</button>
);
}
在某个函数组件中存在多个hook
,此时每个hook
的信息该如何保存呢?这就是上文中hook
对象中next
属性的作用,它是一个链表指针。在hook
对象中,next
属性指向下一个hook
。
换句话说,如果在一个函数组件中存在多个hook
,那么在该fiber
节点的memoizedState
属性中保存该节点的hooks
链表。
函数组件对应 fiber
用 memoizedState
保存 hook
信息,每一个 hook
执行都会产生一个 hook
对象,hook
对象中,保存着当前 hook
的信息,不同 hook
保存的形式不同。每一个 hook
通过 next
链表建立起关系。
四. useRef
定义useRef
函数:
js
export const useRef = (initialValue) => {
const dispatcher = resolveDispatcher();
return dispatcher.useRef(initialValue);
};
实现原理
从上面的例子可以看出来,其实 ref 就是 通过useaRef
函数创建并返回一个对象({ current: null }
),然后将这个对象赋值给fiber
节点的 ref 属性,最后在执行渲染流程时将真实的dom实例保存在这个对象中。
那么现在实现的重点就是在 react 渲染的流程中获取dom实例,但是由于fiber
节点只是抽象出来的对象,并不是真正的dom。所以纵观整个渲染流程,根据各个阶段的不同职责,在render
阶段标记 ref 标记, commit
阶段挂载dom节点时通过标记获取 dom 节点。主要实现流程如下;
1. 标记Ref
标记Ref
需要满足:
mount
时:存在ref
update
时:ref
引用变化
标记的时机包括:
beginWork
2. 执行Ref
操作
包括两类操作:
-
对于正常的绑定操作:
- 解绑之前的
ref
(mutation
阶段) - 绑定新的
ref
(layout
阶段)
- 解绑之前的
-
对于组件卸载:
- 解绑之前的
ref
五. mount
mount
阶段主要是构建hook对象,并将hook对象添加到fiber
节点的hook链表中。这也是所有hook在mount
阶段的基本操作。
最后创建一个current
对象,用来保存数据,这也就是useRef
函数的返回值。
js
function mountRef(initialValue) {
// 构建hook对象并添加到链表中
const hook = mountWorkInProgressHook();
// 创建current对象
const ref = { current: initialValue };
// 保存current对象,这也是useRef函数返回的对象结果
hook.memoizedState = ref;
return ref;
}
在构建hook对象时会有两种情况,如果是第一个hook函数,需要将其保存在fiber
节点的memoizedState
属性中,如果是后续的hook函数,直接通过next
指针连接。
js
function mountWorkInProgressHook() {
const hook = {
memoizedState: null,
updateQueue: null,
next: null,
baseQueue: null,
baseState: null
};
if (workInProgressHook === null) {
// mount时 第一个hook
if (currentlyRenderingFiber === null) {
throw new Error('请在函数组件内调用hook');
} else {
workInProgressHook = hook;
currentlyRenderingFiber.memoizedState = workInProgressHook;
}
} else {
// 后续的hook
// 通过next指针连接
workInProgressHook.next = hook;
workInProgressHook = hook;
}
return workInProgressHook;
}
六. update
更新阶段由于并没有其他操作,所以只需要通过双缓存树的机制更新hook对象。
js
function updateRef(initialValue) {
// 更新hook对象
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
更新hook对象的本质是复用,通过current
树中对应fiber
节点的hook链表,来创建本次更新新的hook对象并更新链表。
currentHook
属性保存current
树中上一个hook对应的对象,所以本次更新如果有值,通过next
指针获取本次处理的hook对应的旧hook对象。如果没有值,说明当前hook函数为函数组件的第一个。
如果这两种情况都没有取到值,说明存在动态hook函数(本次更新比上一次更新hooks数量对应不上)。这种情况是react不被允许的,所以报错"本次执行时的Hook比上次执行时多"。
后续与创建hook对象的逻辑类似,只不过需要复用旧hook对象的属性。
js
function updateWorkInProgressHook() {
let nextCurrentHook;
if (currentHook === null) {
// 这是这个函数组件update时的第一个hook
const current = currentlyRenderingFiber?.alternate;
if (current !== null) {
// 获取对应的memoizedState属性
nextCurrentHook = current?.memoizedState;
} else {
nextCurrentHook = null;
}
} else {
// 这个函数组件update时 后续的hook
nextCurrentHook = currentHook.next;
}
if (nextCurrentHook === null) {
throw new Error(
`组件${currentlyRenderingFiber?.type}本次执行时的Hook比上次执行时多`
);
}
currentHook = nextCurrentHook;
// 复用current树的旧hook对象属性
const newHook = {
memoizedState: currentHook.memoizedState,
updateQueue: currentHook.updateQueue,
next: null,
baseQueue: currentHook.baseQueue,
baseState: currentHook.baseState
};
if (workInProgressHook === null) {
// mount时 第一个hook
if (currentlyRenderingFiber === null) {
throw new Error('请在函数组件内调用hook');
} else {
workInProgressHook = newHook;
currentlyRenderingFiber.memoizedState = workInProgressHook;
}
} else {
// 后续的hook
workInProgressHook.next = newHook;
workInProgressHook = newHook;
}
return workInProgressHook;
}
七. 执行流程
上图是一个简易的整个react初始化的流程,在开始实现useRef
前,有必要先来梳理一下react整个执行流程。
render
由于使用jsx由babel处理后的数据结构并不是真正的dom节点,而是element
结构,一种拥有标记及子节点列表的对象,所以在拿到element
对象后,首先要转化为fiber
节点。
在fiber
架构中,更新操作发生时,react会存在两棵fiber
树(current
树和workInProgress
树),current
树是上次更新生成的fiber
树(初始化阶段为null),workInProgress
树是本次更新需要生成的fiber
树。双缓存树的机制是判断当前处于那个阶段(初始化/更新),复用节点属性的重要依据。在本次更新完成后两棵树会互相转换。
render
阶段实际上是在内存中构建一棵新的fiber
树(称为workInProgress
树),构建过程是依照现有fiber
树(current
树)从root
开始深度优先遍历再回溯到root
的过程,这个过程中每个fiber
节点都会经历两个阶段:beginWork
和completeWork
。
beginWork
是向下调和的过程。就是由 fiberRoot
按照 child 指针逐层向下调和,而completeWork
是向上归并的过程,如果有兄弟节点,会返回 sibling
(同级)兄弟,没有返回 return
父级,一直返回到 FiebrRoot
。
组件的状态计算、diff
的操作以及render
函数的执行,发生在beginWork
阶段,effect
链表的收集、被跳过的优先级的收集,发生在completeWork
阶段。构建workInProgress
树的过程中会有一个workInProgress
的指针记录下当前构建到哪个fiber
节点,这是react更新任务可恢复的重要原因之一。
commit
在render
阶段结束后,会进入commi
t阶段,该阶段不可中断,主要是去依据workInProgress
树中有变化的那些节点(render
阶段的completeWork
过程收集到的effect
链表),去完成DOM操作,将更新应用到页面上,除此之外,还会异步调度useEffect
以及同步执行useLayoutEffect
。
commit
细分可以分为三个阶段:
Before mutation
阶段:执行 DOM 操作前
没修改真实的 DOM ,是获取 DOM 快照的最佳时期,如果是类组件有 getSnapshotBeforeUpdate
,会在这里执行。
mutation
阶段:执行 DOM 操作
对新增元素,更新元素,删除元素。进行真实的 DOM 操作。
layout
阶段:执行 DOM 操作后
DOM 已经更新完毕。
标记
首先定义一个 ref 标记;
js
export const Ref = 0b0010000;
在fiber
节点上标记Ref
,首先想到的就是beginWork
阶段,因为在beginWork
阶段主要的功能就是生成fiber
节点,在生成fiber
节点时通过在函数组件中定义的 ref 属性判断是否需要标记。
beginWork
函数根据不同的 tag 进行不同的处理。注意 标记 ref 只会发生在 **HostComponent**
的类型中 (真正的dom节点类型,比如:div
,span
...)
js
export const beginWork = (wip, renderLane) => {
// 比较,返回子fiberNode
switch (wip.tag) {
// 根节点类型
case HostRoot:
// ...
// dom类型
case HostComponent:
return updateHostComponent(wip);
// 文本类型
case HostText:
// ...
// 函数组件类型
case FunctionComponent:
// ...
default:
break;
}
// ...
};
js
function updateHostComponent(wip) {
const nextProps = wip.pendingProps;
const nextChildren = nextProps.children;
// 标记ref
markRef(wip.alternate, wip);
// ...
}
如果节点中的ref属性有值,还记得在使用useRef
时,我们在dom节点上已经为ref属性赋值了:
js
const demoRef = useRef(null);
// 此时demoRef为 { current: null }
<div ref={demoRef}></div>
// 所以对应到fiber节点中为 ref: { current: null }
ref 标记的标记条件:
- 初始化时:只要本次更新拥有 ref 对象 标记
- 更新阶段:上次更新存在 ref 对象并且与本次更新的 ref 对象不相同 标记
js
// 这个current参数指的是current树中对应的fiber节点
function markRef(current, workInProgress) {
// ref是否有值?
const ref = workInProgress.ref;
if (
(current === null && ref !== null) ||
(current !== null && current.ref !== ref)
) {
// 标记ref标记
workInProgress.flags |= Ref;
}
}
举个🌰:
js
const App = () => {
const divRef = useRef(null)
return (
<div ref={ divRef }></div>
)
}
在beginWork
阶段标记的过程如下(简略版):
执行
根据react的执行流程,在commit
阶段才会生成真正的 dom 实例,所以保存实例的重任也会在这里完成。
由于目前逻辑比较简单,只实现了commit
的两个阶段:
-
mutation
阶段:执行 DOM 操作 -
layout
阶段:执行 DOM 操作后
js
function commitRoot(root) {
const finishedWork = root.finishedWork;
// ...
// 判断是否有副作用
if (subtreeHasEffect || rootHasEffect) {
// mutation
commitMutationEffects(finishedWork, root);
// 切换fiber树
root.current = finishedWork;
// layout
commitLayoutEffects(finishedWork, root);
}
}
mutation
过程结束代表新的dom已经挂载完毕。但是我们的ref对象的引用需要先清除旧的dom引用,在新的dom挂载后再赋值为新的dom引用。
我们的dom挂载过程正好有一个函数用于处理各种副作用标记:
js
export const commitMutationEffects = commitEffects(
'mutation',
MutationMask | PassiveMask,
commitMutationEffectsOnFiber
);
commitEffects
函数将fiber
树又重新回溯一遍,会在每个遍历到的fiber
节点执行传入的函数,用来处理各种标记的副作用,过程类似于render
阶段创建fiber
树的过程。
commitMutationEffectsOnFiber
处理各种副作用标记,包括新增,更新,删除...
详情见:手写mini-react!超万字实现mount首次渲染流程🎉🎉
js
const commitMutationEffectsOnFiber = (finishedWork, root) => {
const { flags, tag } = finishedWork;
// 新增
if ((flags & Placement) !== NoFlags) {
// ...
}
// 更新
if ((flags & Update) !== NoFlags) {
}
// 删除
if ((flags & ChildDeletion) !== NoFlags) {
// ...
}
// 清除ref引用
if ((flags & Ref) !== NoFlags && tag === HostComponent) {
safelyDetachRef(finishedWork);
}
};
需要判断是否具有 Ref 标记,并且类型是 dom 类型的fiber
节点:
如果是函数类型的 ref ,传入null,执行函数。如果是普通值,直接赋值为null。
js
function safelyDetachRef(current) {
const ref = current.ref;
if (ref !== null) {
// 函数类型的ref
if (typeof ref === 'function') {
ref(null);
} else {
ref.current = null;
}
}
}
还有一种情况也需要清除 ref 引用:当一个fiber
节点被标记了删除标记时:
js
const commitMutationEffectsOnFiber = (finishedWork, root) => {
const { flags, tag } = finishedWork;
// 删除
if ((flags & ChildDeletion) !== NoFlags) {
// 获取待删除列表
const deletions = finishedWork.deletions;
if (deletions !== null) {
// 处理待删除列表中所有fiber节点及其子树
deletions.forEach((childToDelete) => {
commitDeletion(childToDelete, root);
});
}
finishedWork.flags &= ~ChildDeletion;
}
};
一个fiber
节点中所有待删除的子节点列表会保存在deletions
属性中,由于在子树中也会涉及到副作用标记的处理,所以还需要遍历所有子树逐个清除副作用,包括 Ref 标记:
js
function commitDeletion(childToDelete, root) {
const rootChildrenToDelete= [];
// commitNestedComponent递归子树
commitNestedComponent(childToDelete, (unmountFiber) => {
switch (unmountFiber.tag) {
case HostComponent:
// HostComponent类型的节点删除
recordHostChildrenToDelete(rootChildrenToDelete, unmountFiber);
// 清除ref引用
safelyDetachRef(unmountFiber);
return;
case HostText:
// ...
case FunctionComponent:
// ...
default:
if (__DEV__) {
console.warn('未处理的unmount类型', unmountFiber);
}
}
});
// ...
}
获取最新的 ref 引用,当然是dom挂载更新后的 Layout
过程中:
js
const commitLayoutEffectsOnFiber = (
finishedWork,
root
) => {
const { flags, tag } = finishedWork;
if ((flags & Ref) !== NoFlags && tag === HostComponent) {
// 绑定新的ref
safelyAttachRef(finishedWork);
// 删除标记
finishedWork.flags &= ~Ref;
}
};
绑定的过程与上面清除的过程类似。注意dom实例保存在fiber
节点的stateNode
属性中。
js
function safelyAttachRef(fiber) {
const ref = fiber.ref;
if (ref !== null) {
// 获取dom实例
const instance = fiber.stateNode;
// 函数?
if (typeof ref === 'function') {
ref(instance);
} else {
ref.current = instance;
}
}
}
大功告成!🎉🎉
写在最后 ⛳
未来可能会更新实现mini-react
和antd
源码解析系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳