写在专栏开头(叠甲)
-
作者并不是前端技术专家,也只是一名喜欢学习新东西的前端技术小白,想要学习源码只是为了应付急转直下的前端行情和找工作的需要,这篇专栏是作者学习的过程中自己的思考和体会,也有很多参考其他教程的部分,如果存在错误或者问题,欢迎向作者指出,作者保证内容 100% 正确,请不要将本专栏作为参考答案。
-
本专栏的阅读需要你具有一定的 React 基础、 JavaScript 基础和前端工程化的基础,作者并不会讲解很多基础的知识点,例如:babel 是什么,jsx 的语法是什么,需要时请自行查阅相关资料。
-
本专栏很多部分参考了大量其他教程,若有雷同,那是作者抄袭他们的,所以本教程完全开源,你可以当成作者对各类教程进行了整合、总结并加入了自己的理解。
本一节的内容
这个章节主要讲解React 的 useRef 这个 api,我们将从使用和源码两个角度来讲它,介绍它作为组件的全局变量和 DOM 引用两个场景下的使用和源码挂载,以及 React 一个和引用相关的 api —— forwardRef
useRef 的定义
我们首先来看看 useRef
的用法:它返回一个可变的 ref 对象,其 .current
属性被初始化为传入的参数(initialValue
)。返回的 ref 对象在组件的整个生命周期内持续存在。
const refContainer = useRef(initialValue);
useRef()
和自建一个 {current: ...}
对象的唯一区别是,useRef
会在每次渲染时返回同一个 ref 对象。当 ref 对象内容发生变化时,useRef
并不会通知你。变更 .current
属性也不会引发组件重新渲染:
function TextInputWithFocusButton() {
const inputEl = useRef(null);
const onButtonClick = () => {
// `current` 指向已挂载到 DOM 上的文本输入元素
inputEl.current.focus();
};
return (
<>
<input ref={inputEl} type="text" />
<button onClick={onButtonClick}>Focus the input</button>
</>
);
}
useRef 的使用场景
以下是几种 useRef
的使用场景:
操作 DOM
这是 useRef
最基础的功能,因为在业务需求中,经常会遇到操作 DOM
的场景,比如:表单获取焦点、通过 DOM
操作实现动画等等。这种情况下就可以使用 useRef
去保存对 DOM
对象的引用。也就是说:当你需要调用 React 外部系统或者浏览器的一些原生 API
的时候,通过 useRef
实现是非常有用的:
function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<div>
<input ref={inputRef} />
<button onClick={handleClick}>
Focus
</button>
<div/>
);
}
但是你需要注意的是,你的渲染不能依赖这个 ref 的属性,否则它将不能及时更新,我们可以通过下一个使用场景来看这个问题:
作为组件生命周期内的全局变量来使用
根据上面的定义我们可以了解到, useRef
存储的数据在组件的整个生命周期内都有效,也就是说,在组件销毁之前,我们维持了它的状态,我们可以在组件的逻辑中随时使用它。同时,在它的内容被修改时,不会引起组件的重新渲染,并且内容被修改,是会立即生效的。
我们之前讲过, setState
并不是同步的,他会根据 React 的调度选择一批更新一起处理。但是有时候我们有需要马上获取到一个 state 的最新状态,虽然 setState
有回调函数可以帮助我们处理逻辑,但是如果需要多次更新数据的话,冗长的回调函数会让我们的代码可读性很差。
这个时候肯定有读者会说,那我开一个变量不就好了,这个时候我们会看一下这句话, useRef
存储的数据在组件的整个生命周期内都有效,在组件销毁之前,我们维持了它的状态。重绘并不是组件销毁,但是重绘会刷新我们定义在其中的普通变量,而 useRef
存储的数据并没有影响。
我们来看这个例子:
- 我们定义了一个
countRef
变量为 0 ,使用handleClick
方法操作它,当我们点击按钮触发这个方法的时候,countRef.current
渲染在页面上的值并不会改变,因为我们的countRef
的改变不会引起重绘,但是我们的console
语句打印出来的内容每次都会 + 1 - 我们又通过
useState
定义了一个count
变量,以及一个handleClick2
方法让他 + 1,当我们调用这个方法的时候,我们的组件会重绘,我们设置的randomFlag
数据会更新(说明我们重新调用了这个 randomFlag ),同时我们展示在页面上的 count 也会 +1 - 当我们点击几次
handleClick
方法的按钮,然后又点击一次handleClick2
方法的按钮,此时我们再点击handleClick
方法,console
出来的值会继续在上次的基础上 +1 ,而不是重置为从 0 开始 - 但是每次我们重绘我们的组件的时候,我们的
randomFlag
都会重新赋值,也就是触发初始化
export default function Counter() {
let countRef = useRef(0);
const [count, setCount] = useState(0);
function handleClick2() {
setCount(count + 1);
}
function handleClick() {
countRef.current = countRef.current + 1;
console.log(countRef.current)
}
const randomFlag = Math.floor(Math.random()* 10000000000000)
return (
<div>
<h1 style={{ color: 'pink' }}>{randomFlag}</h1>
<button onClick={handleClick}>
useRef { countRef.current }
</button>
<button onClick={handleClick2}>
useState { count }
</button>
</div>
);
}
useRef 的源码
useRef
的源码非简洁,我们直接来看:
-
mountRef
获取我们传入的初始值,然后把它存到我们的memoizedState
中,因为我们返回了我们的 ref,它是一个对象,也就是引用类型,所以我们可以直接修改这个 ref 值,它也会作用在memoizedState
上 -
updateRef
则是返回我们的memoizedState
function mountRef<T>(initialValue: T): {| current: T |} {
const hook = mountWorkInProgressHook();
// 存储数据,并返回这个数据
const ref = { current: initialValue };
hook.memoizedState = ref;
return ref;
}
function updateRef<T>(initialValue: T): {| current: T |} {
const hook = updateWorkInProgressHook();
return hook.memoizedState;
}
组件上 Ref 的挂载
当然,我们的 ref 不只有这么简单的一个定义,根据上面的定义我们已经认识到了 ,我们可以通过在组件上绑定 ref 来操作他的 dom ,那么这个 dom 又是怎么样绑定上去的呢,我们一步一步来看:
首先我们需要回到我们的 element 结构,我们可以看到在我们的 React Element 结构中:之前可能有很多读者会疑惑,为什么我们的 key 和 ref 两个属性要单独分开操作,他们不也是 props 吗,这里我们就明白了,key 之前提过是在 diff 算法中使用的,不参与最后的生成,而 ref 在今天解开问题了,它是我们定义的 useRef
或者 createRef
传入的,用于我们获取真实 DOM 的操作,而这个 ref 属性会在生成我们的 Fiber 的时候同步到我们的 Fiber 中:
const element = {
$$typeof: REACT_ELEMENT_TYPE,
type: type,
key: key,
ref: ref, //对组件实例的引用
props: props,
_owner: owner
}
之后在 beginWork
这个阶段,有些组件调用了一个函数:
function markRef(current: Fiber | null, workInProgress: Fiber) {
const ref = workInProgress.ref;
if (
(current === null && ref !== null) ||
(current !== null && current.ref !== ref)
) {
workInProgress.effectTag |= Ref;
}
}
而在 completeWork
这个阶段,又有了另一个同名函数:
function markRef(workInProgress: Fiber) {
workInProgress.effectTag |= Ref;
}
他们的逻辑都是一样的,告诉我们的 React ,这个fiber 节点对应的 DOM 被 ref 了,我们通过 effectTag
中的一个标志位来标识我们的这个 fiber 有没有 ref,这些用于我们之后的逻辑判定要不要进行 ref 的相关操作。
之后我们进入到我们的提交阶段,我们直接来到 commitMutationEffectsOnFiber
函数中,我们之前讲过,不同类型的组件执行逻前,都会调用 recursivelyTraverseMutationEffects
函数进行第一步的处理,它负责进行删除的操作,我们一步步深入,看到它调用了safelyDetachRef
函数,这里有我们需要的逻辑:
- 它首先获取了我们的当前 Fiber 节点的 ref 属性,它挂载了我们的
useRef
- 如果它是一个函数类型的 ref,会执行 ref 函数,参数为null,这个函数类型的 ref 就是我们的 class 组件使用
createRef
创造的,执行这个函数函数可以将其内部的值置为空 - 如果它不是一个 function, 也就是一个带有 current 的 DOM ,我们就把它的
useRef
属性设置为 null,从而清空我们的挂载
function safelyDetachRef(current: Fiber, nearestMountedAncestor: Fiber | null) {
const ref = current.ref;
if (ref !== null) {
if (typeof ref === 'function') {
let retVal;
try {
if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
current.mode & ProfileMode
) {
try {
startLayoutEffectTimer();
retVal = ref(null);
} finally {
recordLayoutEffectDuration(current);
}
} else {
retVal = ref(null);
}
} catch (error) {
captureCommitPhaseError(current, nearestMountedAncestor, error);
}
} else {
ref.current = null;
}
}
}
之后我们继续看在 commitLayoutEffect
这个阶段,我们说过,这个阶段会操作我们的真实 DOM,我们直接进入这个函数,我们跳过已经讲解过的部分,我们看到,在函数的最后有这样的操作,如果我们处理的 Fiber 的标记中有 Ref 标记,我们将进入 commitAttachRef
这个函数,它的作用就是把 ref 和我们的 useRef
绑定,当然如果是在类组件中或者一些另外的组件中,我们会通过safelyAttachRef
这个函数来调用 commitAttachRef
,实际上操作是一致的:
function commitLayoutEffectOnFiber(
finishedRoot: FiberRoot,
current: Fiber | null,
finishedWork: Fiber,
committedLanes: Lanes,
): void {
//.......省略
// 确保能拿到我们的 dom
if (!enableSuspenseLayoutEffectSemantics || !offscreenSubtreeWasHidden) {
if (enableScopeAPI) {
// 排除 ScopeComponent 是因为之前已经处理了
if (finishedWork.flags & Ref && finishedWork.tag !== ScopeComponent) {
commitAttachRef(finishedWork);
}
} else {
if (finishedWork.flags & Ref) {
commitAttachRef(finishedWork);
}
}
}
}
function commitMutationEffectsOnFiber(
finishedWork: Fiber,
root: FiberRoot,
lanes: Lanes,
) {
switch (finishedWork.tag) {
//....
// 对于 ScopeComponent ,这里已经处理了 safelyDetachRef 和 safelyAttachRef
case ScopeComponent: {
if (enableScopeAPI) {
recursivelyTraverseMutationEffects(root, finishedWork, lanes);
commitReconciliationEffects(finishedWork);
if (flags & Ref) {
if (current !== null) {
safelyDetachRef(finishedWork, finishedWork.return);
}
safelyAttachRef(finishedWork, finishedWork.return);
}
if (flags & Update) {
const scopeInstance = finishedWork.stateNode;
prepareScopeUpdate(scopeInstance, finishedWork);
}
}
return;
}
}
}
我们来看看这个函数,它的逻辑是:
- 通过当前 fiber 的 tag 来获取对应的实例:对于 HostComponent ,实例就是获取到的 DOM 节点,其他情况就是 fiber.stateNode
- 判断 ref 的类型,如果是函数类型,调用 ref 函数并将实例传过去;若不是,则将 ref.current 赋值为该实例
function commitAttachRef(finishedWork: Fiber) {
const ref = finishedWork.ref;
if (ref !== null) {
const instance = finishedWork.stateNode;
let instanceToUse;
switch (finishedWork.tag) {
case HostComponent:
instanceToUse = getPublicInstance(instance);
break;
default:
instanceToUse = instance;
}
if (enableScopeAPI && finishedWork.tag === ScopeComponent) {
instanceToUse = instance;
}
if (typeof ref === 'function') {
let retVal;
if (
enableProfilerTimer &&
enableProfilerCommitHooks &&
finishedWork.mode & ProfileMode
) {
try {
startLayoutEffectTimer();
retVal = ref(instanceToUse);
} finally {
recordLayoutEffectDuration(finishedWork);
}
} else {
retVal = ref(instanceToUse);
}
} else {
ref.current = instanceToUse;
}
}
}
拓展:forwardRef
在 React 中,配合 ref 使用的还有一个forwardRef
,它会创建一个React组件,这个组件能够将其接受的 ref 属性转发到其组件树下的另一个组件中。它在以下两种场景中有用:
- 将父组件的ref 传递给子组件
如下的例子:没有使用 forwardRef
时,父组件传入子组件ref
属性,此时ref
指向的是子组件本身,所以想要获取子组件的 DOM,需要增加 buttonRef
字段
interface IProps {
buttonRef: any;
}
class Child extends React.Component<IProps> {
render() {
return (
<div>
<button ref={this.props.buttonRef}> click </button>
</div>
);
}
}
function App() {
const child = useRef<any>();
return (
<div styleName="container">
<Child buttonRef={child} />
</div>
);
}
当我们使用 forwardRef
将 ref 转发。这样子组件在 提供内部的 dom 时,不用扩充额外的 ref 字段:
const Child = forwardRef((props: any, ref: any) => {
return (
<div>
<button ref={ref}>click</button>
</div>
);
});
function App() {
const child = useRef<any>();
return (
<div styleName="container">
<Child ref={child} />
</div>
);
}
- 高阶组件中使用 forwardRef 转发组件 Ref
我们直接来看一个场景:
有一个高阶组件的作用是打印组件的 props:
function logProps(WrappedComponent) {
class LogProps extends React.Component {
componentDidUpdate(prevProps) {
console.log('old props:', prevProps);
console.log('new props:', this.props);
}
render() {
return <WrappedComponent {...this.props} />;
}
}
return LogProps;
}
当其作用于子组件时,因为中间隔了一层,ref 就会传递失败,最终导致父组件调用 ref.current.xxx() 时失败:
import Button from './Button';
const LoggedButton = logProps(Button);
const ref = React.createRef();
// LoggedButton 组件是高阶组件 LogProps。
// 我们的 ref 将指向 LogProps 而不是内部的 Button 组件!
// 这意味着我们不能调用例如 ref.current.xxx() 这样的方法
<LoggedButton label="Click Me" handleClick={handleClick} ref={ref} />;
此时我们就需要在 hoc 中用 forwardRef 再包一层,转发 ref:
function logProps(WrappedComponent) {
class LogProps extends React.Component {
componentDidUpdate(prevProps) {
console.log('old props:', prevProps);
console.log('new props:', this.props);
}
render() {
const { componentRef, ...rest } = this.props;
return <WrappedComponent {...rest} ref={this.props.componentRef} />;
}
}
return forwardRef((props, ref) => {
return <LogProps {...props} componentRef={ref} />;
});
}
总结
这期我们讲了 useRef
这个 hooks,它可以作为我们在函数生命周期里不用于渲染的全局变量来使用,也可以帮助我们获取 DOM,前者仅仅使用了它的缓存功能,后者需要在渲染的过程中,通过 React Element 的 ref 属性进行初始化。同时我们还明白了 useRef 和 useState 的区别,前者不会使得组件重绘,后者则会,但是后者可以在组件销毁前一致保持状态,全局变量则会在每次绘制时重置。为了解决在高阶组件和父子组件中操作 ref 的麻烦,React 又进入了 forwardRef 方便我们操作,以上就是 ref 相关的内容。
既然提到了 useState ,下一节中我准备讲讲这个我们使用的最多的 hooks ,以及和他相似的 useReducer,敬请关注!