ReactPortals傳送門
React Portals
提供了一種將子節點渲染到父組件以外的DOM
節點的解決方案,即允許將JSX
作爲children
渲染至DOM
的不同部分,最常見用例是子組件需要從視覺上脫離父容器,例如對話框、浮動工具欄、提示信息等。
描述
<div>
<SomeComponent />
{createPortal(children, domNode, key?)}
</div>
React Portals
可以翻譯爲傳送門,從字面意思上就可以理解爲我們可以通過這個方法將我們的React
組件傳送到任意指定的位置,可以將組件的輸出渲染到DOM
樹中的任意位置,而不僅僅是組件所在的DOM
層級內。舉個簡單的例子,假設我們ReactDOM.render
掛載組件的DOM
結構是<div id="root"></div>
,那麼對於同一個組件我們是否使用Portal
在整個DOM
節點上得到的效果是不同的:
export const App: FC = () => {
return (
<React.Fragment>
<div>123</div>
<div className="model">
<div>456</div>
</div>
</React.Fragment>
);
};
// ->
<body>
<div id="root">
<div>123</div>
<div class="model">
<div>456</div>
</div>
</div>
</body>
export const App: FC = () => {
return (
<React.Fragment>
<div>123</div>
{ReactDOM.createPortal(
<div className="model">
<div>456</div>
</div>,
document.body
)}
</React.Fragment>
);
};
// ->
<body>
<div id="root">
<div>123</div>
</div>
{/* `DOM`結構掛載到了`body`下 */}
<div class="model">
<div>456</div>
</div>
</body>
從上邊的例子中可以看到我們通過ReactDOM.createPortal
將React
組件掛載到了其他的DOM
結構下,在這裏是掛載到了document.body
下,當然這這也是最常見的做法,這樣我們就可以通過Portal
將組件傳送到目標渲染的位置,由此來更靈活地控制渲染的行爲,並解決一些複雜的UI
交互場景,通常我們可以封裝Portal
組件來更方便地調用。
export const Portal: React.FC = ({ children }) => {
return typeof document === "object" ? ReactDOM.createPortal(children, document.body) : null;
};
export const App: FC = () => (
<Portal>
<SomeComponent />
</Portal>
);
之前我們也聊到了,使用Portals
最常見的場景就是對話框,或者可以認爲是浮動在整個頁面頂部的組件,這樣的組件在DOM
結構上是脫離了父組件的,我們當然可以自行實現相關的能力,例如主動創建一個div
結構掛載到目標DOM
結構下例如document.body
下,然後利用ReactDOM.render
將組建渲染到相關結構中,在組件卸載時再將創建的div
移除,這個方案當然是可行的但是並沒有那麼優雅。當然還有一個方法是使用狀態管理,在目標組件中事先定義好相關的組件,通過狀態管理例如redux
來控制顯隱,這種就是純粹的高射炮打蚊子,就沒有必要再展開了。
其實我們再想一想,既然我們是要脫離父組件結構來實現這個能力,那麼我們沒有必要非得使用Portals
,CSS
的position
定位不是也可以幫助我們將當前的DOM
結構脫離文檔流,也就是說我們沒必要將目標組件的DOM
結構實際地分離出來,只需要藉助position
定位就可以實現效果。當然想法是很美好的,真實場景就變得複雜的多了,那麼脫離文檔流最常用的主要是絕對定位absolute
與固定定位fixed
。首先我們來看一下absolute
,那麼我們使用absolute
其實很容易想到,我們需要從當前組件一直到body
都沒有其他position
是relative/absolute
的元素,這個條件肯定是很難達到的,特別是如果我們寫的是一個組件庫的話,很難控制用戶究竟套了多少層以及究竟用什麼CSS
屬性。那麼此時我們再將目光聚焦到fixed
上,fixed
是相對於視口來定位的,那麼也就不需要像是absolute
那麼強的要求了,即使是父元素存在relative/absolute
也沒有關係。當然這件事沒有這麼簡單,即使是fixed
元素依舊可能會受到父元素樣式的影響,在這裏舉兩個例子,分別是transform
與z-index
。
<!-- 不斷改變`transform: translateY(20px);`的值 `fixed`的元素也在不斷隨之變化 -->
<div style="transform: translateY(20px);">
<div style="position: fixed; left: 10px; top: 10px;">
<div style="background-color: blue; width: 10px; height: 10px;"></div>
</div>
</div>
<!-- 父級元素的`z-index`的層次比同級元素低 即使`fixed`元素`z-index`比父級高 也會被父級同級元素遮擋 -->
<div
style="position: absolute; z-index: 100; width: 100px; height: 100px; background-color: #fff;"
></div>
<div style="position: absolute; z-index: 1">
<div style="position: fixed; left: 10px; top: 10px; z-index: 1000">
<div style="background-color: blue; width: 10px; height: 10px"></div>
</div>
</div>
從上邊的例子中我們可以看出,我們僅僅使用CSS
的position
定位是無法做到完全脫離父組件的,即使我們能夠達到脫離文檔流的效果,也會因爲父組件的樣式而受到影響,特別是在組件庫中,我們作爲第三方組件庫的話是完全沒有辦法控制用戶設計的DOM
結構的,如果僅僅採用脫離文檔流的方法而不實際將DOM
結構分離出來的話,那麼我們的組件就會受到用戶樣式的影響,這是我們不希望看到的。此外,即使我們並不是設計組件庫,而僅僅是在我們的業務中實現相關需求,我們也不希望我們的組件受到父組件的影響,因爲即使最開始我們的結構和樣式沒出現問題,隨着業務越來越複雜,特別是多人協作開發項目,就很容易留下隱患,造成一些不必要的問題,當然我們可以引入E2E
來避免相關問題,這就是另一方面的解決方案了。
綜上,React Portals
提供了一種更靈活地控制渲染的行爲,可以用於解決一些複雜的UI
交互場景,下面是一些常見的應用場景:
- 模態框和對話框: 使用
Portals
可以將模態框或對話框組件渲染到DOM
樹的頂層,確保其可以覆蓋其他組件,並且在層級上獨立於其他組件,這樣可以避免CSS
或z-index
屬性的複雜性,並且在組件層級之外創建一個乾淨的容器。 - 與第三方庫的集成: 有時候,我們可能需要將
React
組件與第三方庫(例如地圖庫或視頻播放器)集成,使用Portals
可以將組件渲染到第三方庫所需的DOM
元素中,即將業務需要的額外組件渲染到原組件封裝好的DOM
結構中,以確保組件在正確的位置和上下文中運行。 - 邏輯分離和組件複用:
Portals
允許我們將組件的渲染輸出與組件的邏輯分離,我們可以將組件的渲染輸出定義在一個單獨的Portal
組件中,並在需要的地方使用該Portal
,這樣可以實現組件的複用,並且可以更好地組織和管理代碼。 - 處理層疊上下文: 在某些情況下,使用
Portals
可以幫助我們解決層疊上下文stacking context
的問題,由於Portals
可以創建獨立的DOM
渲染容器,因此可以避免由於層疊上下文導致的樣式和佈局問題。
MouseEnter事件
即使React Portals
可以將組件傳送到任意的DOM
節點中,但是其行爲和普通的React
組件一樣,其並不會脫離原本的React
組件樹,這其實是一件非常有意思的事情,因爲這樣會看起來,我們可以利用這個特性來實現比較複雜的交互。但是在這之前,我們來重新看一下MouseEnter
與MouseLeave
以及對應的MouseOver
與MouseOut
的原生DOM
事件。
MouseEnter
: 當鼠標光標進入一個元素時觸發,該事件僅在鼠標從元素的外部進入時觸發,不會對元素內部的子元素產生影響。例如,如果有一個嵌套的DOM
結構<div id="a"><div id="b"></div></div>
,此時我們在元素a
上綁定了MouseEnter
事件,當鼠標從該元素外部移動到內部時,MouseEnter
事件將被觸發,而當我們再將鼠標移動到b
元素時,不會再次觸發MouseEnter
事件。MouseLeave
:當鼠標光標離開一個元素時觸發,該事件僅在鼠標從元素內部離開時觸發,不會對元素外部的父元素產生影響。例如,如果有一個嵌套的DOM
結構<div id="a"><div id="b"></div></div>
,此時我們在元素a
上綁定了MouseEnter
事件,當鼠標從該元素內部移動到外部時,MouseLeave
事件將被觸發,而如果此時我們的鼠標是從b
元素移出到a
元素內,不會觸發MouseEnter
事件。MouseOver
: 當鼠標光標進入一個元素時觸發,該事件在鼠標從元素的外部進入時觸發,並且會冒泡到父元素。例如,如果有一個嵌套的DOM
結構<div id="a"><div id="b"></div></div>
,此時我們在元素a
上綁定了MouseOver
事件,當鼠標從該元素外部移動到內部時,MouseOver
事件將被觸發,而當我們再將鼠標移動到b
元素時,由於冒泡會再次觸發綁定在a
元素上的MouseOver
事件,再從b
元素移出到a
元素時會再次觸發MouseOver
事件。MouseOut
: 當鼠標光標離開一個元素時觸發,該事件在鼠標從元素內部離開時觸發,並且會冒泡到父元素。例如,如果有一個嵌套的DOM
結構<div id="a"><div id="b"></div></div>
,此時我們在元素a
上綁定了MouseOut
事件,當鼠標從該元素內部移動到外部時,MouseOut
事件將被觸發,而如果此時我們的鼠標是從b
元素移出到a
元素內,由於冒泡會同樣觸發綁定在MouseOut
事件,再從a
元素移出到外部時,同樣會再次觸發MouseOut
事件。
需要注意的是MouseEnter/MouseLeave
是在捕獲階段執行事件處理函數的,而不能在冒泡階段過程中進行,而MouseOver/MouseOut
是可以在捕獲階段和冒泡階段選擇一個階段來執行事件處理函數的,這個就看在addEventListener
如何處理了。實際上兩種事件流都是可以阻斷的,只不過MouseEnter/MouseLeave
需要在捕獲階段來stopPropagation
,一般情況下是不需要這麼做的。我個人還是比較推薦使用MouseEnter/MouseLeave
,主要有這麼幾點理由:
- 避免冒泡問題:
MouseEnter
和MouseLeave
事件不會冒泡到父元素或其他元素,只在鼠標進入或離開元素本身時觸發,這意味着我們可以更精確地控制事件的觸發範圍,更準確地處理鼠標交互,而不會受到其他元素的干擾,提供更好的用戶體驗。 - 避免重複觸發:
MouseOver
和MouseOut
事件在鼠標懸停在元素內部時會重複觸發,當鼠標從一個元素移動到其子元素時,MouseOut
事件會在父元素觸發一次,然後在子元素觸發一次,MouseOut
事件也是同樣會多次觸發,可以將父元素與所有子元素都看作獨立區域,而事件會冒泡到父元素來執行事件綁定函數,這可能導致重複的事件處理和不必要的邏輯觸發,而MouseEnter
和MouseLeave
事件不會重複觸發,只在鼠標進入或離開元素時觸發一次。 - 簡化交互邏輯:
MouseEnter
和MouseLeave
事件的特性使得處理鼠標移入和移出的交互邏輯變得更直觀和簡化,我們可以僅關注元素本身的進入和離開,而不需要處理父元素或子元素的事件,這種簡化有助於提高代碼的可讀性和可維護性。
當然究竟使用MouseEnter/MouseLeave
還是MouseEnter/MouseLeave
事件還是要看具體的業務場景,如果需要處理鼠標移入和移出元素的子元素時或者需要利用冒泡機制來實現功能,那麼MouseOver
和MouseOut
事件就是更好的選擇,MouseEnter/MouseLeave
能提供更大的靈活性和控制力,讓我們能夠創建複雜的交互效果,並更好地處理用戶與元素的交互,當然應用的複雜性也會相應提高。
讓我們回到MouseEnter/MouseLeave
事件本身上,在這裏https://codesandbox.io/p/sandbox/trigger-component-1hv99o?file=/src/components/mouse-enter-test.tsx:1,1
提供了一個事件的DEMO
可以用來測試事件效果。需要注意的是,在這裏我們是藉助於React
的合成事件來測試的,而在測試的時候也可以比較明顯地發現MouseEnter/MouseLeave
的TS
提示是沒有Capture
這個選項的,例如Click
事件是有onClick
與onClickCapture
來表示冒泡和捕獲階段事件綁定的,而即使是在React
合成事件中MouseEnter/MouseLeave
也只會在捕獲階段執行,所以沒有Capture
事件綁定屬性。
--------------------------
| c | b | a |
| | | |
|------- | |
| | |
|---------------- |
| |
--------------------------
我們分別在三個DOM
上都綁定了MouseEnter
事件,當我們鼠標移動到a
上時,會執行a
元素綁定的事件,當依次將鼠標移動到a
、b
、c
的時候,同樣會以此執行a
、b
、c
的事件綁定函數,並且不會因爲冒泡事件導致父元素事件的觸發,當我們鼠標直接移動到c
的時候,可以看到依舊是按照a
、b
、c
的順序執行,也可以看出來MouseEnter
事件是依賴於捕獲階段執行的。
Portal事件
在前邊也提到了,儘管React Portals
可以被放置在DOM
樹中的任何地方,但在任何其他方面,其行爲和普通的React
子節點行爲一致。我們都知道React
自行維護了一套基於事件代理的合成事件,那麼由於Portal
仍存在於原本的React
組件樹中,這樣就意味着我們的React
事件實際上還是遵循原本的合成事件規則而與DOM
樹中的位置無關,那麼我們就可以認爲其無論其子節點是否是Portal
,像合成事件、Context
這樣的功能特性都是不變的,下面是一些使用React Portals
需要關注的點:
- 事件冒泡會正常工作: 合成事件將通過冒泡傳播到
React
樹的祖先,事件冒泡將按預期工作,而與DOM
中的Portal
節點位置無關。 React
以控制Portal
節點及其生命週期:Portal
未脫離React
組件樹,當通過Portal
渲染子組件時,React
仍然可以控制組件的生命週期。Portal
隻影響DOM
結構: 對於React
來說Portal
僅僅是視覺上渲染的位置變了,只會影響HTML
的DOM
結構,而不會影響React
組件樹。- 預定義的
HTML
掛載點: 使用React Portal
時,我們需要提前定義一個HTML DOM
元素作爲Portal
組件的掛載。
在這裏https://codesandbox.io/p/sandbox/trigger-component-1hv99o?file=/src/components/portal-test.tsx:1,1
提供了一個Portals
與MouseEnter
事件的DEMO
可以用來測試效果。那麼在代碼中實現的嵌套精簡如下:
-------------------
| a |
| ------|------ --------
| | | b | | c |
| | | | | |
| | | | --------
| ------|------
-------------------
const C = ReactDOM.createPortal(<div onMouseEnter={e => console.log("c", e)}></div>, document.body);
const B = ReactDOM.createPortal(
<React.Fragment>
<div onMouseEnter={e => console.log("b", e)}>
{C}
</div>
</React.Fragment>,
document.body
);
const App = (
<React.Fragment>
<div onMouseEnter={e => console.log("a", e)}></div>
{B}
</React.Fragment>
);
// ==>
const App = (
<React.Fragment>
<div onMouseEnter={e => console.log("a", e)}></div>
{ReactDOM.createPortal(
<React.Fragment>
<div onMouseEnter={e => console.log("b", e)}>
{ReactDOM.createPortal(
<div onMouseEnter={e => console.log("c", e)}></div>,
document.body
)}
</div>
</React.Fragment>,
document.body
)}
</React.Fragment>
);
單純從代碼上來看,這就是一個很簡單的嵌套結構,而因爲傳送門Portals
的存在,在真實的DOM
結構上,這段代碼結構表現的效果是這樣的,其中id
只是用來標識React
的DOM
結構,實際並不存在:
<body>
<div id="root">
<div id="a"></div>
</div>
<div id="b"></div>
<div id="c"></div>
<div>
</body>
接下來我們依次來試試定義的MouseEnter
事件觸發情況,首先鼠標移動到a
元素上,控制檯打印a
,符合預期,接下來鼠標移動到b
元素上,控制檯打印b
,同樣符合預期,那麼接下來將鼠標移動到c
,神奇的事情來了,我們會發現會先打印b
再打印c
,而不是僅僅打印了c
,由此我們可以得到雖然看起來DOM
結構不一樣了,但是在React
樹中合成事件依然保持着嵌套結構,C
組件作爲B
組件的子元素,在事件捕獲時依然會從B -> C
觸發MouseEnter
事件,基於此我們可以實現非常有意思的一件事情,多級嵌套的彈出層。
Trigger彈出層
實際上上邊聊的內容都是都是爲這部分內容做鋪墊的,因爲工作的關係我使用ArcoDesign
是非常多的,又由於我實際是做富文本文檔的,需要彈出層來做交互的地方就非常多,所以在平時的工作中會大量使用ArcoDesign
的Trigger
組件https://arco.design/react/components/trigger
,之前我一直非常好奇這個組件的實現,這個組件可以無限層級地嵌套,而且當多級彈出層組件的最後一級鼠標移出之後,所有的彈出層都會被關閉,最主要的是我們只是將其嵌套做了一層業務實現,並沒有做任何的通信傳遞,所以我也一直好奇這部分的實現,直到前一段時間我爲了解決BUG
深入研究了一下相關實現,發現其本質還是利用React Portals
以及React
樹的合成事件來完成的,這其中還是有很多交互實現可以好好學習下的。
同樣的,在這裏也完成了一個DEMO
實現https://codesandbox.io/p/sandbox/trigger-component-1hv99o?file=/src/components/trigger-simple.tsx:1,1
,而在調用時,則直接嵌套即可實現兩層彈出層,當我們鼠標移動到a
元素時,b
元素與c
元素會展示出來,當我們將鼠標移動到c
元素時,d
元素會被展示出來,當我們繼續將鼠標快速移動到d
元素時,所有的彈出層都不會消失,當我們直接將鼠標從d
元素移動到空白區域時,所有的彈出層都會消失,如果我們將其移動到b
元素,那麼只有d
元素會消失。
------------------- ------------- --------
| a | | b | | d |
| | |-------- | | |
| | | c | | --------
| | |-------- |
| | -------------
| |
-------------------
<TriggerSimple
duration={200}
popup={() => (
<div id="b" style={{ height: 100, width: 100, backgroundColor: "green" }}>
<TriggerSimple
popup={() => <div id="d" style={{ height: 50, width: 50, backgroundColor: "blue" }}></div>}
duration={200}
>
<div id="c" style={{ paddingTop: 20 }}>Hover</div>
</TriggerSimple>
</div>
)}
>
<div id="a" style={{ height: 150, width: 150, backgroundColor: "red" }}></div>
</TriggerSimple>
讓我們來拆解一下代碼實現,首先是Portal
組件的封裝,在這裏我們就認爲我們將要掛載的組件是在document.body
上的就可以了,因爲我們要做的是彈出層,在最開始的時候也闡明瞭我們的彈出層DOM
結構需要掛在最外層而不能直接嵌套地放在DOM
結構中,當然如果能夠保證不會出現相關問題,滾動容器不是body
的情況且需要position absolute
的情況下,可以通過getContainer
傳入DOM
節點來制定傳送的位置,當然在這裏我們認爲是body
就可以了。在下面這段實現中我們就通過封裝Portal
組件來調度DOM
節點的掛載和卸載,並且實際的組件也會被掛載到我們剛創建的節點上。
// trigger-simple.tsx
getContainer = () => {
const popupContainer = document.createElement("div");
popupContainer.style.width = "100%";
popupContainer.style.position = "absolute";
popupContainer.style.top = "0";
popupContainer.style.left = "0";
this.popupContainer = popupContainer;
this.appendToContainer(popupContainer);
return popupContainer;
};
// portal.tsx
const Portal = (props: PortalProps) => {
const { getContainer, children } = props;
const containerRef = useRef<HTMLElement | null>(null);
const isFirstRender = useIsFirstRender();
if (isFirstRender || containerRef.current === null) {
containerRef.current = getContainer();
}
useEffect(() => {
return () => {
const container = containerRef.current;
if (container && container.parentNode) {
container.parentNode.removeChild(container);
containerRef.current = null;
}
};
}, []);
return containerRef.current
? ReactDOM.createPortal(children, containerRef.current)
: null;
};
接下來我們來看構造在React
樹中的DOM
結構,這塊可以說是整個實現的精髓,可能會比較繞,可以認爲實際上每個彈出層都分爲了兩塊,一個是原本的child
,另一個是彈出的portal
,這兩個結構是平行的放在React DOM
樹中的,那麼在多級彈出層之後,實際上每個子trigger(portal + child)
都是上層portal
的children
,這個結構可以用一個樹形結構來表示。
<React.Fragment>
{childrenComponent}
{portal}
</React.Fragment>
ROOT
/ \
A(portal) A(child)
/ \
B(portal) B(child)
/ \
C(portal) C(child)
/ \
..... .....
<body>
<div id="root">
<!-- ... -->
<div id="A-child"></div>
<!-- ... -->
</div>
<div id="A-portal">
<div id="B-child"></div>
</div>
<div id="B-portal">
<div id="C-child"></div>
</div>
<div id="C-portal">
<!-- ... -->
</div>
</body>
從樹形結構中我們可以看出來,雖然在DOM
結構中我們現實出來是平鋪的結構,但是在React
的事件樹中卻依舊保持着嵌套結構,那麼我們就很容易解答最開始的一個問題,爲什麼我們可以無限層級地嵌套,而且當多級彈出層組件的最後一級鼠標移出之後,所有的彈出層都會被關閉,就是因爲實際上即使我們的鼠標在最後一級,但是在React
樹結構中其依舊是屬於所有portal
的子元素,既然其是child
那麼實際上我們可以認爲其並沒有移出各級trigger
的元素,自然不會觸發MouseLeave
事件來關閉彈出層,如果我們移出了最後一級彈出層到空白區域,那麼相當於我們移出了所有trigger
實例的portal
元素區域,自然會觸發所有綁定的MouseLeave
事件來關閉彈出層。
那麼雖然上邊我們雖然解釋了Trigger
組件爲什麼能夠維持無限嵌套層級結構下能夠維持彈出層的顯示,並且在最後一級鼠標移出之後能夠關閉所有彈出層,或者從最後一級返回到上一級只關閉最後一級彈出層,但是我們還有一個問題沒有想明白,上邊的問題是因爲所有的trigger
彈出層實例都是上一級trigger
彈出層實例的子元素,那麼我們還有一個平級的portal
與child
元素呢,當我們鼠標移動到child
時,portal
元素會展示出來,而此時我們將鼠標移動到portal
元素時,這個portal
元素並不會消失,而是會一直保持顯示,在這裏的React
樹是不存在嵌套結構的,所以這裏需要對事件進行特殊處理。
onMouseEnter = (e: React.SyntheticEvent<HTMLDivElement, MouseEvent>) => {
console.log("onMouseEnter", this.childrenDom);
const mouseEnterDelay = this.props.duration;
this.clearDelayTimer();
his.setPopupVisible(true, mouseEnterDelay || 0);
};
onMouseLeave = (e: React.SyntheticEvent<HTMLDivElement, MouseEvent>) => {
console.log("onMouseLeave", this.childrenDom);
const mouseLeaveDelay = this.props.duration;
this.clearDelayTimer();
if (this.state.popupVisible) {
this.setPopupVisible(false, mouseLeaveDelay || 0);
}
};
onPopupMouseEnter = () => {
console.log("onPopupMouseEnter", this.childrenDom);
this.clearDelayTimer();
};
onPopupMouseLeave = (e: React.SyntheticEvent<HTMLDivElement, MouseEvent>) => {
console.log("onPopupMouseLeave", this.childrenDom);
const mouseLeaveDelay = this.props.duration;
this.clearDelayTimer();
if (this.state.popupVisible) {
this.setPopupVisible(false, mouseLeaveDelay || 0);
}
};
setPopupVisible = (visible: boolean, delay = 0, callback?: () => void) => {
onst currentVisible = this.state.popupVisible;
if (visible !== currentVisible) {
this.delayToDo(delay, () => {
if (visible) {
this.setState({ popupVisible: true }, () => {
this.showPopup(callback);
});
} else {
this.setState({ popupVisible: false }, () => {
callback && callback();
});
}
});
} else {
callback && callback();
}
};
delayToDo = (delay: number, callback: () => void) => {
if (delay) {
this.clearDelayTimer();
this.delayTimer = setTimeout(() => {
callback();
this.clearDelayTimer();
}, delay);
} else {
callback();
}
};
實際上在這裏的通信會比較簡單,之前我們也提到portal
與child
元素是平級的,那麼我們可以明顯地看出來實際上這是在一個組件內的,那麼整體的實現就會簡單很多,我們可以設計一個延時,並且可以爲portal
和child
分別綁定MouseEnter
和MouseLeave
事件,在這裏我們爲child
綁定的是onMouseEnter
和onMouseLeave
兩個事件處理函數,爲portal
綁定了onPopupMouseEnter
和onPopupMouseLeave
兩個事件處理函數。那麼此時我們模擬一下上邊的情況,當我們鼠標移入child
元素時,會觸發onMouseEnter
事件處理函數,此時我們會清除掉delayTimer
,然後會調用setPopupVisible
方法,此時會將popupVisible
設置爲true
然後顯示出portal
,那麼此時重點來了,我們這裏實際上會有一個delay
的延時,也就是說實際上當我們移出元素時,在delay
時間之後纔會將元素真正的隱藏,那麼如果此時我們將鼠標再移入到portal
,觸發onPopupMouseEnter
事件時調用clearDelayTimer
清除掉delayTimer
,那麼我們就可以阻止元素的隱藏,那麼再往後的嵌套彈出層無論是child
還是portal
本身依舊是上一層portal
的子元素,即使是在子portal
與子child
之間切換也可以利用clearDelayTimer
來阻止元素的隱藏,所以之後的彈出層就可以利用這種方式遞歸處理就可以實現無限嵌套了。我們可以將DEMO
中鼠標從a -> b -> c -> d -> empty
事件打印出來:
onMouseEnter a
onMouseLeave a
onPopupMouseEnter b
onMouseEnter c
onMouseLeave c
onPopupMouseLeave b
onPopupMouseEnter b
onPopupMouseEnter d
onPopupMouseLeave d
onPopupMouseLeave b
至此我們探究了Trigger
組件的實現,當然在實際的處理過程中還有相當多的細節需要處理,例如位置計算、動畫、事件處理等等等等,而且實際上這個組件也有很多我們可以學習的地方,例如如何將外部傳遞的事件處理函數交予children
、React.Children.map
、React.isValidElement
、React.cloneElement
等方法的使用等等,也都是非常有意思的實現。
const getWrappedChildren = () => {
return React.Children.map(children, child => {
if (React.isValidElement(child)) {
const { props } = child;
return React.cloneElement(child, {
...props,
onMouseEnter: mouseEnterHandler,
onMouseLeave: mouseLeaveHandler,
});
} else {
return child;
}
});
};
每日一題
https://github.com/WindrunnerMax/EveryDay
參考
https://zhuanlan.zhihu.com/p/29880992
https://juejin.cn/post/6844904024378982413
https://juejin.cn/post/6904979968413925384
https://segmentfault.com/a/1190000012325351
https://zh-hans.legacy.reactjs.org/docs/portals.html
https://codesandbox.io/p/sandbox/trigger-component-1hv99o
https://zh-hans.react.dev/reference/react-dom/createPortal
https://github.com/arco-design/arco-design/blob/main/components/Trigger/index.tsx