Portal

前端开发中常见的问题之一是从组件中弹出模态对话框。问题在于模态对话框需要在 DOM 树的不同部分渲染,并且触发模态对话框的组件需要有一种影响渲染位置的方式。

在其他框架中,通常通过专用的 API(例如 createPortal())来解决此问题。然而,这样的 API 在服务器端渲染方面效果不佳,因此需要另一种方法。

替代方案

您可以考虑使用这些现代浏览器中的模态对话框的替代方案:

问题

要解决的基本问题是:

  1. 决定在应用程序中渲染弹出窗口的位置(我们称之为 <Portal>)。
  2. 有一种方法可以与 <Portal> 进行通信,以便让它知道何时以及应该渲染哪个组件(从触发弹出窗口的组件)。这通过 <PortalProvider/> 组件实现。

解决方案

让我们将解决方案分解为以下步骤:

  1. 创建一个 PortalProvider 组件,负责管理弹出窗口。
  2. <PortalProvider> 放置在顶级 layout.tsx 组件中。
  3. 创建一个 PortalAPI,用于与 PortalProvider 进行通信。
  4. 创建 <Portal> 组件,用于渲染弹出窗口内容。

使用 PortalProvider

假设我们已经有了一个 PortalProvider,让我们首先关注它的使用方式。

  1. 通过以下方式获取 PortalProvider API:
    const portal = useContext(PortalAPI);
  2. 然后使用 PortalProvider API 来显示弹出窗口:
    portal('modal', <PopupExample name="World" />)
  3. 最后,我们可以使用 PortalCloseAPI API 来隐藏弹出窗口:
    const portalClose = useContext(PortalCloseAPI);
    portalClose();

完整的源代码可以在这里找到:

import {
  $,
  component$,
  useContext,
  useStylesScoped$,
  useTask$,
} from '@builder.io/qwik';
import { PortalCloseAPIContextId, PortalAPI } from './portal-provider';
import PopupExampleCSS from './popup-example.css?inline';
import { useLocation } from '@builder.io/qwik-city';
 
export default component$(() => {
  // 获取 portal API
  const portal = useContext(PortalAPI);
  // 这个函数用于打开模态对话框。
  // Portal 可以被命名,每个 Portal 可以渲染多个项目。
  const openModal = $(() => portal('modal', <PopupExample name="World" />));
 
  // 在服务器上有条件地打开 <Portal/>,以演示 Portal 的服务器端渲染。
  const location = useLocation();
  useTask$(() => {
    location.url.searchParams.get('modal') && openModal();
  });
  return (
    <>
      <div>
        [ <a href="?modal=true">将 Portal 作为 SSR 的一部分渲染</a> |{' '}
        <a href="?">将 Portal 作为客户端交互的一部分渲染</a> ]
      </div>
      <button onClick$={openModal}>显示模态对话框</button>
    </>
  );
});
 
// 这个组件显示为模态对话框。
export const PopupExample = component$<{ name: string }>(({ name }) => {
  useStylesScoped$(PopupExampleCSS);
  // 要关闭一个 Portal,请获取关闭 API。
  const portalClose = useContext(PortalCloseAPIContextId);
  return (
    <div class="popup-example">
      <h1>模态对话框</h1>
      <p>你好,{name}</p>
      <button onClick$={() => portalClose()}>X</button>
    </div>
  );
});

PopupExample 组件的样式是(portal-provider.css):

.modal {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1000;
  width: 100%;
  height: 100%;
  overflow: hidden;
  outline: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: rgba(0, 0, 0, 0.5);
  -webkit-tap-highlight-color: transparent;
}

实现 PortalProvider

PortalProvider 是一个负责渲染弹出窗口的组件。它还提供了一个上下文 API,可用于显示/隐藏弹出窗口。

  1. 创建 PortalProviderContext,用于与 PortalProvider 进行通信。
    useContextProvider(PortalProviderContext, {
     show: $(<T extends {}>(component: Component<T>, props: T) => {...}),
     hide: $(() => {...})
    });
  2. 有条件地渲染组件:
    {
      // 有条件地渲染模态对话框
      modal.value && <div class="modal">
        <modal.value.Component {...modal.value.props} />
      </div>
    }

完整的实现可以在这里找到:

import {
  $,
  Slot,
  component$,
  createContextId,
  useContext,
  useContextProvider,
  useSignal,
  useStylesScoped$,
  type ContextId,
  type QRL,
  type Signal,
} from '@builder.io/qwik';
import { type JSXNode } from '@builder.io/qwik/jsx-runtime';
import CSS from './portal-provider.css?inline';
 
// 定义打开 Portal 的公共 API
export const PortalAPI = createContextId<
  /**
   * 向 Portal 添加 JSX。
   * @param name Portal 名称。
   * @param jsx 要添加的 JSX。
   * @param contexts 要添加到 Portal 的上下文。
   * @returns 用于关闭 Portal 的函数。
   */
  QRL<
    (name: string, jsx: JSXNode, contexts?: ContextPair<any>[]) => () => void
  >
>('PortalProviderAPI');
 
export type ContextPair<T> = { id: ContextId<T>; value: T };
 
// 定义关闭 Portal 的公共 API
export const PortalCloseAPIContextId =
  createContextId<QRL<() => void>>('PortalCloseAPI');
 
// 用于管理 Portal 的内部上下文
const PortalsContextId = createContextId<Signal<Portal[]>>('Portals');
 
interface Portal {
  name: string;
  jsx: JSXNode;
  close: QRL<() => void>;
  contexts: Array<ContextPair<unknown>>;
}
 
export const PortalProvider = component$(() => {
  const portals = useSignal<Portal[]>([]);
  useContextProvider(PortalsContextId, portals);
 
  // 为其他组件提供 PopupManager 的公共 API。
  useContextProvider(
    PortalAPI,
    $((name: string, jsx: JSXNode, contexts?: ContextPair<any>[]) => {
      const portal: Portal = {
        name,
        jsx,
        close: null!,
        contexts: [...(contexts || [])],
      };
      portal.close = $(() => {
        portals.value = portals.value.filter((p) => p !== portal);
      });
      portal.contexts.push({
        id: PortalCloseAPIContextId,
        value: portal.close,
      });
      portals.value = [...portals.value, portal];
      return portal.close;
    })
  );
  return <Slot />;
});
 
/**
 * 重要提示:为了使 <Portal> 在 SSR 中正确渲染,
 * 它需要在打开 Portal 的调用之后进行渲染。
 * (在将内容设置为 Portal 之后,无法在 SSR 中返回到 <Portal/>,
 * 因为它已经被流式传输到客户端。)
 */
export const Portal = component$<{ name: string }>(({ name }) => {
  const portals = useContext(PortalsContextId);
  useStylesScoped$(CSS);
  const myPortals = portals.value.filter((portal) => portal.name === name);
  return (
    <>
      {myPortals.map((portal) => (
        <div data-portal={name}>
          <WrapJsxInContext jsx={portal.jsx} contexts={portal.contexts} />
        </div>
      ))}
    </>
  );
});
 
export const WrapJsxInContext = component$<{
  jsx: JSXNode;
  contexts: Array<ContextPair<any>>;
}>(({ jsx, contexts }) => {
  contexts.forEach(({ id, value }) => useContextProvider(id, value));
  return (
    <>
      {/* Workaround: https://github.com/BuilderIO/qwik/issues/4966 */}
      {/* {jsx} */}
      {[jsx].map((jsx) => jsx)}
    </>
  );
});

PortalProvider 组件的样式是(portal-provider.css):

.modal {
  position: fixed;
  top: 0;
  left: 0;
  z-index: 1000;
  width: 100%;
  height: 100%;
  overflow: hidden;
  outline: 0;
  display: flex;
  align-items: center;
  justify-content: center;
  background-color: rgba(0, 0, 0, 0.5);
  -webkit-tap-highlight-color: transparent;
}

PortalProvider 添加到根 layout.tsx

import { Slot, component$ } from '@builder.io/qwik';
import { Portal, PortalProvider } from './portal-provider';
 
export default component$(() => {
  // 1. 使用 <PortalProvider> 包装根组件,以启用 portal API。
  //    <PortalProvider> 组件将提供一个上下文 API,
  // 允许其他组件创建 portals。
  // 2. 在要渲染 portals 的位置添加 <Portal/>。
  //    (<Portal/> 具有名称,因此您可以有多个 <Portal/> 位置。)
  return (
    <PortalProvider>
      <Slot />
      <Portal name="modal" />
    </PortalProvider>
  );
});

Contributors

Thanks to all the contributors who have helped make this documentation better!

  • mhevery