Menu

Portal trong React: createPortal cho modal và tooltip

createPortal render một phần của component vào một DOM node khác, chẳng hạn document.body, trong khi vẫn giữ nguyên vị trí trong cây React. Dùng nó cho modal, tooltip và menu cần thoát khỏi overflow hidden và z-index.

Trang này có trình soạn thảo chạy trực tiếp. Sửa, chạy và xem đầu ra ngay lập tức.

Portal render một phần của component vào một vị trí khác trong DOM, thường là document.body, trong khi vẫn giữ nguyên vị trí trong cây React. Bạn tạo portal bằng createPortal(children, domNode) từ react-dom. Nhờ portal, modal, tooltip và menu thả xuống thoát được khỏi một component cha có overflow: hidden hoặc stacking context riêng.

Khung bên dưới cắt bỏ mọi thứ nhô ra ngoài nó. Hãy mở cả hai tooltip.

Tooltip thường bị cắt ở đường viền nét đứt. Tooltip dùng portal hiện ra đầy đủ, vì DOM node của nó là con của <body>, không phải của khung. Xóa overflow: 'hidden' khỏi khung, tooltip thường sẽ không còn bị cắt nữa.

Cú pháp

import { createPortal } from 'react-dom';

createPortal(children, domNode, key?)
  • children là JSX bất kỳ: một element, một fragment, một component.
  • domNode là một DOM element đã tồn tại, chẳng hạn document.body hoặc document.getElementById('modal-root'). Nó phải có sẵn khi portal render.
  • key là tùy chọn, dùng khi bạn render một danh sách portal.

createPortal trả về một thứ bạn đặt vào JSX như mọi element khác. Nó không render gì ở vị trí DOM của chính component cha.

Modal là trường hợp kinh điển. Bên trong một thẻ card có transform, lớp phủ position: fixed được định vị theo card đó thay vì theo cửa sổ, rồi overflow: hidden của card cắt mất nó. Khi render vào document.body, lớp phủ sẽ che kín toàn bộ viewport.

Mở modal, rồi nhấn Escape hoặc nhấn vào lớp phủ tối để đóng. Khi modal mở, focus chuyển sang nút Close. Giờ hãy trả về lớp phủ mà không dùng createPortal (bỏ lời gọi hàm và đối số document.body của nó): lớp phủ co lại bằng kích thước của card, vì transform biến card thành khối chứa cho các phần tử fixed.

Sự kiện nổi bọt theo cây React

Portal thay đổi nơi DOM node nằm, không thay đổi nơi component nằm. Sự kiện React nổi bọt (bubble) lên theo cây React, nên một cú nhấn bên trong portal vẫn đến được onClick của component đã render nó, dù trong DOM, nút bấm là con của <body>.

Listener gốc thì khác. Một listener thêm bằng addEventListener đi theo cây DOM và không bao giờ nhận được cú nhấn đó.

Nhấn "Inside the wrapper": cả handler của React lẫn listener gốc đều ghi log. Nhấn "In a portal": chỉ handler của React ghi log. Trên màn hình, nút trong portal nằm ngoài khung nét đứt, nhưng React vẫn coi nó là con.

Thường thì đây chính là điều bạn muốn: một onClick trên component cha của menu nhận được cú nhấn vào các mục trong portal, và các context provider phía trên component cũng áp dụng bên trong portal. Nhưng nó có thể gây bất ngờ với logic "nhấn ra ngoài để đóng". Một onClick trên wrapper dùng để đóng menu cũng sẽ chạy khi nhấn vào bên trong portal của menu, vì vậy hãy chặn lan truyền bên trong menu (e.stopPropagation(), xem trang sự kiện), hoặc dùng một listener gốc trên document và kiểm tra xem target có nằm trong DOM node của portal hay không.

Portal vào vùng chứa riêng

document.body là đích đến đơn giản nhất. Một số ứng dụng thêm một vùng chứa riêng vào index.html để mọi lớp phủ dùng chung một chỗ và một thứ tự xếp chồng:

<body>
    <div id="root"></div>
    <div id="modal-root"></div>
</body>
createPortal(<Modal />, document.getElementById('modal-root'));

Khi render phía máy chủ (Next.js và các framework khác), document không tồn tại trên máy chủ. Chỉ render portal sau khi component đã mount, ví dụ đặt sau một state mounted được một effect gán thành true.

Khi portal cần được định vị ngay cạnh phần tử kích hoạt nó, hãy đo phần tử đó trước. Ví dụ đầu tiên đo nó trong handler của cú nhấn; với một tooltip không được nhấp nháy, hãy đo trong useLayoutEffect, hook chạy trước khi trình duyệt vẽ.

Chỉ chuyển markup vào document.body thì chưa giúp gì cho người dùng bàn phím và trình đọc màn hình. Một modal còn cần:

  • role="dialog" và aria-modal="true", cùng aria-labelledby trỏ đến tiêu đề của nó.
  • Focus chuyển vào trong hộp thoại khi nó mở (ví dụ trên focus vào Close), và quay về nút đã mở nó khi nó đóng.
  • Nhấn Escape để đóng.
  • Giữ focus ở bên trong khi đang mở, để phím Tab không đi lạc vào trang phía sau. Gán thuộc tính inert cho root của ứng dụng trong lúc modal mở sẽ chặn focus và cú nhấn ở đó.

Phần tử gốc <dialog> lo phần lớn việc này cho bạn. Khi mở bằng dialogRef.current.showModal(), nó được vẽ ở top layer của trình duyệt, phía trên mọi z-index, khiến phần còn lại của trang trở nên inert và tự đóng khi nhấn Escape. Nó không cần portal, nên là lựa chọn mặc định tốt cho các hộp thoại xác nhận đơn giản; portal vẫn là công cụ cho tooltip, menu và các lớp phủ tự xây.

Vì sao chỉ z-index là chưa đủ

Lập trình viên thường tìm đến portal sau khi một z-index thật lớn vẫn không đưa được menu lên trên phần còn lại của trang. Nguyên nhân là stacking context. Một phần tử có position và z-index, opacity nhỏ hơn 1, transform, filter hoặc isolation: isolate sẽ tạo ra một stacking context mới, và giá trị z-index của các phần tử con chỉ so với nhau bên trong đó. Một phần tử con có z-index: 9999 bên trong card có z-index: 1 vẫn nằm dưới một card anh em có z-index: 2.

Portal đưa phần tử ra khỏi mọi phần tử tổ tiên như vậy. Là con của <body>, z-index của nó được so với các phần tử cấp cao nhất của trang, nên một giá trị vừa phải như 1000 là đủ cho các lớp phủ.

Lỗi thường gặp

Portal vào một node chưa tồn tại. document.getElementById('modal-root') trả về null nếu phần tử không có, và createPortal ném lỗi "Target container is not a DOM element". Hãy kiểm tra HTML, hoặc portal vào document.body.

Tạo node đích trong lúc render. Viết document.createElement('div') trong thân component sẽ tạo một node mới sau mỗi lần render. Hãy tạo nó một lần trong effect, hoặc dùng một vùng chứa cố định.

Popup không đi theo phần tử kích hoạt. Một tooltip định vị bằng getBoundingClientRect() không đi theo phần tử kích hoạt khi trang cuộn hoặc đổi kích thước. Hãy tính lại khi có scroll và resize (và gỡ các listener đó trong hàm cleanup của effect), hoặc đóng tooltip khi trang cuộn.

Câu hỏi thường gặp

Portal trong React là gì?

Là cách render các phần tử con vào một DOM node nằm ngoài DOM element của component cha. Bạn tạo portal bằng createPortal(children, domNode) từ react-dom. Các phần tử con vẫn giữ vị trí của chúng trong cây React, nên props, state và context hoạt động như bình thường.

Khi nào nên dùng portal?

Khi một thứ gì đó phải hiện phía trên hoặc ra ngoài vùng chứa của nó: modal, tooltip, menu thả xuống, toast. Nếu không, một component cha có overflow: hidden, một transform hoặc stacking context riêng sẽ cắt mất hoặc che khuất nó.

Sự kiện có nổi bọt ra khỏi portal không?

Có, theo cây React. Một cú nhấn bên trong portal sẽ đến các handler onClick của các component cha trong React, dù DOM node nằm trong document.body. Còn các listener gốc thêm bằng addEventListener thì đi theo cây DOM.

Context có hoạt động bên trong portal không?

Có. Giống như sự kiện, context đi theo cây React. Một modal render vào document.body vẫn đọc được theme hay người dùng từ các provider nằm phía trên component đã tạo ra nó.

Làm modal có bắt buộc phải dùng portal không?

Không phải lúc nào cũng cần. Phần tử gốc <dialog> mở bằng showModal() được vẽ ở top layer của trình duyệt, phía trên mọi z-index, và có sẵn cơ chế xử lý focus. Portal là lựa chọn quen thuộc cho modal tự xây, cho tooltip và menu.

Hình minh họa các ngôn ngữ lập trình của Coddy

Học lập trình cùng Coddy

Bắt đầu