写在前面
本系列会实现一个简单的react
,包含最基础的首次渲染,更新,hook
,lane
模型等等,本文是本系列的第一篇。这对于我也是一个很大的挑战,不过这也是一个学习和进步的过程,希望能坚持下去,一起加油!期待多多点赞!😘😘
本文致力于实现一个最简单的useEffect
,代码均已上传至github
,期待star!✨:
本文是系列文章,阅读的联系性非常重要!!
手写mini-react!超万字实现mount首次渲染流程🎉🎉
面试官问我 react scheduler 调度机制原理? 我却支支吾吾答不上来...😭😭
期待点赞!😁😁
食用前指南!本文涉及到react的源码知识,需要对react有基础的知识功底,建议没有接触过react的同学先去官网学习一下基础知识,再看本系列最佳!
一. 基本概念
在实现之前,先来看一下useEffect
的基本使用,useEffect
是react中非常重要,也是最常用的几个hook之一,它的出现可以使函数组件拥有类组件上的一些功能,比如生命周期函数,弥补了函数式组件没有生命周期的缺陷。并且提供了为某些依赖项增加副作用函数,当依赖项发生改变,触发副作用函数。
基本使用:
ts
useEffect(()=>{
return destory
}, deps)
参数:
callback
:useEffect
的第一个入参,最终返回destory
,它会在下一次callback
执行之前调用,其作用是清除上次的callback
产生的副作用;deps
:依赖项,可选参数,是一个数组,可以有多个依赖项,通过依赖改变,执行上一次的callback
返回的destory
和新的effect
第一个参数callback
。
挂载和卸载阶段触发
js
useEffect(() => {
console.log("挂载阶段");
return () => {
console.log("卸载阶段");
};
}, []);
如果依赖项传入一个空数组,那么useEffect
分别会在挂载阶段和组件卸载阶段触发,这个效果可以替代类组件中的生命周期。
依赖项变化触发
js
useEffect(() => {
console.log("num改变执行");
}, [num]);
当num
这个依赖项发生变化时,会执行副作用函数回调。
前面我们已经实现过useState
这个hook,在动手实现useEffect
之前先了解一下react的hook架构。
二. 数据共享层
hook
架构在实现时,脱离了react部分的逻辑,在内部实现了一个数据共享层,类似于提供一个接口。任何满足了规范的函数都可以通过数据共享层接入处理hook
的逻辑。这样就可以与宿主环境解耦,灵活性更高。
js
// 内部数据共享层
export const __SECRET_INTERNALS_DO_NOT_USE_OR_YOU_WILL_BE_FIRED = {
currentDispatcher
};
const currentDispatcher = {
current: null
};
currentDispatcher
为我们本次实现的hook
。
所以对应到我们的render
流程以及hooks
的应用,他们之间的调用关系是这样的:
hooks
怎么知道当前是mount
还是update
?
我们在使用hooks
时,react在内部通过 **currentDispatcher.current**
赋予不同的函数来处理不同阶段的调用,判断hooks 是否在函数组件内部调用。
三. hooks
hooks
可以看做是函数组件和与其对应的fiber
节点进行沟通和的操作的纽带。在react
中处于不同阶段的fiber
节点会被赋予不同的处理函数执行hooks
:
- 初始化阶段 ----->
HookDispatcherOnMount
- 更新阶段 ----->
HookDispatcherOnUpdate
js
const HookDispatcherOnMount = {
useState: mountState,
useEffect: mountEffect
};
const HookDispatcherOnUpdate = {
useState: updateState,
useEffect: updateEffect
};
但是实现之前,还有几个问题需要解决:
如何确定fiber对应的hooks上下文?
还记得我们在处理函数组件类型的fiber
节点时,调用renderWithHooks
函数进行处理,在我们在执行hooks
相关的逻辑时,将当前fiber
节点信息保存在一个全局变量中:
js
// 当前正在render的fiber
let currentlyRenderingFiber = null;
js
export function renderWithHooks(wip: FiberNode) {
// 赋值操作
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
中,我们在处理hooks
的初始化及更新逻辑中就可以获取到当前的fiber
节点信息。
hooks是如何存在的?保存在什么地方?
注意hooks
只存在于函数组件中,但是一个函数组件的fiber
节点时如何保存hooks
信息呢?
答案是:memoizedState
。
fiber
节点中保存着非常多的属性,有作为构造fiber
链表,用于保存位置信息的属性,有作为保存更新队列的属性等等。
而对于函数组件类型的fiber
节点,memoizedState
属性保存hooks
信息。hook
在初始化时,会创建一个对象,保存此hook
所产生的计算值,更新队列,hooks
链表。
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>
);
}
在某个函数组件中存在多个hooks
,此时每个hook
的信息该如何保存呢?这就是上文中hook
对象中next
属性的作用,它是一个链表指针。在hook
对象中,next
属性指向下一个hook
。
换句话说,如果在一个函数组件中存在多个hook
,那么在该fiber
节点的memoizedState
属性中保存该节点的hooks
链表。
函数组件对应 fiber
用 memoizedState
保存 hook
信息,每一个 hook
执行都会产生一个 hook
对象,hook
对象中,保存着当前 hook
的信息,不同 hook
保存的形式不同。每一个 hook
通过 next
链表建立起关系。
useEffect的hook链表结构
useEffect
的hook链表对象与useState
有一些差异,具体体现在useState
的hook对象的memoizedState
属性保存的是具体的值,而useEffect
保存了一个对象:
对象中保存与useEffect
相关的一些信息:create
(依赖函数),destroy
(卸载函数),deps
(依赖项),next
(下一个useEffect
hook对象)。其中next
属性也是一个链表指针,将该fiber
下的所有useEffect
hook对象连接在一起。
也就是说如果一个函数组件类型的fiber
节点中存在许多不同类型的hook,那么这个fiber
节点的memoizedState
属性将会是这样的:
四. useEffect
在介绍react初始化流程时,首先在render
阶段调用beginWork
开始构建fiber
节点,而对于不同的节点,react会进行不同的处理:
js
export const beginWork = (wip) => {
// 返回子fiberNode
switch (wip.tag) {
// 根节点
case HostRoot:
return updateHostRoot(wip);
// 原生dom节点
case HostComponent:
return updateHostComponent(wip);
// 文本节点
case HostText:
return null;
// 函数组件
case FunctionComponent:
return updateFunctionComponent(wip);
default:
if (__DEV__) {
console.warn('beginWork未实现的类型');
}
break;
}
return null;
};
针对函数节点,我们调用updateFunctionComponent
函数进行处理,在初始化时,主要任务是执行函数组件,然后生成此函数组件的fiber
节点,在上文中我们使用了两个函数来执行这两个任务:
js
function updateFunctionComponent(wip) {
// 执行函数组件,生成fiber节点
const nextChildren = renderWithHooks(wip);
// 根据fiber生成真实DOM节点
reconcilerChildren(wip, nextChildren);
return wip.child;
}
而针对于 renderWithHooks
函数来说,在我们加入hooks
相关的逻辑后,显然它需要被承载更重要的能力,就是根据不同的处理阶段来为currentDispatcher.current
赋值不同的hooks
函数,之所以会在这个函数中处理,是因为可以直接避免在其他无关的环境里调用hooks
函数。
currentDispatcher.current
在其他类型的fiber
节点被处理时值都为null
,这样就保证了hooks
函数只在函数组件中被调用。
js
export const useEffect = (create, deps) => {
const dispatcher = resolveDispatcher();
return dispatcher.useEffect(create, deps);
};
当currentDispatcher.current
为null时,说明当前并非函数组件。
js
const currentDispatcher = {
current: null
};
// 错误处理,当currentDispatcher.current为null时,说明当前并非函数组件
export const resolveDispatcher = () => {
const dispatcher = currentDispatcher.current;
if (dispatcher === null) {
throw new Error('hook只能在函数组件中执行');
}
return dispatcher;
};
在renderWithHooks
中依旧根据alternate
的存在判断当前为初始化/更新?
js
// 当前正在render的fiber
let currentlyRenderingFiber = null;
// 当前处理中的hooks
let workInProgressHook = null;
// 当前current树中的hooks
let currentHook = null;
export function renderWithHooks(wip) {
// 赋值操作
currentlyRenderingFiber = wip;
// 重置memoizedState
wip.memoizedState = null;
const current = wip.alternate;
if (current !== null) {
// update
currentDispatcher.current = HookDispatcherOnUpdate;
} else {
// mount
currentDispatcher.current = HookDispatcherOnMount;
}
// ....
// 重置操作
currentlyRenderingFiber = null;
workInProgressHook = null;
currentHook = null;
}
在renderWithHooks
函数中定义了三个全局变量:currentlyRenderingFiber
代表当前正在处理的fiber
节点。workInProgressHook
代表当前处理中的hooks
,也就是workInProgress
树中的。currentHook
代表当前的current
树中的hooks
。
currentDispatcher.current
在初始化阶段和更新阶段分别被赋值给了HookDispatcherOnMount
和HookDispatcherOnUpdate
,分别执行不同的逻辑。
js
const HookDispatcherOnMount = {
useEffect: mountEffect
};
const HookDispatcherOnUpdate = {
useEffect: updateEffect
};
flags
为了在fiber
节点中标记副作用,增加PassiveEffect
代表具有effect
副作用的fiber
节点。
Passive
标记useEffect
对象,HookHasEffect
表示当前effect
本次更新存在副作用。
js
// effect对象的标记
export const Passive = 0b0010;
export const HookHasEffect = 0b0001;
// fiber节点的标记
export const PassiveEffect = 0b0001000;
五. mount
在初始化阶段,useEffect
需要开始初始化相关的数据结构:
- 创建hook对象并连接到原有的hook链表中
- 创建
effect
对象保存到hook
对象中的memoizedState
属性 - 构建
effect
链表,并保存到fiber.updateQueue.lastEffect
属性中
js
function mountEffect(create, deps) {
// 创建hook对象
const hook = mountWorkInProgresHook();
const nextDeps = deps === undefined ? null : deps;
// 为fiber标记flag
currentlyRenderingFiber.flags |= PassiveEffect;
// 生成effect对象,并保存effect链表
hook.memoizedState = pushEffect(
// 标记effect对象的flag
Passive | HookHasEffect,
create,
undefined,
nextDeps
);
}
在mount
阶段创建主要考虑当前执行的useEffect
函数是当前函数组件的第几个,如果是第一个需要初始化给fiber
节点的memoizedState
属性并赋值给全局变量workInProgressHook
。如果是后续的useEffect
,直接使用全局变量workInProgressHook
的next
绑定。
js
function mountWorkInProgresHook() {
// 定义hook对象
const hook = {
memoizedState: null,
updateQueue: null,
next: null
};
if (workInProgressHook === null) {
// mount时 第一个hook
if (currentlyRenderingFiber === null) {
throw new Error('请在函数组件内调用hook');
} else {
workInProgressHook = hook;
currentlyRenderingFiber.memoizedState = workInProgressHook;
}
} else {
// mount时 后续的hook
workInProgressHook.next = hook;
workInProgressHook = hook;
}
return workInProgressHook;
}
pushEffect
函数用来创建effect
对象,并且将同一个函数组件内的多个effect
对象连接成链表的形式,保存在函数组件的fiber
节点的updateQueue.lastEffect
中,在lastEffect
中保存最后一个effect
对象,但是由于effect
链表为环形链表,所以也就相当于将整个effect
链表保存在了lastEffect
属性。
js
function pushEffect(
hookFlags,
create,
destroy,
deps
) {
const effect = {
tag: hookFlags,
create,
destroy,
deps,
next: null
};
const fiber = currentlyRenderingFiber;
const updateQueue = fiber.updateQueue;
// 如果无更新队列updateQueue?
// 1. 创建fiber节点的更新队列
// 2. 创建lastEffect属性保存effect链表
if (updateQueue === null) {
const updateQueue = createFCUpdateQueue();
fiber.updateQueue = updateQueue;
effect.next = effect;
updateQueue.lastEffect = effect;
} else {
// 插入effect
const lastEffect = updateQueue.lastEffect;
// lastEffect是否存在?
if (lastEffect === null) {
effect.next = effect;
updateQueue.lastEffect = effect;
} else {
// 拼接链表,环形
const firstEffect = lastEffect.next;
lastEffect.next = effect;
effect.next = firstEffect;
updateQueue.lastEffect = effect;
}
}
return effect;
}
如果当前fiber
节点(函数组件类型的fiber
节点)没有updateQueue
属性,那么先创建updateQueue
属性,与普通的fiber
节点的更新队列略有不同,函数组件类型的fiber
节点多个一个lastEffect
保存effect
链表:
js
function createFCUpdateQueue<State>() {
const updateQueue = createUpdateQueue();
// 创建lastEffect属性
updateQueue.lastEffect = null;
return updateQueue;
}
// 创建updateQueue
export const createUpdateQueue = () => {
return {
shared: {
pending: null
},
dispatch: null
};
};
如果是链表的第一项,为了创建环形链表,需要将next
指针指向自身,也就是整个链表的头尾节点都是同一个:
js
effect.next = effect;
六. update
update
阶段主要是对比两棵fiber
树中对应的hook
对象和effect
对象是否发生了变化。
hook
对象更新前后有无变化?检查是否能够复用effect
对象更新前后有无变化?检查是否能够复用
effect
对象是否可以复用的判断依据是根据比较依赖项(浅比较),确定新的effect
对象是否加入HookHasEffect
这个flag
标记,如果依赖项没有变化,则不加入副作用标记。
destroy
属性保存上次副作用函数的执行结果,直接在effect
对象中获取。
js
function updateEffect(create, deps) {
// 复用hook对象,构建新的hook链表
const hook = updateWorkInProgresHook();
const nextDeps = deps === undefined ? null : deps;
let destroy;
// currentHook 全局变量,原fiber树对应的hook对象
if (currentHook !== null) {
// 获取原effect对象,destroy卸载函数是上次执行create时保存的执行结果
const prevEffect = currentHook.memoizedState;
// 获取卸载函数
destroy = prevEffect.destroy;
if (nextDeps !== null) {
// 浅比较依赖
const prevDeps = prevEffect.deps;
// 依赖项发生变化?
if (areHookInputsEqual(nextDeps, prevDeps)) {
hook.memoizedState = pushEffect(Passive, create, destroy, nextDeps);
return;
}
}
// 浅比较 不相等
currentlyRenderingFiber.flags |= PassiveEffect;
hook.memoizedState = pushEffect(
Passive | HookHasEffect,
create,
destroy,
nextDeps
);
}
}
对比依赖项:
js
function areHookInputsEqual(nextDeps, prevDeps) {
if (prevDeps === null || nextDeps === null) {
return false;
}
for (let i = 0; i < prevDeps.length && i < nextDeps.length; i++) {
// 使用Object.is浅比较
if (Object.is(prevDeps[i], nextDeps[i])) {
continue;
}
return false;
}
return true;
}
updateWorkInProgresHook
函数复用hook
对象。值得注意的是,如果在执行更新时获取某一个useEffect
旧的hook
对象为空,则说明本次更新比原来多了一个hook
,
这也是hook
不能用在条件判断中的原因,因为条件判断可能会存在动态逻辑,在更新时会导致hook
对象无法对应。
js
function updateWorkInProgresHook() {
let nextCurrentHook;
if (currentHook === null) {
// 这是这个函数组件 update时的第一个hook
const current = currentlyRenderingFiber?.alternate;
if (current !== null) {
nextCurrentHook = current?.memoizedState;
} else {
// mount
nextCurrentHook = null;
}
} else {
// 这个函数组件 update时 后续的hook
nextCurrentHook = currentHook.next;
}
// 本次有多余hook
if (nextCurrentHook === null) {
throw new Error(
`组件${currentlyRenderingFiber?.type}本次执行时的Hook比上次执行时多`
);
}
currentHook = nextCurrentHook;
// 复用hook对象
const newHook = {
memoizedState: currentHook.memoizedState,
updateQueue: currentHook.updateQueue,
next: null
};
if (workInProgressHook === null) {
// mount时 第一个hook
if (currentlyRenderingFiber === null) {
throw new Error('请在函数组件内调用hook');
} else {
workInProgressHook = newHook;
currentlyRenderingFiber.memoizedState = workInProgressHook;
}
} else {
// 后续的hook 使用next连接
workInProgressHook.next = newHook;
workInProgressHook = newHook;
}
return workInProgressHook;
}
七. useEffect工作流程
commit
阶段开始调度副作用,收集回调,在此之前我们要在根节点的属性中新增两个集合,用于收集unmout
时需要执行的回调和update
时需要执行的回调。
diff
export class FiberRootNode {
// ...
pendingPassiveEffects: PendingPassiveEffects;
constructor(container, hostRootFiber) {
// ...
++ this.pendingPassiveEffects = {
++ unmount: [],
++ update: []
++ };
}
}
1. 调度副作用
在进入commit
阶段开始后,也就是进入commitRoot
函数,判断根节点中是否存在PassiveMask
这个标记,代表需要调度执行保存在根节点的副作用函数,然后以普通优先级调度一个任务:
由于调度副作用是异步执行,所以虽然任务会先开始调度,但是收集回调的逻辑会先执行。
js
let rootDoesHasPassiveEffects = false;
function commitRoot(root) {
// ...
if (
(finishedWork.flags & PassiveMask) !== NoFlags ||
(finishedWork.subtreeFlags & PassiveMask) !== NoFlags
) {
if (!rootDoesHasPassiveEffects) {
rootDoesHasPassiveEffects = true;
// 调度副作用
scheduleCallback(NormalPriority, () => {
// 执行副作用
flushPassiveEffects(root.pendingPassiveEffects);
return;
});
}
}
// ...
commitMutationEffects(finishedWork, root);
}
本次更新的任何create
回调都必须在所有上一次更新的destroy
回调执行完后再执行。
整体执行流程包括:
- 遍历
effect
链表 - 首先触发所有
unmount effect
,且对于某个fiber
,如果触发了unmount destroy
,本次更新不会再触发update create
- 触发所有上次更新的
destroy
- 触发所有这次更新的
create
mount
、update
时的区别:
mount
时:标记PassiveEffect
update
时:deps
变化时标记PassiveEffect
js
function flushPassiveEffects(pendingPassiveEffects) {
// 执行unmount集合
pendingPassiveEffects.unmount.forEach((effect) => {
commitHookEffectListUnmount(Passive, effect);
});
pendingPassiveEffects.unmount = [];
// 执行update集合
pendingPassiveEffects.update.forEach((effect) => {
commitHookEffectListDestroy(Passive | HookHasEffect, effect);
});
// 执行update集合
pendingPassiveEffects.update.forEach((effect) => {
commitHookEffectListCreate(Passive | HookHasEffect, effect);
});
pendingPassiveEffects.update = [];
flushSyncCallbacks();
}
在useEffect
执行时,关于副作用回调和销毁函数会涉及这么几种情况:函数组件被销毁,此时应该执行所有的销毁函数,由于此时整个函数组件已经被销毁,后续也不应该再执行副作用函数,所以移除unmount
列表中所有effect
对象的HookHasEffect
标记。
js
// 任务执行
function commitHookEffectList(flags, lastEffect, callback) {
let effect = lastEffect.next
// 循环执行effect链表
do {
if ((effect.tag & flags) === flags) {
callback(effect);
}
effect = effect.next
} while (effect !== lastEffect.next);
}
export function commitHookEffectListUnmount(flags, lastEffect) {
commitHookEffectList(flags, lastEffect, (effect) => {
const destroy = effect.destroy;
if (typeof destroy === 'function') {
destroy();
}
// 移除HookHasEffect标记,后面不会再执行副作用函数
effect.tag &= ~HookHasEffect;
});
}
更新任务先触发所有上次更新的destroy
函数,执行完后,触发所有这次更新的create
函数。本次更新产生的返回值将作为新的effect
对象的destroy
。
js
export function commitHookEffectListDestroy(flags, lastEffect) {
commitHookEffectList(flags, lastEffect, (effect) => {
const destroy = effect.destroy;
if (typeof destroy === 'function') {
destroy();
}
});
}
export function commitHookEffectListCreate(flags, lastEffect) {
commitHookEffectList(flags, lastEffect, (effect) => {
const create = effect.create;
if (typeof create === 'function') {
// 将执行结果保存为destroy
effect.destroy = create();
}
});
}
2. 收集回调
关于收集回调的时机,在进入commit
流程后,首先还要从上到下,从下到上的回溯一遍fiber
树,目的是根据fiber
上的不同标记开始对fiber
节点中保存的dom
进行操作,比如:新增,更新,删除等。(详细参考前面初始化急更新的文章)
之所以会选择在commit
阶段的回溯过程中收集,我们先来看一个例子:
js
export default function App() {
const [num, updateNum] = useState(0);
useEffect(() => {
console.log("App mount");
}, []);
useEffect(() => {
console.log("num change create", num);
return () => {
console.log("num change destroy", num);
};
}, [num]);
return (
<div onClick={() => updateNum(num + 1)}>
{num === 0 ? <Child /> : "noop"}
</div>
);
}
function Child() {
useEffect(() => {
console.log("Child mount");
return () => console.log("Child unmount");
}, []);
return "i am child";
}
在这个例子中父子组件同时定义了useEffect
,那么它的执行顺序是什么样子的呢?
根据mount
阶段的执行结果可知,在初始化时,先执行了子组件Child
中useEffect
函数的初始化,同样副作用函数也会先执行,所以在收集副作用函数时,子组件的effect
副作用函数必要要先被收集。在不同层级的函数组件中遵循先子后父的执行顺序,如果是同一层级则按顺序执行。
按照useEffect
的执行顺序,我们在向上回溯的过程中收集effect
。
js
const commitMutaitonEffectsOnFiber = (
finishedWork,
root
) => {
const flags = finishedWork.flags;
if ((flags & Placement) !== NoFlags) {
// ...
}
if ((flags & Update) !== NoFlags) {
// ...
}
if ((flags & ChildDeletion) !== NoFlags) {
// ...
}
if ((flags & PassiveEffect) !== NoFlags) {
// 收集更新回调
commitPassiveEffect(finishedWork, root, 'update');
// 收集回调后,移除副作用标记
finishedWork.flags &= ~PassiveEffect;
}
};
收集的过程比较简单,获取fiber
节点中的updateQueue.lastEffect
属性,根据type
保存在根节点的pendingPassiveEffects
中。
js
function commitPassiveEffect(fiber, root, type) {
// update unmount
if (
fiber.tag !== FunctionComponent ||
(type === 'update' && (fiber.flags & PassiveEffect) === NoFlags)
) {
return;
}
const updateQueue = fiber.updateQueue
if (updateQueue !== null) {
if (updateQueue.lastEffect === null && __DEV__) {
console.error('当FC存在PassiveEffect flag时,不应该不存在effect');
}
root.pendingPassiveEffects[type].push(updateQueue.lastEffect);
}
}
unmount
列表收集过程在节点删除的流程中进行。
js
function commitDeletion(childToDelete, root) {
const rootChildrenToDelete = [];
// 递归子树
commitNestedComponent(childToDelete, (unmountFiber) => {
switch (unmountFiber.tag) {
case HostComponent:
// ...
return;
case HostText:
// ...
return;
case FunctionComponent:
// 收集卸载函数
commitPassiveEffect(unmountFiber, root, 'unmount');
return;
default:
if (__DEV__) {
console.warn('未处理的unmount类型', unmountFiber);
}
}
});
}
当一个节点被删除或卸载时,不仅仅要删除此节点本身的各种副作用,还需要对所有子节点进行卸载。commitNestedComponent
会针对待删除节点的每个子fiber
节点执行回调函数。收集待删除节点的所有子节点的卸载函数。
写在最后 ⛳
未来可能会更新实现mini-react
和antd
源码解析系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳