今天解决一个 bug 的时候发现了 ref 的两种赋值方式是有区别的。
先说说我这里所说的两种方式具体是指什么。
第一种:使用 useRef():
function MyInput1(props) {
const inputRef = useRef(null)
useEffect(() => { inputRef.current?.focus() }, [])
return <input ref={inputRef} />
}
第二种:使用 useState():
function MyInput2(props) {
const [inputEle, setInputEle] = useState(null)
useEffect(() => { inputEle?.focus() }, [inputEle])
return <input ref={setInputEle} />
}
这两种方式从效果上看是一样的:组件都会获取到焦点。我平时倾向于使用第二种,因为第二种的方式可以让我在 useEffect 里检测到 testEle 的变化,但是今天遇到的一个 bug 恰恰是由于这种方式引起的。
现在我们在第二种方式的基础上使用 useImperativeHandle() 抛出一个 focus() 方法:
const MyInput2 = forwardRef(function MyInput2(props, ref) {
const [inputEle, setInputEle] = useState(null)
useImperativeHandle(ref, () => ({
focus() {
inputEle?.focus()
}
}), [inputEle])
return <input ref={setInputEle} />
})
然后我们在父组件里调用它的方法:
function Parent() {
const myInput2Ref = useRef(null)
useEffect(() => { myInput2Ref.current?.focus() }, [])
return <MyInput2 ref={myInput2Ref} />
}
按照预期,输入框应该获得焦点,但是实际情况是——没有。
但是如果我们用第一种方式:
function MyInput1(props) {
const inputRef = useRef(null)
useImperativeHandle(ref, () => ({
focus() {
inputRef.current?.focus()
}
}), [])
return <input ref={inputRef} />
}
就可以按照预期获取到焦点!
问题出在哪里?
其实我们只需要把 <MyInput1 /> 和 <MyInput2 /> 里调用 focus() 前的问号 ? 去掉就知道原因了。
为什么我调用
focus()前会加问号? 因为 TypeScript 推导出inputRef.current和inputEle可能是null。
在 <MyInput1 /> 中,即使把问号去掉,代码也是能正常运行的,也就是说,使用 useRef() 赋值时,在运行 useEffect() 时 inputRef.current 已经不是 null 了。
在 <MyInput2 /> 中,把问号去掉之后,代码就报错了,因为 effect 第一次运行时 inputEle 是 null。
为什么这两种方式会有这种差异?
在这之前,首先得了解一下 React Hooks 的运行时机。强烈推荐阅读《useEffect 完整指南》(我在遇到这个问题之后又去读了两遍 :joy:),这里先简单阐述一下。
在第一种方式中,运行的过程是这样的:
- React 生成
<input />的 UI 并交给浏览器渲染在网页上 - 浏览器渲染出来 DOM 元素后,React 将
inputRef.current设置成了<input /> - React 开始运行
useEffect(),此时inputRef.current已经是 DOM 元素了
在第二种方式中,运行的过程是这样的:
- React 生成
<input />的 UI 并交给浏览器渲染在网页上 - 浏览器渲染出来 DOM 元素后,React 调用
setState()更新了inputEle这个状态,但是此次渲染已经完成,这个状态的变更将触发下一次渲染 - React 开始运行
useEffect(),此时inputEle仍然是初始值null - 由于
inputEle状态由null变成了 DOM 节点,所以 React 重新生成了 UI - UI 实际上没有发生变化,所以浏览器没有改变 DOM
- React 开始第二次运行
useEffect(),此时inputEle才是 DOM 元素而不是null
把 useEffect() 换作 useImperativeHandle() 来思考也是一样的:当父组件第一次调用 myInput2Ref.current.focus() 的时候,<MyInput2 /> 里的 inputEle 仍然是 null,只有当 <MyInput2 /> 第二次渲染完成后,调用时的 inputEle 才是 DOM 元素——然而我们在父组件里并不能判断出 <MyInput2 /> 有没有按照我们的预期渲染完成,我们也不应该依靠子组件的渲染状态来决定父组件里代码的调用时机。
总结
我们都知道 useState() 的状态变更会触发下一次渲染,但由于 useEffect() 的存在,总是会忽略掉这件事,从而导致代码没有按照预期执行。
虽然已经用了一段时间 Hooks 了,但仍然不太习惯,潜意识里总是按照 Class 组件那一套来“模拟”它的运行过程,没有再进一步思考状态变更的后果。