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