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