ReactPortals傳送門

WindrunnerMax發表於2023-09-29

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.createPortalReact元件掛載到了其他的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來控制顯隱,這種就是純粹的高射炮打蚊子,就沒有必要再展開了。

其實我們再想一想,既然我們是要脫離父元件結構來實現這個能力,那麼我們沒有必要非得使用PortalsCSSposition定位不是也可以幫助我們將當前的DOM結構脫離文件流,也就是說我們沒必要將目標元件的DOM結構實際地分離出來,只需要藉助position定位就可以實現效果。當然想法是很美好的,真實場景就變得複雜的多了,那麼脫離文件流最常用的主要是絕對定位absolute與固定定位fixed。首先我們來看一下absolute,那麼我們使用absolute其實很容易想到,我們需要從當前元件一直到body都沒有其他positionrelative/absolute的元素,這個條件肯定是很難達到的,特別是如果我們寫的是一個元件庫的話,很難控制使用者究竟套了多少層以及究竟用什麼CSS屬性。那麼此時我們再將目光聚焦到fixed上,fixed是相對於視口來定位的,那麼也就不需要像是absolute那麼強的要求了,即使是父元素存在relative/absolute也沒有關係。當然這件事沒有這麼簡單,即使是fixed元素依舊可能會受到父元素樣式的影響,在這裡舉兩個例子,分別是transformz-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>

從上邊的例子中我們可以看出,我們僅僅使用CSSposition定位是無法做到完全脫離父元件的,即使我們能夠達到脫離文件流的效果,也會因為父元件的樣式而受到影響,特別是在元件庫中,我們作為第三方元件庫的話是完全沒有辦法控制使用者設計的DOM結構的,如果僅僅採用脫離文件流的方法而不實際將DOM結構分離出來的話,那麼我們的元件就會受到使用者樣式的影響,這是我們不希望看到的。此外,即使我們並不是設計元件庫,而僅僅是在我們的業務中實現相關需求,我們也不希望我們的元件受到父元件的影響,因為即使最開始我們的結構和樣式沒出現問題,隨著業務越來越複雜,特別是多人協作開發專案,就很容易留下隱患,造成一些不必要的問題,當然我們可以引入E2E來避免相關問題,這就是另一方面的解決方案了。

綜上,React Portals提供了一種更靈活地控制渲染的行為,可以用於解決一些複雜的UI互動場景,下面是一些常見的應用場景:

  • 模態框和對話方塊: 使用Portals可以將模態框或對話方塊元件渲染到DOM樹的頂層,確保其可以覆蓋其他元件,並且在層級上獨立於其他元件,這樣可以避免CSSz-index屬性的複雜性,並且在元件層級之外建立一個乾淨的容器。
  • 與第三方庫的整合: 有時候,我們可能需要將React元件與第三方庫(例如地相簿或影片播放器)整合,使用Portals可以將元件渲染到第三方庫所需的DOM元素中,即將業務需要的額外元件渲染到原元件封裝好的DOM結構中,以確保元件在正確的位置和上下文中執行。
  • 邏輯分離和元件複用: Portals允許我們將元件的渲染輸出與元件的邏輯分離,我們可以將元件的渲染輸出定義在一個單獨的Portal元件中,並在需要的地方使用該Portal,這樣可以實現元件的複用,並且可以更好地組織和管理程式碼。
  • 處理層疊上下文: 在某些情況下,使用Portals可以幫助我們解決層疊上下文stacking context的問題,由於Portals可以建立獨立的DOM渲染容器,因此可以避免由於層疊上下文導致的樣式和佈局問題。

MouseEnter事件

即使React Portals可以將元件傳送到任意的DOM節點中,但是其行為和普通的React元件一樣,其並不會脫離原本的React元件樹,這其實是一件非常有意思的事情,因為這樣會看起來,我們可以利用這個特性來實現比較複雜的互動。但是在這之前,我們來重新看一下MouseEnterMouseLeave以及對應的MouseOverMouseOut的原生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,主要有這麼幾點理由:

  • 避免冒泡問題: MouseEnterMouseLeave事件不會冒泡到父元素或其他元素,只在滑鼠進入或離開元素本身時觸發,這意味著我們可以更精確地控制事件的觸發範圍,更準確地處理滑鼠互動,而不會受到其他元素的干擾,提供更好的使用者體驗。
  • 避免重複觸發: MouseOverMouseOut事件在滑鼠懸停在元素內部時會重複觸發,當滑鼠從一個元素移動到其子元素時,MouseOut事件會在父元素觸發一次,然後在子元素觸發一次,MouseOut事件也是同樣會多次觸發,可以將父元素與所有子元素都看作獨立區域,而事件會冒泡到父元素來執行事件繫結函式,這可能導致重複的事件處理和不必要的邏輯觸發,而MouseEnterMouseLeave事件不會重複觸發,只在滑鼠進入或離開元素時觸發一次。
  • 簡化互動邏輯: MouseEnterMouseLeave事件的特性使得處理滑鼠移入和移出的互動邏輯變得更直觀和簡化,我們可以僅關注元素本身的進入和離開,而不需要處理父元素或子元素的事件,這種簡化有助於提高程式碼的可讀性和可維護性。

當然究竟使用MouseEnter/MouseLeave還是MouseEnter/MouseLeave事件還是要看具體的業務場景,如果需要處理滑鼠移入和移出元素的子元素時或者需要利用冒泡機制來實現功能,那麼MouseOverMouseOut事件就是更好的選擇,MouseEnter/MouseLeave能提供更大的靈活性和控制力,讓我們能夠建立複雜的互動效果,並更好地處理使用者與元素的互動,當然應用的複雜性也會相應提高。

讓我們回到MouseEnter/MouseLeave事件本身上,在這裡https://codesandbox.io/p/sandbox/trigger-component-1hv99o?file=/src/components/mouse-enter-test.tsx:1,1提供了一個事件的DEMO可以用來測試事件效果。需要注意的是,在這裡我們是藉助於React的合成事件來測試的,而在測試的時候也可以比較明顯地發現MouseEnter/MouseLeaveTS提示是沒有Capture這個選項的,例如Click事件是有onClickonClickCapture來表示冒泡和捕獲階段事件繫結的,而即使是在React合成事件中MouseEnter/MouseLeave也只會在捕獲階段執行,所以沒有Capture事件繫結屬性。

--------------------------
|    c |      b |      a |
|      |        |        |
|-------        |        |
|               |        |
|----------------        |
|                        |
--------------------------

我們分別在三個DOM上都繫結了MouseEnter事件,當我們滑鼠移動到a上時,會執行a元素繫結的事件,當依次將滑鼠移動到abc的時候,同樣會以此執行abc的事件繫結函式,並且不會因為冒泡事件導致父元素事件的觸發,當我們滑鼠直接移動到c的時候,可以看到依舊是按照abc的順序執行,也可以看出來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僅僅是視覺上渲染的位置變了,只會影響HTMLDOM結構,而不會影響React元件樹。
  • 預定義的HTML掛載點: 使用React Portal時,我們需要提前定義一個HTML DOM元素作為Portal元件的掛載。

在這裡https://codesandbox.io/p/sandbox/trigger-component-1hv99o?file=/src/components/portal-test.tsx:1,1提供了一個PortalsMouseEnter事件的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只是用來標識ReactDOM結構,實際並不存在:

<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是非常多的,又由於我實際是做富文字文件的,需要彈出層來做互動的地方就非常多,所以在平時的工作中會大量使用ArcoDesignTrigger元件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)都是上層portalchildren,這個結構可以用一個樹形結構來表示。

<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彈出層例項的子元素,那麼我們還有一個平級的portalchild元素呢,當我們滑鼠移動到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();
  }
};

實際上在這裡的通訊會比較簡單,之前我們也提到portalchild元素是平級的,那麼我們可以明顯地看出來實際上這是在一個元件內的,那麼整體的實現就會簡單很多,我們可以設計一個延時,並且可以為portalchild分別繫結MouseEnterMouseLeave事件,在這裡我們為child繫結的是onMouseEnteronMouseLeave兩個事件處理函式,為portal繫結了onPopupMouseEnteronPopupMouseLeave兩個事件處理函式。那麼此時我們模擬一下上邊的情況,當我們滑鼠移入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元件的實現,當然在實際的處理過程中還有相當多的細節需要處理,例如位置計算、動畫、事件處理等等等等,而且實際上這個元件也有很多我們可以學習的地方,例如如何將外部傳遞的事件處理函式交予childrenReact.Children.mapReact.isValidElementReact.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

相關文章