You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 

4.7 KiB

title date author
Modal hook 的有趣 BUG 2022-12-21 zombieJ

最近我们遇到了一个 issue,说是 Modal.useModalcontextHolder 在放置不同的位置时,modal.confirm 弹出位置会不一样:

import React from 'react';
import { Button, Modal } from 'antd';

export default () => {
  const [modal, contextHolder] = Modal.useModal();

  return (
    <div>
      <Modal open>
        <Button
          onClick={() => {
            modal.confirm({ title: 'Hello World' });
          }}
        >
          Confirm
        </Button>

        {/* 🚨 BUG when put here */}
        {contextHolder}
      </Modal>

      {/* ✅ Work as expect when put here */}
      {/* {contextHolder} */}
    </div>
  );
};

正常版本:

Normal

有问题版本:

BUG

从上图可以看到当 contextHolder 放在 Modal 内部时,hooks 调用的弹出位置不正确了。

思路整理

antd 的 Modal 底层调用的是 rc-dialog 组件库,其接受一个 mousePosition 属性,用于控制弹出位置(Dialog/Content/index.tsx):

// pseudocode
const elementOffset = offset(dialogElement);
const transformOrigin = `${mousePosition.x - elementOffset.left}px ${
  mousePosition.y - elementOffset.top
}px`;

其中 offset 方法用于获取窗体本身的坐标位置(util.ts):

// pseudocode
function offset(el: Element) {
  const { left, top } = el.getBoundingClientRect();
  return { left, top };
}

通过断点调试,我们可以发现 mousePosition 的值是正确的,但是 offset 中获取的 rect 的值是错误的:

{
  "left": 0,
  "top": 0,
  "width": 0,
  "height": 0
}

这个值很明显代表窗体组件在动画启动节点尚未添加到 DOM 树中,所以我们需要查看一下 Dialog 添加的逻辑。

createPortal

rc-dialog 通过 rc-portal 在 document 中创建一个节点,然后通过 ReactDOM.createPortal 将组件渲染到这个节点上。对于 contextHolder 位置不同而出现表现不同可以推测,一定是在 document 创建节点的时序出现了问题,于是我们可以进一步看一下 rc-portal 中默认添加节点的部分(useDom.tsx):

// pseudocode
function append() {
  // This is not real world code, just for explain
  document.body.appendChild(document.createElement('div'));
}

useLayoutEffect(() => {
  if (queueCreate) {
    queueCreate(append);
  } else {
    append();
  }
}, []);

其中 queueCreate 是通过 context 获取,目的是为了防止在嵌套层级下,子元素创建先于父元素的情况:

<Modal title="Hello 1" open>
  <Modal title="Hello 2" open>
  <Modal>
<Modal>
<!-- Child `useLayoutEffect` is run before parent. Which makes inject dom before parent -->
<div data-title="Hello 2"></div>
<div data-title="Hello 1"></div>

通过 queueCreate 将子元素的 append 加入队列,然后再通过 useLayoutEffect 执行:

// pseudocode
const [queue, setQueue] = useState<VoidFunction[]>([]);

function queueCreate(appendFn: VoidFunction) {
  setQueue((origin) => {
    const newQueue = [appendFn, ...origin];
    return newQueue;
  });
}

useLayoutEffect(() => {
  if (queue.length) {
    queue.forEach((appendFn) => appendFn());
    setQueue([]);
  }
}, [queue]);

问题分析

由于上述的队列操作,使得 portal 的 dom 在嵌套下会在下一个 useLayoutEffect 触发。这导致添加节点行为后于 rc-dialog 启动动画的 uesLayoutEffect 时机,导致元素不在 document 中而无法获取正确的坐标信息。

由于 Modal 已经是开启状态,其实不需要通过 queue 异步执行,所以我们只需要加一个判断如果是开启状态,直接执行 append 即可:

// pseudocode
const appendedRef = useRef(false);

const queueCreate = !appendedRef.current
  ? (appendFn: VoidFunction) => {
      // same code
    }
  : undefined;

function append() {
  // This is not real world code, just for explain
  document.body.appendChild(document.createElement('div'));
  appendedRef.current = true;
}

// ...

return <PortalContext value={queueCreate}>{children}</PortalContext>;

以上。