微信小程序開通要錢嗎seo超級外鏈發(fā)布
ReactPortals傳送門
React Portals
提供了一種將子節(jié)點(diǎn)渲染到父組件以外的DOM
節(jié)點(diǎn)的解決方案,即允許將JSX
作為children
渲染至DOM
的不同部分,最常見用例是子組件需要從視覺上脫離父容器,例如對話框、浮動(dòng)工具欄、提示信息等。
描述
<div><SomeComponent />{createPortal(children, domNode, key?)}
</div>
React Portals
可以翻譯為傳送門,從字面意思上就可以理解為我們可以通過這個(gè)方法將我們的React
組件傳送到任意指定的位置,可以將組件的輸出渲染到DOM
樹中的任意位置,而不僅僅是組件所在的DOM
層級內(nèi)。舉個(gè)簡單的例子,假設(shè)我們ReactDOM.render
掛載組件的DOM
結(jié)構(gòu)是<div id="root"></div>
,那么對于同一個(gè)組件我們是否使用Portal
在整個(gè)DOM
節(jié)點(diǎn)上得到的效果是不同的:
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`結(jié)構(gòu)掛載到了`body`下 */}<div class="model"><div>456</div></div>
</body>
從上邊的例子中可以看到我們通過ReactDOM.createPortal
將React
組件掛載到了其他的DOM
結(jié)構(gòu)下,在這里是掛載到了document.body
下,當(dāng)然這這也是最常見的做法,這樣我們就可以通過Portal
將組件傳送到目標(biāo)渲染的位置,由此來更靈活地控制渲染的行為,并解決一些復(fù)雜的UI
交互場景,通常我們可以封裝Portal
組件來更方便地調(diào)用。
export const Portal: React.FC = ({ children }) => {return typeof document === "object" ? ReactDOM.createPortal(children, document.body) : null;
};export const App: FC = () => (<Portal><SomeComponent /></Portal>
);
之前我們也聊到了,使用Portals
最常見的場景就是對話框,或者可以認(rèn)為是浮動(dòng)在整個(gè)頁面頂部的組件,這樣的組件在DOM
結(jié)構(gòu)上是脫離了父組件的,我們當(dāng)然可以自行實(shí)現(xiàn)相關(guān)的能力,例如主動(dòng)創(chuàng)建一個(gè)div
結(jié)構(gòu)掛載到目標(biāo)DOM
結(jié)構(gòu)下例如document.body
下,然后利用ReactDOM.render
將組建渲染到相關(guān)結(jié)構(gòu)中,在組件卸載時(shí)再將創(chuàng)建的div
移除,這個(gè)方案當(dāng)然是可行的但是并沒有那么優(yōu)雅。當(dāng)然還有一個(gè)方法是使用狀態(tài)管理,在目標(biāo)組件中事先定義好相關(guān)的組件,通過狀態(tài)管理例如redux
來控制顯隱,這種就是純粹的高射炮打蚊子,就沒有必要再展開了。
其實(shí)我們再想一想,既然我們是要脫離父組件結(jié)構(gòu)來實(shí)現(xiàn)這個(gè)能力,那么我們沒有必要非得使用Portals
,CSS
的position
定位不是也可以幫助我們將當(dāng)前的DOM
結(jié)構(gòu)脫離文檔流,也就是說我們沒必要將目標(biāo)組件的DOM
結(jié)構(gòu)實(shí)際地分離出來,只需要借助position
定位就可以實(shí)現(xiàn)效果。當(dāng)然想法是很美好的,真實(shí)場景就變得復(fù)雜的多了,那么脫離文檔流最常用的主要是絕對定位absolute
與固定定位fixed
。首先我們來看一下absolute
,那么我們使用absolute
其實(shí)很容易想到,我們需要從當(dāng)前組件一直到body
都沒有其他position
是relative/absolute
的元素,這個(gè)條件肯定是很難達(dá)到的,特別是如果我們寫的是一個(gè)組件庫的話,很難控制用戶究竟套了多少層以及究竟用什么CSS
屬性。那么此時(shí)我們再將目光聚焦到fixed
上,fixed
是相對于視口來定位的,那么也就不需要像是absolute
那么強(qiáng)的要求了,即使是父元素存在relative/absolute
也沒有關(guān)系。當(dāng)然這件事沒有這么簡單,即使是fixed
元素依舊可能會(huì)受到父元素樣式的影響,在這里舉兩個(gè)例子,分別是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`比父級高 也會(huì)被父級同級元素遮擋 -->
<divstyle="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
定位是無法做到完全脫離父組件的,即使我們能夠達(dá)到脫離文檔流的效果,也會(huì)因?yàn)楦附M件的樣式而受到影響,特別是在組件庫中,我們作為第三方組件庫的話是完全沒有辦法控制用戶設(shè)計(jì)的DOM
結(jié)構(gòu)的,如果僅僅采用脫離文檔流的方法而不實(shí)際將DOM
結(jié)構(gòu)分離出來的話,那么我們的組件就會(huì)受到用戶樣式的影響,這是我們不希望看到的。此外,即使我們并不是設(shè)計(jì)組件庫,而僅僅是在我們的業(yè)務(wù)中實(shí)現(xiàn)相關(guān)需求,我們也不希望我們的組件受到父組件的影響,因?yàn)榧词棺铋_始我們的結(jié)構(gòu)和樣式?jīng)]出現(xiàn)問題,隨著業(yè)務(wù)越來越復(fù)雜,特別是多人協(xié)作開發(fā)項(xiàng)目,就很容易留下隱患,造成一些不必要的問題,當(dāng)然我們可以引入E2E
來避免相關(guān)問題,這就是另一方面的解決方案了。
綜上,React Portals
提供了一種更靈活地控制渲染的行為,可以用于解決一些復(fù)雜的UI
交互場景,下面是一些常見的應(yīng)用場景:
- 模態(tài)框和對話框: 使用
Portals
可以將模態(tài)框或?qū)υ捒蚪M件渲染到DOM
樹的頂層,確保其可以覆蓋其他組件,并且在層級上獨(dú)立于其他組件,這樣可以避免CSS
或z-index
屬性的復(fù)雜性,并且在組件層級之外創(chuàng)建一個(gè)干凈的容器。 - 與第三方庫的集成: 有時(shí)候,我們可能需要將
React
組件與第三方庫(例如地圖庫或視頻播放器)集成,使用Portals
可以將組件渲染到第三方庫所需的DOM
元素中,即將業(yè)務(wù)需要的額外組件渲染到原組件封裝好的DOM
結(jié)構(gòu)中,以確保組件在正確的位置和上下文中運(yùn)行。 - 邏輯分離和組件復(fù)用:
Portals
允許我們將組件的渲染輸出與組件的邏輯分離,我們可以將組件的渲染輸出定義在一個(gè)單獨(dú)的Portal
組件中,并在需要的地方使用該Portal
,這樣可以實(shí)現(xiàn)組件的復(fù)用,并且可以更好地組織和管理代碼。 - 處理層疊上下文: 在某些情況下,使用
Portals
可以幫助我們解決層疊上下文stacking context
的問題,由于Portals
可以創(chuàng)建獨(dú)立的DOM
渲染容器,因此可以避免由于層疊上下文導(dǎo)致的樣式和布局問題。
MouseEnter事件
即使React Portals
可以將組件傳送到任意的DOM
節(jié)點(diǎn)中,但是其行為和普通的React
組件一樣,其并不會(huì)脫離原本的React
組件樹,這其實(shí)是一件非常有意思的事情,因?yàn)檫@樣會(huì)看起來,我們可以利用這個(gè)特性來實(shí)現(xiàn)比較復(fù)雜的交互。但是在這之前,我們來重新看一下MouseEnter
與MouseLeave
以及對應(yīng)的MouseOver
與MouseOut
的原生DOM
事件。
MouseEnter
: 當(dāng)鼠標(biāo)光標(biāo)進(jìn)入一個(gè)元素時(shí)觸發(fā),該事件僅在鼠標(biāo)從元素的外部進(jìn)入時(shí)觸發(fā),不會(huì)對元素內(nèi)部的子元素產(chǎn)生影響。例如,如果有一個(gè)嵌套的DOM
結(jié)構(gòu)<div id="a"><div id="b"></div></div>
,此時(shí)我們在元素a
上綁定了MouseEnter
事件,當(dāng)鼠標(biāo)從該元素外部移動(dòng)到內(nèi)部時(shí),MouseEnter
事件將被觸發(fā),而當(dāng)我們再將鼠標(biāo)移動(dòng)到b
元素時(shí),不會(huì)再次觸發(fā)MouseEnter
事件。MouseLeave
:當(dāng)鼠標(biāo)光標(biāo)離開一個(gè)元素時(shí)觸發(fā),該事件僅在鼠標(biāo)從元素內(nèi)部離開時(shí)觸發(fā),不會(huì)對元素外部的父元素產(chǎn)生影響。例如,如果有一個(gè)嵌套的DOM
結(jié)構(gòu)<div id="a"><div id="b"></div></div>
,此時(shí)我們在元素a
上綁定了MouseEnter
事件,當(dāng)鼠標(biāo)從該元素內(nèi)部移動(dòng)到外部時(shí),MouseLeave
事件將被觸發(fā),而如果此時(shí)我們的鼠標(biāo)是從b
元素移出到a
元素內(nèi),不會(huì)觸發(fā)MouseEnter
事件。MouseOver
: 當(dāng)鼠標(biāo)光標(biāo)進(jìn)入一個(gè)元素時(shí)觸發(fā),該事件在鼠標(biāo)從元素的外部進(jìn)入時(shí)觸發(fā),并且會(huì)冒泡到父元素。例如,如果有一個(gè)嵌套的DOM
結(jié)構(gòu)<div id="a"><div id="b"></div></div>
,此時(shí)我們在元素a
上綁定了MouseOver
事件,當(dāng)鼠標(biāo)從該元素外部移動(dòng)到內(nèi)部時(shí),MouseOver
事件將被觸發(fā),而當(dāng)我們再將鼠標(biāo)移動(dòng)到b
元素時(shí),由于冒泡會(huì)再次觸發(fā)綁定在a
元素上的MouseOver
事件,再從b
元素移出到a
元素時(shí)會(huì)再次觸發(fā)MouseOver
事件。MouseOut
: 當(dāng)鼠標(biāo)光標(biāo)離開一個(gè)元素時(shí)觸發(fā),該事件在鼠標(biāo)從元素內(nèi)部離開時(shí)觸發(fā),并且會(huì)冒泡到父元素。例如,如果有一個(gè)嵌套的DOM
結(jié)構(gòu)<div id="a"><div id="b"></div></div>
,此時(shí)我們在元素a
上綁定了MouseOut
事件,當(dāng)鼠標(biāo)從該元素內(nèi)部移動(dòng)到外部時(shí),MouseOut
事件將被觸發(fā),而如果此時(shí)我們的鼠標(biāo)是從b
元素移出到a
元素內(nèi),由于冒泡會(huì)同樣觸發(fā)綁定在MouseOut
事件,再從a
元素移出到外部時(shí),同樣會(huì)再次觸發(fā)MouseOut
事件。
需要注意的是MouseEnter/MouseLeave
是在捕獲階段執(zhí)行事件處理函數(shù)的,而不能在冒泡階段過程中進(jìn)行,而MouseOver/MouseOut
是可以在捕獲階段和冒泡階段選擇一個(gè)階段來執(zhí)行事件處理函數(shù)的,這個(gè)就看在addEventListener
如何處理了。實(shí)際上兩種事件流都是可以阻斷的,只不過MouseEnter/MouseLeave
需要在捕獲階段來stopPropagation
,一般情況下是不需要這么做的。我個(gè)人還是比較推薦使用MouseEnter/MouseLeave
,主要有這么幾點(diǎn)理由:
- 避免冒泡問題:
MouseEnter
和MouseLeave
事件不會(huì)冒泡到父元素或其他元素,只在鼠標(biāo)進(jìn)入或離開元素本身時(shí)觸發(fā),這意味著我們可以更精確地控制事件的觸發(fā)范圍,更準(zhǔn)確地處理鼠標(biāo)交互,而不會(huì)受到其他元素的干擾,提供更好的用戶體驗(yàn)。 - 避免重復(fù)觸發(fā):
MouseOver
和MouseOut
事件在鼠標(biāo)懸停在元素內(nèi)部時(shí)會(huì)重復(fù)觸發(fā),當(dāng)鼠標(biāo)從一個(gè)元素移動(dòng)到其子元素時(shí),MouseOut
事件會(huì)在父元素觸發(fā)一次,然后在子元素觸發(fā)一次,MouseOut
事件也是同樣會(huì)多次觸發(fā),可以將父元素與所有子元素都看作獨(dú)立區(qū)域,而事件會(huì)冒泡到父元素來執(zhí)行事件綁定函數(shù),這可能導(dǎo)致重復(fù)的事件處理和不必要的邏輯觸發(fā),而MouseEnter
和MouseLeave
事件不會(huì)重復(fù)觸發(fā),只在鼠標(biāo)進(jìn)入或離開元素時(shí)觸發(fā)一次。 - 簡化交互邏輯:
MouseEnter
和MouseLeave
事件的特性使得處理鼠標(biāo)移入和移出的交互邏輯變得更直觀和簡化,我們可以僅關(guān)注元素本身的進(jìn)入和離開,而不需要處理父元素或子元素的事件,這種簡化有助于提高代碼的可讀性和可維護(hù)性。
當(dāng)然究竟使用MouseEnter/MouseLeave
還是MouseEnter/MouseLeave
事件還是要看具體的業(yè)務(wù)場景,如果需要處理鼠標(biāo)移入和移出元素的子元素時(shí)或者需要利用冒泡機(jī)制來實(shí)現(xiàn)功能,那么MouseOver
和MouseOut
事件就是更好的選擇,MouseEnter/MouseLeave
能提供更大的靈活性和控制力,讓我們能夠創(chuàng)建復(fù)雜的交互效果,并更好地處理用戶與元素的交互,當(dāng)然應(yīng)用的復(fù)雜性也會(huì)相應(yīng)提高。
讓我們回到MouseEnter/MouseLeave
事件本身上,在這里https://codesandbox.io/p/sandbox/trigger-component-1hv99o?file=/src/components/mouse-enter-test.tsx:1,1
提供了一個(gè)事件的DEMO
可以用來測試事件效果。需要注意的是,在這里我們是借助于React
的合成事件來測試的,而在測試的時(shí)候也可以比較明顯地發(fā)現(xiàn)MouseEnter/MouseLeave
的TS
提示是沒有Capture
這個(gè)選項(xiàng)的,例如Click
事件是有onClick
與onClickCapture
來表示冒泡和捕獲階段事件綁定的,而即使是在React
合成事件中MouseEnter/MouseLeave
也只會(huì)在捕獲階段執(zhí)行,所以沒有Capture
事件綁定屬性。
--------------------------
| c | b | a |
| | | |
|------- | |
| | |
|---------------- |
| |
--------------------------
我們分別在三個(gè)DOM
上都綁定了MouseEnter
事件,當(dāng)我們鼠標(biāo)移動(dòng)到a
上時(shí),會(huì)執(zhí)行a
元素綁定的事件,當(dāng)依次將鼠標(biāo)移動(dòng)到a
、b
、c
的時(shí)候,同樣會(huì)以此執(zhí)行a
、b
、c
的事件綁定函數(shù),并且不會(huì)因?yàn)槊芭菔录?dǎo)致父元素事件的觸發(fā),當(dāng)我們鼠標(biāo)直接移動(dòng)到c
的時(shí)候,可以看到依舊是按照a
、b
、c
的順序執(zhí)行,也可以看出來MouseEnter
事件是依賴于捕獲階段執(zhí)行的。
Portal事件
在前邊也提到了,盡管React Portals
可以被放置在DOM
樹中的任何地方,但在任何其他方面,其行為和普通的React
子節(jié)點(diǎn)行為一致。我們都知道React
自行維護(hù)了一套基于事件代理的合成事件,那么由于Portal
仍存在于原本的React
組件樹中,這樣就意味著我們的React
事件實(shí)際上還是遵循原本的合成事件規(guī)則而與DOM
樹中的位置無關(guān),那么我們就可以認(rèn)為其無論其子節(jié)點(diǎn)是否是Portal
,像合成事件、Context
這樣的功能特性都是不變的,下面是一些使用React Portals
需要關(guān)注的點(diǎn):
- 事件冒泡會(huì)正常工作: 合成事件將通過冒泡傳播到
React
樹的祖先,事件冒泡將按預(yù)期工作,而與DOM
中的Portal
節(jié)點(diǎn)位置無關(guān)。 React
以控制Portal
節(jié)點(diǎn)及其生命周期:Portal
未脫離React
組件樹,當(dāng)通過Portal
渲染子組件時(shí),React
仍然可以控制組件的生命周期。Portal
只影響DOM
結(jié)構(gòu): 對于React
來說Portal
僅僅是視覺上渲染的位置變了,只會(huì)影響HTML
的DOM
結(jié)構(gòu),而不會(huì)影響React
組件樹。- 預(yù)定義的
HTML
掛載點(diǎn): 使用React Portal
時(shí),我們需要提前定義一個(gè)HTML DOM
元素作為Portal
組件的掛載。
在這里https://codesandbox.io/p/sandbox/trigger-component-1hv99o?file=/src/components/portal-test.tsx:1,1
提供了一個(gè)Portals
與MouseEnter
事件的DEMO
可以用來測試效果。那么在代碼中實(shí)現(xiàn)的嵌套精簡如下:
-------------------
| 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>
);
單純從代碼上來看,這就是一個(gè)很簡單的嵌套結(jié)構(gòu),而因?yàn)閭魉烷TPortals
的存在,在真實(shí)的DOM
結(jié)構(gòu)上,這段代碼結(jié)構(gòu)表現(xiàn)的效果是這樣的,其中id
只是用來標(biāo)識React
的DOM
結(jié)構(gòu),實(shí)際并不存在:
<body><div id="root"><div id="a"></div></div><div id="b"></div><div id="c"></div><div>
</body>
接下來我們依次來試試定義的MouseEnter
事件觸發(fā)情況,首先鼠標(biāo)移動(dòng)到a
元素上,控制臺(tái)打印a
,符合預(yù)期,接下來鼠標(biāo)移動(dòng)到b
元素上,控制臺(tái)打印b
,同樣符合預(yù)期,那么接下來將鼠標(biāo)移動(dòng)到c
,神奇的事情來了,我們會(huì)發(fā)現(xiàn)會(huì)先打印b
再打印c
,而不是僅僅打印了c
,由此我們可以得到雖然看起來DOM
結(jié)構(gòu)不一樣了,但是在React
樹中合成事件依然保持著嵌套結(jié)構(gòu),C
組件作為B
組件的子元素,在事件捕獲時(shí)依然會(huì)從B -> C
觸發(fā)MouseEnter
事件,基于此我們可以實(shí)現(xiàn)非常有意思的一件事情,多級嵌套的彈出層。
Trigger彈出層
實(shí)際上上邊聊的內(nèi)容都是都是為這部分內(nèi)容做鋪墊的,因?yàn)楣ぷ鞯年P(guān)系我使用ArcoDesign
是非常多的,又由于我實(shí)際是做富文本文檔的,需要彈出層來做交互的地方就非常多,所以在平時(shí)的工作中會(huì)大量使用ArcoDesign
的Trigger
組件https://arco.design/react/components/trigger
,之前我一直非常好奇這個(gè)組件的實(shí)現(xiàn),這個(gè)組件可以無限層級地嵌套,而且當(dāng)多級彈出層組件的最后一級鼠標(biāo)移出之后,所有的彈出層都會(huì)被關(guān)閉,最主要的是我們只是將其嵌套做了一層業(yè)務(wù)實(shí)現(xiàn),并沒有做任何的通信傳遞,所以我也一直好奇這部分的實(shí)現(xiàn),直到前一段時(shí)間我為了解決BUG
深入研究了一下相關(guān)實(shí)現(xiàn),發(fā)現(xiàn)其本質(zhì)還是利用React Portals
以及React
樹的合成事件來完成的,這其中還是有很多交互實(shí)現(xiàn)可以好好學(xué)習(xí)下的。
同樣的,在這里也完成了一個(gè)DEMO
實(shí)現(xiàn)https://codesandbox.io/p/sandbox/trigger-component-1hv99o?file=/src/components/trigger-simple.tsx:1,1
,而在調(diào)用時(shí),則直接嵌套即可實(shí)現(xiàn)兩層彈出層,當(dāng)我們鼠標(biāo)移動(dòng)到a
元素時(shí),b
元素與c
元素會(huì)展示出來,當(dāng)我們將鼠標(biāo)移動(dòng)到c
元素時(shí),d
元素會(huì)被展示出來,當(dāng)我們繼續(xù)將鼠標(biāo)快速移動(dòng)到d
元素時(shí),所有的彈出層都不會(huì)消失,當(dāng)我們直接將鼠標(biāo)從d
元素移動(dòng)到空白區(qū)域時(shí),所有的彈出層都會(huì)消失,如果我們將其移動(dòng)到b
元素,那么只有d
元素會(huì)消失。
------------------- ------------- --------
| a | | b | | d |
| | |-------- | | |
| | | c | | --------
| | |-------- |
| | -------------
| |
-------------------
<TriggerSimpleduration={200}popup={() => (<div id="b" style={{ height: 100, width: 100, backgroundColor: "green" }}><TriggerSimplepopup={() => <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>
讓我們來拆解一下代碼實(shí)現(xiàn),首先是Portal
組件的封裝,在這里我們就認(rèn)為我們將要掛載的組件是在document.body
上的就可以了,因?yàn)槲覀円龅氖菑棾鰧?#xff0c;在最開始的時(shí)候也闡明了我們的彈出層DOM
結(jié)構(gòu)需要掛在最外層而不能直接嵌套地放在DOM
結(jié)構(gòu)中,當(dāng)然如果能夠保證不會(huì)出現(xiàn)相關(guān)問題,滾動(dòng)容器不是body
的情況且需要position absolute
的情況下,可以通過getContainer
傳入DOM
節(jié)點(diǎn)來制定傳送的位置,當(dāng)然在這里我們認(rèn)為是body
就可以了。在下面這段實(shí)現(xiàn)中我們就通過封裝Portal
組件來調(diào)度DOM
節(jié)點(diǎn)的掛載和卸載,并且實(shí)際的組件也會(huì)被掛載到我們剛創(chuàng)建的節(jié)點(diǎn)上。
// 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;
};
接下來我們來看構(gòu)造在React
樹中的DOM
結(jié)構(gòu),這塊可以說是整個(gè)實(shí)現(xiàn)的精髓,可能會(huì)比較繞,可以認(rèn)為實(shí)際上每個(gè)彈出層都分為了兩塊,一個(gè)是原本的child
,另一個(gè)是彈出的portal
,這兩個(gè)結(jié)構(gòu)是平行的放在React DOM
樹中的,那么在多級彈出層之后,實(shí)際上每個(gè)子trigger(portal + child)
都是上層portal
的children
,這個(gè)結(jié)構(gòu)可以用一個(gè)樹形結(jié)構(gòu)來表示。
<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>
從樹形結(jié)構(gòu)中我們可以看出來,雖然在DOM
結(jié)構(gòu)中我們現(xiàn)實(shí)出來是平鋪的結(jié)構(gòu),但是在React
的事件樹中卻依舊保持著嵌套結(jié)構(gòu),那么我們就很容易解答最開始的一個(gè)問題,為什么我們可以無限層級地嵌套,而且當(dāng)多級彈出層組件的最后一級鼠標(biāo)移出之后,所有的彈出層都會(huì)被關(guān)閉,就是因?yàn)閷?shí)際上即使我們的鼠標(biāo)在最后一級,但是在React
樹結(jié)構(gòu)中其依舊是屬于所有portal
的子元素,既然其是child
那么實(shí)際上我們可以認(rèn)為其并沒有移出各級trigger
的元素,自然不會(huì)觸發(fā)MouseLeave
事件來關(guān)閉彈出層,如果我們移出了最后一級彈出層到空白區(qū)域,那么相當(dāng)于我們移出了所有trigger
實(shí)例的portal
元素區(qū)域,自然會(huì)觸發(fā)所有綁定的MouseLeave
事件來關(guān)閉彈出層。
那么雖然上邊我們雖然解釋了Trigger
組件為什么能夠維持無限嵌套層級結(jié)構(gòu)下能夠維持彈出層的顯示,并且在最后一級鼠標(biāo)移出之后能夠關(guān)閉所有彈出層,或者從最后一級返回到上一級只關(guān)閉最后一級彈出層,但是我們還有一個(gè)問題沒有想明白,上邊的問題是因?yàn)樗械?code>trigger彈出層實(shí)例都是上一級trigger
彈出層實(shí)例的子元素,那么我們還有一個(gè)平級的portal
與child
元素呢,當(dāng)我們鼠標(biāo)移動(dòng)到child
時(shí),portal
元素會(huì)展示出來,而此時(shí)我們將鼠標(biāo)移動(dòng)到portal
元素時(shí),這個(gè)portal
元素并不會(huì)消失,而是會(huì)一直保持顯示,在這里的React
樹是不存在嵌套結(jié)構(gòu)的,所以這里需要對事件進(jìn)行特殊處理。
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();}
};
實(shí)際上在這里的通信會(huì)比較簡單,之前我們也提到portal
與child
元素是平級的,那么我們可以明顯地看出來實(shí)際上這是在一個(gè)組件內(nèi)的,那么整體的實(shí)現(xiàn)就會(huì)簡單很多,我們可以設(shè)計(jì)一個(gè)延時(shí),并且可以為portal
和child
分別綁定MouseEnter
和MouseLeave
事件,在這里我們?yōu)?code>child綁定的是onMouseEnter
和onMouseLeave
兩個(gè)事件處理函數(shù),為portal
綁定了onPopupMouseEnter
和onPopupMouseLeave
兩個(gè)事件處理函數(shù)。那么此時(shí)我們模擬一下上邊的情況,當(dāng)我們鼠標(biāo)移入child
元素時(shí),會(huì)觸發(fā)onMouseEnter
事件處理函數(shù),此時(shí)我們會(huì)清除掉delayTimer
,然后會(huì)調(diào)用setPopupVisible
方法,此時(shí)會(huì)將popupVisible
設(shè)置為true
然后顯示出portal
,那么此時(shí)重點(diǎn)來了,我們這里實(shí)際上會(huì)有一個(gè)delay
的延時(shí),也就是說實(shí)際上當(dāng)我們移出元素時(shí),在delay
時(shí)間之后才會(huì)將元素真正的隱藏,那么如果此時(shí)我們將鼠標(biāo)再移入到portal
,觸發(fā)onPopupMouseEnter
事件時(shí)調(diào)用clearDelayTimer
清除掉delayTimer
,那么我們就可以阻止元素的隱藏,那么再往后的嵌套彈出層無論是child
還是portal
本身依舊是上一層portal
的子元素,即使是在子portal
與子child
之間切換也可以利用clearDelayTimer
來阻止元素的隱藏,所以之后的彈出層就可以利用這種方式遞歸處理就可以實(shí)現(xiàn)無限嵌套了。我們可以將DEMO
中鼠標(biāo)從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
組件的實(shí)現(xiàn),當(dāng)然在實(shí)際的處理過程中還有相當(dāng)多的細(xì)節(jié)需要處理,例如位置計(jì)算、動(dòng)畫、事件處理等等等等,而且實(shí)際上這個(gè)組件也有很多我們可以學(xué)習(xí)的地方,例如如何將外部傳遞的事件處理函數(shù)交予children
、React.Children.map
、React.isValidElement
、React.cloneElement
等方法的使用等等,也都是非常有意思的實(shí)現(xiàn)。
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