背景: 之前將React的基礎知識以及狀態管理相關的知識都過了一遍,查漏補缺的同時對React也有了一些新鮮的認知,接下來這個模塊的名字很有意思:脫圍機制,內容也比之前的部分難理解一些。但整體看下來,理解之后對React的使用上也會更上一層樓。就繼續學習吧~
前期回顧:
重學React(一):描述UI
重學React(二):添加交互
重學React(三):狀態管理
重學React(四):狀態管理二
學習內容:
React官網教程:https://zh-hans.react.dev/learn/escape-hatches
其他輔助資料(看到再補充)
補充說明:這次學習更多的是以學習筆記的形式記錄,看到哪記到哪
什么是脫圍機制
在React中,除了React之外,我們還需要連接外部系統,比如需要連接服務器接口,獲取服務器傳來的數據,再比如操作DOM方法,比如focus,scroll等等。這些功能的前提需要“跳出”React自身的渲染邏輯,所以被稱為脫圍機制。接下來就開始學習如何脫圍吧~
使用 ref 引用值
在實際編碼中,偶爾會遇到希望組件能記住某些信息,這些信息的修改不觸發頁面重新渲染,比如記錄setTimeout的id,這個id本身跟渲染毫無關系,只是用來標識當前的計時器以及在卸載組件時銷毀它,如果不記錄下來,就很難實現銷毀,容易造成內存泄漏,此時就需要使用ref
給組件添加ref
import { useRef } from 'react';export const App () {
// useRef返回一個current對象
// { current: 0 } // current的value是向 useRef 傳入的值,任何類型都可以const ref = useRef(0);
}
可以使用ref.current 屬性訪問該 ref 的當前值。ref 是一個普通的 JavaScript 對象,具有可以被讀取和修改的 current 屬性。
這個值是有意被設置為可變的,意味著既可以讀取它也可以寫入它。就像一個 React 追蹤不到的、用來存儲組件信息的秘密“口袋”。
示例:制作秒表
import { useState, useRef } from 'react';export default function Stopwatch() {
// 記錄開始時間和當前時間,因為這兩個時間需要計算并渲染出最后的結果,所以使用state,實現實時渲染const [startTime, setStartTime] = useState(null);const [now, setNow] = useState(null);// 用來記錄當前計時器的id,便于重置時clearInterval,它在頁面重新渲染時不需要改變,而是在進行操作時手動處理,所以使用ref進行記錄const intervalRef = useRef(null);
// 每次點擊開始時,將當前時間和記錄時間重置function handleStart() {setStartTime(Date.now());setNow(Date.now());clearInterval(intervalRef.current);intervalRef.current = setInterval(() => {// 每隔十秒更新當前時間setNow(Date.now());}, 10);}function handleStop() {clearInterval(intervalRef.current);}let secondsPassed = 0;// 每次渲染用當前時間減去開始時間,就能得到過去了多少時間if (startTime != null && now != null) {secondsPassed = (now - startTime) / 1000;}return (<><h1>時間過去了: {secondsPassed.toFixed(3)}</h1><button onClick={handleStart}>開始</button><button onClick={handleStop}>停止</button></>);
}
ref 和 state 的不同之處
// React 內部,useRef的內部運行機制可以簡單由useState實現
// 第一次渲染期間,useRef 返回 { current: initialValue }。 該對象由 React 存儲,因此在下一次渲染期間將返回相同的對象。
// 在這個示例中,state 設置函數沒有被用到。它是不必要的,因為 useRef 總是需要返回相同的對象!
function useRef(initialValue) {const [ref, unused] = useState({ current: initialValue });return ref;
}
ref使用場景
- 存儲 timeout ID
- 存儲和操作 DOM 元素
- 存儲不需要被用來計算 JSX 的其他對象。
總的來說,如果組件需要存儲一些值,但不影響渲染邏輯,請選擇 ref,這通常是不會影響組件外觀的瀏覽器 API。
ref 的最佳實踐
使用ref的原則
- 將 ref 視為脫圍機制。 在使用外部系統或瀏覽器 API 時,ref 很有用。但如果很大一部分應用程序邏輯和數據流都依賴于 ref,可能需要重新考慮方法是否有問題。
- 不要在渲染過程中讀取或寫入 ref.current。 如果渲染過程中需要某些信息,請使用 state 代替。由于 React 不知道 ref.current 何時發生變化,即使在渲染時讀取它也會使組件的行為難以預測。(唯一的例外是像 if (!ref.current) ref.current = new Thing() 這樣的代碼,它只在第一次渲染期間設置一次 ref。)
ref本身就是一個普通的js對象,所以它的數據會實時更新,不會像state一樣以快照的形式每隔一段時間才更新。所以只要ref的值不涉及渲染,React就不會關心你對 ref 或其內容做了什么。
使用Ref操作DOM
這是ref最常見的使用場景。在大部分情況下,React 會自動處理更新 DOM 以匹配渲染輸出,所以不需要操作DOM。但在實現某些效果的情況下,比如控制DOM的滾動,讓某個元素獲得焦點等等,React沒有內置方法,而是需要一個指向 DOM 節點的 ref 來實現。
接下來是具體的實現以及原理:
使文本輸入框獲得焦點
// 引入hook
import { useRef } from 'react';export default function Form() {
// 聲明一個refconst inputRef = useRef(null);function handleClick() {// inputRef.current中保存的就是input節點,可以直接使用這個節點內置的API,這里使用的是focusinputRef.current.focus();}return (<>// 將 ref 作為 ref 屬性值傳遞給想要獲取的 DOM 節點的 JSX 標簽<input ref={inputRef} /><button onClick={handleClick}>聚焦輸入框</button></>);
}
如何使用 ref 回調管理 ref 列表
考慮一個場景:有n個列表,需要給每個列表都綁定一個ref,n的個數是未知的,所以我們不能預先將ref給一一聲明了,因為 Hook 只能在組件的頂層被調用。所以不能在循環語句、條件語句或 map() 函數中調用 useRef 。解決這個問題有兩種思路:
- 用一個 ref 引用其父元素,然后用 DOM 操作方法如 querySelectorAll 來尋找它的子節點。然而,這種方法很脆弱,如果 DOM 結構發生變化,可能會失效或報錯
- ref 回調,也就是將函數傳遞給 ref 屬性。當需要設置 ref 時,React 將傳入 DOM 節點來調用 ref 回調,并在需要清除它時傳入 null 。這可以維護自己的數組或 Map,并通過其索引或某種類型的 ID 訪問任何 ref
看個例子如何用第二個方法來解決問題:
注意事項:啟用嚴格模式后,ref 回調將在開發中運行兩次
import { useRef, useState } from "react";export default function CatFriends() {const itemsRef = useRef(null);const [catList, setCatList] = useState(setupCatList);function scrollToCat(cat) {const map = getMap();const node = map.get(cat);node.scrollIntoView({behavior: "smooth",block: "nearest",inline: "center",});}function getMap() {if (!itemsRef.current) {// 首次運行時初始化 Map。itemsRef.current = new Map();}return itemsRef.current;}return (<><nav><button onClick={() => scrollToCat(catList[0])}>Neo</button><button onClick={() => scrollToCat(catList[5])}>Millie</button><button onClick={() => scrollToCat(catList[9])}>Bella</button></nav><div><ul>{catList.map((cat) => (<likey={cat}ref={(node) => {// 將這個getMap函數傳入,這樣DOM的ref就可以以map的形式操作const map = getMap();// 添加到 Map 中map.set(cat, node);// 從 Map 中移除return () => {map.delete(cat);};}}><img src={cat} /></li>))}</ul></div></>);
}function setupCatList() {const catList = [];for (let i = 0; i < 10; i++) {catList.push("https://loremflickr.com/320/240/cat?lock=" + i);}return catList;
}
訪問另一個組件的 DOM 節點
有時候會有A組件操作B組件DOM節點的需求,比如在執行某些操作后,實現表單輸入框的自動聚焦。但Ref 是一個脫圍機制,也就是除了在迫不得已的情況下盡量別用。手動操作其它 組件的 DOM 節點可能會讓代碼變得脆弱。如果真的要用,可以看看這個例子。
import { useRef } from 'react';function MyInput({ ref }) {
// 子組件從props中獲取ref,綁定在對應的DOM節點上return <input ref={ref} />;
}export default function MyForm() {
// 在父組件里聲明refconst inputRef = useRef(null);function handleClick() {inputRef.current.focus();}return (<>// 把ref作為參數傳到子組件中<MyInput ref={inputRef} /><button onClick={handleClick}>聚焦輸入框</button></>);
}
這樣做確實可以實現在A組件中調用B組件的DOM,但某些情況下,可能只需要調用B組件DOM的其中一些方法,比如在這個例子里只需要調用focus方法,但這樣寫會將DOM所有方法都給了MyForm組件。還有些更加極端的需求,A組件可能需要調用B組件中的某些方法,這個時候,可以使用useImperativeHandle
來實現
import { useRef, useImperativeHandle } from "react";function MyInput({ ref }) {const realInputRef = useRef(null);// useImperativeHandle 指示 React 將你自己指定的對象作為父組件的 ref 值。 // 所以 Form 組件內的 inputRef.current 將只有 focus 方法。useImperativeHandle(ref, () => ({// 只暴露 focus,沒有別的// ref在這里不是 DOM 節點,而是在 useImperativeHandle 調用中創建的自定義對象。所以除了DOM方法外,還可以將其他A組件需要調用的方法也一并傳入focus() {realInputRef.current.focus();},someFun() {console.log('test')}}));return <input ref={realInputRef} />;
};export default function Form() {const inputRef = useRef(null);function handleClick() {inputRef.current.focus();}return (<><MyInput ref={inputRef} /><button onClick={handleClick}>聚焦輸入框</button></>);
}
React 何時添加 refs
在 React 中,每次更新都分為 兩個階段:
- 在 渲染 階段, React 調用你的組件來確定屏幕上應該顯示什么。
- 在 提交 階段, React 把變更應用于 DOM。
在第一次渲染期間,DOM 節點尚未創建,因此 ref.current 將為 null。在渲染更新的過程中,DOM 節點還沒有更新。所以讀取它們還為時過早。
React 在提交階段設置 ref.current。在更新 DOM 之前,React 將受影響的 ref.current 值設置為 null。更新 DOM 后,React 立即將它們設置到相應的 DOM 節點。
通常,你將從事件處理器訪問 refs。 如果想使用 ref 執行某些操作,但沒有特定的事件可以執行此操作,可能需要一個 effect。這就是后面的內容了。
彩蛋:用 flushSync 同步更新 state
請看下面這個代碼,需要實現的是添加一個新的待辦事項,并將屏幕向下滾動到列表的最后一個子項。請注意,出于某種原因,它總是滾動到最后一個添加之前的待辦事項
import { useState, useRef } from 'react';export default function TodoList() {const listRef = useRef(null);const [text, setText] = useState('');const [todos, setTodos] = useState(initialTodos);function handleAdd() {const newTodo = { id: nextId++, text: text };setText('');setTodos([ ...todos, newTodo]);listRef.current.lastChild.scrollIntoView({behavior: 'smooth',block: 'nearest'});}return (<><button onClick={handleAdd}>添加</button><inputvalue={text}onChange={e => setText(e.target.value)}/><ul ref={listRef}>{todos.map(todo => (<li key={todo.id}>{todo.text}</li>))}</ul></>);
}let nextId = 0;
let initialTodos = [];
for (let i = 0; i < 20; i++) {initialTodos.push({id: nextId++,text: '待辦 #' + (i + 1)});
}
執行代碼后會發現,原本想要滾動到最后新加的待辦事項中,但實際上會滾到上一個事項,自動滾動無法定位到新添加的待辦事項中。
問題出現在這兩行代碼中:
// 在 React 中,state 更新是排隊進行的,setTodos 不會立即更新 DOM。
// 當ref操作scroll事件使得列表滾動到最后一個元素時,尚未添加待辦事項
// 因此這里需要實現setTodos立即更新
setTodos([ ...todos, newTodo]);
listRef.current.lastChild.scrollIntoView();// 可以使用react-dom中的flushSync來實現這個強制更新DOM的過程
import { flushSync } from 'react-dom';
// ...只展示關鍵代碼
flushSync(() => {setTodos([ ...todos, newTodo]);
});
listRef.current.lastChild.scrollIntoView();
使用 refs 操作 DOM 的最佳實踐
還是反復強調的事情,Ref是一種脫圍機制,所以必須只在需要跳出“React”范圍的時候才能使用,否則如果胡亂修改DOM元素,一旦跟React自身的渲染機制沖突了,就容易造成不可預期的后果。
因此,需要避免更改由 React 管理的 DOM 節點。 對 React 管理的元素進行修改、添加子元素、從中刪除子元素會導致不一致的視覺結果,或造成代碼崩潰。總之就是,不是不能改,而是改的時候需要小心些。
ref的場景就學完了,接下來是Effect的模塊,這個模塊比較長,就單獨再開一篇來講好了~