大家好,我卡頌。
React
新文件有個很有意思的細節:useRef
、useEffect
這兩個API
的介紹,在文件中所在的章節叫Escape Hatches
(逃生艙)。
顯然,正常航行時是不需要逃生艙的,只有在遇到危險時會用到。
如果開發者過多依賴這兩個API
,可能是誤用。
在React新文件:不要濫用effect哦中我們談到useEffect
的正確使用場景。
今天,我們來聊聊Ref
的使用場景。
歡迎加入人類高質量前端框架群,帶飛
為什麼是逃生艙?
先思考一個問題:為什麼ref
、effect
被歸類到逃生艙中?
這是因為二者操作的都是脫離React控制的因素。
effect
中處理的是副作用。比如:在useEffect
中修改了document.title
。
document.title
不屬於React
中的狀態,React
無法感知他的變化,所以被歸類到effect
中。
同樣,使DOM聚焦需要呼叫element.focus()
,直接執行DOM API
也是不受React
控制的。
雖然他們是脫離React控制的因素,但為了保證應用的健壯,React
也要儘可能防止他們失控。
失控的Ref
對於Ref
,什麼叫失控呢?
首先來看不失控的情況:
- 執行
ref.current
的focus
、blur
等方法 - 執行
ref.current.scrollIntoView
使element
滾動到視野內 - 執行
ref.current.getBoundingClientRect
測量DOM
尺寸
這些情況下,雖然我們操作了DOM
,但涉及的都是React控制範圍外的因素,所以不算失控。
但是下面的情況:
- 執行
ref.current.remove
移除DOM
- 執行
ref.current.appendChild
插入子節點
同樣是操作DOM
,但這些屬於React控制範圍內的因素,通過ref
執行這些操作就屬於失控的情況。
舉個例子,下面是React文件中的例子:
按鈕1點選後會插入/移除 P節點,按鈕2點選後會呼叫DOM API
移除P節點:
export default function Counter() {
const [show, setShow] = useState(true);
const ref = useRef(null);
return (
<div>
<button
onClick={() => {
setShow(!show);
}}>
Toggle with setState
</button>
<button
onClick={() => {
ref.current.remove();
}}>
Remove from the DOM
</button>
{show && <p ref={ref}>Hello world</p>}
</div>
);
}
按鈕1通過React
控制的方式移除P節點。
按鈕2直接操作DOM
移除P節點。
如果這兩種移除P節點的方式混用,那麼先點選按鈕1再點選按鈕2就會報錯:
這就是使用Ref操作DOM造成的失控情況導致的。
如何限制失控
現在問題來了,既然叫失控了,那就是React
沒法控制的(React
總不能限制開發者不能使用DOM API
吧?),那如何限制失控呢?
在React
中,元件可以分為:
- 高階元件
- 低階元件
低階元件指那些基於DOM封裝的元件,比如下面的元件,直接基於input
節點封裝:
function MyInput(props) {
return <input {...props} />;
}
在低階元件中,是可以直接將ref
指向DOM
的,比如:
function MyInput(props) {
const ref = useRef(null);
return <input ref={ref} {...props} />;
}
高階元件指那些基於低階元件封裝的元件,比如下面的Form
元件,基於Input
元件封裝:
function Form() {
return (
<>
<MyInput/>
</>
)
}
高階元件無法直接將ref
指向DOM
,這一限制就將ref失控的範圍控制在單個元件內,不會出現跨越元件的ref失控。
以文件中的示例為例,如果我們想在Form
元件中點選按鈕,操作input
聚焦:
function MyInput(props) {
return <input {...props} />;
}
function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
input聚焦
</button>
</>
);
}
點選後,會報錯:
這是因為在Form
元件中向MyInput
傳遞ref
失敗了,inputRef.current
並沒有指向input
節點。
究其原因,就是上面說的為了將ref失控的範圍控制在單個元件內,React預設情況下不支援跨元件傳遞ref。
人為取消限制
如果一定要取消這個限制,可以使用forwardRef API
顯式傳遞ref
:
const MyInput = forwardRef((props, ref) => {
return <input {...props} ref={ref} />;
});
function Form() {
const inputRef = useRef(null);
function handleClick() {
inputRef.current.focus();
}
return (
<>
<MyInput ref={inputRef} />
<button onClick={handleClick}>
Focus the input
</button>
</>
);
}
使用forwardRef
(forward
在這裡是傳遞的意思)後,就能跨元件傳遞ref
。
在例子中,我們將inputRef
從Form
跨元件傳遞到MyInput
中,並與input
產生關聯。
在實踐中,一些同學可能覺得forwardRef
這一API
有些多此一舉。
但從ref失控的角度看,forwardRef
的意圖就很明顯了:既然開發者手動呼叫forwardRef
破除防止ref失控的限制,那他應該知道自己在做什麼,也應該自己承擔相應的風險。
同時,有了forwardRef
的存在,發生ref相關錯誤後也更容易定位錯誤。
useImperativeHandle
除了限制跨元件傳遞ref外,還有一種防止ref失控的措施,那就是useImperativeHandle
,他的邏輯是這樣的:
既然ref失控是由於使用了不該被使用的DOM方法(比如appendChild),那我可以限制ref中只存在可以被使用的方法。
用useImperativeHandle
修改我們的MyInput
元件:
const MyInput = forwardRef((props, ref) => {
const realInputRef = useRef(null);
useImperativeHandle(ref, () => ({
focus() {
realInputRef.current.focus();
},
}));
return <input {...props} ref={realInputRef} />;
});
現在,Form
元件中通過inputRef.current
只能取到如下資料結構:
{
focus() {
realInputRef.current.focus();
},
}
就杜絕了開發者通過ref取到DOM後,執行不該被使用的API,出現ref失控的情況。
總結
正常情況,Ref
的使用比較少,他是作為逃生艙而存在的。
為了防止錯用/濫用導致ref失控
,React
限制預設情況下,不能跨元件傳遞ref。
為了破除這種限制,可以使用forwardRef
。
為了減少ref
對DOM
的濫用,可以使用useImperativeHandle
限制ref
傳遞的資料結構。