React 新文檔:不要濫用 Ref 哦
大家好,我卡頌。
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]:
「按鈕 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 失控」。
以文檔中的示例 [2] 爲例,如果我們想在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傳遞的數據結構。
參考資料
[1] React 文檔中的例子: https://codesandbox.io/s/sandpack-project-forked-s33q3c
[2] 文檔中的示例: https://codesandbox.io/s/sandpack-project-forked-7zqgmd
本文由 Readfog 進行 AMP 轉碼,版權歸原作者所有。
來源:https://mp.weixin.qq.com/s/bI3rohCePnay2JVsQvdtfg