ReactPortals傳送門

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
發表評論
所有評論
還沒有人評論,想成為第一個評論的人麼? 請在上方評論欄輸入並且點擊發布.
相關文章