Web Totals

Học từ web lớn #17: Reverse portal, đổi chỗ không remount (Zappos)

Zappos render widget lưu sản phẩm một lần rồi đặt DOM của nó vào gallery hay sidebar tuỳ A/B test. Reverse portal giữ nguyên state, iframe và video khi đổi chỗ.

hunghg255hunghg25523 min read
#web-patterns#zappos#react#portal#dom
Học từ web lớn #17: Reverse portal, đổi chỗ không remount (Zappos)

Trên trang sản phẩm của Zappos có một widget "♥ Lưu vào bộ sưu tập". Tuỳ nhánh A/B test, widget nằm ở hai chỗ khác nhau: góc ảnh trong gallery, hoặc cột thông tin bên phải. Khi người dùng bật chế độ zoom ảnh, chỗ trong gallery biến mất, rồi quay lại khi tắt zoom.

Cách viết tự nhiên trong React là render widget ở cả hai chỗ, kèm điều kiện:

<Gallery>{inGallery && !zoom && <CollectionWidget />}</Gallery>
<Sidebar>{!inGallery && <CollectionWidget />}</Sidebar>
tsx

Vị trí trong cây React đổi thì React coi đó là một component khác: unmount cái cũ, mount cái mới. Mọi thứ bên trong widget mất sạch: state, chữ đang gõ dở, request đang chạy, iframe (nếu có) tải lại từ đầu, video đang phát dừng lại. Với widget đơn giản thì chịu được. Với trình phát video, bản đồ, editor, hay một iframe thanh toán thì không.

Tip

Tóm tắt

  • React gắn state với vị trí trong cây. Muốn đổi chỗ trên màn hình mà không mất state, phải tách "chỗ render" (cây React) khỏi "chỗ hiển thị" (DOM).
  • Reverse portal: render component một lần vào một element tách rời bằng createPortal, rồi di chuyển chính element đó tới chỗ cần hiển thị.
  • Di chuyển bằng Element.moveBefore() thì iframe không tải lại, video không dừng, focus không mất (Chrome có từ bản 133, xem caniuse). Trình duyệt chưa có thì vẫn giữ được state React.
  • Thư viện có sẵn: react-reverse-portal.

Thử trong demo: bấm ♥ vài lần, gõ ghi chú, đợi iframe đếm vài giây rồi đổi chỗ. Ở chế độ "Cách ngây thơ", số lần mount tăng, ♥ về 0, iframe đếm lại từ 0. Ở chế độ reverse portal, không gì mất cả.

#Zappos làm thế nào?

File zappos.com/src/helpers/PortalNodeManager.tsx (xem trong repo) có ba thứ:

  • createInjectionContext() tạo một "portal node": một <div> tạo bằng document.createElement, chưa gắn vào đâu, kèm hai hàm mount và unmount.
  • BranchPortal render children vào div đó bằng createPortal. Đây là chỗ cây React thực sự sống.
  • LeafPortal render một <div> rỗng làm chỗ giữ (placeholder). Trong effect, nó thay placeholder bằng div của portal node bằng replaceChild.

Phần mount rút gọn (unmount làm ngược lại: replaceChild placeholder về chỗ cũ):

mount: (newParent, newPlaceholder) => {
  if (newPlaceholder === lastPlaceholder) return; // đã gắn ở đây rồi
  portalNode.unmount();
  newParent.replaceChild(portalNode.element, newPlaceholder);
  parent = newParent;
  lastPlaceholder = newPlaceholder;
},
ts

Cách dùng trong src/containers/ProductDetail.tsx: constructor gọi this.blueSkyCollectionNode = createInjectionContext(), hàm makeFavoritesButton bọc SocialCollectionsWidget trong <BranchPortal node={...}>. Hai LeafPortal nằm ở hai nơi: một trong ProductDetail (khi không ở nhánh hydraBlueSkyPdp), một trong ProductGallery.tsx (chỉ khi !zoomIsActive). Comment trong code nói rõ lý do:

For the BlueSky PDP, we render the collection widget in a different spot. Instead of duplicating markup, we create two separate containers for each experience, and render into either via a portal depending on the treatment/control assignment.

BranchPortal còn nhận props từ LeafPortal: Leaf gọi node.setPortalProps(props), Branch thay hàm này (bằng Object.assign(node, …)) để mỗi lần được gọi thì setState, rồi cloneElement children với props đó. Nhờ vậy mỗi chỗ hiển thị có thể truyền cấu hình riêng cho widget.

Ý tưởng này cùng họ với thư viện react-reverse-portal (createHtmlPortalNode, InPortal, OutPortal). Code của Zappos không ghi nguồn, nên không rõ họ viết lại từ đầu hay chép rồi sửa.

#Ba chỗ dễ vỡ trong bản của Zappos

Đọc kỹ thì bản này có ba vấn đề. Ở cách Zappos đang dùng thì chúng chưa gây lỗi, nhưng chép sang dự án khác sẽ dễ dính.

1. Gỡ LeafPortal khỏi một cha vẫn còn thì React crash. Placeholder đã bị replaceChild lấy ra khỏi DOM. Khi LeafPortal unmount, React gỡ node DOM của nó (removeChild(placeholder)) trong pha mutation, trước khi chạy cleanup của useEffect. Placeholder không còn là con của cha nữa, nên trình duyệt ném lỗi. Mình chạy thử file gốc với React 19: <div>{show && <LeafPortal node={node} />}</div>, đổi show thành false, kết quả là Failed to execute 'removeChild' on 'Node': The node to be removed is not a child of this node. và cả cây React bị gỡ. Zappos thoát được vì trong gallery LeafPortal nằm trong một <div> riêng (AddToCollection) bị gỡ cùng, còn ở ProductDetail thì điều kiện hydraBlueSkyPdp (có lẽ) không đổi trong lúc trang đang mở.

2. Effect chạy lại ở mỗi lần render. LeafPortal khai báo useEffect(…, [node, props]). props là object mới ở mỗi lần render, nên mỗi lần cha render lại là một lần unmount rồi mount: element bị tháo khỏi DOM rồi gắn lại. State React không mất, nhưng nút đang được focus sẽ mất focus, iframe bên trong (nếu có) sẽ tải lại.

3. Thứ tự effect làm mất props lần đầu. Ở ProductDetail, LeafPortal đứng trước BranchPortal trong JSX nên effect của Leaf chạy trước. Lúc đó Branch chưa kịp "thay" setPortalProps, nên props Leaf gửi đi chỉ được ghi vào biến initialProps, mà Branch đã đọc biến này lúc render (khi còn rỗng). Zappos không truyền props nào qua Leaf nên không thấy.

#Bên trong trình duyệt: di chuyển một node DOM

Có hai cách đưa element tới chỗ mới:

Cách Điều gì xảy ra
appendChild / replaceChild / insertBefore Node bị gỡ khỏi document rồi chèn lại. Trình duyệt chạy các bước "removing steps": iframe bị huỷ và tải lại, phần tử đang focus mất focus, animation CSS và transition chạy lại, video có thể bị dừng.
moveBefore (mới) Node được chuyển mà không rời document. Iframe, video, focus, animation giữ nguyên. Yêu cầu: node và chỗ đến đều đang ở trong cùng document.

moveBefore có trong Chrome từ bản 133 (đầu 2025). Các trình duyệt khác đang triển khai, tính tới 10/2026 nên kiểm tra caniuse trước khi dựa vào nó. Bản viết lại bên dưới dùng moveBefore nếu có, không có thì quay về appendChild.

Điều kiện "node phải đang ở trong document" là chỗ khó. Khi widget chuyển từ gallery sang sidebar trong một lần render, thứ tự công việc của React là:

sequenceDiagram
  participant R as React commit
  participant Old as "OutPortal cũ (gallery)"
  participant K as "Kho ẩn trong body"
  participant New as "OutPortal mới (sidebar)"
  R->>Old: chạy cleanup của useLayoutEffect
  Old->>K: moveBefore: chuyển element vào kho
  R->>R: gỡ div host cũ khỏi DOM (lúc này đã rỗng)
  R->>New: chạy useLayoutEffect mới
  K->>New: moveBefore: chuyển element ra host mới

Nếu để element nằm yên trong host cũ, React gỡ host cũ thì element cũng rời document theo, và lần chuyển sau chỉ còn cách appendChild. Vì vậy cleanup phải chuyển element vào một "kho" ẩn trong body trước khi React gỡ host. Điều này chỉ đúng với useLayoutEffect: React chạy cleanup của layout effect trong lúc xử lý cây bị xoá, trước khi gỡ node DOM. Cleanup của useEffect chạy sau, khi node đã bị gỡ rồi (đây cũng là lý do bản của Zappos crash ở vấn đề 1).

#Viết lại

Ba phần: hàm di chuyển, portal node, và hai component InPortal/OutPortal.

#Di chuyển: moveBefore nếu được, không thì appendChild

src/lib/reversePortal.tsx
type MoveBefore = (node: Node, child: Node | null) => void;

/** Element.moveBefore: di chuyển node mà KHÔNG gỡ khỏi document (iframe không tải lại, video không dừng). */
function moveInto(parent: HTMLElement, node: HTMLElement): 'move' | 'insert' {
  const moveBefore = (parent as unknown as { moveBefore?: MoveBefore }).moveBefore;
  if (moveBefore && node.isConnected && parent.isConnected) {
    try {
      moveBefore.call(parent, node, null);
      return 'move';
    } catch {
      // khác document/shadow root… thì quay về cách thường
    }
  }
  parent.appendChild(node);
  return 'insert';
}
tsx

moveBefore(node, null) nghĩa là "chuyển vào cuối", giống appendChild. Hàm này ném lỗi nếu node chưa ở trong document hoặc khác document, nên có try để quay về cách thường.

#Portal node: mount, unmount và cái kho

src/lib/reversePortal.tsx
    mount(nextHost) {
      const el = getElement();
      if (host === nextHost && el.parentNode === nextHost) return 'noop';
      host = nextHost;
      const how = moveInto(nextHost, el);
      notify();
      return how;
    },
    unmount(prevHost) {
      // OutPortal khác đã nhận node rồi (mount mới chạy trước cleanup cũ): bỏ qua
      if (host !== prevHost) return;
      host = null;
      // Chuyển vào kho NGAY, trước khi React gỡ host khỏi DOM. Nhờ vậy node luôn ở trong
      // document và lần mount sau dùng được moveBefore.
      if (element) moveInto(getParking(), element);
      props = {};
      notify();
    },
  };
tsx

Khác Zappos ở hai điểm. Thứ nhất, không thay placeholder: element được gắn vào bên trong div host của OutPortal. Host là div rỗng do React tạo, React không quản lý con của nó, nên ta thêm/bớt con thoải mái. Khi React gỡ host thì không có gì bất ngờ: nó gỡ đúng node nó đã tạo. Vấn đề 1 biến mất.

Thứ hai, unmount chuyển element vào kho thay vì gỡ hẳn. Kiểm tra host !== prevHost để bỏ qua trường hợp một OutPortal khác đã nhận node trước đó (hai OutPortal cùng tồn tại một lúc: cái mới "giành" node).

#InPortal và OutPortal

InPortal rất ngắn: đọc props hiện tại của node bằng useSyncExternalStore (node là một external store nhỏ), cloneElement chúng vào child, rồi createPortal(child, node.getElement()). Đây là chỗ cây React sống, nên phải đặt ở chỗ không bị unmount khi đổi vị trí: thường là cha chung của mọi chỗ hiển thị.

OutPortal là phần đáng xem:

src/lib/reversePortal.tsx
/** Chỗ hiển thị: lấy element của node về đây, kèm props (nếu có) cho component bên trong. */
export function OutPortal<P extends AnyProps>({ node, ...props }: OutPortalProps<P>) {
  const hostRef = useRef<HTMLDivElement>(null);

  // useLayoutEffect: gắn trước khi trình duyệt vẽ (không nháy), và cleanup chạy TRƯỚC khi React
  // gỡ host khỏi DOM, nên unmount() kịp chuyển node vào kho.
  useLayoutEffect(() => {
    const host = hostRef.current!;
    node.mount(host);
    return () => node.unmount(host);
  }, [node]);

  // props đổi thì chỉ cập nhật props, không tháo/gắn lại DOM.
  // Không có mảng deps: `props` là object mới mỗi render, nên tự so nông với lần trước.
  useLayoutEffect(() => {
    if (node.getHost() !== hostRef.current) return;
    if (!shallowEqual(node.getProps(), props)) node.setProps(props as unknown as Partial<P>);
  });

  return <div ref={hostRef} style={{ display: 'contents' }} />;
}
tsx
  • Effect thứ nhất chỉ phụ thuộc node, nên không tháo/gắn lại mỗi lần render (sửa vấn đề 2).
  • Effect thứ hai không có mảng deps, chạy sau mỗi render, so nông props với lần trước rồi mới setProps. Props được đẩy đi sau khi OutPortal đã nhận node, nên không bị mất như vấn đề 3.
  • Host và element đều display: contents, nên không thêm hộp layout nào: widget bố cục như con trực tiếp của chỗ hiển thị.
Xem toàn bộ src/lib/reversePortal.tsx
src/lib/reversePortal.tsx
import {
  Children,
  cloneElement,
  isValidElement,
  useLayoutEffect,
  useRef,
  useState,
  useSyncExternalStore,
  type ReactNode,
} from 'react';
import { createPortal } from 'react-dom';

/**
 * Reverse portal: render một cây React MỘT lần (InPortal), rồi "đặt" DOM của nó vào bất kỳ chỗ
 * nào trên trang (OutPortal). Đổi chỗ không làm cây remount: state, effect, input đang gõ…
 * vẫn nguyên. Ý tưởng từ `helpers/PortalNodeManager.tsx` của Zappos (LeafPortal / BranchPortal),
 * cùng họ với thư viện react-reverse-portal.
 */

type AnyProps = Record<string, unknown>;

export interface PortalNode<P extends AnyProps = AnyProps> {
  /** element chứa cây React của InPortal; chính element này được di chuyển giữa các OutPortal */
  getElement(): HTMLElement;
  /** OutPortal đang hiển thị node, null = không ai hiển thị (đang nằm trong "kho") */
  getHost(): HTMLElement | null;
  /** props do OutPortal hiện tại truyền vào, InPortal sẽ gắn vào child */
  getProps(): Partial<P>;
  setProps(props: Partial<P>): void;
  subscribe(listener: () => void): () => void;
  /** gắn node vào host; trả về cách đã làm để log/debug */
  mount(host: HTMLElement): 'move' | 'insert' | 'noop';
  unmount(host: HTMLElement): void;
}

type MoveBefore = (node: Node, child: Node | null) => void;

/** Element.moveBefore: di chuyển node mà KHÔNG gỡ khỏi document (iframe không tải lại, video không dừng). */
function moveInto(parent: HTMLElement, node: HTMLElement): 'move' | 'insert' {
  const moveBefore = (parent as unknown as { moveBefore?: MoveBefore }).moveBefore;
  if (moveBefore && node.isConnected && parent.isConnected) {
    try {
      moveBefore.call(parent, node, null);
      return 'move';
    } catch {
      // khác document/shadow root… thì quay về cách thường
    }
  }
  parent.appendChild(node);
  return 'insert';
}

function shallowEqual(a: AnyProps, b: AnyProps) {
  const keys = Object.keys(a);
  return keys.length === Object.keys(b).length && keys.every((key) => Object.is(a[key], b[key]));
}

let parking: HTMLElement | null = null;

/** "Kho" ẩn trong body: node không được OutPortal nào hiển thị thì nằm ở đây, vẫn ở trong document. */
function getParking(): HTMLElement {
  if (!parking || !parking.isConnected) {
    parking = document.createElement('div');
    parking.dataset.portalParking = '';
    parking.hidden = true;
    document.body.appendChild(parking);
  }
  return parking;
}

export function createPortalNode<P extends AnyProps = AnyProps>(): PortalNode<P> {
  let element: HTMLElement | null = null; // tạo lười: lúc SSR chưa có document
  let host: HTMLElement | null = null;
  let props: Partial<P> = {};
  const listeners = new Set<() => void>();
  const notify = () => listeners.forEach((listener) => listener());

  const getElement = () => {
    if (!element) {
      element = document.createElement('div');
      // không tạo thêm hộp layout: con bên trong bố cục như con trực tiếp của host
      element.style.display = 'contents';
    }
    return element;
  };

  return {
    getElement,
    getHost: () => host,
    getProps: () => props,
    setProps(next) {
      props = next;
      notify();
    },
    subscribe(listener) {
      listeners.add(listener);
      return () => listeners.delete(listener);
    },
    mount(nextHost) {
      const el = getElement();
      if (host === nextHost && el.parentNode === nextHost) return 'noop';
      host = nextHost;
      const how = moveInto(nextHost, el);
      notify();
      return how;
    },
    unmount(prevHost) {
      // OutPortal khác đã nhận node rồi (mount mới chạy trước cleanup cũ): bỏ qua
      if (host !== prevHost) return;
      host = null;
      // Chuyển vào kho NGAY, trước khi React gỡ host khỏi DOM. Nhờ vậy node luôn ở trong
      // document và lần mount sau dùng được moveBefore.
      if (element) moveInto(getParking(), element);
      props = {};
      notify();
    },
  };
}

/** Giữ một PortalNode ổn định suốt vòng đời component (tạo một lần). */
export function usePortalNode<P extends AnyProps = AnyProps>(): PortalNode<P> {
  const [node] = useState(() => createPortalNode<P>());
  return node;
}

/** Biết node đang được hiển thị ở đâu (vd: tự dừng video khi không ai hiển thị). */
export function usePortalHost<P extends AnyProps>(node: PortalNode<P>): HTMLElement | null {
  return useSyncExternalStore(node.subscribe, node.getHost, () => null);
}

/**
 * Render children MỘT lần vào element của node. Đặt InPortal ở chỗ "sống lâu" (cha chung của
 * mọi vị trí), không đặt bên trong vị trí sẽ biến mất.
 */
export function InPortal<P extends AnyProps>({
  node,
  children,
}: {
  node: PortalNode<P>;
  children: ReactNode;
}) {
  const props = useSyncExternalStore(node.subscribe, node.getProps, node.getProps);
  const content = Children.map(children, (child) =>
    isValidElement(child) ? cloneElement(child, props as Partial<unknown>) : child,
  );
  return createPortal(content, node.getElement());
}

export type OutPortalProps<P extends AnyProps> = { node: PortalNode<P> } & Partial<P>;

/** Chỗ hiển thị: lấy element của node về đây, kèm props (nếu có) cho component bên trong. */
export function OutPortal<P extends AnyProps>({ node, ...props }: OutPortalProps<P>) {
  const hostRef = useRef<HTMLDivElement>(null);

  // useLayoutEffect: gắn trước khi trình duyệt vẽ (không nháy), và cleanup chạy TRƯỚC khi React
  // gỡ host khỏi DOM, nên unmount() kịp chuyển node vào kho.
  useLayoutEffect(() => {
    const host = hostRef.current!;
    node.mount(host);
    return () => node.unmount(host);
  }, [node]);

  // props đổi thì chỉ cập nhật props, không tháo/gắn lại DOM.
  // Không có mảng deps: `props` là object mới mỗi render, nên tự so nông với lần trước.
  useLayoutEffect(() => {
    if (node.getHost() !== hostRef.current) return;
    if (!shallowEqual(node.getProps(), props)) node.setProps(props as unknown as Partial<P>);
  });

  return <div ref={hostRef} style={{ display: 'contents' }} />;
}
tsx

Khác bản gốc: Zappos dùng useEffect + replaceChild placeholder, đẩy props bằng cách ghi đè hàm trên node, và gỡ element khỏi document khi không ai hiển thị. Bản viết lại gắn element vào trong host bằng useLayoutEffect, có kho ẩn, dùng moveBefore khi trình duyệt hỗ trợ, và coi node là external store cho cả props lẫn vị trí hiện tại (usePortalHost).

#Tại sao không làm cách khác?

Nâng state lên cha chung. Với state React thuần (số lần ♥, chữ đang gõ) thì đây là cách đúng và nên thử trước. Nhưng nó không giữ được những thứ nằm ngoài React: iframe, <video>, instance của thư viện bản đồ, editor (CodeMirror, Monaco), WebSocket mà component mở. Những thứ đó gắn với node DOM, node bị huỷ là mất.

Render một chỗ, dùng CSS để "dời" đi. position: absolute + đo toạ độ của chỗ đích. Được với overlay, nhưng mất luồng layout (chỗ đích không chừa khoảng trống, phải tự tính lại khi resize, cuộn).

key cố định. key chỉ có tác dụng giữa các anh em trong cùng một cha. Hai chỗ khác cha thì cùng key vẫn là hai component khác nhau.

<Activity> của React (trước gọi là Offscreen). Giữ cây React khi ẩn, không giúp khi đổi chỗ. Hai thứ bổ sung cho nhau, xem thêm keep-alive tab của Facebook.

#Edge case và cạm bẫy

  • InPortal đặt sai chỗ. Nếu InPortal nằm trong gallery và gallery bị unmount thì widget vẫn unmount. Đặt nó ở cha chung sống lâu nhất (trang, layout).
  • Context đi theo InPortal, không theo OutPortal. Component bên trong đọc context từ chỗ nó được render (cây React), không phải chỗ nó hiển thị. Theme provider ở sidebar sẽ không áp dụng cho widget. Cần thì truyền qua props của OutPortal.
  • Event cũng đi theo cây React. Event React nổi bọt (bubble) qua InPortal lên cha của nó, không qua cha DOM ở chỗ hiển thị. Event DOM gốc (addEventListener) thì ngược lại: đi theo DOM.
  • Lúc chưa có chỗ hiển thị. Component vẫn mount, nhưng element đang ở ngoài document (lần đầu) hoặc trong kho ẩn. Code đo kích thước (getBoundingClientRect, ResizeObserver) sẽ nhận 0. Dùng usePortalHost(node) để biết đang được hiển thị hay không.
  • Video vẫn phát trong kho. Kho chỉ hidden, không dừng media. Nếu không ai hiển thị thì component nên tự pause(), dựa vào usePortalHost.
  • SSR. createPortal cần element thật nên không render được ở server. Bản viết lại tạo element lười (chỉ khi gọi getElement), nhưng InPortal vẫn phải render ở client (bọc trong component chỉ render sau khi mount).
  • CSS theo vị trí. Selector kiểu .sidebar .widget vẫn áp dụng vì DOM thật nằm trong sidebar. Nhưng CSS Modules/CSS-in-JS gắn với component thì đi theo component. Kiểm tra cả hai chỗ.

#Dùng trong dự án

Video đang phát ở trang chi tiết, thu nhỏ thành mini player khi cuộn xuống:

function Page() {
  const playerNode = usePortalNode<{ compact: boolean }>();
  const mini = useScrolledPast(600); // tự viết: true khi đã cuộn qua video
  return (
    <>
      <InPortal node={playerNode}><VideoPlayer src='/talk.mp4' /></InPortal>
      <article>{!mini && <OutPortal node={playerNode} compact={false} />}</article>
      {mini && <aside className='mini-player'><OutPortal node={playerNode} compact /></aside>}
    </>
  );
}
tsx

Những chỗ hợp với reverse portal:

  • Layout đổi theo A/B test hoặc breakpoint mà không muốn render hai bản (đúng trường hợp của Zappos).
  • Mini player, picture-in-picture "giả", chat bubble bung ra thành panel.
  • Kéo thả một panel nặng (editor, bản đồ) giữa các cột của dashboard.

Không nên dùng khi chỉ có state React đơn giản: nâng state lên là đủ, ít phép thuật hơn.

#Kiểm thử

Ví dụ test (bản phác, không có trong repo)
import { act, render, screen, fireEvent } from '@testing-library/react';
import { useState } from 'react';
import { createPortalNode, InPortal, OutPortal } from './reversePortal';

function Counter() {
  const [n, setN] = useState(0);
  return <button onClick={() => setN(n + 1)}>count {n}</button>;
}

test('đổi chỗ không mất state', () => {
  const node = createPortalNode();
  function App({ slot }: { slot: 'a' | 'b' }) {
    return (
      <>
        <InPortal node={node}>
          <Counter />
        </InPortal>
        <div data-testid='a'>{slot === 'a' && <OutPortal node={node} />}</div>
        <div data-testid='b'>{slot === 'b' && <OutPortal node={node} />}</div>
      </>
    );
  }
  const { rerender } = render(<App slot='a' />);
  fireEvent.click(screen.getByText('count 0'));
  rerender(<App slot='b' />);
  expect(screen.getByTestId('b')).toHaveTextContent('count 1');
  expect(screen.getByTestId('a')).toBeEmptyDOMElement();
});

test('gỡ OutPortal khỏi cha vẫn còn thì không crash, node vào kho', () => {
  const node = createPortalNode();
  const { rerender } = render(
    <>
      <InPortal node={node}>
        <Counter />
      </InPortal>
      <div>{true && <OutPortal node={node} />}</div>
    </>,
  );
  rerender(
    <>
      <InPortal node={node}>
        <Counter />
      </InPortal>
      <div>{false}</div>
    </>,
  );
  expect(node.getHost()).toBeNull();
  expect(document.querySelector('[data-portal-parking]')).toContainElement(node.getElement());
});
tsx

jsdom không có moveBefore, nên test chạy nhánh appendChild. Phần giữ iframe cần test bằng trình duyệt thật (Playwright): đếm số lần sự kiện load của iframe trước và sau khi đổi chỗ.

#So với thư viện

  • react-reverse-portal: cùng ý tưởng (createHtmlPortalNode, InPortal, OutPortal), có thêm bản cho SVG, đã dùng lâu năm. Nếu không cần sửa gì thì dùng luôn thư viện này. Khi dùng, kiểm tra xem bản bạn cài đã dùng moveBefore chưa (đọc changelog), vì chuyện giữ iframe phụ thuộc vào đó.
  • Mã nguồn Zappos: ngắn, đủ cho trường hợp của họ, nhưng có ba chỗ dễ vỡ ở trên.
  • Bản trong bài: khoảng 170 dòng, không phụ thuộc gì, dùng để hiểu cơ chế hoặc chép khi cần điều chỉnh.

Bài này thuộc series Học từ mã nguồn các web lớn (65 bài, đọc theo thứ tự).

Xem nhanh danh sách bài trong series

Mở đầu

Phần 1: trải nghiệm người dùng

Phần 2: hiệu năng và đo lường

Phần 3: nhiều tab, phiên đăng nhập, cập nhật

Phần 4: kiến trúc

Phần 5: soạn thảo và hiển thị nội dung

Phần 6: tương tác

Phần 7: mạng, dữ liệu và async

Phần 8: hiệu năng và vận hành

Phần 9: thuật toán và dữ liệu phía client

Phần 10: thời gian, ngôn ngữ, định dạng

Phần 11: tương tác và giao diện

Phần 12: media

Phần 13: đo lường, bảo mật, vận hành

Code + demo: examples/web-patterns · Mở trên StackBlitz