UseLayoutEffect的秘密,你知道嗎?
前言
在React中針對(duì)DOM操作的最常見(jiàn)方法是使用refs來(lái)訪(fǎng)問(wèn)DOM節(jié)點(diǎn),其實(shí)還有一種方法,就是使用useLayoutEffect來(lái)訪(fǎng)問(wèn)DOM節(jié)點(diǎn),根據(jù)實(shí)際 DOM 測(cè)量(例如元素的大小或位置)來(lái)更改元素。
今天,我們就來(lái)講講useLayoutEffect如何處理DOM,還有從底層是如何實(shí)現(xiàn)的?
好了,天不早了,干點(diǎn)正事哇。
我們能所學(xué)到的知識(shí)點(diǎn)
- 前置知識(shí)點(diǎn)
- useEffect 導(dǎo)致布局閃爍
- 使用 useLayoutEffect 修復(fù)閃爍問(wèn)題
- 瀏覽器如何渲染頁(yè)面
- useEffect vs useLayoutEffect
- 在 Next.js 和其他 SSR 框架中使用 useLayoutEffect
1. 前置知識(shí)點(diǎn)
「前置知識(shí)點(diǎn)」,只是做一個(gè)概念的介紹,不會(huì)做深度解釋。因?yàn)椋@些概念在下面文章中會(huì)有出現(xiàn),為了讓行文更加的順暢,所以將本該在文內(nèi)的概念解釋放到前面來(lái)?!溉绻蠹覍?duì)這些概念熟悉,可以直接忽略」同時(shí),由于閱讀我文章的群體有很多,所以有些知識(shí)點(diǎn)可能「我視之若珍寶,爾視只如草芥,棄之如敝履」。以下知識(shí)點(diǎn),請(qǐng)「酌情使用」。
強(qiáng)制布局
在EventLoop = TaskQueue + RenderQueue有介紹,然后我們?cè)诤?jiǎn)單提一下。
強(qiáng)制布局(Forced Synchronous Layout 或 Forced Reflow)是Web性能優(yōu)化領(lǐng)域的一個(gè)術(shù)語(yǔ),它指的是瀏覽器在能夠繼續(xù)「處理后續(xù)操作之前,必須完成當(dāng)前的布局計(jì)算」。
當(dāng)強(qiáng)制執(zhí)行布局時(shí),瀏覽器會(huì)暫停JS主線(xiàn)程,盡管調(diào)用棧不是空的。
有很多我們耳熟能詳?shù)牟僮?,都?huì)觸發(fā)強(qiáng)制布局。
圖片
其中有我們很熟悉的getBoundingClientRect(),下文中會(huì)有涉及。
想了解更多??觸發(fā)強(qiáng)制布局的操作[1]。
阻塞渲染
在瀏覽器中,阻塞渲染是指當(dāng)瀏覽器在加載網(wǎng)頁(yè)時(shí)遇到阻塞資源(通常是外部資源如樣式表、JavaScript文件或圖像等),它會(huì)停止渲染頁(yè)面的過(guò)程,直到這些資源被下載、解析和執(zhí)行完畢。這種行為會(huì)導(dǎo)致頁(yè)面加載速度變慢,用戶(hù)可能會(huì)感覺(jué)到頁(yè)面加載較慢或者出現(xiàn)空白的情況。
舉例來(lái)說(shuō),如果一個(gè)網(wǎng)頁(yè)中引用了外部的JavaScript文件,并且這個(gè)文件比較大或者加載速度較慢,瀏覽器會(huì)等待這個(gè)JavaScript文件下載完成后才繼續(xù)渲染頁(yè)面,導(dǎo)致頁(yè)面在此過(guò)程中停滯或者出現(xiàn)明顯的加載延遲。
下面是一個(gè)簡(jiǎn)單的示例,展示了一個(gè)會(huì)阻塞頁(yè)面加載的情況:
<!DOCTYPE html>
<html>
<head>
<title>阻塞渲染示例</title>
<!-- 假設(shè)這是一個(gè)較大的外部 JavaScript 文件 -->
<script src="large_script.js"></script>
<style>
/* 一些樣式 */
</style>
</head>
<body>
<h1>阻塞渲染示例</h1>
<!-- 頁(yè)面其余內(nèi)容 -->
</body>
</html>
在這個(gè)示例中,large_script.js 是一個(gè)較大的 JavaScript 文件,它會(huì)阻塞頁(yè)面的加載和渲染。瀏覽器在遇到這個(gè) <script> 標(biāo)簽時(shí)會(huì)暫停頁(yè)面的渲染,直到large_script.js 文件完全下載、解析并執(zhí)行完畢,然后才會(huì)繼續(xù)渲染頁(yè)面的其余內(nèi)容。
為了減少阻塞渲染對(duì)頁(yè)面加載速度的影響,可以采取一些優(yōu)化策略,比如:
- 「異步加載資源」:使用 async 或 defer 屬性加載 JavaScript 文件,讓它們不會(huì)阻塞頁(yè)面渲染。
- 「資源合并與壓縮」:將多個(gè)小文件合并為一個(gè)大文件,并對(duì)文件進(jìn)行壓縮,減少下載時(shí)間。
- 「延遲加載」:將不是立即需要的資源推遲加載,比如在頁(yè)面滾動(dòng)到特定位置或用戶(hù)執(zhí)行某些操作時(shí)再加載。
2. useEffect 導(dǎo)致布局閃爍
假設(shè)存在以下場(chǎng)景:有一個(gè)「響應(yīng)式」導(dǎo)航組件,它會(huì)根據(jù)容器的大小來(lái)調(diào)整其子元素的數(shù)量。
圖片
如果,容器不能容納這些組件,那么它會(huì)在容器的右側(cè)顯示一個(gè)“更多”按鈕,點(diǎn)擊后會(huì)顯示一個(gè)下拉菜單,其中包含剩余未展示的子項(xiàng)目
圖片
讓我們先從簡(jiǎn)單的邏輯入手,先創(chuàng)建一個(gè)簡(jiǎn)單的導(dǎo)航組件,它將呈現(xiàn)一個(gè)鏈接列表:(直接遍歷items來(lái)渲染對(duì)應(yīng)的項(xiàng)目)
const Component = ({ items }) => {
return (
<div className="navigation">
{items.map((item) => (
<a href={item.href}>{item.name}</a>
))}
</div>
);
};
上面的代碼,只負(fù)責(zé)對(duì)items進(jìn)行遍歷和展示,沒(méi)有任何響應(yīng)式的處理。要想實(shí)現(xiàn)響應(yīng)式,我們需要計(jì)算「可用空間」中可以容納多少個(gè)項(xiàng)目。為此,我們需要知道容器的寬度以及每個(gè)項(xiàng)目的尺寸。并且,我們無(wú)法「未卜先知」其項(xiàng)目中文案信息,也就無(wú)法提前做任何工作,例如通過(guò)計(jì)算每個(gè)項(xiàng)目的文本長(zhǎng)度來(lái)計(jì)算剩余空間。
既然,我們無(wú)法未雨綢繆,那我們只能亡羊補(bǔ)牢了,也就是我們只有在瀏覽器已經(jīng)把這些項(xiàng)目都渲染出來(lái)后,然后通過(guò)原生 JavaScript API(例如getBoundingClientRect)來(lái)獲取這些項(xiàng)目的尺寸。
借助 getBoundingClientRect 獲取項(xiàng)目尺寸
我們需要分幾步來(lái)完成。
1. 獲取元素的訪(fǎng)問(wèn)權(quán)
創(chuàng)建一個(gè) Ref 并將其分配給包裝這些項(xiàng)目的 div
const Component = ({ items }) => {
const ref = useRef(null);
return (
<div className="navigation" ref={ref}>
...
</div>
);
};
2. 在 useEffect 中獲取元素的尺寸
const Component = ({ items }) => {
useEffect(() => {
const div = ref.current;
const { width } = div.getBoundingClientRect();
}, [ref]);
return ...
}
3. 迭代 div 的子元素并將其寬度提取到數(shù)組中
const Component = ({ items }) => {
useEffect(() => {
// 與以前相同的代碼
// 將div的子元素轉(zhuǎn)換為數(shù)組
const children = [...div.childNodes];
// 所有子元素的寬度
const childrenWidths = children.map(child => child.getBoundingClientRect().width)
}, [ref]);
return ...
}
既然,父容器的寬度和所有子元素的寬度都已經(jīng)計(jì)算出來(lái)了,我們現(xiàn)在可以開(kāi)始計(jì)算可用空間。
現(xiàn)在,我們只需遍歷該數(shù)組,計(jì)算子元素的寬度,將這些總和與父 div 比較,并找到「最后一個(gè)可見(jiàn)項(xiàng)目」。
4. 處理“更多”按鈕
當(dāng)我們胸有成竹的把上述代碼運(yùn)行后,猛然發(fā)現(xiàn),我們還缺失了一個(gè)重要的步驟:如何在瀏覽器中渲染更多按鈕。我們也需要考慮它的寬度。
同樣,我們只能在瀏覽器中渲染它時(shí)才能獲取其寬度。因此,我們必須在「首次渲染」期間明確添加按鈕:
const Component = ({ items }) => {
return (
<div className="navigation">
{items.map((item) => (
<a href={item.href}>{item.name}</a>
))}
{/* 在鏈接后明確添加“更多”按鈕 */}
<button id="more">...</button>
</div>
);
};
5. 函數(shù)抽離
如果我們將計(jì)算寬度的所有邏輯抽象成一個(gè)函數(shù),那么在我們的useEffect中會(huì)有類(lèi)似這樣的東西:
useEffect(() => {
const { moreWidth, necessaryWidths, containerWidth } = getPrecalculatedWidths(
ref.current
);
const itemIndex = getLastVisibleItem({
containerWidth,
necessaryWidths,
moreWidth,
});
}, [ref]);
getPrecalculatedWidths
// 定義右側(cè)間隙的常量
const rightGap = 10;
// 獲取子元素的預(yù)先計(jì)算寬度信息
const getPrecalculatedWidths = (element: HTMLElement) => {
// 獲取容器的寬度和左側(cè)位置
const {
width: containerWidth,
left: containerLeft
} = element.getBoundingClientRect();
// 獲取容器的所有子元素
const children = Array.from(element.childNodes) as HTMLElement[];
// 初始化“more”按鈕寬度和子元素寬度數(shù)組
let moreWidth = 0;
const necessaryWidths = children.reduce<number[]>((result, node) => {
// 提取“more”按鈕的寬度并跳過(guò)計(jì)算
if (node.getAttribute("id") === "more") {
moreWidth = node.getBoundingClientRect().width;
return result;
}
// 計(jì)算子元素的寬度,考慮了左側(cè)位置和右側(cè)間隙
const rect = node.getBoundingClientRect();
const width = rect.width + (rect.left - containerLeft) + rightGap;
return [...result, width];
}, []);
// 返回預(yù)先計(jì)算的寬度信息對(duì)象
return {
moreWidth,
necessaryWidths,
containerWidth
};
};
getLastVisibleItem
其中g(shù)etLastVisibleItem函數(shù)執(zhí)行所有數(shù)學(xué)計(jì)算并返回一個(gè)數(shù)字——最后一個(gè)可以適應(yīng)可用空間的鏈接的索引。
// 獲取在給定容器寬度內(nèi)可見(jiàn)的最后一個(gè)子元素的索引
const getLastVisibleItem = ({
necessaryWidths,
containerWidth,
moreWidth,
}: {
necessaryWidths: number[],
containerWidth: number,
moreWidth: number,
}) => {
// 如果沒(méi)有子元素寬度信息,返回0
if (!necessaryWidths?.length) return 0;
// 如果最后一個(gè)子元素寬度小于容器寬度,說(shuō)明所有元素都能完全顯示
if (necessaryWidths[necessaryWidths.length - 1] < containerWidth) {
return necessaryWidths.length - 1;
}
// 過(guò)濾出所有寬度加上“more”按鈕寬度小于容器寬度的子元素
const visibleItems = necessaryWidths.filter((width) => {
return width + moreWidth < containerWidth;
});
// 返回可見(jiàn)子元素的最后一個(gè)的索引,如果沒(méi)有可見(jiàn)的元素,則返回0
return visibleItems.length ? visibleItems.length - 1 : 0;
};
從React角度來(lái)看,我們既然得到了這個(gè)數(shù)字,我們就需要觸發(fā)組件的更新,并讓它刪除不應(yīng)該展示的組件。
我們需要在獲取該數(shù)字時(shí)將其保存在狀態(tài)中:
const Component = ({ items }) => {
// 將初始值設(shè)置為-1,以表示我們尚未運(yùn)行計(jì)算
const [lastVisibleMenuItem, setLastVisibleMenuItem] = useState(-1);
useEffect(() => {
const itemIndex = getLastVisibleItem(ref.current);
// 使用實(shí)際數(shù)字更新?tīng)顟B(tài)
setLastVisibleMenuItem(itemIndex);
}, [ref]);
};
然后,在渲染菜單時(shí),考慮根據(jù)lastVisibleMenuItem來(lái)控制子元素的內(nèi)容
const Component = ({ items }) => {
// 如果是第一次渲染且值仍然是默認(rèn)值,則渲染所有內(nèi)容
if (lastVisibleMenuItem === -1) {
// 在這里渲染所有項(xiàng)目,與以前相同
return ...
}
// 如果最后可見(jiàn)的項(xiàng)目不是數(shù)組中的最后一個(gè),則顯示“更多”按鈕
const isMoreVisible = lastVisibleMenuItem < items.length - 1;
// 過(guò)濾掉那些索引大于最后可見(jiàn)的項(xiàng)目的項(xiàng)目
const filteredItems = items.filter((item, index) => index <= lastVisibleMenuItem);
return (
<div className="navigation">
{/* 僅呈現(xiàn)可見(jiàn)項(xiàng)目 */}
{filteredItems.map(item => <a href={item.href}>{item.name}</a>)}
{/* 有條件地呈現(xiàn)“更多” */}
{isMoreVisible && <button id="more">...</button>}
</div>
)
}
現(xiàn)在,在state用實(shí)際數(shù)字更新后,它將觸發(fā)導(dǎo)航的重新渲染,React 將重新渲染項(xiàng)目并刪除那些不可見(jiàn)的項(xiàng)目。
6. 監(jiān)聽(tīng) resize 事件
為了實(shí)現(xiàn)真正的響應(yīng)式,我們還需要監(jiān)聽(tīng)resize事件并重新計(jì)算數(shù)字。
// 用dimensions來(lái)存儲(chǔ) necessaryWidths和moreWidth
const [dimensions, setDimensions] = useState<{
necessaryWidths: number[];
moreWidth: number;
}>({
necessaryWidths: [],
moreWidth: 0
});
useEffect(() => {
const listener = () => {
if (!ref.current) return;
const newIndex = getLastVisibleItem({
containerWidth: ref.current.getBoundingClientRect().width,
necessaryWidths: dimensions.necessaryWidths,
moreWidth: dimensions.moreWidth,
});
if (newIndex !== lastVisibleMenuItem) {
setLastVisibleMenuItem(newIndex);
}
};
window.addEventListener("resize", listener);
return () => {
window.removeEventListener("resize", listener);
};
}, [lastVisibleMenuItem, dimensions, ref]);
上面的代碼雖然不是全部的代碼,但是主要的邏輯就是實(shí)現(xiàn)在響應(yīng)式的組件,并且能夠在屏幕大小發(fā)生變化時(shí)重新計(jì)算寬度。
但是呢,在在 CPU 計(jì)算能力下降時(shí),出產(chǎn)生內(nèi)容閃動(dòng)的情況。也就是,在某個(gè)時(shí)刻,我們先看到所有的項(xiàng)目和更多按鈕,隨后,根據(jù)可用空間的多少,會(huì)隱藏掉部分項(xiàng)目。
3. 使用 useLayoutEffect 修復(fù)閃爍問(wèn)題
上面出現(xiàn)閃爍的根本原因就是:我們先把所有元素都渲染出來(lái)了,然后依據(jù)計(jì)算后的剩余空間來(lái)控制哪些元素可見(jiàn)/隱藏。 也就是我們做的是一種「先渲染再刪除」的操作。在useLayoutEffect沒(méi)出現(xiàn)之前,其實(shí)大家解決這類(lèi)問(wèn)題的方式都很奇葩。還是沿用第一次渲染全部元素,但是設(shè)置這些元素不可見(jiàn)(不透明度設(shè)置為 0/或者在可見(jiàn)區(qū)域之外的某個(gè)地方的某個(gè) div 中呈現(xiàn)這些元素),然后在計(jì)算后再將那些滿(mǎn)足條件的元素顯示出來(lái)。
然而,在 React 16.8+,我們可以用 useLayoutEffect 替換 useEffect 鉤子。
const Component = ({ items }) => {
// 一切都完全相同,只是鉤子的名稱(chēng)不同
useLayoutEffect(() => {
// 代碼仍然一樣
}, [ref]);
};
僅需要一行代碼就可以解決上面的閃爍問(wèn)題。神不神奇。
雖然,useLayoutEffect能解決我們的問(wèn)題,但是根據(jù)React 官方文檔[2],它是有一定的缺陷的。
- 文檔明確表示 useLayoutEffect 可能會(huì)影響性能,應(yīng)該避免使用。
- 文檔還說(shuō)它在瀏覽器重新繪制屏幕之前觸發(fā),這意味著 useEffect 在其后觸發(fā)。
雖然,useLayoutEffect能解決我們的問(wèn)題,但是也有一定的風(fēng)險(xiǎn)。所以,我們需要對(duì)其有一個(gè)更深的認(rèn)知,這樣才可以在遇到類(lèi)似的問(wèn)題,有的放矢。
然后,要想深入了解useLayoutEffect,就需要從瀏覽器的角度來(lái)探查原因了。
so,讓我們講點(diǎn)瀏覽器方面的東西。
4. 瀏覽器如何渲染頁(yè)面
我們之前在EventLoop = TaskQueue + RenderQueue從EventLoop的角度分析了,瀏覽器渲染頁(yè)面的流程。所以,我們就簡(jiǎn)單的回顧一下。
「瀏覽器不會(huì)實(shí)時(shí)連續(xù)地更新屏幕上需要顯示的所有內(nèi)容」,而是會(huì)將所有內(nèi)容分成一系列幀,并逐幀地顯示它們。在瀏覽器中,我們可以看到這些幀,它們被稱(chēng)為幀,或者幀緩沖,因?yàn)樗鼈兪菫g覽器用來(lái)顯示內(nèi)容的一系列幀。
瀏覽器顯示頁(yè)面的過(guò)程像你像領(lǐng)導(dǎo)展示PPT的過(guò)程。
你展示了一張PPT,然后等待他們理解你天馬行空的創(chuàng)意后,隨后你才可以切換到一張PPT。就這樣周而復(fù)始的執(zhí)行上面的操作。
如果一個(gè)非常慢的瀏覽器被要求制定如何畫(huà)貓頭鷹的指令,它可能實(shí)際上會(huì)是如下的步驟:
圖片
- 第一步:畫(huà)了兩個(gè)圓
- 第二步:把剩余的所有細(xì)節(jié)都補(bǔ)充完成
上述的過(guò)程非???。通常,現(xiàn)代瀏覽器嘗試保持 60 FPS 的速率,即每秒 60 幀。每 16.6 毫秒左右切換一張PPT。
渲染任務(wù)
更新這些PPT的信息被分成任務(wù)。
任務(wù)被放入隊(duì)列中。瀏覽器從隊(duì)列中抓取一個(gè)任務(wù)并執(zhí)行它。如果有更多時(shí)間,它執(zhí)行下一個(gè)任務(wù),依此類(lèi)推,直到在16.6ms 的間隙中沒(méi)有更多時(shí)間為止,然后刷新屏幕。然后繼續(xù)不停地工作,以便我們能夠進(jìn)行一些重要的事情。
在正常的 Javascript 中,任務(wù)是我們放在腳本中并「同步執(zhí)行」的所有內(nèi)容。
const app = document.getElementById("app");
const child = document.createElement("div");
child.innerHTML = "<h1>前端柒八九!</h1>";
app.appendChild(child);
child.style = "border: 10px solid red";
child.style = "border: 20px solid green";
child.style = "border: 30px solid black";
如上我們通過(guò)id 獲取一個(gè)元素,將它放入 app 變量中,創(chuàng)建一個(gè) div,更新其 HTML,將該 div 附加到 app,然后三次更改 div 的邊框?!笇?duì)于瀏覽器來(lái)說(shuō),整個(gè)過(guò)程將被視為一個(gè)任務(wù)」。因此,它將執(zhí)行每一行,然后繪制最終結(jié)果:帶有黑色邊框的 div。
我們「無(wú)法在屏幕上看到這個(gè)紅綠黑的過(guò)渡」。
如果任務(wù)花費(fèi)的時(shí)間超過(guò) 16.6ms 會(huì)發(fā)生什么呢?。瀏覽器不能停止它或拆分它。它「將繼續(xù)進(jìn)行,直到完成,然后繪制最終結(jié)果」。如果我在這些邊框更新之間添加 1 秒的同步延遲:
const waitSync = (ms) => {
let start = Date.now(),
now = start;
while (now - start < ms) {
now = Date.now();
}
};
child.style = "border: 10px solid red";
waitSync(1000);
child.style = "border: 20px solid green";
waitSync(1000);
child.style = "border: 30px solid black";
waitSync(1000);
我們?nèi)匀粺o(wú)法看到“中間”結(jié)果。我們只會(huì)盯著空白屏幕直到瀏覽器解決它,并在最后看到黑色邊框。這就是我們所說(shuō)的阻塞渲染代碼。
盡管 React 也是 Javascript,但是不是作為一個(gè)單一的任務(wù)執(zhí)行的。我們可以通過(guò)各種異步方式(回調(diào)、事件處理程序、promises 等)「將整個(gè)應(yīng)用程序渲染為更小的任務(wù)」
如果我只是用 setTimeout 包裝那些樣式調(diào)整,即使是 0 延遲:
setTimeout(() => {
child.style = "border: 10px solid red";
wait(1000);
setTimeout(() => {
child.style = "border: 20px solid green";
wait(1000);
setTimeout(() => {
child.style = "border: 30px solid black";
wait(1000);
}, 0);
}, 0);
}, 0);
這里處理方式和我們之前處理堆棧溢出的方式是一樣的。
然后,每個(gè)定時(shí)器都將被視為一個(gè)新的任務(wù)。因此,瀏覽器將能夠在完成一個(gè)任務(wù)之后并在開(kāi)始下一個(gè)任務(wù)之前重新繪制屏幕。我們將能夠看到從紅到綠再到黑的緩慢的過(guò)渡,而不是在白屏上停留三秒鐘。
這就是 React 為我們所做的事情。實(shí)質(zhì)上,它是一個(gè)非常復(fù)雜且高效的引擎,將由數(shù)百個(gè) npm 依賴(lài)項(xiàng)與我們自己的代碼組合而成的塊分解成瀏覽器能夠在 16.6ms 內(nèi)處理的最小塊。
5. useEffect vs useLayoutEffect
回到上面話(huà)題,為什么我們用了useLayoutEffect就解決了頁(yè)面閃爍的問(wèn)題。
useLayoutEffect 是 React 在組件更新期間「同步運(yùn)行的內(nèi)容」。
const Component = () => {
useLayoutEffect(() => {
// 做一些事情
});
return ...;
};
我們?cè)诮M件內(nèi)部渲染的任何內(nèi)容都將與 useLayoutEffect 被統(tǒng)籌為同一任務(wù)。即使在 useLayoutEffect 內(nèi)部更新state(我們通常認(rèn)為這是一個(gè)異步任務(wù)),React 仍然會(huì)確?!刚麄€(gè)流程以同步方式運(yùn)行」。
如果我們回到一開(kāi)始實(shí)現(xiàn)的導(dǎo)航示例。從瀏覽器的角度來(lái)看,它只是一個(gè)任務(wù):
圖片
這種情況與我們無(wú)法看到的紅綠黑邊框過(guò)渡的情況完全相同!
另一方面,使用 useEffect 的流程將分為兩個(gè)任務(wù):
圖片
第一個(gè)任務(wù)渲染了帶有所有按鈕的初始導(dǎo)航。而第二個(gè)任務(wù)刪除我們不需要的那些子元素。在「兩者之間重新繪制屏幕」!與setTimeout內(nèi)的邊框情況完全相同。
所以回答我們一開(kāi)始的問(wèn)題。使用 useLayoutEffect它會(huì)影響性能!我們最不希望的是我們整個(gè) React 應(yīng)用程序變成一個(gè)巨大的同步任務(wù)。
只有在需要根據(jù)元素的實(shí)際大小調(diào)整 UI 而導(dǎo)致的視覺(jué)閃爍時(shí)使用 useLayoutEffect。對(duì)于其他所有情況,useEffect 是更好的選擇。
對(duì)于useEffect有一點(diǎn)我們需要額外說(shuō)明一下。
大家都認(rèn)為 useEffect在瀏覽器渲染后觸發(fā),其實(shí)不完全對(duì)。
useEffect 有時(shí)在渲染前執(zhí)行
在正常的流程中,React 更新過(guò)程如下:
- React工作:渲染虛擬DOM,安排effect,更新真實(shí)DOM
- 調(diào)用 useLayoutEffect
- React 釋放控制,瀏覽器繪制新的DOM
- 調(diào)用 useEffect
React文檔并沒(méi)有明確說(shuō)明 useEffect 何時(shí)確切地執(zhí)行,它發(fā)生在「布局和繪制之后,通過(guò)延遲事件進(jìn)行」。
然而,在文檔中有一個(gè)更有趣的段落:
盡管 useEffect 被延遲到瀏覽器繪制之后,但它保證在「任何新的渲染之前」執(zhí)行。React總是會(huì)在「開(kāi)始新的更新之前刷新前一個(gè)渲染」的effect。
如果 useLayoutEffect 觸發(fā)state更新時(shí),那么effect必須在那次更新之前被刷新,即在繪制之前。下面是一個(gè)時(shí)間軸:
圖片
- React 更新 1:渲染虛擬DOM,安排effect,更新DOM
- 調(diào)用 useLayoutEffect
- 更新state,安排重新渲染(re-render)
- 調(diào)用 useEffect
- React 更新 2
- 調(diào)用 useLayoutEffect 從更新 2
- React 釋放控制,瀏覽器繪制新的DOM
- 調(diào)用 useEffect 從更新 2
在瀏覽者中就會(huì)出現(xiàn)如下的瀑布流。
圖片
上面的案例說(shuō)明了,useLayoutEffect可以在繪制之前強(qiáng)制提前刷新effect。而像
- ref <div ref={HERE}>
- requestAnimationFrame
- 從 useLayoutEffect 調(diào)度的微任務(wù)
也會(huì)觸發(fā)相同的行為。
如果,我們不想在useLayoutEffect強(qiáng)制刷新useEffect。我們可以跳過(guò)狀態(tài)更新。
使用ref直接對(duì)DOM進(jìn)行修改。這樣,React不會(huì)安排更新,也不需要急切地刷新effect。
const clearRef = useRef();
const measure = () => {
// 不用擔(dān)心 react,我會(huì)處理的:
clearRef.current.display = el.current.offsetWidth > 200 ? null : 'none';
};
useLayoutEffect(() => measure(), []);
useEffect(() => {
window.addEventListener("resize", measure);
return () => window.removeEventListener("resize", measure);
}, []);
return (
<label>
<input {...props} ref={el} />
<button ref={clearRef} onClick={onClear}>clear</button>
</label>
);
6. 在 Next.js 和其他 SSR 框架中使用 useLayoutEffect
當(dāng)我們將使用useLayoutEffect處理過(guò)的自適應(yīng)導(dǎo)航組件寫(xiě)入到任何一個(gè)SSR框架時(shí),你會(huì)發(fā)現(xiàn)它還是會(huì)產(chǎn)生閃爍現(xiàn)象。
當(dāng)我們啟用了 SSR 時(shí),意味著在后端的某個(gè)地方調(diào)用類(lèi)似React.renderToString(<App />)的東西。然后,React 遍歷應(yīng)用中的所有組件,“渲染”它們(即調(diào)用它們的函數(shù),它們畢竟只是函數(shù)),然后生成這些組件表示的 HTML。
圖片
然后,將此 HTML 注入要發(fā)送到瀏覽器的頁(yè)面中,「一切都在服務(wù)器上生成」。之后,瀏覽器下載頁(yè)面,向我們顯示頁(yè)面,下載所有腳本(包括 React),隨后運(yùn)行它們,React 通過(guò)預(yù)生成的 HTML,為其注入一些互動(dòng)效果,我們的頁(yè)面就會(huì)變的有交互性了。
問(wèn)題在于:在我們生成初始 HTML 時(shí),還沒(méi)有瀏覽器。因此,任何涉及計(jì)算元素實(shí)際大小的操作(就像我們?cè)?nbsp;useLayoutEffect 中做的那樣)在服務(wù)器上將不起作用:只有字符串,而沒(méi)有具有尺寸的元素。而且由于 useLayoutEffect 的整個(gè)目的是獲得對(duì)元素大小的訪(fǎng)問(wèn)權(quán),因此在服務(wù)器上運(yùn)行它沒(méi)有太多意義。
因此,我們?cè)跒g覽器顯示我們的頁(yè)面之前在“第一次通過(guò)”階段渲染的內(nèi)容就是在我們組件中渲染的內(nèi)容:所有按鈕的一行,包括“更多”按鈕。在瀏覽器有機(jī)會(huì)執(zhí)行所有內(nèi)容并使 React 變得活躍之后,它最終可以運(yùn)行 useLayoutEffect,最終按鈕才會(huì)隱藏。但視覺(jué)故障依然存在。
如何解決這個(gè)問(wèn)題涉及用戶(hù)體驗(yàn)問(wèn)題,完全取決于我們想“默認(rèn)”向用戶(hù)展示什么。我們可以向他們顯示一些“加載”狀態(tài)而不是菜單?;蛘咧伙@示一兩個(gè)最重要的菜單項(xiàng)。或者甚至完全隱藏項(xiàng)目,并僅在客戶(hù)端上渲染它們。這取決于你。
一種方法是引入一些shouldRender狀態(tài)變量,并在 useEffect 中將其變?yōu)閠rue:
const Component = () => {
const [shouldRender, setShouldRender] = useState(false);
useEffect(() => {
setShouldRender(true);
}, []);
if (!shouldRender) return <SomeNavigationSubstitute />;
return <Navigation />;
};
useEffect 只會(huì)在客戶(hù)端運(yùn)行,因此初始 SSR 通過(guò)將向我們顯示替代組件。然后,客戶(hù)端代碼將介入,useEffect 將運(yùn)行,狀態(tài)將更改,React 將其替換為正常的響應(yīng)式導(dǎo)航。
Reference
[1]
觸發(fā)強(qiáng)制布局的操作:https://gist.github.com/paulirish/5d52fb081b3570c81e3a
[2]React 官方文檔:https://react.dev/reference/react/useLayoutEffect