Portal
前端开发中常见的问题之一是从组件中弹出模态对话框。问题在于模态对话框需要在 DOM 树的不同部分渲染,并且触发模态对话框的组件需要有一种影响渲染位置的方式。
在其他框架中,通常通过专用的 API(例如 createPortal()
)来解决此问题。然而,这样的 API 在服务器端渲染方面效果不佳,因此需要另一种方法。
替代方案
您可以考虑使用这些现代浏览器中的模态对话框的替代方案:
- https://developer.mozilla.org/en-US/docs/Web/HTML/Element/dialog
- https://developer.chrome.com/blog/introducing-popover-api/
问题
要解决的基本问题是:
- 决定在应用程序中渲染弹出窗口的位置(我们称之为
<Portal>
)。 - 有一种方法可以与
<Portal>
进行通信,以便让它知道何时以及应该渲染哪个组件(从触发弹出窗口的组件)。这通过<PortalProvider/>
组件实现。
解决方案
让我们将解决方案分解为以下步骤:
- 创建一个
PortalProvider
组件,负责管理弹出窗口。 - 将
<PortalProvider>
放置在顶级layout.tsx
组件中。 - 创建一个
PortalAPI
,用于与PortalProvider
进行通信。 - 创建
<Portal>
组件,用于渲染弹出窗口内容。
使用 PortalProvider
假设我们已经有了一个 PortalProvider
,让我们首先关注它的使用方式。
- 通过以下方式获取
PortalProvider
API:const portal = useContext(PortalAPI);
- 然后使用
PortalProvider
API 来显示弹出窗口:portal('modal', <PopupExample name="World" />)
- 最后,我们可以使用
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,可用于显示/隐藏弹出窗口。
- 创建
PortalProviderContext
,用于与PortalProvider
进行通信。useContextProvider(PortalProviderContext, { show: $(<T extends {}>(component: Component<T>, props: T) => {...}), hide: $(() => {...}) });
- 有条件地渲染组件:
{ // 有条件地渲染模态对话框 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>
);
});