外观
第66章—Ref的实现原理
2560字约9分钟
2024-09-19
ref 是 React 里常用的特性,我们会用它来拿到 dom 的引用,或者用来保存渲染过程中不变的数据。
我们创建个项目试一下:
npx create-vite
去掉 index.css 和 StrictMode
改下 App.tsx
import { useRef, useEffect } from "react";
export default function App() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(()=> {
inputRef.current?.focus();
}, []);
return <input ref={inputRef} type="text" />
}
把开发服务跑起来:
npm run dev
创建个调试配置:
{
"type": "chrome",
"request": "launch",
"name": "Launch Chrome against localhost",
"url": "http://localhost:5173",
"webRoot": "${workspaceFolder}"
}
可以看到,useRef 可以拿到 dom 的引用:
此外,useRef 还可以保存渲染中不变的一些值:
import { useEffect, useRef, useState } from "react";
export default function App() {
const [num, setNum] = useState(0);
const timerRef = useRef<number>();
useEffect(() => {
timerRef.current = setInterval(() => {
setNum(num => num + 1);
}, 100);
}, []);
return <div>
{num}
<button onClick={() => {
clearInterval(timerRef.current!);
}}>停止</button>
</div>
}
当传入 null 时,返回的是 RefObject 类型,用来保存 dom 引用:
传其他值返回的是 MutableRefObject,可以修改 current,保存其它值:
而在 class 组件里用 createRef:
import React from "react";
export default class App extends React.Component{
constructor() {
super();
this.inputRef = React.createRef();
}
componentDidMount() {
this.inputRef.current.focus();
}
render() {
return <input ref={this.inputRef} type="text" />
}
}
如果想转发 ref 给父组件,可以用 forwardRef:
import React, { useRef, forwardRef, useImperativeHandle, useEffect } from "react";
const ForwardRefMyInput = forwardRef<HTMLInputElement>((props, ref) => {
return <input {...props} ref={ref} type="text" />
}
)
export default function App() {
const inputRef = useRef<HTMLInputElement>(null);
useEffect(() => {
inputRef.current?.focus();
}, [])
return (
<div className="App">
<ForwardRefMyInput ref={inputRef} />
</div>
);
}
而且还可以使用 useImperativeHandle 自定义传给父元素的 ref:
import React, { useRef, forwardRef, useImperativeHandle, useEffect } from "react";
interface RefType {
aaa: Function
}
const ForwardRefMyInput = forwardRef<RefType>((props, ref) => {
const inputRef = useRef<HTMLInputElement>(null);
useImperativeHandle(ref, () => {
return {
aaa() {
inputRef.current?.focus();
}
}
});
return <input {...props} ref={inputRef} type="text" />
}
)
export default function App() {
const apiRef = useRef<RefType>(null);
useEffect(() => {
apiRef.current?.aaa();
}, [])
return (
<div className="App">
<ForwardRefMyInput ref={apiRef} />
</div>
);
}
这就是我们平时用到的所有的 ref api 了。
小结一下:
- 函数组件里用 useRef 保存 dom 引用或者自定义的值,而在类组件里用 createRef
- forwardRef 可以转发子组件的 ref 给父组件,还可以用 useImperativeHandle 来修改转发的 ref 的值
相信开发 React 项目,大家或多或少会用到这些 api。
那这些 ref api 的实现原理是什么呢?
下面我们就从源码来探究下:
我们通过 jsx 写的代码,最终会编译成 React.createElement 等 render function,执行之后产生 vdom:
所谓的 vdom 就是这样的节点对象:
vdom 是一个 children 属性连接起来的树。
react 会先把它转成 fiber 链表:
vdom 树转 fiber 链表树的过程就叫做 reconcile,这个阶段叫 render。
render 阶段会从根组件开始 reconcile,根据不同的类型做不同的处理,拿到渲染的结果之后再进行 reconcileChildren,这个过程叫做 beginWork:
比如函数组件渲染完产生的 vom 会继续 renconcileChildren:
beginWork 只负责渲染组件,然后继续渲染 children,一层层的递归。
全部渲染完之后,会递归回来,这个阶段会调用 completeWork:
这个阶段会创建需要的 dom,然后记录增删改的 tag 等,同时也记录下需要执行的其他副作用到 fiber 上。
之后 commit 阶段才会遍历 fiber 链表根据 tag 来执行增删改 dom 等 effect。
commit 阶段也分了三个小阶段,beforeMutation、mutation、layout:
它们都是消费的同一条 fiber 链表,但是每个阶段做的事情不同
mutation 阶段会根据标记增删改 dom,也就是这样的:
所以这个阶段叫做 mutation,它之前的一个阶段叫做 beforeMutation,而它之后的阶段叫做 layout。
小结下 react 的流程:
通过 jsx 写的代码会编译成 render function,执行产生 vdom,也就是 React Element 对象的树。
react 分为 render 和 commit 两个阶段:
render 阶段会递归做 vdom 转 fiber,beginWork 里递归进行 reconcile、reconcileChildren,completeWork 里创建 dom,记录增删改等 tag 和其他 effect
commit 阶段遍历 fiber 链表,做三轮处理,这三轮分别叫做 before mutation、mutation、layout,mutation 阶段会根据 tag 做 dom 增删改。
ref 的实现同样是在这个流程里的。
首先,我们 ref 属性一般是加在原生标签上的,比如 input、div、p 这些,所以看 HostComponent 的分支就可以了,HostComponent 就是原生标签。
可以看到处理原生标签的 fiber 节点时,beginWork 里会走到这个分支:
里面调用 markRef 打了个标记:
前面说的 tag 就是指这个 flags。
然后就到了 commit 阶段,开始根据 flags 做不同处理:
在 layout 阶段,这时候已经操作完 dom 了,就会遍历 fiber 链表,给 HostComponent 设置新的 ref。
ref 的元素就是在 fiber.stateNode 属性上保存的在 render 阶段就创建好了的 dom,:
这样,在代码里的 ref.current 就能拿到这个元素了:
而且我们可以发现,他只是对 ref.current 做了赋值,并不管你是用 createRef 创建的、useRef 创建的,还是自己创建的一个普通对象。
我们试验一下:
我创建了一个普通对象,current 属性依然被赋值为 input 元素。
那我们用 createRef、useRef 的意义是啥呢?
看下源码就知道了:
createRef 也是创建了一个这样的对象,只不过 Object.seal 了,不能增删属性。
用自己创建的对象其实也没啥问题。
那 useRef 呢?
useRef 也是一样的,只不过是保存在了 fiber 节点 hook 链表元素的 memoizedState 属性上。
只是保存位置的不同,没啥很大的区别。
同样,用 forwardRef 转发的 ref 也很容易理解,只是保存的位置变了,变成了从父组件传过来的 ref:
那 forwardRef 是怎么实现这个 ref 转发的呢?
我们再看下源码:
forwarRef 函数其实就是创建了个专门的 React Element 类型:
然后 beginWork 处理到这个类型的节点会做专门的处理:
也就是把它的 ref 传递给函数组件:
渲染函数组件的时候专门留了个后门来传第二个参数:
所以函数组件里就可以拿到 ref 参数了:
这样就完成了 ref 从父组件到子组件的传递:
那 useImperativeHandle 是怎么实现的修改 ref 的值呢?
源码里可以看到 useImperativeHandle 底层就是 useEffect,只不过是回调函数是把传入的 ref 和 create 函数给 bind 到 imperativeHandleEffect 这个函数了:
而这个函数里就是更新 ref.current 的逻辑:
我们知道,useEffect 是在 commit 阶段异步调度的,在 layout 更新 dom 之后了,自然可以拿到新的 dom:
更新了 ref 的值:
这样,useImperativeHandle 就成功修改了 forwardRef 传过来的 ref。
总结
我们平时会用到 createRef、useRef、forwardRef、useImperativeHandle 这些 api,而理解它们的原理需要熟悉 react 的运行流程,也就是 render(beginWork、completeWork) + commit(before mutation、mutation、layout)的流程。
render 阶段处理到原生标签的也就是 HostComponent 类型的时候,如果有 ref 属性会在 fiber.flags 里加一个标记。
commit 阶段会在 layout 操作完 dom 后遍历 fiber 链表更新 HostComponent 的 ref,也就是把 fiber.stateNode 赋值给 ref.current。
react 并不关心 ref 是哪里创建的,用 createRef、useRef 创建的,或者 forwardRef 传过来的都行,甚至普通对象也可以,createRef、useRef 只是把普通对象 Object.seal 了一下。
forwarRef 是创建了单独的 React Element 类型,在 beginWork 处理到它的时候做了特殊处理,也就是把它的 ref 作为第二个参数传递给了函数组件,这就是它 ref 转发的原理。
useImperativeHandle 的底层实现就是 useEffect,只不过执行的函数是它指定的,bind 了传入的 ref 和 create 函数,这样在 layout 阶段调用 hook 的 effect 函数的时候就可以更新 ref 了。
理解了 react 渲染流程之后,很多特性只是其中多一个 switch case 的分支而已,就比如 ref。