面试官:说一下 useRef 是如何实现的?我:? 😭

写在前面

本系列会实现一个简单的react,包含最基础的首次渲染,更新,hooklane模型等等,本文是本系列的第一篇。这对于我也是一个很大的挑战,不过这也是一个学习和进步的过程,希望能坚持下去,一起加油!期待多多点赞!😘😘

本文致力于实现一个最简单的useRef,代码均已上传至github,期待star!✨:

github.com/kongyich/ti...

本文是系列文章,阅读的联系性非常重要!!

手写mini-react!超万字实现mount首次渲染流程🎉🎉

进击的hooks!实现react(反应)(反应)中的hooks架构和useState 🚀🚀

更新!更新!实现react更新及diff流程

深入react源码!实现react事件模型🎉🎉

爆肝第五篇! 实现react调度更新与Lane模型✌️✌️

面试官问我 react scheduler 调度机制原理? 我却支支吾吾答不上来...😭😭

手写mini-react!实现react并发更新机制 🎉🎉

进击的hooks!useEffect执行机制大揭秘 🥳🥳

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​操作

包括两类操作:

  1. 对于正常的绑定操作:

    • 解绑之前的refmutation阶段)
    • 绑定新的reflayout阶段)
  2. 对于组件卸载:

  • 解绑之前的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-reactantd源码解析系列,希望能一直坚持下去,期待多多点赞🤗🤗,一起进步!🥳🥳 ‍ ‍ ‍

相关推荐
她似晚风般温柔7891 小时前
Uniapp + Vue3 + Vite +Uview + Pinia 分商家实现购物车功能(最新附源码保姆级)
开发语言·javascript·uni-app
王中阳Go2 小时前
字节跳动的微服务独家面经
微服务·面试·golang
Jiaberrr2 小时前
前端实战:使用JS和Canvas实现运算图形验证码(uniapp、微信小程序同样可用)
前端·javascript·vue.js·微信小程序·uni-app
everyStudy2 小时前
JS中判断字符串中是否包含指定字符
开发语言·前端·javascript
城南云小白2 小时前
web基础+http协议+httpd详细配置
前端·网络协议·http
前端小趴菜、2 小时前
Web Worker 简单使用
前端
web_learning_3212 小时前
信息收集常用指令
前端·搜索引擎
Ylucius3 小时前
动态语言? 静态语言? ------区别何在?java,js,c,c++,python分给是静态or动态语言?
java·c语言·javascript·c++·python·学习
tabzzz3 小时前
Webpack 概念速通:从入门到掌握构建工具的精髓
前端·webpack
200不是二百3 小时前
Vuex详解
前端·javascript·vue.js