Skip to content

RootRenderer Portal 渲染器

将子元素渲染到 document.body 的 Portal 组件,用于模态框、提示等需要脱离文档流的场景。

导入

ts
import { RootRenderer } from '@xcloud/ui-mobile';

示例

基础用法

tsx
import { RootRenderer } from '@xcloud/ui-mobile';

function Modal({ children }) {
  return (
    <RootRenderer>
      <div className="fixed inset-0 bg-black/50 flex items-center justify-center">
        {children}
      </div>
    </RootRenderer>
  );
}

API

属性类型默认值说明
childrenReactNode-要渲染的子元素

实现原理

RootRenderer 是 React Portal 的简单封装:

tsx
export const RootRenderer = ({ children }: RootRendererProps) => {
  // SSR 安全检查
  if (!canUseDOM || !document.body) {
    return isValidElement(children) ? children : null;
  }

  // 使用 Portal 渲染到 body
  return createPortal(children, document.body);
};

SSR 支持

  • 服务端渲染时,canUseDOMfalse
  • 降级为普通渲染,避免 document 引用错误
  • 客户端水合后自动切换为 Portal 渲染

使用场景

1. 模态对话框

tsx
function Dialog({ open, onClose, children }) {
  if (!open) return null;

  return (
    <RootRenderer>
      <div className="fixed inset-0 z-50">
        <div className="absolute inset-0 bg-black/50" onClick={onClose} />
        <div className="relative z-10 flex items-center justify-center min-h-full">
          {children}
        </div>
      </div>
    </RootRenderer>
  );
}

2. 全局提示

tsx
function Toast({ message, type }) {
  return (
    <RootRenderer>
      <div className="fixed top-4 right-4 z-[9999]">
        <div className={`px-4 py-3 rounded-lg shadow-lg ${
          type === 'success' ? 'bg-green-500' : 'bg-red-500'
        } text-white`}>
          {message}
        </div>
      </div>
    </RootRenderer>
  );
}

3. Drawer 抽屉

tsx
function Drawer({ open, side = 'right', children }) {
  if (!open) return null;

  return (
    <RootRenderer>
      <div className="fixed inset-0 z-40">
        <div className="absolute inset-0 bg-black/30" />
        <div className={`absolute top-0 bottom-0 bg-white shadow-xl ${
          side === 'right' ? 'right-0' : 'left-0'
        } w-80`}>
          {children}
        </div>
      </div>
    </RootRenderer>
  );
}

4. 全局加载指示器

tsx
function GlobalLoader({ loading }) {
  if (!loading) return null;

  return (
    <RootRenderer>
      <div className="fixed inset-0 z-[10000] flex items-center justify-center bg-black/20">
        <div className="animate-spin rounded-full h-12 w-12 border-4 border-blue-500 border-t-transparent" />
      </div>
    </RootRenderer>
  );
}

优势

1. 避免样式冲突

普通渲染可能受到父容器样式影响:

tsx
{/* ❌ 可能被裁剪 */}
<div className="overflow-hidden relative">
  <Modal />  {/* 内容可能被 overflow: hidden 裁剪 */}
</div>

{/* ✅ 不受父容器影响 */}
<div className="overflow-hidden relative">
  <RootRenderer>
    <Modal />  {/* 渲染到 body,不会被裁剪 */}
  </RootRenderer>
</div>

2. z-index 层级

tsx
{/* ❌ 可能被其他元素遮挡 */}
<div className="z-10">
  <Modal className="z-50" />  {/* 仍然受父容器 z-10 限制 */}
</div>

{/* ✅ 独立层级 */}
<RootRenderer>
  <Modal className="z-50" />  {/* 真正的 z-50,不受父容器影响 */}
</RootRenderer>

3. 事件冒泡控制

Portal 元素虽然 DOM 位置在 body,但 React 事件冒泡遵循组件树:

tsx
<div onClick={handleParentClick}>
  <RootRenderer>
    <div onClick={handleChildClick}>
      {/* 点击这里会先触发 handleChildClick,再触发 handleParentClick */}
    </div>
  </RootRenderer>
</div>

与直接使用 createPortal 的区别

特性RootRenderercreatePortal
SSR 安全✅ 内置检查❌ 需要手动处理
代码简洁✅ 一行代码⚠️ 需要检查 canUseDOM
目标容器固定为 body可自定义
使用场景通用场景需要自定义容器时

最佳实践

  1. 条件渲染: 在 Portal 外部控制,避免不必要的渲染

    tsx
    {open && <RootRenderer><Modal /></RootRenderer>}  // ✅ 好
    <RootRenderer>{open && <Modal />}</RootRenderer>  // ⚠️ 可以,但会有空 Portal
  2. 焦点管理: Portal 内的元素需要手动管理焦点

    tsx
    useEffect(() => {
      if (open) {
        const firstInput = modalRef.current?.querySelector('input');
        firstInput?.focus();
      }
    }, [open]);
  3. 无障碍性: 添加适当的 ARIA 属性

    tsx
    <RootRenderer>
      <div
        role="dialog"
        aria-modal="true"
        aria-labelledby="modal-title"
      >
        <h2 id="modal-title">标题</h2>
      </div>
    </RootRenderer>
  4. 清理: 组件卸载时 Portal 会自动清理,无需手动处理

性能优化

  1. 懒加载: 对于不常用的 Portal 组件,使用 React.lazy

    tsx
    const Modal = lazy(() => import('./Modal'));
    
    {showModal && (
      <Suspense fallback={null}>
        <RootRenderer>
          <Modal />
        </RootRenderer>
      </Suspense>
    )}
  2. 避免重复渲染: 使用 memo 包装 Portal 内容

    tsx
    const ModalContent = memo(({ data }) => <div>{data}</div>);
    
    <RootRenderer>
      <ModalContent data={data} />
    </RootRenderer>

常见问题

Q: RootRenderer 与 Snackbar 的关系?

A: Snackbar 内部使用 RootRenderer 实现 Portal 渲染:

tsx
export const Snackbar = ({ children, ...props }) => {
  return (
    <RootRenderer>
      <div className="fixed bottom-4 left-4 right-4">
        {children}
      </div>
    </RootRenderer>
  );
};

Q: 可以自定义目标容器吗?

A: RootRenderer 固定渲染到 body。如需自定义容器,直接使用 createPortal:

tsx
import { createPortal } from 'react-dom';

const customContainer = document.getElementById('portal-root');
return createPortal(children, customContainer);

Q: SSR 时如何处理样式?

A: 服务端渲染时 RootRenderer 会降级为普通渲染,确保样式正确:

tsx
// 在服务端和客户端都能正常工作
<RootRenderer>
  <div className="modal">...</div>  {/* 样式会正确应用 */}
</RootRenderer>

浏览器兼容性

  • ✅ 所有现代浏览器
  • ✅ IE11+ (支持 React Portal)
  • ✅ SSR 环境 (降级为普通渲染)

相关组件

源码参考

完整实现仅 10 行代码,极其简洁:

tsx
import { isValidElement, ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { canUseDOM } from '@xcloud/ui-core/utils';

export interface RootRendererProps {
  children?: ReactNode;
}

export const RootRenderer = ({ children }: RootRendererProps) => {
  if (!canUseDOM || !document.body) {
    return isValidElement(children) ? children : null;
  }

  return createPortal(children, document.body);
};

基于 MIT 许可发布